diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
new file mode 100644
index 0000000..46c2bfc
--- /dev/null
+++ b/app/api/chat/route.ts
@@ -0,0 +1,30 @@
+import { streamText } from 'ai';
+import { google } from '@ai-sdk/google';
+import { getCurrentUser } from '@/lib/auth/session';
+import { cookies } from 'next/headers';
+
+export const runtime = 'edge';
+
+export async function POST(req: Request) {
+ // Check authentication
+ const cookieStore = await cookies();
+ const authCookie = cookieStore.get('ponderants-auth');
+
+ if (!authCookie) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const { messages } = await req.json();
+
+ // Use Google's Gemini model for chat
+ const result = streamText({
+ model: google('gemini-1.5-flash'),
+ messages,
+ system: `You are a thoughtful interviewer helping the user explore and capture their ideas.
+ Ask insightful questions to help them develop their thoughts.
+ Be concise but encouraging. When the user expresses a complete thought,
+ acknowledge it and help them refine it into a clear, structured idea.`,
+ });
+
+ return result.toDataStreamResponse();
+}
diff --git a/app/chat/page.tsx b/app/chat/page.tsx
index 4e0a14c..1de13bf 100644
--- a/app/chat/page.tsx
+++ b/app/chat/page.tsx
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth/session';
-import { Center, Paper, Stack, Title, Text } from '@mantine/core';
+import { ChatInterface } from '@/components/ChatInterface';
export default async function ChatPage() {
const user = await getCurrentUser();
@@ -10,21 +10,5 @@ export default async function ChatPage() {
redirect('/login');
}
- return (
-
-
-
-
- Welcome to Ponderants
-
-
- Logged in as: {user.handle}
-
-
- DID: {user.did}
-
-
-
-
- );
+ return ;
}
diff --git a/components/ChatInterface.tsx b/components/ChatInterface.tsx
new file mode 100644
index 0000000..fe49125
--- /dev/null
+++ b/components/ChatInterface.tsx
@@ -0,0 +1,108 @@
+'use client';
+
+import { useChat } from 'ai';
+import { Container, ScrollArea, Paper, Group, TextInput, Button, Stack, Text, Box } from '@mantine/core';
+import { useEffect, useRef } from 'react';
+import { MicrophoneRecorder } from './MicrophoneRecorder';
+
+export function ChatInterface() {
+ const viewport = useRef(null);
+
+ const {
+ messages,
+ input,
+ handleInputChange,
+ handleSubmit,
+ setInput,
+ isLoading,
+ } = useChat({
+ api: '/api/chat',
+ });
+
+ // Auto-scroll to bottom when new messages arrive
+ useEffect(() => {
+ if (viewport.current) {
+ viewport.current.scrollTo({
+ top: viewport.current.scrollHeight,
+ behavior: 'smooth',
+ });
+ }
+ }, [messages]);
+
+ return (
+
+
+ {/* Chat messages area */}
+
+
+ {messages.length === 0 && (
+
+ Start a conversation by typing or speaking...
+
+ )}
+ {messages.map((message) => (
+
+
+ {message.content}
+
+
+ ))}
+
+
+
+ {/* Input area */}
+
+
+
+ );
+}
diff --git a/components/MicrophoneRecorder.tsx b/components/MicrophoneRecorder.tsx
new file mode 100644
index 0000000..f1052d0
--- /dev/null
+++ b/components/MicrophoneRecorder.tsx
@@ -0,0 +1,154 @@
+'use client';
+
+import { ActionIcon, Tooltip } from '@mantine/core';
+import { IconMicrophone, IconMicrophoneOff } from '@tabler/icons-react';
+import { useState, useRef } from 'react';
+
+// Define the shape of the Deepgram transcript
+interface DeepgramTranscript {
+ channel: {
+ alternatives: Array<{
+ transcript: string;
+ }>;
+ };
+ is_final: boolean;
+ speech_final: boolean;
+}
+
+type Props = {
+ /**
+ * Callback function to update the chat input with the new transcript.
+ * @param transcript - The full, combined transcript
+ */
+ onTranscriptUpdate: (transcript: string) => void;
+ /**
+ * Callback function to signal the final transcript for this "thought".
+ * @param transcript - The final, punctuated transcript
+ */
+ onTranscriptFinalized: (transcript: string) => void;
+};
+
+export function MicrophoneRecorder({ onTranscriptUpdate, onTranscriptFinalized }: Props) {
+ const [isRecording, setIsRecording] = useState(false);
+ const mediaRecorderRef = useRef(null);
+ const socketRef = useRef(null);
+
+ // Store the combined transcript for the current utterance
+ const transcriptRef = useRef('');
+
+ const stopRecording = () => {
+ if (mediaRecorderRef.current) {
+ mediaRecorderRef.current.stop();
+ mediaRecorderRef.current = null;
+ }
+ if (socketRef.current) {
+ socketRef.current.close();
+ socketRef.current = null;
+ }
+ setIsRecording(false);
+
+ // Finalize the transcript
+ if (transcriptRef.current) {
+ onTranscriptFinalized(transcriptRef.current);
+ }
+ transcriptRef.current = '';
+ };
+
+ const startRecording = async () => {
+ transcriptRef.current = ''; // Reset transcript
+ try {
+ // 1. Get the temporary Deepgram key
+ const response = await fetch('/api/voice-token', { method: 'POST' });
+ const data = await response.json();
+
+ if (data.error) {
+ throw new Error(data.error);
+ }
+
+ const { key } = data;
+
+ // 2. Access the microphone
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+
+ // 3. Open direct WebSocket to Deepgram
+ const socket = new WebSocket(
+ 'wss://api.deepgram.com/v1/listen?interim_results=true&punctuate=true',
+ ['token', key]
+ );
+ socketRef.current = socket;
+
+ socket.onopen = () => {
+ // 4. Create MediaRecorder
+ const mediaRecorder = new MediaRecorder(stream, {
+ mimeType: 'audio/webm',
+ });
+ mediaRecorderRef.current = mediaRecorder;
+
+ // 5. Send audio chunks on data available
+ mediaRecorder.ondataavailable = (event) => {
+ if (event.data.size > 0 && socket.readyState === WebSocket.OPEN) {
+ socket.send(event.data);
+ }
+ };
+
+ // Start recording and chunking audio every 250ms
+ mediaRecorder.start(250);
+ setIsRecording(true);
+ };
+
+ // 6. Receive transcripts
+ socket.onmessage = (event) => {
+ const data = JSON.parse(event.data) as DeepgramTranscript;
+ const transcript = data.channel.alternatives[0]?.transcript || '';
+
+ if (transcript) {
+ transcriptRef.current = transcript;
+ onTranscriptUpdate(transcript);
+ }
+
+ // If it's a "speech final" event, this utterance is done.
+ if (data.speech_final) {
+ stopRecording();
+ }
+ };
+
+ socket.onclose = () => {
+ // Clean up stream
+ stream.getTracks().forEach((track) => track.stop());
+ if (isRecording) {
+ stopRecording(); // Ensure cleanup
+ }
+ };
+
+ socket.onerror = (err) => {
+ console.error('WebSocket error:', err);
+ stopRecording();
+ };
+ } catch (error) {
+ console.error('Error starting recording:', error);
+ setIsRecording(false);
+ }
+ };
+
+ const handleToggleRecord = () => {
+ if (isRecording) {
+ stopRecording();
+ } else {
+ startRecording();
+ }
+ };
+
+ return (
+
+
+ {isRecording ? : }
+
+
+ );
+}
diff --git a/docs/steps/step-07.md b/docs/steps/step-07.md
index 8e7d279..791c5c1 100644
--- a/docs/steps/step-07.md
+++ b/docs/steps/step-07.md
@@ -1,202 +1,286 @@
-# **File: COMMIT\_06\_WRITE\_FLOW.md**
+# **File: COMMIT\_07\_CHAT.md**
-## **Commit 6: Core Write-Through Cache API**
+## **Commit 7: AI Interviewer: UI & Backend**
### **Objective**
-Implement the POST /api/nodes route. This is the core "write-through cache" logic, which is the architectural foundation of the application. It must:
+Build the conversational chat interface using the Vercel AI SDK. This includes:
-1. Authenticate the user via their SurrealDB JWT.
-2. Retrieve their ATproto access token (from the encrypted cookie).
-3. **Step 1 (Truth):** Publish the new node to their PDS using the com.ponderants.node lexicon.
-4. **Step 2 (Cache):** Generate a gemini-embedding-001 vector from the node's body.
-5. **Step 3 (Cache):** Write the node, its atp\_uri, and its embedding to our SurrealDB cache.
+1. A chat UI (client) that passes a persona in the request body.
+2. An API route (server) that receives the persona and injects it into the system prompt.
+3. **Crucially:** Using the AI SDK's tool or schema feature to force the AI to return a specific JSON object when it detects a "complete thought".18 This is far more reliable than prompt-based JSON instructions.
### **Implementation Specification**
-**1\. Create lib/db.ts**
+**1\. Define Structured Output Schema (lib/ai-schemas.ts)**
-Create a helper file at /lib/db.ts for connecting to SurrealDB:
+Create a file at /lib/ai-schemas.ts to define the Zod schema for the AI's structured output:
TypeScript
-import { Surreal } from 'surrealdb.js';
-
-const db \= new Surreal();
+import { z } from 'zod';
/\*\*
- \* Connects to the SurrealDB instance.
- \* @param {string} token \- The user's app-specific (SurrealDB) JWT.
+ \* This Zod schema defines the \*only\* structured output
+ \* we want the AI to be able to generate. We will pass
+ \* this to the Vercel AI SDK to guarantee the AI's output
+ \* conforms to this shape. \[18\]
\*/
-export async function connectToDB(token: string) {
- if (\!db.connected) {
- await db.connect(process.env.SURREALDB\_URL\!);
- }
-
- // Authenticate as the user for this request.
- // This enforces the row-level security (PERMISSIONS)
- // defined in the schema for all subsequent queries.
- await db.authenticate(token);
-
- return db;
-}
-
-**2\. Create lib/ai.ts**
-
-Create a helper file at /lib/ai.ts for AI operations:
-
-TypeScript
-
-import { GoogleGenerativeAI } from '@google/generative-ai';
-
-const genAI \= new GoogleGenerativeAI(process.env.GOOGLE\_API\_KEY\!);
-
-const embeddingModel \= genAI.getGenerativeModel({
- model: 'gemini-embedding-001',
+export const NodeSuggestionSchema \= z.object({
+ action: z.literal('suggest\_node'),
+ title: z
+ .string()
+ .describe('A concise, descriptive title for the thought node.'),
+ body: z
+ .string()
+ .describe('The full, well-structured content of the thought node.'),
});
-/\*\*
- \* Generates a vector embedding for a given text.
- \* @param text The text to embed.
- \* @returns A 1536-dimension vector (Array\).
- \*/
-export async function generateEmbedding(text: string): Promise\ {
- try {
- const result \= await embeddingModel.embedContent(text);
- return result.embedding.values;
- } catch (error) {
- console.error('Error generating embedding:', error);
- throw new Error('Failed to generate AI embedding.');
- }
-}
+export type NodeSuggestion \= z.infer\;
-**3\. Create Write API Route (app/api/nodes/route.ts)**
+**2\. Create Chat API Route (app/api/chat/route.ts)**
-Create the main API file at /app/api/nodes/route.ts:
+Create the file at /app/api/chat/route.ts:
TypeScript
-import { NextRequest, NextResponse } from 'next/server';
-import { cookies } from 'next/headers';
-import { AtpAgent, RichText } from '@atproto/api';
-import { connectToDB } from '@/lib/db';
-import { generateEmbedding } from '@/lib/ai';
+import {
+ streamText,
+ StreamTextResult,
+ createStreamableValue,
+} from '@ai-sdk/react';
+import { google } from '@ai-sdk/google';
+import { createOpenAI } from '@ai-sdk/openai';
+import { NextRequest } from 'next/server';
+import { NodeSuggestionSchema } from '@/lib/ai-schemas';
+import { z } from 'zod';
-export async function POST(request: NextRequest) {
- const surrealJwt \= cookies().get('ponderants-auth')?.value;
- const atpAccessToken \= cookies().get('atproto\_access\_token')?.value;
+// Note: Ensure GOOGLE\_API\_KEY is set in your.env.local
+const googleAi \= google('gemini-1.5-flash');
- if (\!surrealJwt ||\!atpAccessToken) {
- return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
- }
+export async function POST(req: NextRequest) {
+ const { messages, data } \= await req.json();
- let userDid: string;
- try {
- // Decode the JWT to get the DID for the SurrealDB query
- // In a real app, we'd verify it, but for now we just
- // pass it to connectToDB which authenticates with it.
- const { payload } \= jwt.decode(surrealJwt, { complete: true })\!;
- userDid \= (payload as { did: string }).did;
- } catch (e) {
- return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
- }
+ // Get the 'persona' from the custom 'data' (or 'body') object
+ const { persona } \= z
+ .object({
+ persona: z.string().optional().default('Socratic'),
+ })
+ .parse(data);
- const { title, body, links } \= (await request.json()) as {
- title: string;
- body: string;
- links: string; // Array of at-uri strings
- };
+ // Dynamically create the system prompt
+ const systemPrompt \= \`You are a ${persona} thought partner.
+Your goal is to interview the user to help them explore and structure their ideas.
+When you identify a complete, self-contained idea, you MUST use the 'suggest\_node' tool
+to propose it as a new "thought node". Do not suggest a node until the
+idea is fully formed.
+For all other conversation, just respond as a helpful AI.\`;
- if (\!title ||\!body) {
- return NextResponse.json({ error: 'Title and body are required' }, { status: 400 });
- }
-
- const createdAt \= new Date().toISOString();
-
- // \--- Step 1: Write to Source of Truth (ATproto) \---
- let atp\_uri: string;
- let atp\_cid: string;
-
- try {
- const agent \= new AtpAgent({ service: 'https://bsky.social' }); // The service URL may need to be dynamic
- await agent.resumeSession({ accessJwt: atpAccessToken, did: userDid, handle: '' }); // Simplified resume
-
- // Format the body as RichText
- const rt \= new RichText({ text: body });
- await rt.detectFacets(agent); // Detect links, mentions
-
- const response \= await agent.post({
- $type: 'com.ponderants.node',
- repo: userDid,
- collection: 'com.ponderants.node',
- record: {
- title,
- body: rt.text,
- facets: rt.facets, // Include facets for rich text
- links: links?.map(uri \=\> ({ $link: uri })) ||, // Convert URIs to strong refs
- createdAt,
- },
- });
+ // Use the Vercel AI SDK's streamText function
+ const result: StreamTextResult \= await streamText({
+ model: googleAi,
+ system: systemPrompt,
+ messages: messages,
- atp\_uri \= response.uri;
- atp\_cid \= response.cid;
+ // This is the critical part:
+ // We provide the schema as a 'tool' to the model. \[20\]
+ tools: {
+ suggest\_node: {
+ description: 'Suggest a new thought node when an idea is complete.',
+ schema: NodeSuggestionSchema,
+ },
+ },
+ });
+
+ // Return the streaming response
+ return result.toAIStreamResponse();
+}
- } catch (error) {
- console.error('ATproto write error:', error);
- return NextResponse.json({ error: 'Failed to publish to PDS' }, { status: 500 });
- }
+**3\. Create Chat UI (app/chat/page.tsx)**
- // \--- Step 2: Generate AI Embedding (Cache) \---
- let embedding: number;
- try {
- embedding \= await generateEmbedding(title \+ '\\n' \+ body);
- } catch (error) {
- console.error('Embedding error:', error);
- return NextResponse.json({ error: 'Failed to generate embedding' }, { status: 500 });
- }
+Create the file at /app/chat/page.tsx:
- // \--- Step 3: Write to App View Cache (SurrealDB) \---
- try {
- const db \= await connectToDB(surrealJwt);
+TypeScript
- // Create the node record in our cache.
- // The \`user\_did\` field is set, satisfying the 'PERMISSIONS'
- // clause defined in the schema.
- const newNode \= await db.create('node', {
- user\_did: userDid,
- atp\_uri: atp\_uri,
- title: title,
- body: body, // Store the raw text body
- embedding: embedding,
- // coords\_3d will be calculated later
- });
+'use client';
- // Handle linking
- if (links && links.length \> 0) {
- // Find the corresponding cache nodes for the AT-URIs
- const targetNodes: { id: string } \= await db.query(
- 'SELECT id FROM node WHERE user\_did \= $did AND atp\_uri IN $links',
- { did: userDid, links: links }
- );
-
- // Create graph relations
- for (const targetNode of targetNodes) {
- await db.query('RELATE $from-\>links\_to-\>$to', {
- from: (newNode as any).id,
- to: targetNode.id,
+import { useChat } from '@ai-sdk/react';
+import {
+ Stack,
+ TextInput,
+ Button,
+ Paper,
+ ScrollArea,
+ Title,
+ Container,
+ Group,
+ Text,
+ LoadingOverlay,
+} from '@mantine/core';
+import { useRouter } from 'next/navigation';
+import { useEffect, useRef } from 'react';
+import { NodeSuggestion } from '@/lib/ai-schemas';
+
+export default function ChatPage() {
+ const router \= useRouter();
+ const viewport \= useRef\(null);
+
+ const {
+ messages,
+ input,
+ handleInputChange,
+ handleSubmit,
+ data,
+ isLoading,
+ } \= useChat({
+ // Send the persona in the 'data' (formerly 'body') property \[21\]
+ data: {
+ persona: 'Socratic', // This could be a \ value
+ },
+ // The 'experimental\_onToolCall' handler is fired when
+ // the AI returns the structured JSON 'suggest\_node' tool.
+ experimental\_onToolCall: (toolCall, appendToolResult) \=\> {
+ if (toolCall.toolName \=== 'suggest\_node') {
+ const { title, body } \= toolCall.args as NodeSuggestion;
+
+ // Redirect to the editor with the AI-generated draft
+ const query \= new URLSearchParams({ title, body }).toString();
+ router.push(\`/editor/new?${query}\`);
+
+ // Return a message to display in the chat
+ return appendToolResult({
+ toolName: 'suggest\_node',
+ args: { title, body },
+ result: \`Drafting node: "${title}"\`,
});
}
- }
-
- return NextResponse.json(newNode);
+ },
+ });
- } catch (error) {
- console.error('SurrealDB write error:', error);
- // TODO: Implement rollback for the ATproto post?
- return NextResponse.json({ error: 'Failed to save to app cache' }, { status: 500 });
- }
+ // Auto-scroll to bottom
+ useEffect(() \=\> {
+ viewport.current?.scrollTo({
+ top: viewport.current.scrollHeight,
+ behavior: 'smooth',
+ });
+ }, \[messages\]);
+
+ return (
+ \
+ \
+ Ponderants Interview
+ \
+
+ \
+ \
+ {messages.map((m) \=\> (
+ \
+ \{m.role \=== 'user'? 'You' : 'AI'}\
+ \{m.content}\
+ \
+ ))}
+ \
+ \
+
+ \
+ \
+ );
}
### **Test Specification**
-This is an API-only commit. It will be tested via the end-to-end flow in **Commit 10 (Linking)**, which will provide the UI (the "Publish" button) to trigger this route.
+**1\. Create Test File (tests/magnitude/07-chat.mag.ts)**
+
+Create a file at /tests/magnitude/07-chat.mag.ts:
+
+TypeScript
+
+import { test } from 'magnitude-test';
+
+test('\[Happy Path\] User can chat with AI', async (agent) \=\> {
+ // Act: Go to chat page
+ await agent.act('Navigate to /chat');
+
+ // Check: Ensure the initial state is correct
+ await agent.check('The title "Ponderants Interview" is visible');
+ await agent.check('The chat input field is empty');
+
+ // Act: Send a message
+ await agent.act(
+ 'Enter "I have an idea about decentralized social media" into the chat input'
+ );
+ await agent.act('Click the "Send" button');
+
+ // Check: User's message appears
+ await agent.check(
+ 'The message "I have an idea about decentralized social media" appears in the chat list'
+ );
+
+ // Check: AI response appears (mocked)
+ // We mock the /api/chat response to return a simple text stream
+ await agent.check(
+ 'A new message from "AI" appears in the chat list with a response'
+ );
+});
+
+test(' AI can trigger a node suggestion', async (agent) \=\> {
+ // Act: Go to chat page
+ await agent.act('Navigate to /chat');
+
+ // Act: Send a message that should trigger a node
+ await agent.act(
+ 'Enter "I think I have a fully formed thought: ATproto is the future of the internet because it separates data from the application." into the chat input'
+ );
+
+ // We mock the /api/chat response to return the 'suggest\_node' tool call
+ // with specific 'title' and 'body' arguments.
+ await agent.act('Click the "Send" button');
+
+ // Check: The 'experimental\_onToolCall' handler should fire
+ // and redirect the user to the editor.
+ await agent.check(
+ 'The browser URL is now "http://localhost:3000/editor/new"'
+ );
+
+ // Check: The editor page is pre-filled with the AI-generated content
+ await agent.check(
+ 'The page URL contains the query parameter "title=ATproto: The Future of the Internet"'
+ );
+ await agent.check(
+ 'The page URL contains the query parameter "body=ATproto is the future..."'
+ );
+});
+
diff --git a/package.json b/package.json
index eca20ac..ca190e7 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"@mantine/hooks": "latest",
"@react-three/drei": "latest",
"@react-three/fiber": "latest",
+ "@tabler/icons-react": "^3.35.0",
"ai": "latest",
"jsonwebtoken": "latest",
"next": "latest",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b20b706..3f43970 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -38,6 +38,9 @@ importers:
'@react-three/fiber':
specifier: latest
version: 9.4.0(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.181.0)
+ '@tabler/icons-react':
+ specifier: ^3.35.0
+ version: 3.35.0(react@19.2.0)
ai:
specifier: latest
version: 5.0.89(zod@4.1.12)
@@ -969,6 +972,14 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
+ '@tabler/icons-react@3.35.0':
+ resolution: {integrity: sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==}
+ peerDependencies:
+ react: '>= 16'
+
+ '@tabler/icons@3.35.0':
+ resolution: {integrity: sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==}
+
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
@@ -4103,6 +4114,13 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@tabler/icons-react@3.35.0(react@19.2.0)':
+ dependencies:
+ '@tabler/icons': 3.35.0
+ react: 19.2.0
+
+ '@tabler/icons@3.35.0': {}
+
'@tweenjs/tween.js@23.1.3': {}
'@tybys/wasm-util@0.10.1':
diff --git a/tests/magnitude/09-voice.mag.ts b/tests/magnitude/09-voice.mag.ts
new file mode 100644
index 0000000..3bd20c7
--- /dev/null
+++ b/tests/magnitude/09-voice.mag.ts
@@ -0,0 +1,40 @@
+import { test } from 'magnitude-test';
+
+test('[Happy Path] User can record voice and see transcript', async (agent) => {
+ // Act: Go to chat page
+ await agent.act('Navigate to /chat');
+
+ // Check: Verify initial state
+ await agent.check('The chat input field is empty');
+ await agent.check('A "Start Recording" button is visible');
+
+ // Act: Click the record button
+ // Note: This will require mocking the /api/voice-token response and the
+ // MediaDevices/WebSocket browser APIs in a real test environment
+ await agent.act('Click the "Start Recording" button');
+
+ // Check: UI updates to recording state
+ await agent.check('A "Stop Recording" button is visible');
+
+ // Act: Simulate receiving a transcript from the (mocked) Deepgram WebSocket
+ await agent.act(
+ 'Simulate an interim transcript "Hello world" from the Deepgram WebSocket'
+ );
+
+ // Check: The input field is updated
+ await agent.check('The chat input field contains "Hello world"');
+
+ // Act: Simulate a final transcript
+ await agent.act(
+ 'Simulate a final transcript "Hello world." from the Deepgram WebSocket'
+ );
+
+ // Check: The "Stop Recording" button is gone
+ await agent.check('A "Start Recording" button is visible again');
+
+ // Check: The chat input is cleared (because it was submitted)
+ await agent.check('The chat input field is empty');
+
+ // Check: The finalized transcript appears as a user message
+ await agent.check('The message "Hello world." appears in the chat list');
+});