This document outlines the implementation plan for Hive Phase 9, focusing on platform polish (Cmd+W override, PATH fix), session control (abort streaming, input persistence), UX affordances (copy on hover, file search), file tree completeness (hidden files), and streaming correctness (subagent routing, subtool loading).
The implementation is divided into 12 focused sessions, each with:
- Clear objectives
- Definition of done
- Testing criteria for verification
Phase 9 builds upon Phase 8 — all Phase 8 infrastructure is assumed to be in place.
test/
├── phase-9/
│ ├── session-1/
│ │ └── path-fix.test.ts
│ ├── session-2/
│ │ └── hidden-files.test.ts
│ ├── session-3/
│ │ └── cmd-w-override.test.ts
│ ├── session-4/
│ │ └── abort-streaming.test.ts
│ ├── session-5/
│ │ └── subagent-tagging.test.ts
│ ├── session-6/
│ │ └── subagent-renderer.test.ts
│ ├── session-7/
│ │ └── subtool-loading.test.ts
│ ├── session-8/
│ │ └── copy-on-hover.test.ts
│ ├── session-9/
│ │ └── input-persistence.test.ts
│ ├── session-10/
│ │ └── file-search-store.test.ts
│ ├── session-11/
│ │ └── file-search-dialog.test.ts
│ └── session-12/
│ └── integration-verification.test.ts
pnpm add fix-pathAll other features use existing packages: React, Zustand, Electron, lucide-react, cmdk, better-sqlite3, sonner.
- Install
fix-pathto inherit the user's full shell PATH when Electron is launched from Finder/Dock/Spotlight - Call it at app startup before any child process spawning
pnpm add fix-pathIn src/main/index.ts, add the import at the top of the file:
import fixPath from 'fix-path'Then call it as the very first thing inside app.whenReady(), before database init or IPC registration (before line 217):
app.whenReady().then(() => {
// Fix PATH for macOS when launched from Finder/Dock/Spotlight.
// Must run before any child process spawning (opencode, scripts).
fixPath()
log.info('App starting', { version: app.getVersion(), platform: process.platform })
// ... rest of existing initialization
})Confirm that opencode-service.ts line 72 (env: { ...process.env }) and script-runner.ts (lines 30–34, 180, 220, 295) spread process.env — they will automatically pick up the patched PATH. No changes needed in these files.
package.json— addfix-pathdependencysrc/main/index.ts— import and callfixPath()
-
fix-pathis listed inpackage.jsondependencies -
fixPath()is called at the top ofapp.whenReady()before any service initialization - When launched from Finder,
process.env.PATHincludes Homebrew (/opt/homebrew/bin) and other user paths -
opencode servestarts successfully when app is launched from Dock -
pnpm lintpasses -
pnpm testpasses
- Build the app:
pnpm build && pnpm build:mac - Launch the built app from Finder (not from terminal)
- Select a worktree and open/create a session
- Verify the session connects to OpenCode (no "opencode not found" error)
- Run a script via the script runner — verify it can access Homebrew/nvm binaries
Session 2: Hidden Files in File Tree
- Remove the blanket dotfile filter so hidden files like
.env,.gitignore,.vscode/appear in the file tree - Keep
.gitand.DS_Storeexcluded via the existingIGNORE_DIRSandIGNORE_FILESsets
In src/main/ipc/file-tree-handlers.ts, remove lines 95–98:
// REMOVE these lines:
// Skip hidden files/folders (starting with .) except important ones
if (entry.name.startsWith('.') && ![''].includes(entry.name)) {
continue
}The existing guards on lines 88–93 already handle .git (in IGNORE_DIRS) and .DS_Store (in IGNORE_FILES).
In the same file, remove lines 155–157:
// REMOVE these lines:
if (entry.name.startsWith('.')) {
continue
}src/main/ipc/file-tree-handlers.ts— remove twocontinueblocks
-
scanDirectory()no longer skips entries starting with. -
scanSingleDirectory()no longer skips entries starting with. -
.git/remains hidden (excluded byIGNORE_DIRS) -
.DS_Storeremains hidden (excluded byIGNORE_FILES) -
.env,.gitignore,.prettierrc,.eslintrc,.github/,.vscode/appear in the file tree - File tree sort order is correct (directories first, then files, alphabetically)
-
pnpm lintpasses -
pnpm testpasses
- Start the app, select a worktree that contains dotfiles (
.env,.gitignore, etc.) - Open the file tree sidebar
- Verify dotfiles and dot-directories are visible
- Verify
.git/is NOT shown - Verify
.DS_Storeis NOT shown - Expand a dot-directory (e.g.,
.github/) — verify its children load correctly - Click a dotfile (e.g.,
.env) — verify it opens in the file preview
// test/phase-9/session-2/hidden-files.test.ts
describe('Session 2: Hidden Files', () => {
test('scanDirectory includes dotfiles', async () => {
// Create a temp directory with .env, .gitignore, .vscode/, .git/, .DS_Store
// Call scanDirectory
// Verify .env and .gitignore are in results
// Verify .vscode/ is in results
// Verify .git/ is NOT in results (IGNORE_DIRS)
// Verify .DS_Store is NOT in results (IGNORE_FILES)
})
test('scanSingleDirectory includes dotfiles', async () => {
// Same as above but for scanSingleDirectory
})
test('dotfiles are sorted correctly', async () => {
// Verify directories come first, then files
// Verify alphabetical within each group
})
})- Intercept Cmd+W at the Electron main process level to prevent window closure
- Forward it to the renderer to close the active session tab (or no-op)
- Replace
{ role: 'fileMenu' }with a custom File menu that omits the native Close Window accelerator
In src/main/index.ts, extend the existing before-input-event handler (lines 125–136) to also intercept Cmd+W:
mainWindow.webContents.on('before-input-event', (event, input) => {
// Existing Cmd+T interception (lines 126-135)...
// Intercept Cmd+W — never close the window
if (
input.key.toLowerCase() === 'w' &&
(input.meta || input.control) &&
!input.alt &&
!input.shift &&
input.type === 'keyDown'
) {
event.preventDefault()
mainWindow!.webContents.send('shortcut:close-session')
}
})In src/main/index.ts (line 249), replace:
// BEFORE:
{ role: 'fileMenu' },
// AFTER:
{
label: 'File',
submenu: [
{
label: 'New Session',
accelerator: 'CmdOrCtrl+T',
click: () => { mainWindow?.webContents.send('shortcut:new-session') }
},
{
label: 'Close Tab',
accelerator: 'CmdOrCtrl+W',
click: () => { mainWindow?.webContents.send('shortcut:close-session') }
},
{ type: 'separator' },
{ role: 'quit' }
]
},In src/preload/index.ts, add to the systemOps namespace:
onCloseSessionShortcut: (callback: () => void) => {
const handler = (): void => {
callback()
}
ipcRenderer.on('shortcut:close-session', handler)
return () => {
ipcRenderer.removeListener('shortcut:close-session', handler)
}
}In src/preload/index.d.ts, add to the systemOps interface:
onCloseSessionShortcut: (callback: () => void) => () => voidIn src/renderer/src/hooks/useKeyboardShortcuts.ts, add a useEffect to listen for the main-process Cmd+W forwarding:
useEffect(() => {
if (!window.systemOps?.onCloseSessionShortcut) return
const cleanup = window.systemOps.onCloseSessionShortcut(() => {
const { activeSessionId } = useSessionStore.getState()
if (!activeSessionId) return // no-op if no session open
useSessionStore
.getState()
.closeSession(activeSessionId)
.then((result) => {
if (result.success) {
toast.success('Session closed')
} else {
toast.error(result.error || 'Failed to close session')
}
})
})
return cleanup
}, [])In useKeyboardShortcuts.ts (line 133), change:
// BEFORE:
allowInInput: false,
// AFTER:
allowInInput: true,src/main/index.ts—before-input-eventhandler, custom File menusrc/preload/index.ts—onCloseSessionShortcutsrc/preload/index.d.ts— type declarationsrc/renderer/src/hooks/useKeyboardShortcuts.ts— IPC listener,allowInInput
- Cmd+W never closes the Electron window
- Cmd+W closes the active session tab if one is open
- Cmd+W is a silent no-op when no session is open (no toast, no error)
- Cmd+W works when the textarea is focused
- The File menu shows "Close Tab" with Cmd+W accelerator, not "Close Window"
- Cmd+Q still quits the app
-
pnpm lintpasses -
pnpm testpasses
- Start the app with a session open
- Press Cmd+W — verify the session tab closes, NOT the window
- With no sessions open, press Cmd+W — verify nothing happens (no-op)
- Focus the textarea, press Cmd+W — verify it still closes the session
- Press Cmd+Q — verify the app quits normally
- Check the File menu — verify "Close Tab" (Cmd+W) is listed, not "Close Window"
// test/phase-9/session-3/cmd-w-override.test.ts
describe('Session 3: Cmd+W Override', () => {
test('Cmd+W keyDown sends close-session IPC', () => {
// Simulate before-input-event with meta=true, key='w', type='keyDown'
// Verify event.preventDefault called
// Verify webContents.send called with 'shortcut:close-session'
})
test('Cmd+W keyUp does NOT trigger', () => {
// type='keyUp'
// Verify NOT intercepted
})
test('renderer closes active session on IPC', () => {
// Mock activeSessionId = 'abc'
// Trigger the IPC callback
// Verify closeSession('abc') called
})
test('renderer no-ops when no active session', () => {
// Mock activeSessionId = null
// Trigger the IPC callback
// Verify closeSession NOT called, no toast
})
})- Wire up the OpenCode SDK's
session.abort()through the full IPC chain - Replace the send button with a stop button when streaming with empty input
- Handle
MessageAbortedErrorgracefully
In src/main/services/opencode-service.ts, add a public method:
async abort(worktreePath: string, opencodeSessionId: string): Promise<boolean> {
const instance = this.instances.get(worktreePath)
if (!instance?.client) {
throw new Error('No OpenCode instance for worktree')
}
const result = await instance.client.session.abort({
path: { id: opencodeSessionId },
query: { directory: worktreePath }
})
return result.data === true
}In src/main/ipc/opencode-handlers.ts, add before the closing log.info:
ipcMain.handle(
'opencode:abort',
async (_event, worktreePath: string, opencodeSessionId: string) => {
log.info('IPC: opencode:abort', { worktreePath, opencodeSessionId })
try {
const result = await openCodeService.abort(worktreePath, opencodeSessionId)
return { success: result }
} catch (error) {
log.error('IPC: opencode:abort failed', { error })
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
)In src/preload/index.ts, add to the opencodeOps namespace:
abort: (worktreePath: string, opencodeSessionId: string) =>
ipcRenderer.invoke('opencode:abort', worktreePath, opencodeSessionId)In src/preload/index.d.ts, add to opencodeOps:
// Abort a streaming session
abort: (worktreePath: string, opencodeSessionId: string) =>
Promise<{ success: boolean; error?: string }>In src/renderer/src/components/sessions/SessionView.tsx, add Square to the lucide-react imports. Add a handleAbort callback:
const handleAbort = useCallback(async () => {
if (!worktreePath || !opencodeSessionId) return
await window.opencodeOps.abort(worktreePath, opencodeSessionId)
}, [worktreePath, opencodeSessionId])Replace the send button JSX (lines 1618–1632) with conditional rendering:
{isStreaming && !inputValue.trim() ? (
<Button
onClick={handleAbort}
size="sm"
variant="destructive"
className="h-7 w-7 p-0"
aria-label="Stop streaming"
title="Stop streaming"
data-testid="stop-button"
>
<Square className="h-3 w-3" />
</Button>
) : (
<Button
onClick={handleSend}
disabled={!inputValue.trim()}
size="sm"
className="h-7 w-7 p-0"
aria-label={isStreaming ? 'Queue message' : 'Send message'}
title={isStreaming ? 'Queue message' : 'Send message'}
data-testid="send-button"
>
{isStreaming ? (
<ListPlus className="h-3.5 w-3.5" />
) : (
<Send className="h-3.5 w-3.5" />
)}
</Button>
)}In the stream handler, when processing events after an abort, the SDK may send an error event with name: "MessageAbortedError". In the existing error handling, suppress toasts for this error type. The session.idle event that follows will finalize normally, preserving the partial response.
src/main/services/opencode-service.ts—abort()methodsrc/main/ipc/opencode-handlers.ts—opencode:aborthandlersrc/preload/index.ts—abortmethodsrc/preload/index.d.ts— type declarationsrc/renderer/src/components/sessions/SessionView.tsx— stop button,handleAbort
-
OpenCodeService.abort()callsclient.session.abort()with correct params -
opencode:abortIPC handler registered and returns{ success: boolean } - Preload exposes
window.opencodeOps.abort() - When streaming and input is empty: stop button (red square icon) shown instead of send
- When streaming and input has text: queue button (ListPlus) shown (existing behavior)
- When not streaming: send button shown (existing behavior)
- Clicking stop calls abort and streaming halts
- Partial response is preserved after abort
- No error toast on abort (user-initiated)
-
pnpm lintpasses -
pnpm testpasses
- Send a message to trigger a long response
- While streaming, with input field empty, verify the button shows a red square (stop icon)
- Click the stop button — verify streaming stops, partial response remains visible
- Type text while streaming — verify the button changes to queue icon (ListPlus)
- Clear the text — verify it changes back to stop
- After abort, send a new message — verify normal behavior resumes
// test/phase-9/session-4/abort-streaming.test.ts
describe('Session 4: Abort Streaming', () => {
test('stop button shown when streaming and input empty', () => {
// Render with isStreaming=true, inputValue=''
// Verify stop-button testid present, send-button absent
})
test('queue button shown when streaming and input has text', () => {
// Render with isStreaming=true, inputValue='hello'
// Verify send-button testid present with ListPlus icon
})
test('send button shown when not streaming', () => {
// Render with isStreaming=false
// Verify send-button testid present with Send icon
})
test('handleAbort calls window.opencodeOps.abort', async () => {
// Mock window.opencodeOps.abort
// Click stop button
// Verify abort called with correct worktreePath and sessionId
})
})- Tag stream events from child/subagent sessions with a
childSessionIdfield - Guard
maybeNotifySessionComplete()to only fire for the parent session's ownsession.idle - Prevent child events from being persisted as top-level parent messages
In src/main/services/opencode-service.ts, in the handleEvent() method (around line 987), after resolving the hive session ID, track whether this event came from a child session:
// After line 999 (hiveSessionId resolved):
const directHiveId = this.getMappedHiveSessionId(instance, sessionId, eventDirectory)
const isChildEvent = !directHiveId && !!hiveSessionIdThe logic: if getMappedHiveSessionId returned nothing for the raw sessionId but we got a hiveSessionId through resolveParentSession, this is a child event.
Replace lines 1003–1008:
// BEFORE:
if (eventType === 'session.idle') {
log.info('Forwarding session.idle to renderer', { ... })
this.maybeNotifySessionComplete(hiveSessionId)
}
// AFTER:
if (eventType === 'session.idle') {
log.info('Forwarding session.idle to renderer', {
opencodeSessionId: sessionId,
hiveSessionId,
isChildEvent
})
if (!isChildEvent) {
this.maybeNotifySessionComplete(hiveSessionId)
}
}Modify the StreamEvent construction (lines 1015–1019):
const streamEvent: StreamEvent = {
type: eventType,
sessionId: hiveSessionId,
data: event.properties || event,
...(isChildEvent ? { childSessionId: sessionId } : {})
}Before line 1012, add a guard:
// Only persist events from the parent session as top-level messages.
// Child/subagent events will be rendered inside SubtaskCards, not as standalone messages.
if (!isChildEvent) {
this.persistStreamEvent(hiveSessionId, eventType, event.properties || event)
}If there's a type definition for StreamEvent, add the optional field:
interface StreamEvent {
type: string
sessionId: string
data: unknown
childSessionId?: string
}In src/preload/index.d.ts, update OpenCodeStreamEvent:
interface OpenCodeStreamEvent {
type: string
sessionId: string
data: unknown
childSessionId?: string
}src/main/services/opencode-service.ts— child detection, notification guard, event tagging, persistence guardsrc/preload/index.d.ts—OpenCodeStreamEvent.childSessionId
- Child events are detected via
isChildEventflag -
maybeNotifySessionComplete()only fires for parentsession.idle, not child - Forwarded stream events include
childSessionIdwhen from a subagent - Child events are NOT persisted as top-level parent session messages
- Parent events continue to be persisted normally
-
OpenCodeStreamEventtype includes optionalchildSessionId -
pnpm lintpasses -
pnpm testpasses
- Send a prompt that triggers subagent use (e.g., "Use the Task tool to research X")
- Verify no "session completed" notification appears when the subagent finishes
- Verify the notification DOES appear when the entire parent session completes
- Check the database — verify no subagent text/tool rows appear as standalone assistant messages
// test/phase-9/session-5/subagent-tagging.test.ts
describe('Session 5: Subagent Event Tagging', () => {
test('child event detected when resolveParentSession succeeds', () => {
// getMappedHiveSessionId returns null for child session ID
// resolveParentSession returns a parent ID
// Verify isChildEvent = true
})
test('parent event detected when direct mapping exists', () => {
// getMappedHiveSessionId returns hive ID directly
// Verify isChildEvent = false
})
test('notification only fires for parent session.idle', () => {
// Emit session.idle with isChildEvent=true
// Verify maybeNotifySessionComplete NOT called
// Emit session.idle with isChildEvent=false
// Verify maybeNotifySessionComplete called
})
test('child events tagged with childSessionId', () => {
// Process a child event
// Verify streamEvent has childSessionId field
})
test('parent events do not have childSessionId', () => {
// Process a parent event
// Verify streamEvent does NOT have childSessionId
})
})- Route child session events into SubtaskCard parts instead of top-level streaming parts
- Update subtask status when child
session.idleor error arrives - Maintain a mapping of child session IDs to subtask indices
In src/renderer/src/components/sessions/SessionView.tsx, add:
const childToSubtaskIndexRef = useRef<Map<string, number>>(new Map())When a subtask part is added (around line 854–869), register the mapping:
} else if (part.type === 'subtask') {
const subtaskIndex = streamingPartsRef.current.length // index it will be at
updateStreamingPartsRef((parts) => [
...parts,
{
type: 'subtask',
subtask: {
id: part.id || `subtask-${Date.now()}`,
sessionID: part.sessionID || '',
prompt: part.prompt || '',
description: part.description || '',
agent: part.agent || 'unknown',
parts: [],
status: 'running'
}
}
])
// Map child session ID to this subtask's index
if (part.sessionID) {
childToSubtaskIndexRef.current.set(part.sessionID, subtaskIndex)
}
immediateFlush()
setIsStreaming(true)
}At the top of the message.part.updated handler, before the existing part-type switch, add:
if (event.type === 'message.part.updated') {
// Route child events into their SubtaskCard
if (event.childSessionId) {
const subtaskIdx = childToSubtaskIndexRef.current.get(event.childSessionId)
if (subtaskIdx !== undefined) {
const part = event.data?.part
if (part?.type === 'text') {
updateStreamingPartsRef((parts) => {
const updated = [...parts]
const subtask = updated[subtaskIdx]
if (subtask?.type === 'subtask') {
const lastPart = subtask.subtask.parts[subtask.subtask.parts.length - 1]
if (lastPart?.type === 'text') {
lastPart.text = (lastPart.text || '') + (event.data?.delta || part.text || '')
} else {
subtask.subtask.parts = [
...subtask.subtask.parts,
{ type: 'text', text: event.data?.delta || part.text || '' }
]
}
}
return updated
})
scheduleFlush()
} else if (part?.type === 'tool') {
// Create tool_use part inside subtask
const state = part.state || part
const toolId = state.toolCallId || state.id || `tool-${Date.now()}`
updateStreamingPartsRef((parts) => {
const updated = [...parts]
const subtask = updated[subtaskIdx]
if (subtask?.type === 'subtask') {
const existing = subtask.subtask.parts.find(
(p) => p.type === 'tool_use' && p.toolUse?.id === toolId
)
if (existing && existing.type === 'tool_use' && existing.toolUse) {
// Update existing tool
const statusMap: Record<string, string> = {
running: 'running',
completed: 'success',
error: 'error'
}
existing.toolUse.status = (statusMap[state.status] || 'running') as
| 'pending'
| 'running'
| 'success'
| 'error'
if (state.time?.end) existing.toolUse.endTime = state.time.end
if (state.status === 'completed') existing.toolUse.output = state.output
if (state.status === 'error') existing.toolUse.error = state.error
} else {
// Add new tool
subtask.subtask.parts = [
...subtask.subtask.parts,
{
type: 'tool_use',
toolUse: {
id: toolId,
name: state.name || 'unknown',
input: state.input,
status: 'running',
startTime: state.time?.start || Date.now()
}
}
]
}
}
return updated
})
immediateFlush()
}
setIsStreaming(true)
return // Don't process as top-level part
}
}
// ... existing top-level part processing
}In the session.idle handler, add a child check:
} else if (event.type === 'session.idle') {
// Child session idle — update subtask status, don't finalize parent
if (event.childSessionId) {
const subtaskIdx = childToSubtaskIndexRef.current.get(event.childSessionId)
if (subtaskIdx !== undefined) {
updateStreamingPartsRef((parts) => {
const updated = [...parts]
const subtask = updated[subtaskIdx]
if (subtask?.type === 'subtask') {
subtask.subtask.status = 'completed'
}
return updated
})
immediateFlush()
}
return // Don't finalize the parent session
}
// ... existing parent session.idle handling
}In the session initialization cleanup, reset the ref:
childToSubtaskIndexRef.current.clear()src/renderer/src/components/sessions/SessionView.tsx— child-to-subtask mapping, event routing, subtask status updates
-
childToSubtaskIndexRefmaps child session IDs to subtask indices - Child
message.part.updatedevents with text append to the subtask's text parts - Child
message.part.updatedevents with tools create/update tool_use parts inside the subtask - Child
session.idleupdates the subtask status to'completed' - Child events do NOT appear as top-level streaming parts
- Parent events continue to render as top-level parts
- SubtaskCard shows live content during streaming (not just "Processing...")
- Mapping is cleared on session change
-
pnpm lintpasses -
pnpm testpasses
- Send a prompt that triggers subagent/Task tool usage
- Observe the SubtaskCard — verify it shows live text and tool cards inside it (not "Processing...")
- Verify the parent response text doesn't include subagent content interleaved
- When the subagent finishes, verify the SubtaskCard status changes from spinner to checkmark
- Expand the SubtaskCard — verify nested content is visible
// test/phase-9/session-6/subagent-renderer.test.ts
describe('Session 6: Subagent Content Routing', () => {
test('child text event appends to subtask parts', () => {
// Create a subtask with sessionID='child-1'
// Process a message.part.updated with childSessionId='child-1', part.type='text'
// Verify the text appears in subtask.parts, not top-level
})
test('child tool event creates tool_use in subtask', () => {
// Process a message.part.updated with childSessionId='child-1', part.type='tool'
// Verify tool_use appears in subtask.parts
})
test('child session.idle updates subtask status to completed', () => {
// Process session.idle with childSessionId='child-1'
// Verify subtask.status changed to 'completed'
})
test('child session.idle does NOT finalize parent', () => {
// Process session.idle with childSessionId
// Verify finalizeResponseFromDatabase NOT called
// Verify isStreaming still true
})
test('parent events unaffected by child routing', () => {
// Process a message.part.updated WITHOUT childSessionId
// Verify it appends to top-level streamingParts
})
})- Prevent
message.updatedfrom child sessions from triggering premature finalization - Ensure
isStreamingstaystrueuntil the parent's ownsession.idlearrives
In SessionView.tsx, in the message.updated handler (around line 925), add a child guard:
} else if (event.type === 'message.updated') {
if (eventRole === 'user') return
// Skip finalization for child/subagent messages
if (event.childSessionId) return
// ... existing echo detection and finalization logic
}Confirm that the child session.idle guard from Session 6 prevents premature finalization. The return statement before the existing parent session.idle logic ensures setIsSending(false) and finalizeResponseFromDatabase() only run for the parent.
Confirm that individual tool cards update their own status (spinner → check → error) via the upsertToolUse function without affecting global isStreaming. The setIsStreaming(true) calls on tool events (line 853) only set it to true, never false — this is correct.
src/renderer/src/components/sessions/SessionView.tsx—message.updatedchild guard
-
message.updatedfrom child sessions is ignored (no finalization) -
session.idlefrom child sessions does not trigger parent finalization (Session 6 guard) -
isStreamingremainstruewhile any tool is running -
isStreamingonly becomesfalsewhen parentsession.idlearrives - Individual tool cards show correct status (running → success/error) independently
- The streaming cursor and "Streaming..." label stay visible until all work completes
-
pnpm lintpasses -
pnpm testpasses
- Send a prompt that triggers multiple tool calls (e.g., "Read files X, Y, and Z")
- Watch the first tool complete — verify the streaming indicator (cursor, "Streaming..." label) stays active
- Watch subsequent tools complete — verify streaming stays active until all are done
- Only when
session.idlefires should streaming stop - Send a prompt with subagents — verify subagent completion doesn't stop parent streaming
// test/phase-9/session-7/subtool-loading.test.ts
describe('Session 7: Subtool Loading Indicator', () => {
test('message.updated from child does not trigger finalization', () => {
// Process message.updated with childSessionId set
// Verify hasFinalizedCurrentResponseRef NOT set
// Verify finalizeResponseFromDatabase NOT called
})
test('message.updated from parent with time.completed triggers finalization', () => {
// Process message.updated WITHOUT childSessionId, with info.time.completed
// Verify finalization proceeds
})
test('isStreaming stays true after first tool completes', () => {
// Add 3 tool_use parts in streaming
// Complete the first tool
// Verify isStreaming still true
})
test('isStreaming becomes false on parent session.idle', () => {
// Process parent session.idle (no childSessionId)
// Verify isStreaming set to false via finalizeResponseFromDatabase
})
})- Add a copy-to-clipboard button that appears on hover over any message
- Follow the existing
CodeBlock.tsxpattern for hover reveal and clipboard access
Create src/renderer/src/components/sessions/CopyMessageButton.tsx:
import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
interface CopyMessageButtonProps {
content: string
}
export function CopyMessageButton({ content }: CopyMessageButtonProps) {
const [copied, setCopied] = useState(false)
if (!content.trim()) return null
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
setCopied(true)
toast.success('Copied to clipboard')
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('Failed to copy')
}
}
return (
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="absolute top-2 right-2 h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity z-10 bg-background/80 backdrop-blur-sm"
aria-label="Copy message"
data-testid="copy-message-button"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3 text-muted-foreground" />
)}
</Button>
)
}In src/renderer/src/components/sessions/MessageRenderer.tsx, add the group wrapper:
import { CopyMessageButton } from './CopyMessageButton'
export function MessageRenderer({ message, isStreaming = false, cwd }: MessageRendererProps) {
return (
<div className="group relative">
<CopyMessageButton content={message.content} />
{message.role === 'user' ? (
<UserBubble content={message.content} timestamp={message.timestamp} />
) : (
<AssistantCanvas
content={message.content}
timestamp={message.timestamp}
isStreaming={isStreaming}
parts={message.parts}
cwd={cwd}
/>
)}
</div>
)
}src/renderer/src/components/sessions/CopyMessageButton.tsx— NEWsrc/renderer/src/components/sessions/MessageRenderer.tsx—groupwrapper + copy button
-
CopyMessageButtoncomponent created - Copy button appears at top-right of any message on hover
- Button is hidden by default (
opacity-0) - Button is hidden for empty/whitespace messages
- Clicking copies
message.contentto clipboard - Check icon shown for 2s after copy
- Toast "Copied to clipboard" on success
- Button doesn't obscure message content
-
pnpm lintpasses -
pnpm testpasses
- Hover over a user message — verify copy button appears at top-right
- Hover over an assistant message — verify copy button appears at top-right
- Move mouse away — verify copy button disappears
- Click the copy button — verify check icon appears, toast shown, clipboard has message text
- Paste in an external app — verify the copied text matches the message content
// test/phase-9/session-8/copy-on-hover.test.ts
describe('Session 8: Copy on Hover', () => {
test('CopyMessageButton renders for non-empty content', () => {
// Render with content='Hello world'
// Verify button exists in DOM
})
test('CopyMessageButton hidden for empty content', () => {
// Render with content=' '
// Verify returns null
})
test('clicking copy writes to clipboard', async () => {
// Mock navigator.clipboard.writeText
// Render and click
// Verify writeText called with content
})
test('MessageRenderer wraps with group class', () => {
// Render MessageRenderer with a user message
// Verify outer div has 'group' and 'relative' classes
// Verify CopyMessageButton is rendered
})
})- Persist input field drafts per session to SQLite
- Load drafts on session switch, save on unmount and after 3s debounce
- Clear drafts on message send
In src/main/db/schema.ts, bump version and add migration:
export const CURRENT_SCHEMA_VERSION = 6
// Add to MIGRATIONS array:
{
version: 6,
name: 'add_session_draft_input',
up: `ALTER TABLE sessions ADD COLUMN draft_input TEXT DEFAULT NULL;`,
down: `-- SQLite does not support DROP COLUMN; recreate table if needed`
}In src/main/db/database.ts, add:
getSessionDraft(sessionId: string): string | null {
const row = this.db.prepare('SELECT draft_input FROM sessions WHERE id = ?').get(sessionId) as { draft_input: string | null } | undefined
return row?.draft_input ?? null
}
updateSessionDraft(sessionId: string, draft: string | null): void {
this.db.prepare('UPDATE sessions SET draft_input = ? WHERE id = ?').run(draft, sessionId)
}In the appropriate handler file (e.g., src/main/ipc/database-handlers.ts), add:
ipcMain.handle('db:session:getDraft', (_event, sessionId: string) => {
return db.getSessionDraft(sessionId)
})
ipcMain.handle('db:session:updateDraft', (_event, sessionId: string, draft: string | null) => {
db.updateSessionDraft(sessionId, draft)
})Add to window.db.session in src/preload/index.ts:
getDraft: (sessionId: string) => ipcRenderer.invoke('db:session:getDraft', sessionId),
updateDraft: (sessionId: string, draft: string | null) =>
ipcRenderer.invoke('db:session:updateDraft', sessionId, draft)In src/preload/index.d.ts, add to the session DB ops:
getDraft: (sessionId: string) => Promise<string | null>
updateDraft: (sessionId: string, draft: string | null) => Promise<void>In src/renderer/src/components/sessions/SessionView.tsx:
Add an inputValueRef to track the current value in cleanup:
const inputValueRef = useRef('')Keep it in sync:
const handleInputChange = useCallback(
(value: string) => {
setInputValue(value)
inputValueRef.current = value
// Debounce draft persistence (3 seconds)
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
draftTimerRef.current = setTimeout(() => {
window.db.session.updateDraft(sessionId, value || null)
}, 3000)
},
[sessionId]
)Load draft on mount:
// Inside the session initialization effect:
window.db.session.getDraft(sessionId).then((draft) => {
if (draft) {
setInputValue(draft)
inputValueRef.current = draft
}
})Save on unmount:
useEffect(() => {
return () => {
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
const currentValue = inputValueRef.current
if (currentValue) {
window.db.session.updateDraft(sessionId, currentValue)
}
}
}, [sessionId])Clear on send:
// In handleSend, after setInputValue(''):
inputValueRef.current = ''
window.db.session.updateDraft(sessionId, null)src/main/db/schema.ts— migrationsrc/main/db/database.ts—getSessionDraft,updateSessionDraftsrc/main/ipc/database-handlers.ts— IPC handlerssrc/preload/index.ts— expose draft methodssrc/preload/index.d.ts— type declarationssrc/renderer/src/components/sessions/SessionView.tsx— load/save/clear logic
-
CURRENT_SCHEMA_VERSIONbumped to 6 -
draft_inputcolumn added to sessions table via migration -
getSessionDraftandupdateSessionDraftDB methods work - IPC handlers registered for
db:session:getDraftanddb:session:updateDraft - Preload exposes
getDraftandupdateDraftonwindow.db.session - Opening a session loads any saved draft into the input field
- Typing debounces a save after 3 seconds of inactivity
- Switching sessions saves the current draft immediately (unmount)
- Sending a message clears the draft from DB and input
- Restarting the app restores drafts for active sessions
-
pnpm lintpasses -
pnpm testpasses
- Open a session, type "hello world" in the input — do NOT send
- Switch to a different session
- Switch back — verify "hello world" is still in the input
- Type "test draft", wait 4 seconds
- Close and reopen the app — verify "test draft" is in the input for that session
- Send the message — verify the input clears
- Switch away and back — verify the input is empty (draft was cleared on send)
// test/phase-9/session-9/input-persistence.test.ts
describe('Session 9: Input Persistence', () => {
test('draft loaded on session mount', async () => {
// Mock window.db.session.getDraft returning 'saved draft'
// Render SessionView
// Verify inputValue becomes 'saved draft'
})
test('draft saved after 3 second debounce', async () => {
// Type into input
// Verify updateDraft NOT called immediately
// Advance timers by 3000ms
// Verify updateDraft called with current text
})
test('draft saved on unmount', () => {
// Render SessionView, type text
// Unmount
// Verify updateDraft called with current text
})
test('draft cleared on send', () => {
// Type and send
// Verify updateDraft called with null
})
})- Create the Zustand store for the file search dialog
- Register the Cmd+D shortcut via
before-input-eventand IPC
Create src/renderer/src/stores/useFileSearchStore.ts:
import { create } from 'zustand'
interface FileSearchState {
isOpen: boolean
searchQuery: string
selectedIndex: number
open: () => void
close: () => void
toggle: () => void
setSearchQuery: (query: string) => void
setSelectedIndex: (index: number) => void
moveSelection: (direction: 'up' | 'down', maxIndex: number) => void
}
export const useFileSearchStore = create<FileSearchState>((set) => ({
isOpen: false,
searchQuery: '',
selectedIndex: 0,
open: () => set({ isOpen: true, searchQuery: '', selectedIndex: 0 }),
close: () => set({ isOpen: false, searchQuery: '', selectedIndex: 0 }),
toggle: () =>
set((state) =>
state.isOpen
? { isOpen: false, searchQuery: '', selectedIndex: 0 }
: { isOpen: true, searchQuery: '', selectedIndex: 0 }
),
setSearchQuery: (query) => set({ searchQuery: query, selectedIndex: 0 }),
setSelectedIndex: (index) => set({ selectedIndex: index }),
moveSelection: (direction, maxIndex) =>
set((state) => ({
selectedIndex:
direction === 'up'
? Math.max(0, state.selectedIndex - 1)
: Math.min(maxIndex, state.selectedIndex + 1)
}))
}))In src/renderer/src/lib/keyboard-shortcuts.ts, add:
{
id: 'nav:file-search',
label: 'Search Files',
description: 'Open the file search dialog',
category: 'navigation',
defaultBinding: { key: 'd', modifiers: ['meta'] }
}In src/main/index.ts, add to the before-input-event handler:
if (
input.key.toLowerCase() === 'd' &&
(input.meta || input.control) &&
!input.alt &&
!input.shift &&
input.type === 'keyDown'
) {
event.preventDefault()
mainWindow!.webContents.send('shortcut:file-search')
}In src/preload/index.ts, add to systemOps:
onFileSearchShortcut: (callback: () => void) => {
const handler = (): void => {
callback()
}
ipcRenderer.on('shortcut:file-search', handler)
return () => {
ipcRenderer.removeListener('shortcut:file-search', handler)
}
}In src/preload/index.d.ts:
onFileSearchShortcut: (callback: () => void) => () => voidAdd a useEffect for the IPC listener:
useEffect(() => {
if (!window.systemOps?.onFileSearchShortcut) return
const cleanup = window.systemOps.onFileSearchShortcut(() => {
useFileSearchStore.getState().toggle()
})
return cleanup
}, [])Also add the shortcut handler:
{
id: 'nav:file-search',
binding: getEffectiveBinding('nav:file-search'),
allowInInput: true,
handler: () => {
useFileSearchStore.getState().toggle()
}
}Add export { useFileSearchStore } from './useFileSearchStore' to the stores barrel export.
src/renderer/src/stores/useFileSearchStore.ts— NEWsrc/renderer/src/lib/keyboard-shortcuts.ts— shortcut definitionsrc/main/index.ts— Cmd+D interceptionsrc/preload/index.ts—onFileSearchShortcutsrc/preload/index.d.ts— type declarationsrc/renderer/src/hooks/useKeyboardShortcuts.ts— IPC listener + handler
-
useFileSearchStorecreated withisOpen,searchQuery,selectedIndex, actions -
nav:file-searchshortcut defined with{ key: 'd', modifiers: ['meta'] } - Cmd+D intercepted at
before-input-eventlevel - IPC forwarded to renderer via
shortcut:file-search - Cmd+D toggles the file search store open/close
- Store resets query and selection on open/close
-
pnpm lintpasses -
pnpm testpasses
- Press Cmd+D — verify
useFileSearchStore.isOpenbecomestrue(log in devtools) - Press Cmd+D again — verify it toggles back to
false - Verify Cmd+D works from textarea focus
// test/phase-9/session-10/file-search-store.test.ts
describe('Session 10: File Search Store', () => {
test('open sets isOpen true and resets query', () => {
useFileSearchStore.getState().open()
expect(useFileSearchStore.getState().isOpen).toBe(true)
expect(useFileSearchStore.getState().searchQuery).toBe('')
})
test('close sets isOpen false', () => {
useFileSearchStore.getState().open()
useFileSearchStore.getState().close()
expect(useFileSearchStore.getState().isOpen).toBe(false)
})
test('toggle flips isOpen', () => {
useFileSearchStore.getState().toggle()
expect(useFileSearchStore.getState().isOpen).toBe(true)
useFileSearchStore.getState().toggle()
expect(useFileSearchStore.getState().isOpen).toBe(false)
})
test('moveSelection stays within bounds', () => {
useFileSearchStore.getState().moveSelection('down', 5)
expect(useFileSearchStore.getState().selectedIndex).toBe(1)
useFileSearchStore.getState().moveSelection('up', 5)
expect(useFileSearchStore.getState().selectedIndex).toBe(0)
useFileSearchStore.getState().moveSelection('up', 5)
expect(useFileSearchStore.getState().selectedIndex).toBe(0) // stays at 0
})
})- Build the
FileSearchDialogcomponent with fuzzy file matching - Wire it to the file tree store and file viewer store
- Render it in
AppLayout
Create src/renderer/src/components/file-search/FileSearchDialog.tsx:
The component should:
- Render only when
useFileSearchStore.isOpenistrue - Use
cmdkfor the input + list pattern (consistent with command palette) - Flatten the file tree from
useFileTreeStoreinto a searchable list - Fuzzy-match against file name and relative path
- Limit results to 50 items
- Enter opens the selected file via
useFileViewerStore.openFile() - Escape closes the dialog
- Arrow keys navigate results
import { useEffect, useCallback, useMemo } from 'react'
import { Command } from 'cmdk'
import { FileCode, Search } from 'lucide-react'
import { useFileSearchStore } from '@/stores/useFileSearchStore'
import { useFileTreeStore } from '@/stores'
import { useFileViewerStore } from '@/stores'
import { useWorktreeStore } from '@/stores'
// Flatten file tree to searchable array
function flattenTree(
nodes: FileTreeNode[]
): Array<{ name: string; path: string; relativePath: string }> {
const result: Array<{ name: string; path: string; relativePath: string }> = []
const walk = (nodes: FileTreeNode[]) => {
for (const node of nodes) {
if (!node.isDirectory) {
result.push({ name: node.name, path: node.path, relativePath: node.relativePath })
}
if (node.children) walk(node.children)
}
}
walk(nodes)
return result
}
// Fuzzy match scoring
function scoreMatch(query: string, file: { name: string; relativePath: string }): number {
const q = query.toLowerCase()
const name = file.name.toLowerCase()
const path = file.relativePath.toLowerCase()
if (name === q) return 100
if (name.startsWith(q)) return 80
if (name.includes(q)) return 60
if (path.includes(q)) return 40
// Subsequence match
let qi = 0
for (let i = 0; i < path.length && qi < q.length; i++) {
if (path[i] === q[qi]) qi++
}
return qi === q.length ? 20 : 0
}Create src/renderer/src/components/file-search/index.ts:
export { FileSearchDialog } from './FileSearchDialog'In src/renderer/src/components/layout/AppLayout.tsx, add:
import { FileSearchDialog } from '@/components/file-search'
// Inside the component JSX:
<FileSearchDialog />src/renderer/src/components/file-search/FileSearchDialog.tsx— NEWsrc/renderer/src/components/file-search/index.ts— NEWsrc/renderer/src/components/layout/AppLayout.tsx— render dialog
-
FileSearchDialogrenders whenisOpenis true - Input field auto-focuses on open
- Typing filters files by fuzzy match on name and relative path
- Results limited to 50 items
- Arrow keys navigate the list
- Enter opens the selected file in the file viewer
- Escape closes the dialog
- Clicking outside closes the dialog
- Dialog looks consistent with command palette styling
- File icons shown in results
- Relative path shown as secondary text
-
pnpm lintpasses -
pnpm testpasses
- Press Cmd+D — verify the file search dialog appears
- Type a file name (e.g., "index") — verify matching files appear as you type
- Arrow down to a result, press Enter — verify the file opens in the preview editor
- Press Escape — verify the dialog closes
- Press Cmd+D, type a partial path (e.g., "main/serv") — verify
opencode-service.tsappears - Click outside the dialog — verify it closes
// test/phase-9/session-11/file-search-dialog.test.ts
describe('Session 11: File Search Dialog', () => {
test('flattenTree extracts all files recursively', () => {
const tree = [
{
name: 'src',
isDirectory: true,
children: [
{
name: 'index.ts',
isDirectory: false,
path: '/src/index.ts',
relativePath: 'src/index.ts'
}
],
path: '/src',
relativePath: 'src'
},
{ name: 'README.md', isDirectory: false, path: '/README.md', relativePath: 'README.md' }
]
const flat = flattenTree(tree)
expect(flat).toHaveLength(2)
expect(flat[0].name).toBe('index.ts')
})
test('scoreMatch returns highest for exact name match', () => {
expect(scoreMatch('index.ts', { name: 'index.ts', relativePath: 'src/index.ts' })).toBe(100)
})
test('scoreMatch returns 0 for no match', () => {
expect(scoreMatch('xyz', { name: 'index.ts', relativePath: 'src/index.ts' })).toBe(0)
})
test('dialog opens file on enter', () => {
// Mock useFileViewerStore.openFile
// Open dialog, select a file, press Enter
// Verify openFile called with correct path
})
})- Verify all Phase 9 features work correctly together
- Test cross-feature interactions
- Run lint and tests
- Fix any edge cases or regressions
- Launch from Finder → connect to session → send message → abort mid-stream → verify clean abort
- Verify
opencodebinary is found (PATH fix) AND abort SDK call succeeds
- Open a session, type a draft, press Cmd+D → verify file search opens (draft preserved in background)
- Close file search, press Cmd+W → verify session closes (draft should be saved on unmount)
- Reopen the session → verify draft is gone (it was a closed session)
3. Hidden files + file search interaction
- Verify dotfiles (
.env,.gitignore) appear in Cmd+D file search results - Select a dotfile from search → verify it opens in preview
- Send a prompt triggering subagents → abort mid-subagent → verify clean halt
- Verify SubtaskCard shows partial content and stops updating
- During streaming, hover over a partially-streamed assistant message → verify copy button appears
- Click copy → verify partial text is copied (whatever has been produced so far)
- Type a draft → send (clears draft) → abort → type new draft → verify new draft persists
Run through:
- Launch app → PATH fix works → connect → Cmd+T new session → type draft → Cmd+D search file → open file → close file tab → verify draft persisted → send message → streaming with subagents → subagent content in SubtaskCard → hover copy → abort with stop button → Cmd+W close session → verify window stays open
pnpm lint
pnpm testFix any failures.
- All files modified in sessions 1–11
- All 9 features work correctly in isolation
- Cross-feature interactions work correctly
- No regressions in Phase 8 features (auto-scroll, streaming flush, echo fix, Cmd+T)
- No console errors during normal operation
- No leaked timers, rAF callbacks, or IPC listeners
-
pnpm lintpasses -
pnpm testpasses - Full happy path smoke test passes
Run through each integration scenario listed in Tasks above. Pay special attention to:
- Abort during subagent work (timing-sensitive)
- Draft persistence across rapid session switches
- File search over lazily-loaded tree nodes
// test/phase-9/session-12/integration-verification.test.ts
describe('Session 12: Integration & Verification', () => {
test('abort stops streaming cleanly', () => {
// Send message, start streaming, call abort
// Verify streaming stops, partial content preserved
})
test('subagent content routes into SubtaskCard', () => {
// Stream with subagent events
// Verify SubtaskCard has content, top-level doesn't have subagent text
})
test('copy works during streaming', () => {
// Start streaming, hover message, click copy
// Verify clipboard has partial text
})
test('draft survives app restart', () => {
// Type draft, unmount (simulating app close), remount
// Verify draft loaded from DB
})
test('file search finds dotfiles', () => {
// Open file search, type '.env'
// Verify .env appears in results
})
test('Cmd+W never closes window', () => {
// Trigger Cmd+W IPC event
// Verify BrowserWindow.close NOT called
})
test('lint passes', () => {
// pnpm lint exit code 0
})
test('tests pass', () => {
// pnpm test exit code 0
})
})Session 1 (PATH Fix) ── independent, main process only
Session 2 (Hidden Files) ── independent, file-tree-handlers only
Session 3 (Cmd+W Override) ── independent, main+preload+renderer
Session 4 (Abort Streaming) ── independent, full IPC chain
Session 5 (Subagent Tagging) ── independent, main process only
|
└──► Session 6 (Subagent Renderer) ── depends on Session 5 (needs childSessionId)
|
└──► Session 7 (Subtool Loading) ── depends on Session 6 (uses childSessionId guard)
Session 8 (Copy on Hover) ── independent, renderer only
Session 9 (Input Persistence) ── independent, full IPC chain
Session 10 (File Search Store) ── independent, renderer+main+preload
|
└──► Session 11 (File Search Dialog) ── depends on Session 10 (needs store)
Session 12 (Integration) ── requires sessions 1-11
┌──────────────────────────────────────────────────────────────────────┐
│ Time → │
│ │
│ Track A: [S1: PATH] [S2: Hidden Files] │
│ Track B: [S3: Cmd+W] │
│ Track C: [S4: Abort] │
│ Track D: [S5: Subagent Tag] → [S6: Subagent Render] → [S7: Loading]│
│ Track E: [S8: Copy on Hover] │
│ Track F: [S9: Input Persistence] │
│ Track G: [S10: File Search Store] → [S11: File Search Dialog] │
│ │
│ All ────────────────────────────────────────► [S12: Integration] │
└──────────────────────────────────────────────────────────────────────┘
Maximum parallelism: Tracks A–G are fully independent. Within each track, sessions are sequential.
Critical path: Track D (Sessions 5 → 6 → 7) is the longest sequential chain at 3 sessions.
Minimum total: 4 rounds — (S1–S5, S8–S10 in parallel) → (S2, S6, S11 in parallel) → (S7) → (S12).
- Smart auto-scroll with FAB
- Adaptive streaming flush (rAF-based)
- User message echo fix
- Cmd+T interception via
before-input-event
Per PRD Phase 9:
- Toggle for show/hide hidden files (all dotfiles shown,
.gitand.DS_Storehardcoded) - Markdown rendering in copy output (raw text only)
- Abort with partial retry
- Input draft undo/redo history
- File search by file contents (names/paths only)
- File search frecency ranking
- Subagent progress percentage in SubtaskCard
- Nested subagent chains (only one level of child→parent)
- Custom file tree exclusion list
- Configurable draft auto-save interval
| Operation | Target |
|---|---|
| PATH fix startup overhead | < 500ms |
| File tree scan with dotfiles | No measurable regression |
| Cmd+W interception | < 5ms from keypress to IPC delivery |
| Abort round-trip (button → stop) | < 200ms |
| Draft debounce persistence | 3000ms after last keystroke |
| Draft load on session switch | < 50ms |
| Copy to clipboard | < 50ms for messages up to 100KB |
| File search fuzzy matching | < 10ms for 10,000 files |
| Subagent event routing | No additional latency (map lookup) |
| Streaming indicator accuracy | Active until parent session.idle |
fix-pathover manual shell spawn: Well-maintained package, handles edge cases (shell timeout, non-zsh shells, Windows no-op). Avoids reimplementing shell PATH extraction.- Custom File menu over
fileMenurole: Electron'sfileMenuincludes "Close Window" (Cmd+W) which cannot be overridden. A custom menu gives full control over accelerators and actions. - SDK
session.abort()over AbortController: The OpenCode SDK provides a first-class abort API that properly signals the server. An AbortController would only cancel the client-side SSE subscription, not the server-side processing. draft_inputcolumn on sessions table over separate table: Simpler schema, no joins needed. Drafts have a 1:1 relationship with sessions. Cleanup is automatic via existing row lifecycle.- Child event tagging over separate event channels: Adding
childSessionIdto existing stream events is non-breaking and avoids creating a parallel event infrastructure. The renderer can distinguish child vs parent events with a single field check. - File search using
cmdkover custom implementation: Consistent UX with the existing command palette.cmdkhandles keyboard navigation, focus management, and accessibility out of the box.