# PiAgentUI STT Realtime Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add backend-proxied realtime speech-to-text so microphone dictation appears live in the message composer and remains editable after stop.
**Architecture:** Extend the existing STT settings/types with realtime endpoint fields, add a dedicated `/api/stt/realtime` WebSocket proxy in the extension server, and update the voice hook/InputBox composition model to merge partial/final transcripts without duplicate text. Preserve the current file-upload transcription path for `mode: 'file'`.
**Tech Stack:** React 19, TypeScript, Vitest/jsdom, Node `http`, `ws`, `MediaRecorder`, PiAgentUI SQLite settings store.
---
## File map
- Modify `src/types/api/stt.ts`: add realtime settings and realtime event types shared by frontend.
- Modify `extensions/piagentui-settings-store.ts`: persist optional `realtimeEndpoint` and `realtimeModel` through the existing JSON settings namespace.
- Modify `extensions/piagentui-server.ts`: sanitize realtime settings, validate updates, route `/api/stt/realtime` in WebSocket upgrade handling, and proxy client/provider events.
- Modify `extensions/piagentui-server.test.ts`: add focused backend realtime tests.
- Modify `src/api/stt.ts`: add a helper to build an authenticated realtime WebSocket URL.
- Modify `src/api/stt.test.ts`: test realtime WebSocket URL generation with token auth.
- Modify `src/hooks/useVoiceTranscription.ts`: support `mode: 'realtime'`, websocket lifecycle, chunk sending, partial/final callbacks.
- Modify `src/hooks/useVoiceTranscription.test.tsx`: test realtime hook behavior with mocked WebSocket and MediaRecorder.
- Modify `src/features/chat/InputBox.tsx`: add live dictation composition refs/callbacks and pass them to `InputToolbar`.
- Modify `src/features/chat/InputBox.test.tsx`: test partial replacement, final consolidation, append, and replace behavior.
- Modify `src/features/chat/input/InputToolbar.tsx`: pass realtime delta callback to `VoiceInputButton`.
- Modify `src/features/chat/input/VoiceInputButton.tsx`: pass realtime delta callback to hook and expose busy/error states without changing visual structure.
- Modify `src/features/chat/input/VoiceInputButton.test.tsx`: ensure the hook receives both final and delta callbacks.
- Modify `src/features/settings/components/SttSettings.tsx`: expose `Mode`, `Realtime endpoint`, and `Realtime model` controls.
- Modify `src/features/settings/components/SttSettings.test.tsx`: verify realtime settings can be displayed and saved.
## Task 1: Extend STT settings and frontend URL helper
**Files:**
- Modify: `src/types/api/stt.ts`
- Modify: `src/api/stt.ts`
- Test: `src/api/stt.test.ts`
- [x] **Step 1: Write failing API/types test**
Add to `src/api/stt.test.ts`:
```ts
it('builds an authenticated realtime STT websocket URL', () => {
const url = getSttRealtimeUrl()
expect(url).toBe('ws://127.0.0.1:58785/api/stt/realtime?token=token-123')
})
```
Update the import:
```ts
import { getSttRealtimeUrl, getSttSettings, transcribeAudio, updateSttSettings } from './stt'
```
- [x] **Step 2: Run test to verify RED**
Run:
```bash
npm run test:run -- src/api/stt.test.ts
```
Expected: FAIL because `getSttRealtimeUrl` is not exported.
- [x] **Step 3: Implement minimal types and helper**
In `src/types/api/stt.ts`, extend `SttSettings` and `UpdateSttSettingsRequest`:
```ts
realtimeEndpoint: string
realtimeModel: string
```
```ts
realtimeEndpoint?: string
realtimeModel?: string
```
Add event types:
```ts
export type SttRealtimeDelta =
| { type: 'transcript.partial'; text: string }
| { type: 'transcript.final'; text: string }
| { type: 'error'; error: string }
| { type: 'ready' }
| { type: 'closed' }
```
In `src/api/stt.ts`, add:
```ts
export function getSttRealtimeUrl(): string {
const baseUrl = serverStore
.getActiveBaseUrl()
.replace(/^http:/, 'ws:')
.replace(/^https:/, 'wss:')
const token = serverStore.getActiveToken()
const url = new URL('/api/stt/realtime', baseUrl)
if (token) url.searchParams.set('token', token)
return url.toString()
}
```
- [x] **Step 4: Run test to verify GREEN**
Run:
```bash
npm run test:run -- src/api/stt.test.ts
```
Expected: PASS.
## Task 2: Persist and sanitize realtime STT settings
**Files:**
- Modify: `extensions/piagentui-settings-store.ts`
- Modify: `extensions/piagentui-server.ts`
- Modify: `src/features/settings/components/SttSettings.tsx`
- Test: `extensions/piagentui-server.test.ts`
- Test: `src/features/settings/components/SttSettings.test.tsx`
- [ ] **Step 1: Write failing settings tests**
In `src/features/settings/components/SttSettings.test.tsx`, extend `SETTINGS`:
```ts
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
```
Add test:
```ts
it('edits realtime transcription settings', async () => {
render()
fireEvent.change(await screen.findByLabelText('Mode'), { target: { value: 'realtime' } })
fireEvent.change(screen.getByLabelText('Realtime endpoint'), {
target: { value: '/v1/realtime/transcriptions' },
})
fireEvent.change(screen.getByLabelText('Realtime model'), {
target: { value: 'gpt-4o-realtime-preview' },
})
fireEvent.click(screen.getByRole('button', { name: 'Save transcription settings' }))
await waitFor(() =>
expect(updateSttSettings).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'realtime',
realtimeEndpoint: '/v1/realtime/transcriptions',
realtimeModel: 'gpt-4o-realtime-preview',
}),
),
)
})
```
In `extensions/piagentui-server.test.ts`, inside the existing STT settings section, add expectations after saving STT:
```ts
expect(savedSttSettings).toMatchObject({
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
})
expect(savedSttSettings.apiKey).toBeUndefined()
```
- [ ] **Step 2: Run tests to verify RED**
Run:
```bash
npm run test:run -- src/features/settings/components/SttSettings.test.tsx extensions/piagentui-server.test.ts
```
Expected: FAIL because mode/realtime fields are not rendered/sanitized yet.
- [ ] **Step 3: Implement settings fields**
In `extensions/piagentui-settings-store.ts`, add optional fields to `StoredSttSettings`:
```ts
realtimeEndpoint?: string
realtimeModel?: string
```
In `extensions/piagentui-server.ts`, update defaults in `getEffectiveSttSettings()`:
```ts
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
```
Update `sanitizeSttSettings()`:
```ts
realtimeEndpoint: effective.realtimeEndpoint || '/realtime/transcriptions',
realtimeModel: effective.realtimeModel || effective.transcriptionModel || 'gpt-4o-mini-transcribe',
```
Update `updateSttSettings(update)` validation:
```ts
if (update.realtimeEndpoint !== undefined) {
if (typeof update.realtimeEndpoint !== 'string' || !update.realtimeEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid realtimeEndpoint' }
}
next.realtimeEndpoint = update.realtimeEndpoint
}
if (update.realtimeModel !== undefined) {
if (typeof update.realtimeModel !== 'string' || !update.realtimeModel.trim()) {
return { ok: false, error: 'Invalid realtimeModel' }
}
next.realtimeModel = update.realtimeModel.trim()
}
```
In `src/features/settings/components/SttSettings.tsx`, include in `saveSettings()` payload:
```ts
realtimeEndpoint: settings.realtimeEndpoint,
realtimeModel: settings.realtimeModel,
```
Add controls in the grid:
```tsx
;
{
settings.mode === 'realtime' && (
<>
>
)
}
```
Also import `SttMode`.
- [ ] **Step 4: Run tests to verify GREEN**
Run:
```bash
npm run test:run -- src/features/settings/components/SttSettings.test.tsx extensions/piagentui-server.test.ts
```
Expected: PASS.
## Task 3: Backend realtime WebSocket proxy
**Files:**
- Modify: `extensions/piagentui-server.ts`
- Test: `extensions/piagentui-server.test.ts`
- [ ] **Step 1: Write failing backend realtime test**
Add a test to `extensions/piagentui-server.test.ts` near existing STT tests. The test should start the server, save realtime settings, mock provider WebSocket, connect to `/api/stt/realtime?token=${token}`, and verify normalized events. Use this shape:
```ts
it('proxies realtime STT websocket events without exposing the API key', async () => {
const saveSttRes = await fetch(`${baseUrl}/api/settings/stt`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
providerKind: 'openai-compatible',
mode: 'realtime',
baseUrl: 'https://api.openai.test/v1',
apiKeyRequired: false,
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-realtime-preview',
}),
})
expect(saveSttRes.status).toBe(200)
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/stt/realtime?token=${token}`)
const messages: any[] = []
ws.on('message', data => messages.push(JSON.parse(String(data))))
await waitFor(() => expect(messages.some(message => message.type === 'ready')).toBe(true))
ws.send(JSON.stringify({ type: 'audio.append', audio: Buffer.from('audio').toString('base64') }))
ws.send(JSON.stringify({ type: 'audio.commit' }))
await waitFor(() => expect(messages.some(message => message.type === 'transcript.final')).toBe(true))
expect(messages).toContainEqual({ type: 'transcript.final', text: 'olá realtime' })
ws.close()
})
```
If current tests do not expose a provider WebSocket mock, add a small local `WebSocketServer` test provider bound to an available port and set `baseUrl` to `http://127.0.0.1:/v1`.
- [ ] **Step 2: Run test to verify RED**
Run:
```bash
npm run test:run -- extensions/piagentui-server.test.ts
```
Expected: FAIL because `/api/stt/realtime` is not routed.
- [ ] **Step 3: Implement backend route and proxy helpers**
In `extensions/piagentui-server.ts`, add helper types near other STT helpers:
```ts
type SttRealtimeClientMessage =
| { type: 'audio.append'; audio: string }
| { type: 'audio.commit' }
| { type: 'session.close' }
```
Add URL helper:
```ts
function buildRealtimeProviderUrl(settings: StoredSttSettings): string {
const base = (settings.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '')
const endpoint = settings.realtimeEndpoint || '/realtime/transcriptions'
const wsBase = base.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
return `${wsBase}${endpoint}`
}
```
Add normalizer:
```ts
function normalizeRealtimeProviderMessage(raw: string): { type: string; text?: string; error?: string } | null {
let event: any
try {
event = JSON.parse(raw)
} catch {
return null
}
const partialText = event.text_delta || event.delta || event.partial || event.transcript?.partial
if (typeof partialText === 'string') return { type: 'transcript.partial', text: partialText }
const finalText = event.text || event.final || event.transcript?.text
if (typeof finalText === 'string') return { type: 'transcript.final', text: finalText }
const error = event.error?.message || event.error
if (typeof error === 'string') return { type: 'error', error }
return null
}
```
Add handler:
```ts
function handleSttRealtimeWebSocket(client: WebSocket, req: http.IncomingMessage) {
const auth = checkAuth(req)
if (!auth.ok) {
client.send(JSON.stringify({ type: 'error', error: 'Unauthorized' }))
client.close()
return
}
const settings = getEffectiveSttSettings()
if (!settings.enabled || settings.mode !== 'realtime') {
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT is not enabled' }))
client.close()
return
}
if (settings.apiKeyRequired && !settings.apiKey) {
client.send(JSON.stringify({ type: 'error', error: 'STT API key is not configured' }))
client.close()
return
}
if (!validateSttBaseUrl(settings.baseUrl)) {
client.send(JSON.stringify({ type: 'error', error: 'Invalid STT baseUrl' }))
client.close()
return
}
const headers: Record = {}
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`
const provider = new WebSocket(buildRealtimeProviderUrl(settings), { headers })
const closeBoth = () => {
if (provider.readyState === WebSocket.OPEN || provider.readyState === WebSocket.CONNECTING) provider.close()
if (client.readyState === WebSocket.OPEN) client.close()
}
provider.on('open', () => {
provider.send(
JSON.stringify({ type: 'session.configure', model: settings.realtimeModel || settings.transcriptionModel }),
)
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'ready' }))
})
provider.on('message', data => {
const normalized = normalizeRealtimeProviderMessage(String(data))
if (normalized && client.readyState === WebSocket.OPEN) client.send(JSON.stringify(normalized))
})
provider.on('error', () => {
if (client.readyState === WebSocket.OPEN)
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT provider failed' }))
})
provider.on('close', () => {
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'closed' }))
})
client.on('message', data => {
if (provider.readyState !== WebSocket.OPEN) return
let message: SttRealtimeClientMessage
try {
message = JSON.parse(String(data))
} catch {
client.send(JSON.stringify({ type: 'error', error: 'Invalid realtime message' }))
return
}
provider.send(JSON.stringify(message))
})
client.on('close', closeBoth)
client.on('error', closeBoth)
}
```
In `server.on('upgrade')`, route by pathname before generic connection handling:
```ts
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname
wss!.handleUpgrade(request, socket, head, (ws: WebSocket) => {
if (pathname === '/api/stt/realtime') {
handleSttRealtimeWebSocket(ws, request)
return
}
wss!.emit('connection', ws, request)
})
```
- [ ] **Step 4: Run backend test to verify GREEN**
Run:
```bash
npm run test:run -- extensions/piagentui-server.test.ts
```
Expected: PASS.
## Task 4: Realtime hook lifecycle
**Files:**
- Modify: `src/hooks/useVoiceTranscription.ts`
- Test: `src/hooks/useVoiceTranscription.test.tsx`
- [ ] **Step 1: Write failing realtime hook test**
Add mocked WebSocket class in `src/hooks/useVoiceTranscription.test.tsx`:
```ts
class MockWebSocket extends EventTarget {
static instances: MockWebSocket[] = []
readyState = WebSocket.CONNECTING
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: MessageEvent) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
constructor(public readonly url: string) {
super()
MockWebSocket.instances.push(this)
}
send(data: string) {
this.sent.push(data)
}
close() {
this.readyState = WebSocket.CLOSED
this.onclose?.()
}
open() {
this.readyState = WebSocket.OPEN
this.onopen?.()
}
receive(message: unknown) {
this.onmessage?.({ data: JSON.stringify(message) } as MessageEvent)
}
}
```
Add test:
```ts
it('streams realtime transcription deltas while recording', async () => {
vi.mocked(getSttSettings).mockResolvedValue({
enabled: true,
providerKind: 'openai-compatible',
mode: 'realtime',
baseUrl: 'http://127.0.0.1:8022/v1',
apiKeyConfigured: false,
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'model-file',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'model-realtime',
insertMode: 'append',
})
Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: MockWebSocket })
const onTranscription = vi.fn()
const onTranscriptionDelta = vi.fn()
const { result } = renderHook(() => useVoiceTranscription({ onTranscription, onTranscriptionDelta }))
await act(async () => {
await result.current.startRecording()
})
const ws = MockWebSocket.instances[0]
act(() => {
ws.open()
ws.receive({ type: 'ready' })
})
expect(result.current.state).toBe('recording')
act(() => ws.receive({ type: 'transcript.partial', text: 'olá' }))
expect(onTranscriptionDelta).toHaveBeenCalledWith({ type: 'transcript.partial', text: 'olá' }, 'append')
await act(async () => result.current.stopRecording())
expect(ws.sent.some(data => JSON.parse(data).type === 'audio.commit')).toBe(true)
})
```
- [ ] **Step 2: Run test to verify RED**
Run:
```bash
npm run test:run -- src/hooks/useVoiceTranscription.test.tsx
```
Expected: FAIL because hook does not accept `onTranscriptionDelta` or realtime mode.
- [ ] **Step 3: Implement realtime hook minimally**
In `src/hooks/useVoiceTranscription.ts`:
- import `getSttRealtimeUrl` and `SttRealtimeDelta`;
- extend options:
```ts
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
```
Add refs:
```ts
const wsRef = useRef(null)
const realtimeInsertModeRef = useRef('append')
```
In `startRecording`, after config validation, branch:
```ts
if (activeSettings.mode === 'realtime') {
await startRealtimeRecording(activeSettings)
return
}
```
Implement `startRealtimeRecording(activeSettings)`:
```ts
const startRealtimeRecording = useCallback(
async (activeSettings: SttSettings) => {
if (typeof WebSocket === 'undefined') {
setError('Realtime voice transcription is not supported in this browser')
setState('error')
return
}
realtimeInsertModeRef.current = activeSettings.insertMode
const ws = new WebSocket(getSttRealtimeUrl())
wsRef.current = ws
ws.onmessage = event => {
let message: SttRealtimeDelta
try {
message = JSON.parse(String(event.data))
} catch {
return
}
if (message.type === 'error') {
setError(message.error)
setState('error')
return
}
if (message.type === 'ready') return
onTranscriptionDelta?.(message, realtimeInsertModeRef.current)
}
ws.onerror = () => {
setError('Realtime voice transcription failed')
setState('error')
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const mimeType = MediaRecorder.isTypeSupported?.('audio/webm') ? 'audio/webm' : undefined
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
recorderRef.current = recorder
recorder.ondataavailable = async event => {
if (event.data.size <= 0 || ws.readyState !== WebSocket.OPEN) return
const audio = await event.data.arrayBuffer()
ws.send(JSON.stringify({ type: 'audio.append', audio: btoa(String.fromCharCode(...new Uint8Array(audio))) }))
}
recorder.start(500)
setState('recording')
},
[onTranscriptionDelta],
)
```
Update `stopRecording`:
```ts
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'audio.commit' }))
wsRef.current.close()
wsRef.current = null
cleanupStream()
setState('idle')
return
}
```
- [ ] **Step 4: Run hook tests to verify GREEN**
Run:
```bash
npm run test:run -- src/hooks/useVoiceTranscription.test.tsx
```
Expected: PASS.
## Task 5: InputBox live transcript composition
**Files:**
- Modify: `src/features/chat/InputBox.tsx`
- Modify: `src/features/chat/input/InputToolbar.tsx`
- Modify: `src/features/chat/input/VoiceInputButton.tsx`
- Test: `src/features/chat/InputBox.test.tsx`
- Test: `src/features/chat/input/VoiceInputButton.test.tsx`
- [ ] **Step 1: Write failing InputBox tests**
Update the `InputToolbar` mock in `src/features/chat/InputBox.test.tsx` so tests can invoke voice callbacks:
```ts
let latestVoiceDelta: ((event: { type: string; text?: string }, insertMode: SttInsertMode) => void) | undefined
vi.mock('./input/InputToolbar', () => ({
InputToolbar: ({ onSend, canSend, onVoiceTranscriptionDelta }: any) => {
latestVoiceDelta = onVoiceTranscriptionDelta
return (
)
},
}))
```
Add tests:
```ts
it('shows realtime voice partials without duplicating previous partial text', () => {
render()
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(textarea, { target: { value: 'Resumo:' } })
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá' }, 'append'))
expect(textarea.value).toBe('Resumo: olá')
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá mundo' }, 'append'))
expect(textarea.value).toBe('Resumo: olá mundo')
})
it('keeps final realtime transcription editable in the composer', () => {
render()
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: 'olá' }, 'replace'))
act(() => latestVoiceDelta?.({ type: 'transcript.final', text: 'olá mundo' }, 'replace'))
fireEvent.change(textarea, { target: { value: `${textarea.value}!` } })
expect(textarea.value).toBe('olá mundo!')
})
```
- [ ] **Step 2: Run tests to verify RED**
Run:
```bash
npm run test:run -- src/features/chat/InputBox.test.tsx src/features/chat/input/VoiceInputButton.test.tsx
```
Expected: FAIL because `onVoiceTranscriptionDelta` is not wired.
- [ ] **Step 3: Implement composition**
In `VoiceInputButton.tsx`:
```ts
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
interface VoiceInputButtonProps {
disabled?: boolean
onTranscription: (text: string, insertMode: SttInsertMode) => void
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
}
```
Pass to hook:
```ts
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({
onTranscription,
onTranscriptionDelta,
})
```
In `InputToolbar.tsx`, extend props and pass through:
```ts
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
```
```tsx
```
In `InputBox.tsx`, import `SttRealtimeDelta`, add refs:
```ts
const voiceDraftBaseRef = useRef(null)
const voiceDraftFinalRef = useRef('')
const voiceDraftPartialRef = useRef('')
```
Add helper:
```ts
const composeVoiceDraft = useCallback((insertMode: SttInsertMode) => {
const base = voiceDraftBaseRef.current ?? ''
const body = `${voiceDraftFinalRef.current}${voiceDraftPartialRef.current}`.trim()
if (!body) return base
if (insertMode === 'replace' || !base.trim()) return body
const needsSpace = !base.endsWith(' ') && !base.endsWith('\n')
return `${base}${needsSpace ? ' ' : ''}${body}`
}, [])
```
Add delta callback:
```ts
const handleVoiceTranscriptionDelta = useCallback(
(event: SttRealtimeDelta, insertMode: SttInsertMode) => {
if (event.type !== 'transcript.partial' && event.type !== 'transcript.final') return
if (voiceDraftBaseRef.current === null) {
voiceDraftBaseRef.current = insertMode === 'replace' ? '' : text
voiceDraftFinalRef.current = ''
voiceDraftPartialRef.current = ''
}
if (event.type === 'transcript.partial') {
voiceDraftPartialRef.current = event.text
} else {
voiceDraftFinalRef.current = `${voiceDraftFinalRef.current} ${event.text}`.trim()
voiceDraftPartialRef.current = ''
}
setText(composeVoiceDraft(insertMode))
requestAnimationFrame(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.focus()
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
})
},
[composeVoiceDraft, text],
)
```
Pass to toolbar:
```tsx
onVoiceTranscriptionDelta = { handleVoiceTranscriptionDelta }
```
In existing `handleVoiceTranscription`, clear voice refs before final file-mode insert:
```ts
voiceDraftBaseRef.current = null
voiceDraftFinalRef.current = ''
voiceDraftPartialRef.current = ''
```
- [ ] **Step 4: Run InputBox tests to verify GREEN**
Run:
```bash
npm run test:run -- src/features/chat/InputBox.test.tsx src/features/chat/input/VoiceInputButton.test.tsx
```
Expected: PASS.
## Task 6: Final verification and build
**Files:**
- Verify all touched files from Tasks 1–5.
- [ ] **Step 1: Format only touched files**
Run:
```bash
node node_modules/prettier/bin/prettier.cjs --write src/types/api/stt.ts src/api/stt.ts src/api/stt.test.ts src/hooks/useVoiceTranscription.ts src/hooks/useVoiceTranscription.test.tsx src/features/chat/InputBox.tsx src/features/chat/InputBox.test.tsx src/features/chat/input/InputToolbar.tsx src/features/chat/input/VoiceInputButton.tsx src/features/chat/input/VoiceInputButton.test.tsx src/features/settings/components/SttSettings.tsx src/features/settings/components/SttSettings.test.tsx extensions/piagentui-settings-store.ts extensions/piagentui-server.ts extensions/piagentui-server.test.ts
```
Expected: Prettier completes without errors.
- [ ] **Step 2: Run focused tests**
Run:
```bash
npm run test:run -- src/api/stt.test.ts src/hooks/useVoiceTranscription.test.tsx src/features/chat/InputBox.test.tsx src/features/chat/input/VoiceInputButton.test.tsx src/features/settings/components/SttSettings.test.tsx extensions/piagentui-server.test.ts
```
Expected: all selected test files pass.
- [ ] **Step 3: Run typechecks**
Run:
```bash
npm run typecheck
npm run typecheck:extensions
```
Expected: both commands exit 0.
- [ ] **Step 4: Build frontend dist**
Run:
```bash
npm run build
```
Expected: `tsc -b && vite build` exits 0 and updates `dist/`.
- [ ] **Step 5: Check whitespace**
Run:
```bash
git diff --check
```
Expected: no output.
- [ ] **Step 6: Runtime smoke test**
After restarting PiAgentUI:
1. Settings → Voice → enable STT.
2. Set `Mode` to `Realtime WebSocket`.
3. Configure a compatible realtime STT server.
4. Open a chat session.
5. Click microphone.
6. Speak a short sentence.
7. Confirm partial text appears in the message field before stopping.
8. Click stop.
9. Confirm the final text remains editable and is not sent automatically.
Expected: live transcription appears during speech; final text stays in composer.