This document outlines the implementation plan for Hive Phase 15, focusing on context indicator bug fix, tool call correlation after tab switching, question dialog persistence across tabs, user message ordering, copy branch name, favorite models, worktree last-message time, open in Chrome, Cmd+W file tab close, and merge conflicts button.
The implementation is divided into 13 focused sessions, each with:
- Clear objectives
- Definition of done
- Testing criteria for verification
Phase 15 builds upon Phase 14 — all Phase 14 infrastructure is assumed to be in place.
Session 1 (Context Indicator Bug Fix) ── no deps
Session 2 (Tool Call Correlation Fix) ── no deps
Session 3 (Question Dialog Persistence) ── no deps
Session 4 (User Message Ordering Fix) ── no deps
Session 5 (Copy Branch Name) ── no deps
Session 6 (Cmd+W File Tab Close) ── no deps
Session 7 (Last Message Time Store) ── no deps
Session 8 (Last Message Time UI) ── blocked by Session 7 (needs store)
Session 9 (Favorite Models) ── no deps
Session 10 (Open in Chrome Backend) ── no deps
Session 11 (Open in Chrome UI) ── blocked by Session 10 (needs IPC)
Session 12 (Merge Conflicts Button) ── no deps
Session 13 (Integration & Verification) ── blocked by Sessions 1-12
┌──────────────────────────────────────────────────────────────────────┐
│ Time → │
│ │
│ Track A: [S1: Context Fix] │
│ Track B: [S2: Tool Correlation] │
│ Track C: [S3: Question Persist] │
│ Track D: [S4: Message Order] │
│ Track E: [S5: Copy Branch] │
│ Track F: [S6: Cmd+W File Close] │
│ Track G: [S7: Last Msg Store] → [S8: Last Msg UI] │
│ Track H: [S9: Favorite Models] │
│ Track I: [S10: Chrome Backend] → [S11: Chrome UI] │
│ Track J: [S12: Merge Conflicts] │
│ │
│ All ──────────────────────────────────────────► [S13: Integration] │
└──────────────────────────────────────────────────────────────────────┘
Maximum parallelism: Sessions 1-7, 9, 10, 12 are fully independent. Session 8 depends on Session 7 (status store). Session 11 depends on Session 10 (IPC handler).
Minimum total: 3 rounds:
- (S1, S2, S3, S4, S5, S6, S7, S9, S10, S12 in parallel)
- (S8, S11 — after their dependencies complete)
- (S13)
Recommended serial order (if doing one at a time):
S3 → S4 → S2 → S1 → S6 → S5 → S7 → S8 → S12 → S9 → S10 → S11 → S13
Rationale: S3-S4 are the highest-impact bug fixes (stuck sessions, misordered messages), S2 fixes confusing detached tool results, S1 fixes context display, S6 and S5 are small UX fixes, S7-S8 are sequential store+UI work, S12 reuses existing patterns, S9 is a self-contained feature, S10-S11 are sequential Chrome work, S13 validates everything.
test/
├── phase-15/
│ ├── session-1/
│ │ └── context-background.test.ts
│ ├── session-2/
│ │ └── tool-correlation.test.ts
│ ├── session-3/
│ │ └── question-persistence.test.ts
│ ├── session-4/
│ │ └── message-ordering.test.ts
│ ├── session-5/
│ │ └── copy-branch-name.test.tsx
│ ├── session-6/
│ │ └── cmd-w-file-close.test.ts
│ ├── session-7/
│ │ └── last-message-time-store.test.ts
│ ├── session-8/
│ │ └── last-message-time-ui.test.tsx
│ ├── session-9/
│ │ └── favorite-models.test.ts
│ ├── session-10/
│ │ └── open-in-chrome-backend.test.ts
│ ├── session-11/
│ │ └── open-in-chrome-ui.test.tsx
│ ├── session-12/
│ │ └── merge-conflicts.test.tsx
│ └── session-13/
│ └── integration-verification.test.ts
# No new dependencies — all features use existing packages:
# - zustand (stores — already installed)
# - lucide-react (icons — already installed)
# - sonner (toasts — already installed)
# - Electron APIs: shell, clipboard (built-in)- Fix background sessions showing 0 tokens by extracting context data in the global listener
- Guard
resetSessionTokensduring DB reconstruction to avoid clearing valid cached data when no tokens are found in the DB scan - Ensure context indicator shows correct values immediately when switching to a session that completed in the background
In src/renderer/src/hooks/useOpenCodeGlobalListener.ts, the existing session.updated handler (lines 36-42) only handles title updates. Replace the entire session.updated block with a broader message.updated handler that also extracts tokens for background sessions:
Current code (lines 34-42):
if (event.type === 'session.updated' && sessionId !== activeId) {
const sessionTitle = event.data?.info?.title || event.data?.title
if (sessionTitle) {
useSessionStore.getState().updateSessionName(sessionId, sessionTitle)
}
return
}New code:
// Handle message.updated for background sessions — extract title + tokens
if (event.type === 'message.updated' && sessionId !== activeId) {
const sessionTitle = event.data?.info?.title || event.data?.title
if (sessionTitle) {
useSessionStore.getState().updateSessionName(sessionId, sessionTitle)
}
// Extract tokens for background sessions
const info = event.data?.info
if (info?.time?.completed) {
const data = event.data as Record<string, unknown> | undefined
if (data) {
const tokens = extractTokens(data)
if (tokens) {
const modelRef = extractModelRef(data) ?? undefined
useContextStore.getState().setSessionTokens(sessionId, tokens, modelRef)
}
const cost = extractCost(data)
if (cost > 0) {
useContextStore.getState().addSessionCost(sessionId, cost)
}
}
}
return
}
// Keep session.updated for background title sync (some events use this type)
if (event.type === 'session.updated' && sessionId !== activeId) {
const sessionTitle = event.data?.info?.title || event.data?.title
if (sessionTitle) {
useSessionStore.getState().updateSessionName(sessionId, sessionTitle)
}
return
}Add the needed imports at the top:
import { extractTokens, extractCost, extractModelRef } from '@/lib/token-utils'
import { useContextStore } from '@/stores/useContextStore'In src/renderer/src/components/sessions/SessionView.tsx, the DB reconstruction (lines 769-801) calls resetSessionTokens before scanning. If the scan finds nothing, the session is left at 0 even if the global listener had already populated valid data.
Current code (lines 772-801):
useContextStore.getState().resetSessionTokens(sessionId)
let totalCost = 0
let snapshotSet = false
for (let i = dbMessages.length - 1; i >= 0; i--) {
// ... scan and extract ...
}
if (totalCost > 0) {
useContextStore.getState().setSessionCost(sessionId, totalCost)
}New code — scan first, only reset+set if data was found:
let totalCost = 0
let snapshotSet = false
let snapshotTokens: TokenInfo | null = null
let snapshotModelRef: SessionModelRef | undefined
for (let i = dbMessages.length - 1; i >= 0; i--) {
const msg = dbMessages[i]
if (msg.role === 'assistant' && msg.opencode_message_json) {
try {
const msgJson = JSON.parse(msg.opencode_message_json)
totalCost += extractCost(msgJson)
if (!snapshotSet) {
const tokens = extractTokens(msgJson)
if (tokens) {
snapshotTokens = tokens
snapshotModelRef = extractModelRef(msgJson) ?? undefined
snapshotSet = true
}
}
} catch {
// Ignore parse errors
}
}
}
// Only reset and apply if we found data — otherwise keep whatever
// the global listener or a previous load may have set
if (snapshotTokens || totalCost > 0) {
useContextStore.getState().resetSessionTokens(sessionId)
if (snapshotTokens) {
useContextStore.getState().setSessionTokens(sessionId, snapshotTokens, snapshotModelRef)
}
if (totalCost > 0) {
useContextStore.getState().setSessionCost(sessionId, totalCost)
}
}Add TokenInfo and SessionModelRef to imports from @/stores/useContextStore if not already present.
src/renderer/src/hooks/useOpenCodeGlobalListener.ts— addmessage.updatedtoken extraction for background sessionssrc/renderer/src/components/sessions/SessionView.tsx— guardresetSessionTokensinloadMessagesFromDatabase
- Sessions completing in the background have non-zero context indicator values when switching to them
- Token extraction in the global listener correctly uses
extractTokens,extractCost,extractModelRef -
loadMessagesFromDatabasedoes not reset valid cached tokens when DB scan finds no data - Sessions with token data in the DB still reconstruct correctly on mount
- Active session token extraction (real-time streaming) is unaffected
-
pnpm lintpasses -
pnpm testpasses
- Open a worktree, start a session, send a message
- Switch to a different worktree tab while the session is streaming
- Wait for the session to complete (background)
- Switch back — verify the context indicator shows non-zero values
- Restart the app, open the same session — verify context reconstructs from DB
- Stay on a session while it streams — verify real-time token updates still work
// test/phase-15/session-1/context-background.test.ts
describe('Session 1: Context Indicator Bug Fix', () => {
test('global listener extracts tokens from message.updated for background sessions', () => {
// Set activeSessionId to 'session-A'
// Fire onStream with type: 'message.updated', sessionId: 'session-B'
// and data: { info: { time: { completed: '...' } }, tokens: { input: 100, output: 50 } }
// Verify useContextStore.setSessionTokens called with 'session-B' and correct tokens
})
test('global listener does NOT extract tokens for the active session', () => {
// Set activeSessionId to 'session-A'
// Fire onStream with type: 'message.updated', sessionId: 'session-A'
// Verify useContextStore.setSessionTokens NOT called (active session handles its own)
})
test('global listener extracts cost from message.updated', () => {
// Fire onStream with message.updated carrying cost: 0.0123 for background session
// Verify useContextStore.addSessionCost called with correct value
})
test('loadMessagesFromDatabase does not reset tokens when DB has no data', () => {
// Set up useContextStore with valid tokens for session
// Call loadMessagesFromDatabase with empty dbMessages array
// Verify tokens were NOT reset (still present in store)
})
test('loadMessagesFromDatabase resets and sets when DB has token data', () => {
// Set up dbMessages with assistant message containing tokens
// Call loadMessagesFromDatabase
// Verify resetSessionTokens then setSessionTokens called
})
})- Fix tool call results appearing as detached entries after tab switching
- Preserve streaming parts across tab switches for sessions that are actively streaming
- Ensure
upsertToolUsecan always find the matching tool entry when a result arrives
In src/renderer/src/components/sessions/SessionView.tsx, the session init effect (lines 821-834) clears streaming state unconditionally. Change it to preserve state for sessions that are actively streaming.
Current code (lines 829-834):
streamingPartsRef.current = []
streamingContentRef.current = ''
childToSubtaskIndexRef.current = new Map()
setStreamingParts([])
setStreamingContent('')
hasFinalizedCurrentResponseRef.current = falseNew code:
// Only clear streaming display state if NOT currently streaming this session.
// When the user switches away and back to an actively-streaming session,
// we preserve streamingPartsRef so incoming tool results can find their
// matching callID via upsertToolUse instead of creating detached entries.
if (!isStreaming) {
streamingPartsRef.current = []
streamingContentRef.current = ''
childToSubtaskIndexRef.current = new Map()
setStreamingParts([])
setStreamingContent('')
}
hasFinalizedCurrentResponseRef.current = falseThe cleanup at lines 1497-1502 unsubscribes the stream listener. However, the streaming state refs (streamingPartsRef) are React refs that live with the component instance — they are destroyed on unmount regardless.
The restoration at lines 1269-1288 reads from the DB. The problem is the DB may lag behind in-flight tool calls. Improve the restoration to merge with any already-present streaming parts instead of replacing:
Current code (lines 1275-1288):
if (loadedMessages.length > 0) {
const lastMsg = loadedMessages[loadedMessages.length - 1]
if (lastMsg.role === 'assistant' && lastMsg.parts && lastMsg.parts.length > 0) {
streamingPartsRef.current = lastMsg.parts.map((p) => ({ ...p }))
setStreamingParts([...streamingPartsRef.current])
// Also restore text content
const textParts = lastMsg.parts.filter((p) => p.type === 'text')
if (textParts.length > 0) {
const content = textParts.map((p) => p.text || '').join('')
streamingContentRef.current = content
setStreamingContent(content)
}
}
}New code — merge DB parts with any already-present streaming parts:
if (loadedMessages.length > 0) {
const lastMsg = loadedMessages[loadedMessages.length - 1]
if (lastMsg.role === 'assistant' && lastMsg.parts && lastMsg.parts.length > 0) {
const dbParts = lastMsg.parts.map((p) => ({ ...p }))
if (streamingPartsRef.current.length > 0) {
// Merge: DB parts are the base, but keep any streaming parts
// that have a tool_use with a callID not yet in the DB parts
const dbToolIds = new Set(
dbParts.filter((p) => p.type === 'tool_use' && p.toolUse?.id).map((p) => p.toolUse!.id)
)
const extraParts = streamingPartsRef.current.filter(
(p) => p.type === 'tool_use' && p.toolUse?.id && !dbToolIds.has(p.toolUse.id)
)
streamingPartsRef.current = [...dbParts, ...extraParts]
} else {
streamingPartsRef.current = dbParts
}
setStreamingParts([...streamingPartsRef.current])
const textParts = streamingPartsRef.current.filter((p) => p.type === 'text')
if (textParts.length > 0) {
const content = textParts.map((p) => p.text || '').join('')
streamingContentRef.current = content
setStreamingContent(content)
}
}
}src/renderer/src/components/sessions/SessionView.tsx— conditional clearing, merge-based restoration
- Tool call results always merge into their originating tool card after tab switching
- No detached "orphan" tool result entries appear in the message stream
- Switching away and back to an actively-streaming session preserves the current tool state
- Sessions that are NOT streaming still get a clean state on entry
- Text content is preserved correctly across tab switches during streaming
- Finalization after streaming completes works correctly
-
pnpm lintpasses -
pnpm testpasses
- Start a session that triggers a
Writetool call - While the tool is still running (spinner visible), switch to a different worktree tab
- Wait a moment, then switch back
- Verify the tool call result appears merged into the original tool card (single card, status updates from running → success)
- Repeat with
ReadandBashtools - Verify finalization (stream completion) still works after switching back
- Switch to a session that is NOT streaming — verify clean state with no leftover parts
// test/phase-15/session-2/tool-correlation.test.ts
describe('Session 2: Tool Call Correlation Fix', () => {
test('streaming parts preserved when isStreaming is true during init', () => {
// Set isStreaming = true
// Pre-populate streamingPartsRef with a tool_use part { id: 'tool-1', status: 'running' }
// Trigger session init effect (re-mount)
// Verify streamingPartsRef still contains the tool-1 part
})
test('streaming parts cleared when isStreaming is false during init', () => {
// Set isStreaming = false
// Pre-populate streamingPartsRef with parts
// Trigger session init effect
// Verify streamingPartsRef is empty
})
test('DB restoration merges with existing streaming parts', () => {
// Set streamingPartsRef with tool_use { id: 'tool-2', status: 'running' }
// Mock loadMessagesFromDatabase returning message with parts [text, tool_use { id: 'tool-1' }]
// After restoration, verify streamingPartsRef contains both tool-1 (from DB) and tool-2 (preserved)
})
test('upsertToolUse finds existing tool after restoration', () => {
// Restore parts from DB with tool_use { id: 'write-123', status: 'running' }
// Call upsertToolUse('write-123', { status: 'success', output: '...' })
// Verify the existing part is updated, no new part created
})
})- Fix question dialogs disappearing when switching away from a worktree tab with a pending question
- Handle
question.askedevents in the global listener for background sessions - Remove
clearSessioncalls from SessionView unmount cleanup
In src/renderer/src/components/sessions/SessionView.tsx, modify the cleanup function (lines 1497-1502):
Current code:
return () => {
unsubscribe()
useQuestionStore.getState().clearSession(sessionId)
usePermissionStore.getState().clearSession(sessionId)
}New code:
return () => {
unsubscribe()
// DO NOT clear questions or permissions — they must persist across tab switches.
// They are removed individually when answered/rejected via removeQuestion/removePermission.
}In src/renderer/src/hooks/useOpenCodeGlobalListener.ts, add question event handling for background sessions. Add before the session.status check:
import { useQuestionStore } from '@/stores/useQuestionStore'
// Inside the onStream handler:
// Handle question events for background sessions
if (event.type === 'question.asked' && sessionId !== activeId) {
const request = event.data
if (request?.id && request?.questions) {
useQuestionStore.getState().addQuestion(sessionId, request)
useWorktreeStatusStore.getState().setSessionStatus(sessionId, 'answering')
}
return
}
if (
(event.type === 'question.replied' || event.type === 'question.rejected') &&
sessionId !== activeId
) {
const requestId = event.data?.requestID || event.data?.requestId || event.data?.id
if (requestId) {
useQuestionStore.getState().removeQuestion(sessionId, requestId)
}
return
}This ensures that when a question arrives while viewing a different tab, the question is stored and the worktree shows "Answer questions" status. When the user switches back, SessionView reads getActiveQuestion(sessionId) from the store and renders QuestionPrompt.
src/renderer/src/components/sessions/SessionView.tsx— removeclearSessionfrom unmount cleanupsrc/renderer/src/hooks/useOpenCodeGlobalListener.ts— addquestion.asked/replied/rejectedhandling for background sessions
- Switching to a worktree tab with a pending question shows the question dialog immediately
- The question dialog is functional — answers can be submitted and rejected
- Questions arriving while on a different tab are stored and visible on switch back
- "Answer questions" amber status appears on the worktree row when a background question arrives
- Answering or rejecting a question removes it from the store correctly
- Multiple concurrent questions across different sessions are handled independently
-
pnpm lintpasses -
pnpm testpasses
- Open a session and trigger a question (e.g., through a tool that asks a question)
- While the question is showing, switch to a different worktree tab
- Verify the original worktree shows "Answer questions" with amber icon
- Switch back to the original worktree
- Verify the question dialog appears and is functional
- Submit an answer — verify the session continues
- Trigger another question on a background session (let it arrive while on another tab)
- Switch to that session — verify the question appears
// test/phase-15/session-3/question-persistence.test.ts
describe('Session 3: Question Dialog Persistence', () => {
test('questions survive SessionView unmount', () => {
// Add a question to useQuestionStore for session-A
// Mount SessionView for session-A — verify question renders
// Unmount SessionView (simulate tab switch)
// Verify useQuestionStore still has the question for session-A
})
test('global listener adds question for background session', () => {
// Set activeSessionId to 'session-A'
// Fire onStream with type: 'question.asked', sessionId: 'session-B'
// Verify useQuestionStore.addQuestion called with 'session-B'
// Verify useWorktreeStatusStore.setSessionStatus called with 'answering'
})
test('global listener ignores question events for active session', () => {
// Set activeSessionId to 'session-A'
// Fire onStream with type: 'question.asked', sessionId: 'session-A'
// Verify useQuestionStore.addQuestion NOT called (active session handles its own)
})
test('global listener removes question on reply for background session', () => {
// Add question to store for session-B
// Fire onStream with type: 'question.replied', sessionId: 'session-B'
// Verify useQuestionStore.removeQuestion called
})
test('question dialog renders when switching to session with pending question', () => {
// Add question to useQuestionStore for session-A
// Mount SessionView for session-A
// Verify QuestionPrompt component renders with correct question data
})
})- Fix user messages appearing at wrong positions when
finalizeResponseFromDatabaseraces with new message sends - Use merge-based replacement in
loadMessagesFromDatabaseinstead of blind array replacement
In src/renderer/src/components/sessions/SessionView.tsx, modify loadMessagesFromDatabase (line 759):
Current code:
const loadedMessages = dbMessages.map(dbMessageToOpenCode)
setMessages(loadedMessages)New code:
const loadedMessages = dbMessages.map(dbMessageToOpenCode)
setMessages((currentMessages) => {
// Find any local messages not yet in the DB result
// (e.g., user messages sent during async DB load)
const loadedIds = new Set(loadedMessages.map((m) => m.id))
const localOnly = currentMessages.filter((m) => !loadedIds.has(m.id))
// Append local-only messages at the end to preserve user intent
return localOnly.length > 0 ? [...loadedMessages, ...localOnly] : loadedMessages
})This ensures that if a user sends a message while finalizeResponseFromDatabase is in flight, the user's message won't be lost or repositioned — it stays at the end of the list.
Add a ref to track whether a new prompt was sent during the current streaming cycle:
const newPromptPendingRef = useRef(false)In handleSend, set it to true:
// In handleSend, after calling window.opencodeOps.prompt:
newPromptPendingRef.current = trueIn finalizeResponseFromDatabase, check and skip full reload if a new prompt is pending — the next finalization cycle will capture everything:
const finalizeResponseFromDatabase = async (): Promise<void> => {
if (newPromptPendingRef.current) {
// A new prompt was sent during this stream — skip full reload.
// The next stream completion will finalize both responses.
newPromptPendingRef.current = false
resetStreamingState()
return
}
try {
await loadMessagesFromDatabase()
} catch (error) {
console.error('Failed to refresh messages after stream completion:', error)
toast.error('Failed to refresh response')
} finally {
resetStreamingState()
setIsSending(false)
}
}Reset the ref when a stream starts (in the session.status busy handler):
if (status.type === 'busy') {
setIsStreaming(true)
newPromptPendingRef.current = false
}src/renderer/src/components/sessions/SessionView.tsx— merge-basedsetMessages, new prompt guard in finalization
- User messages always appear at the end of the message list (before streaming content)
- No message reordering occurs during finalization
- Sending a message while a previous response is finalizing does not lose or reorder the new message
- Fast sequential message sends maintain correct order
- Finalization still correctly loads assistant responses from the DB
- Token reconstruction in
loadMessagesFromDatabaseis unaffected -
pnpm lintpasses -
pnpm testpasses
- Send a message to a session and wait for the response to complete
- Immediately send another message before the finalization toast appears
- Verify the second message appears below the first response (not in a random position)
- Send 3-4 rapid messages — verify all appear in the correct order
- Verify assistant responses still render correctly after finalization
// test/phase-15/session-4/message-ordering.test.ts
describe('Session 4: User Message Ordering Fix', () => {
test('loadMessagesFromDatabase preserves locally-added messages', () => {
// Set messages state to [msg-A, msg-B, msg-C] where msg-C was locally added
// Call loadMessagesFromDatabase which returns [msg-A, msg-B] from DB
// Verify final state is [msg-A, msg-B, msg-C] (msg-C preserved at end)
})
test('loadMessagesFromDatabase does not duplicate messages already in DB', () => {
// Set messages state to [msg-A, msg-B]
// Call loadMessagesFromDatabase which returns [msg-A, msg-B, msg-D]
// Verify final state is [msg-A, msg-B, msg-D] (no duplicates)
})
test('finalization skips full reload when new prompt is pending', () => {
// Set newPromptPendingRef.current = true
// Call finalizeResponseFromDatabase
// Verify loadMessagesFromDatabase was NOT called
// Verify resetStreamingState was called
})
test('finalization performs full reload when no new prompt pending', () => {
// Set newPromptPendingRef.current = false
// Call finalizeResponseFromDatabase
// Verify loadMessagesFromDatabase was called
})
test('newPromptPendingRef resets on session.status busy', () => {
// Set newPromptPendingRef to true
// Fire session.status { type: 'busy' }
// Verify newPromptPendingRef is false
})
})- Add a "Copy branch name" button in the window header next to the branch name text
- Show a toast confirmation on copy
In src/renderer/src/components/layout/Header.tsx, add a clipboard icon button next to the branch name span (lines 42-44):
Current code (lines 39-45):
{selectedProject ? (
<span className="text-sm font-medium truncate" data-testid="header-project-info">
{selectedProject.name}
{selectedWorktree?.branch_name && selectedWorktree.name !== '(no-worktree)' && (
<span className="text-primary font-normal"> ({selectedWorktree.branch_name})</span>
)}
</span>New code:
{selectedProject ? (
<span className="text-sm font-medium truncate" data-testid="header-project-info">
{selectedProject.name}
{selectedWorktree?.branch_name && selectedWorktree.name !== '(no-worktree)' && (
<>
<span className="text-primary font-normal"> ({selectedWorktree.branch_name})</span>
<button
onClick={(e) => {
e.stopPropagation()
window.projectOps.copyToClipboard(selectedWorktree.branch_name)
toast.success('Branch name copied')
}}
className="ml-1 p-0.5 rounded hover:bg-accent transition-colors inline-flex items-center"
title="Copy branch name"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
data-testid="copy-branch-name"
>
<Copy className="h-3 w-3 text-muted-foreground" />
</button>
</>
)}
</span>Add imports:
import { Copy } from 'lucide-react'
import { toast } from 'sonner'The button uses WebkitAppRegion: 'no-drag' because the header is a window drag region (line 32).
src/renderer/src/components/layout/Header.tsx— add copy button with clipboard icon
- A clipboard icon button appears next to the branch name in the header
- Clicking the button copies the branch name to the clipboard
- A "Branch name copied" toast appears on click
- The button is clickable (not captured by the window drag region)
- The button does not appear when there is no branch name (e.g.,
(no-worktree)) -
pnpm lintpasses -
pnpm testpasses
- Select a worktree with a branch name — verify the clipboard icon appears next to the branch name
- Click the icon — verify "Branch name copied" toast appears
- Paste into a text editor — verify the exact branch name was copied (no parentheses or extra text)
- Select the default worktree with
(no-worktree)— verify no icon appears - Try to drag the window by clicking the icon — verify the button is clickable, not a drag handle
// test/phase-15/session-5/copy-branch-name.test.tsx
describe('Session 5: Copy Branch Name', () => {
test('copy button renders when branch name exists', () => {
// Mock selectedWorktree with branch_name: 'feature/auth'
// Render Header
// Verify button with data-testid="copy-branch-name" exists
})
test('copy button not rendered for (no-worktree)', () => {
// Mock selectedWorktree with name: '(no-worktree)'
// Render Header
// Verify no copy-branch-name button
})
test('clicking copy button calls copyToClipboard with branch name', async () => {
const copyMock = vi.fn()
// Mock window.projectOps.copyToClipboard = copyMock
// Mock selectedWorktree with branch_name: 'feature/auth'
// Render Header, click the copy button
// Verify copyMock called with 'feature/auth'
})
})- Make Cmd+W close the active file tab when a file is focused
- Only close the session tab when no file or diff is active
- Clear active diff view when a diff is focused
In src/renderer/src/hooks/useKeyboardShortcuts.ts, modify the handler (lines 114-134):
Current code:
const cleanup = window.systemOps.onCloseSessionShortcut(() => {
const { activeSessionId } = useSessionStore.getState()
if (!activeSessionId) return
useSessionStore
.getState()
.closeSession(activeSessionId)
.then((result) => {
if (result.success) {
toast.success('Session closed')
} else {
toast.error(result.error || 'Failed to close session')
}
})
})New code:
const cleanup = window.systemOps.onCloseSessionShortcut(() => {
const { activeFilePath, activeDiff } = useFileViewerStore.getState()
// Priority 1: Close active file tab
if (activeFilePath) {
useFileViewerStore.getState().closeFile(activeFilePath)
return
}
// Priority 2: Clear active diff view
if (activeDiff) {
useFileViewerStore.getState().clearActiveDiff()
return
}
// Priority 3: Close active session tab
const { activeSessionId } = useSessionStore.getState()
if (!activeSessionId) return
useSessionStore
.getState()
.closeSession(activeSessionId)
.then((result) => {
if (result.success) {
toast.success('Session closed')
} else {
toast.error(result.error || 'Failed to close session')
}
})
})Add import:
import { useFileViewerStore } from '@/stores/useFileViewerStore'src/renderer/src/hooks/useKeyboardShortcuts.ts— checkactiveFilePath/activeDiffbefore closing session
- Cmd+W closes the active file tab when a file tab is focused
- After closing a file tab, the view switches to the session (or next file tab)
- Cmd+W clears the diff view when a diff is active
- Cmd+W closes the session tab when no file or diff is active
- Cmd+W never closes the Electron window (existing behavior preserved)
-
pnpm lintpasses -
pnpm testpasses
- Open a file from the file tree (creates a file tab) — verify it's focused
- Press Cmd+W — verify the file tab closes and the session tab becomes active
- Open two file tabs — press Cmd+W — verify only the active file tab closes
- Click a git file diff — press Cmd+W — verify the diff view closes
- With no file or diff active — press Cmd+W — verify the session tab closes
- Verify Cmd+W never closes the Electron window
// test/phase-15/session-6/cmd-w-file-close.test.ts
describe('Session 6: Cmd+W File Tab Close', () => {
test('closes file tab when activeFilePath is set', () => {
// Mock useFileViewerStore with activeFilePath: '/path/to/file.ts'
// Fire onCloseSessionShortcut callback
// Verify closeFile called with '/path/to/file.ts'
// Verify closeSession NOT called
})
test('clears diff when activeDiff is set and no file active', () => {
// Mock useFileViewerStore with activeFilePath: null, activeDiff: { ... }
// Fire callback
// Verify clearActiveDiff called
// Verify closeSession NOT called
})
test('closes session when no file and no diff active', () => {
// Mock useFileViewerStore with activeFilePath: null, activeDiff: null
// Mock useSessionStore with activeSessionId: 'session-1'
// Fire callback
// Verify closeSession called with 'session-1'
})
test('no-op when nothing is active', () => {
// Mock everything as null
// Fire callback
// Verify no close functions called
})
})- Add per-worktree last-message-time tracking to
useWorktreeStatusStore - Create a
formatRelativeTimeutility function - Update timestamp on message send and background session completion
In src/renderer/src/stores/useWorktreeStatusStore.ts, add to the state interface and implementation:
interface WorktreeStatusState {
sessionStatuses: Record<string, SessionStatus | null>
lastMessageTimeByWorktree: Record<string, number> // worktreeId → epoch ms
// ... existing actions ...
setLastMessageTime: (worktreeId: string, timestamp: number) => void
getLastMessageTime: (worktreeId: string) => number | null
}Add to the store:
lastMessageTimeByWorktree: {},
setLastMessageTime: (worktreeId: string, timestamp: number) => {
set((state) => ({
lastMessageTimeByWorktree: {
...state.lastMessageTimeByWorktree,
[worktreeId]: Math.max(
state.lastMessageTimeByWorktree[worktreeId] ?? 0,
timestamp
)
}
}))
},
getLastMessageTime: (worktreeId: string) => {
return get().lastMessageTimeByWorktree[worktreeId] ?? null
},Create src/renderer/src/lib/format-utils.ts:
export function formatRelativeTime(timestamp: number): string {
const now = Date.now()
const diffMs = now - timestamp
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'now'
if (diffMin < 60) return `${diffMin}m`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h`
const diffDay = Math.floor(diffHr / 24)
if (diffDay < 7) return `${diffDay}d`
const diffWeek = Math.floor(diffDay / 7)
return `${diffWeek}w`
}In src/renderer/src/components/sessions/SessionView.tsx, after saving the user message in handleSend (around line 1662):
// Update last message time for the worktree
if (worktreeId) {
useWorktreeStatusStore.getState().setLastMessageTime(worktreeId, Date.now())
}In src/renderer/src/hooks/useOpenCodeGlobalListener.ts, when a background session goes idle, update the last-message time. Resolve worktree from session using the session store:
if (status?.type === 'idle' && sessionId !== activeId) {
useWorktreeStatusStore.getState().setSessionStatus(sessionId, 'unread')
// Update last message time for the worktree
const sessions = useSessionStore.getState().sessionsByWorktree
for (const [worktreeId, wSessions] of sessions) {
if (wSessions.some((s) => s.id === sessionId)) {
useWorktreeStatusStore.getState().setLastMessageTime(worktreeId, Date.now())
break
}
}
}src/renderer/src/stores/useWorktreeStatusStore.ts— addlastMessageTimeByWorktree,setLastMessageTime,getLastMessageTimesrc/renderer/src/lib/format-utils.ts— new file withformatRelativeTimesrc/renderer/src/components/sessions/SessionView.tsx— callsetLastMessageTimeinhandleSendsrc/renderer/src/hooks/useOpenCodeGlobalListener.ts— callsetLastMessageTimeon background session idle
-
setLastMessageTimestores the latest timestamp per worktree (max of existing and new) -
getLastMessageTimereturns the stored timestamp or null -
formatRelativeTimereturns correct strings: "now", "3m", "2h", "1d", "2w" - Sending a user message updates the worktree's last-message time
- Background session completion updates the worktree's last-message time
-
pnpm lintpasses -
pnpm testpasses
- Run
formatRelativeTimetests to verify all time bracket outputs - Send a message in a session — verify
getLastMessageTimereturns a recent timestamp - Let a background session complete — verify the worktree's time updates
// test/phase-15/session-7/last-message-time-store.test.ts
describe('Session 7: Last Message Time Store', () => {
test('formatRelativeTime returns "now" for < 1 minute', () => {
expect(formatRelativeTime(Date.now() - 30000)).toBe('now')
})
test('formatRelativeTime returns "Xm" for minutes', () => {
expect(formatRelativeTime(Date.now() - 5 * 60000)).toBe('5m')
})
test('formatRelativeTime returns "Xh" for hours', () => {
expect(formatRelativeTime(Date.now() - 3 * 3600000)).toBe('3h')
})
test('formatRelativeTime returns "Xd" for days', () => {
expect(formatRelativeTime(Date.now() - 2 * 86400000)).toBe('2d')
})
test('formatRelativeTime returns "Xw" for weeks', () => {
expect(formatRelativeTime(Date.now() - 14 * 86400000)).toBe('2w')
})
test('setLastMessageTime stores timestamp for worktree', () => {
const store = useWorktreeStatusStore.getState()
store.setLastMessageTime('wt-1', 1000)
expect(store.getLastMessageTime('wt-1')).toBe(1000)
})
test('setLastMessageTime keeps max timestamp', () => {
const store = useWorktreeStatusStore.getState()
store.setLastMessageTime('wt-1', 2000)
store.setLastMessageTime('wt-1', 1000) // older
expect(store.getLastMessageTime('wt-1')).toBe(2000)
})
test('getLastMessageTime returns null for unknown worktree', () => {
expect(useWorktreeStatusStore.getState().getLastMessageTime('unknown')).toBeNull()
})
})- Display the relative time since the last message on each worktree row
- Auto-refresh the display every 60 seconds so "now" transitions to "1m" etc.
In src/renderer/src/components/worktrees/WorktreeItem.tsx, change the status row from a single <span> to a flex container:
Current code (lines 302-307):
<span className={cn('text-[11px] block', statusClass)} data-testid="worktree-status-text">
{displayStatus}
</span>New code:
<div className="flex items-center justify-between">
<span className={cn('text-[11px]', statusClass)} data-testid="worktree-status-text">
{displayStatus}
</span>
{lastMessageTime && (
<span
className="text-[10px] text-muted-foreground tabular-nums shrink-0 ml-1"
title={new Date(lastMessageTime).toLocaleString()}
data-testid="worktree-last-message-time"
>
{formatRelativeTime(lastMessageTime)}
</span>
)}
</div>Add state and imports:
import { formatRelativeTime } from '@/lib/format-utils'
const lastMessageTime = useWorktreeStatusStore((s) => s.getLastMessageTime(worktree.id))Add a timer that forces re-render every 60 seconds so the relative time stays current:
const [, setTick] = useState(0)
useEffect(() => {
const timer = setInterval(() => setTick((n) => n + 1), 60000)
return () => clearInterval(timer)
}, [])Place this inside the WorktreeItem component body, or in a parent like WorktreeList so a single timer covers all items.
src/renderer/src/components/worktrees/WorktreeItem.tsx— display relative time, auto-refresh timer
- Each worktree row shows a relative time string on the right side of the status row
- Time string formats: "now", "3m", "2h", "1d", "2w"
- Time string is gray (
text-muted-foreground) and small (text-[10px]) - Time has a tooltip showing the full date/time on hover
- Worktrees with no messages show no time string
- Time auto-refreshes every 60 seconds (e.g., "now" → "1m")
- The time does not interfere with the status text layout
-
pnpm lintpasses -
pnpm testpasses
- Send a message in a session — verify "now" appears on the right side of the worktree row
- Wait 60+ seconds — verify it changes to "1m"
- Switch to a worktree that has never had messages — verify no time string appears
- Hover over the time — verify a full date/time tooltip appears
- Verify the status text ("Working", "Ready", etc.) is still left-aligned and unchanged
// test/phase-15/session-8/last-message-time-ui.test.tsx
describe('Session 8: Last Message Time UI', () => {
test('renders relative time when lastMessageTime exists', () => {
// Mock useWorktreeStatusStore.getLastMessageTime to return Date.now() - 120000
// Render WorktreeItem
// Verify text "2m" appears in the element with data-testid="worktree-last-message-time"
})
test('does not render time when no lastMessageTime', () => {
// Mock getLastMessageTime to return null
// Render WorktreeItem
// Verify no element with data-testid="worktree-last-message-time"
})
test('time element has tooltip with full date', () => {
// Mock getLastMessageTime to return a timestamp
// Render WorktreeItem
// Verify the time element has a title attribute containing a date string
})
test('status text and time are in a flex row', () => {
// Render WorktreeItem with status and time
// Verify parent element has flex + justify-between classes
})
})- Add
favoriteModelsto the settings store for persistence - Add right-click to toggle favorite on model items in the dropdown
- Show a "Favorites" section at the top of the model dropdown with starred models
In src/renderer/src/stores/useSettingsStore.ts:
Add to AppSettings interface:
export interface AppSettings {
// ... existing fields
favoriteModels: string[] // Array of "providerID::modelID" keys
}Add to DEFAULT_SETTINGS:
const DEFAULT_SETTINGS: AppSettings = {
// ... existing
favoriteModels: []
}Add to SettingsState interface:
toggleFavoriteModel: (providerID: string, modelID: string) => voidAdd action:
toggleFavoriteModel: (providerID: string, modelID: string) => {
const key = `${providerID}::${modelID}`
const current = get().favoriteModels
const updated = current.includes(key)
? current.filter((k) => k !== key)
: [...current, key]
set({ favoriteModels: updated })
const settings = extractSettings({ ...get(), favoriteModels: updated } as SettingsState)
saveToDatabase(settings)
},Add favoriteModels to extractSettings and partialize.
In src/renderer/src/components/sessions/ModelSelector.tsx:
Add store access:
const favoriteModels = useSettingsStore((s) => s.favoriteModels)
const toggleFavoriteModel = useSettingsStore((s) => s.toggleFavoriteModel)Add favorite helpers:
const isFavorite = useCallback(
(model: ModelInfo) => favoriteModels.includes(`${model.providerID}::${model.id}`),
[favoriteModels]
)
const favoriteModelObjects = useMemo(
() => providers.flatMap((p) => p.models.filter((m) => isFavorite(m))),
[providers, isFavorite]
)Before the filteredProviders.map(...) block (around line 231), add a favorites section:
{
favoriteModelObjects.length > 0 && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground flex items-center gap-1">
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" /> Favorites
</DropdownMenuLabel>
{favoriteModelObjects.map((model) => (
<DropdownMenuItem
key={`fav-${model.providerID}:${model.id}`}
onClick={() => handleSelectModel(model)}
onContextMenu={(e) => {
e.preventDefault()
toggleFavoriteModel(model.providerID, model.id)
}}
className="flex items-center justify-between gap-2 cursor-pointer"
>
<span className="flex items-center gap-1.5">
<Star className="h-3 w-3 text-yellow-500 fill-yellow-500 shrink-0" />
<span className="truncate text-sm">{getDisplayName(model)}</span>
</span>
{isActiveModel(model) && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)
}Add onContextMenu to each regular DropdownMenuItem (line 242-253):
<DropdownMenuItem
onClick={() => handleSelectModel(model)}
onContextMenu={(e) => {
e.preventDefault()
toggleFavoriteModel(model.providerID, model.id)
}}
className="flex items-center justify-between gap-2 cursor-pointer"
data-testid={`model-item-${model.id}`}
>
<span className="flex items-center gap-1.5">
{isFavorite(model) && <Star className="h-3 w-3 text-yellow-500 fill-yellow-500 shrink-0" />}
<span className="truncate text-sm">{getDisplayName(model)}</span>
</span>
{active && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>Add import:
import { Star } from 'lucide-react'src/renderer/src/stores/useSettingsStore.ts— addfavoriteModels,toggleFavoriteModelsrc/renderer/src/components/sessions/ModelSelector.tsx— favorites section, right-click toggle, star icons
- Right-clicking a model in the dropdown toggles its favorite status
- Starred models appear in a "Favorites" section at the top of the dropdown
- Starred models show a filled yellow star icon in both the favorites section and their normal provider section
- Un-starring a model (right-click again) removes it from the favorites section
- Clicking a favorited model in the favorites section selects it as the active model
- Favorites persist across app restarts (stored in settings DB + localStorage)
- The "Favorites" section header only appears when at least one model is favorited
-
pnpm lintpasses -
pnpm testpasses
- Open the model dropdown — verify no favorites section initially
- Right-click a model — verify a star appears next to it and a "Favorites" section appears at the top
- Right-click the same model again — verify the star is removed and the favorites section disappears
- Star 2-3 models — verify they all appear in the favorites section
- Click a model in the favorites section — verify it becomes the active model
- Restart the app — verify favorites persist
- Use the filter input — verify favorites section still works with filtered results
// test/phase-15/session-9/favorite-models.test.ts
describe('Session 9: Favorite Models', () => {
test('toggleFavoriteModel adds model to favorites', () => {
const store = useSettingsStore.getState()
store.toggleFavoriteModel('anthropic', 'claude-sonnet-4')
expect(store.favoriteModels).toContain('anthropic::claude-sonnet-4')
})
test('toggleFavoriteModel removes model from favorites', () => {
const store = useSettingsStore.getState()
store.toggleFavoriteModel('anthropic', 'claude-sonnet-4') // add
store.toggleFavoriteModel('anthropic', 'claude-sonnet-4') // remove
expect(store.favoriteModels).not.toContain('anthropic::claude-sonnet-4')
})
test('favoriteModels persists in extractSettings', () => {
// Set favoriteModels to ['anthropic::claude-sonnet-4']
// Verify extractSettings includes favoriteModels
})
test('favorites section renders when favorites exist', () => {
// Mock useSettingsStore.favoriteModels with one entry
// Mock providers with matching model
// Render ModelSelector, open dropdown
// Verify "Favorites" label is visible
})
test('favorites section hidden when no favorites', () => {
// Mock useSettingsStore.favoriteModels as empty
// Render ModelSelector, open dropdown
// Verify "Favorites" label is NOT visible
})
})- Add IPC handler for opening a URL in Chrome with optional custom command
- Add preload bridge and type declarations
- Add
customChromeCommandto settings store
In src/renderer/src/stores/useSettingsStore.ts:
Add to AppSettings:
customChromeCommand: string // Custom chrome launch command, e.g. "open -n -a ..."Add to DEFAULT_SETTINGS:
customChromeCommand: ''Add to extractSettings and partialize.
In src/main/ipc/system-handlers.ts (or create if needed), add a handler for system:openInChrome:
import { shell } from 'electron'
import { exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
ipcMain.handle(
'system:openInChrome',
async (_event, { url, customCommand }: { url: string; customCommand?: string }) => {
try {
if (customCommand) {
const cmd = customCommand.replace(/\{url\}/g, url)
await execAsync(cmd)
} else {
await shell.openExternal(url)
}
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
)Register this handler in src/main/index.ts if using a new file.
In src/preload/index.ts, add to the systemOps namespace:
openInChrome: (url: string, customCommand?: string) =>
ipcRenderer.invoke('system:openInChrome', { url, customCommand }),In src/preload/index.d.ts, add to the SystemOps interface:
openInChrome: (url: string, customCommand?: string) => Promise<{ success: boolean; error?: string }>src/renderer/src/stores/useSettingsStore.ts— addcustomChromeCommandsrc/main/ipc/system-handlers.ts— addsystem:openInChromeIPC handlersrc/preload/index.ts— addopenInChrometosystemOpssrc/preload/index.d.ts— add type declaration
-
window.systemOps.openInChrome(url)opens the URL in the default browser -
window.systemOps.openInChrome(url, customCmd)runs the custom command with{url}replaced - The handler returns
{ success: true }on success - The handler returns
{ success: false, error: '...' }on failure -
customChromeCommandis stored and persisted in the settings store -
pnpm lintpasses -
pnpm testpasses
- Call
window.systemOps.openInChrome('http://localhost:3000')from the dev console — verify browser opens - Set a custom command and call with it — verify the custom command runs
- Pass an invalid command — verify error is returned
// test/phase-15/session-10/open-in-chrome-backend.test.ts
describe('Session 10: Open in Chrome Backend', () => {
test('customChromeCommand defaults to empty string', () => {
expect(useSettingsStore.getState().customChromeCommand).toBe('')
})
test('customChromeCommand persists via updateSetting', () => {
const store = useSettingsStore.getState()
store.updateSetting('customChromeCommand', 'open -a Chrome {url}')
expect(store.customChromeCommand).toBe('open -a Chrome {url}')
})
test('openInChrome type declaration matches expected signature', () => {
// TypeScript compilation validates this — no runtime test needed
// Verified by pnpm lint passing
})
})- Detect dev server URLs from run output
- Show an "Open in Chrome" button in the session tab bar when a web app is running
- Add a right-click configuration popover for the custom Chrome command
In src/renderer/src/lib/format-utils.ts (already created in Session 7), add:
const DEV_SERVER_URL_PATTERN = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d{3,5}\/?/
export function extractDevServerUrl(output: string[]): string | null {
// Scan last 50 lines for a dev server URL
for (let i = output.length - 1; i >= Math.max(0, output.length - 50); i--) {
const match = output[i].match(DEV_SERVER_URL_PATTERN)
if (match) return match[0]
}
return null
}In src/renderer/src/components/sessions/SessionTabs.tsx, add state and rendering after the right scroll arrow (around line 496):
import { Globe } from 'lucide-react'
import { useScriptStore } from '@/stores/useScriptStore'
import { useLayoutStore } from '@/stores/useLayoutStore'
import { useSettingsStore } from '@/stores/useSettingsStore'
import { extractDevServerUrl } from '@/lib/format-utils'
// Inside the component:
const runOutput = useScriptStore((s) =>
selectedWorktreeId ? s.scriptStates[selectedWorktreeId]?.runOutput : null
)
const runRunning = useScriptStore((s) =>
selectedWorktreeId ? (s.scriptStates[selectedWorktreeId]?.runRunning ?? false) : false
)
const activeBottomTab = useLayoutStore((s) => s.activeBottomTab)
const customChromeCommand = useSettingsStore((s) => s.customChromeCommand)
const detectedUrl = useMemo(() => {
if (!runRunning || activeBottomTab !== 'run' || !runOutput) return null
return extractDevServerUrl(runOutput)
}, [runRunning, activeBottomTab, runOutput])
const [chromeConfigOpen, setChromeConfigOpen] = useState(false)
const [chromeCommandInput, setChromeCommandInput] = useState(customChromeCommand)Render after the right scroll arrow:
{
detectedUrl && (
<div className="relative shrink-0 border-l border-border">
<button
onClick={() => {
window.systemOps.openInChrome(detectedUrl, customChromeCommand || undefined)
}}
onContextMenu={(e) => {
e.preventDefault()
setChromeCommandInput(customChromeCommand)
setChromeConfigOpen(true)
}}
className="flex items-center gap-1 px-2 py-1.5 text-xs hover:bg-accent transition-colors"
title={`Open ${detectedUrl} in Chrome (right-click to configure)`}
data-testid="open-in-chrome"
>
<Globe className="h-3.5 w-3.5" />
<span className="text-[11px]">Chrome</span>
</button>
{chromeConfigOpen && (
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md p-3 w-80">
<label className="text-xs font-medium block mb-1">Custom Chrome Command</label>
<p className="text-[10px] text-muted-foreground mb-2">
Use {'{url}'} as placeholder. Leave empty for default browser.
</p>
<input
value={chromeCommandInput}
onChange={(e) => setChromeCommandInput(e.target.value)}
placeholder='open -a "Google Chrome" {url}'
className="w-full text-xs bg-background border rounded px-2 py-1 mb-2"
onKeyDown={(e) => e.stopPropagation()}
/>
<div className="flex justify-end gap-1">
<button
onClick={() => setChromeConfigOpen(false)}
className="text-xs px-2 py-1 rounded hover:bg-accent"
>
Cancel
</button>
<button
onClick={() => {
useSettingsStore.getState().updateSetting('customChromeCommand', chromeCommandInput)
setChromeConfigOpen(false)
toast.success('Chrome command saved')
}}
className="text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
Save
</button>
</div>
</div>
)}
</div>
)
}src/renderer/src/lib/format-utils.ts— addextractDevServerUrlsrc/renderer/src/components/sessions/SessionTabs.tsx— add Chrome button and config popover
- "Open in Chrome" button appears in the tab bar when the Run tab is active and a dev server URL is detected
- Clicking the button opens the detected URL in Chrome (or default browser)
- Right-clicking the button shows a configuration popover for the custom Chrome command
- Saving a custom command persists it and uses it for future opens
- The button disappears when the run process stops or the Run tab is deselected
- URL detection works for common dev servers:
localhost:3000,127.0.0.1:5173,0.0.0.0:8080 -
pnpm lintpasses -
pnpm testpasses
- Configure a run script that starts a Next.js/Vite dev server
- Press Cmd+R to run — wait for the server URL to appear in output
- Switch to the Run tab — verify the "Chrome" button appears in the tab bar
- Click the button — verify the URL opens in a browser
- Right-click the button — verify the config popover appears
- Enter a custom Chrome command with
{url}— click Save — verify toast appears - Click the button again — verify it uses the custom command
- Stop the run process — verify the button disappears
- Switch away from the Run tab — verify the button disappears
// test/phase-15/session-11/open-in-chrome-ui.test.tsx
describe('Session 11: Open in Chrome UI', () => {
test('extractDevServerUrl finds localhost URL', () => {
const output = ['Starting server...', ' > Local: http://localhost:3000/', 'ready']
expect(extractDevServerUrl(output)).toBe('http://localhost:3000/')
})
test('extractDevServerUrl finds 127.0.0.1 URL', () => {
const output = ['Server running at http://127.0.0.1:5173']
expect(extractDevServerUrl(output)).toBe('http://127.0.0.1:5173')
})
test('extractDevServerUrl returns null when no URL found', () => {
const output = ['Building...', 'Done.']
expect(extractDevServerUrl(output)).toBeNull()
})
test('extractDevServerUrl scans last 50 lines only', () => {
const output = Array(100).fill('noise')
output[10] = 'http://localhost:3000' // too far back
expect(extractDevServerUrl(output)).toBeNull()
})
test('Chrome button renders when URL detected and run tab active', () => {
// Mock useScriptStore with runRunning: true, runOutput containing URL
// Mock useLayoutStore with activeBottomTab: 'run'
// Render SessionTabs
// Verify button with data-testid="open-in-chrome" exists
})
test('Chrome button hidden when run tab not active', () => {
// Mock activeBottomTab: 'terminal'
// Verify no open-in-chrome button
})
})- Detect merge-conflicted files from git status
- Show a bold "CONFLICTS" button next to the review button when conflicts exist
- Clicking the button creates a new session and auto-sends "Fix merge conflicts"
In src/renderer/src/components/git/GitStatusPanel.tsx, add conflict detection:
const conflictedFiles = useMemo(() => fileStatuses.filter((f) => f.status === 'C'), [fileStatuses])
const hasConflicts = conflictedFiles.length > 0
const [isFixingConflicts, setIsFixingConflicts] = useState(false)Add the handleFixConflicts callback (follows the same pattern as handleReview at lines 255-326):
const handleFixConflicts = useCallback(async () => {
if (!worktreePath) return
setIsFixingConflicts(true)
try {
const worktreeStore = useWorktreeStore.getState()
const selectedWorktreeId = worktreeStore.selectedWorktreeId
if (!selectedWorktreeId) {
toast.error('No worktree selected')
return
}
let projectId = ''
for (const [projId, worktrees] of worktreeStore.worktreesByProject) {
if (worktrees.some((w) => w.id === selectedWorktreeId)) {
projectId = projId
break
}
}
if (!projectId) {
toast.error('Could not find project for worktree')
return
}
const branchName = branchInfo?.name || 'unknown'
const sessionStore = useSessionStore.getState()
const result = await sessionStore.createSession(selectedWorktreeId, projectId)
if (!result.success || !result.session) {
toast.error('Failed to create session')
return
}
await sessionStore.updateSessionName(result.session.id, `Merge Conflicts — ${branchName}`)
sessionStore.setPendingMessage(result.session.id, 'Fix merge conflicts')
} catch (error) {
console.error('Failed to start conflict resolution:', error)
toast.error('Failed to start conflict resolution')
} finally {
setIsFixingConflicts(false)
}
}, [worktreePath, branchInfo])In the header buttons area (lines 362-389), add before the review button:
import { AlertTriangle } from 'lucide-react'
// Inside <div className="flex items-center gap-0.5">:
{
hasConflicts && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] font-bold text-orange-500 hover:text-orange-400 hover:bg-orange-500/10"
onClick={handleFixConflicts}
disabled={isFixingConflicts}
title={`${conflictedFiles.length} file(s) with merge conflicts — click to fix with AI`}
data-testid="git-merge-conflicts-button"
>
{isFixingConflicts ? (
<Loader2 className="h-3 w-3 animate-spin mr-0.5" />
) : (
<AlertTriangle className="h-3 w-3 mr-0.5" />
)}
CONFLICTS
</Button>
)
}src/renderer/src/components/git/GitStatusPanel.tsx— conflict detection, "CONFLICTS" button,handleFixConflicts
- "CONFLICTS" button appears in bold orange when any conflicted files (
status === 'C') exist - The button shows the AlertTriangle icon and text "CONFLICTS"
- Clicking the button creates a new session named "Merge Conflicts — {branchName}"
- The session auto-sends "Fix merge conflicts" as the first message
- The button shows a spinner while creating the session
- The button is hidden when there are no conflicts
- The review button and refresh button continue to work alongside the conflicts button
-
pnpm lintpasses -
pnpm testpasses
- Create a merge conflict: make conflicting changes on two branches, merge one into the other
- Open the git panel — verify "CONFLICTS" button appears in bold orange
- Click the button — verify a new session is created
- Verify the session name is "Merge Conflicts — {branchName}"
- Verify "Fix merge conflicts" is auto-sent as the first message
- Resolve the conflicts, refresh git status — verify the button disappears
- Verify the review button still works independently
// test/phase-15/session-12/merge-conflicts.test.tsx
describe('Session 12: Merge Conflicts Button', () => {
test('CONFLICTS button renders when conflicted files exist', () => {
// Mock useGitStore with files containing status: 'C'
// Render GitStatusPanel
// Verify button with data-testid="git-merge-conflicts-button" exists
// Verify it contains text "CONFLICTS"
})
test('CONFLICTS button hidden when no conflicts', () => {
// Mock useGitStore with files containing only status: 'M', 'A', '?'
// Render GitStatusPanel
// Verify no git-merge-conflicts-button
})
test('clicking CONFLICTS creates session with correct name', async () => {
const createSession = vi.fn().mockResolvedValue({ success: true, session: { id: 's1' } })
const updateSessionName = vi.fn()
const setPendingMessage = vi.fn()
// Mock stores with conflicted files and branch name 'feature/auth'
// Click CONFLICTS button
// Verify createSession called
// Verify updateSessionName called with 'Merge Conflicts — feature/auth'
// Verify setPendingMessage called with 'Fix merge conflicts'
})
test('button shows spinner while creating session', async () => {
// Mock createSession to return a pending promise
// Click CONFLICTS button
// Verify Loader2 spinner is visible
})
})- Verify all Phase 15 features work correctly together
- Test cross-feature interactions
- Run lint and tests
- Fix any edge cases or regressions
- Start a session, send a message, switch to another worktree
- Wait for completion — verify context indicator shows correct values on switch back
- Verify "unread" dot also appears on the worktree row
- Start a session that triggers a tool call AND a question
- Switch away while the tool is running
- Switch back — verify tool result is merged correctly AND question dialog appears
- Send messages rapidly in a session
- Verify all messages appear in order
- Verify context indicator updates correctly with each response
- Open a file tab from the file tree
- Copy the branch name from the header — verify it works while file tab is active
- Press Cmd+W — verify the file tab closes (not the session)
- Press Cmd+W again — verify the session closes
- Star two models via right-click
- Open the dropdown — verify favorites section appears at top
- Select a favorite — verify it becomes active
- Send a message — verify the model is used correctly
- Send a message — verify "now" appears on the worktree row
- Start a session on a second worktree, switch away
- Let it complete — verify time updates and "unread" dot appears together
- Start a dev server via the Run tab
- Verify "Chrome" button appears in the tab bar
- Click it — verify browser opens
- Right-click, set a custom command, save — verify it persists
- Stop the server — verify button disappears
- Create a merge conflict
- Verify CONFLICTS button appears
- Click it — verify session creates and sends prompt
- If the LLM asks a question during conflict resolution, verify the question dialog works
Walk through the complete flow:
- Open app → select a worktree → verify context indicator from previous sessions loads
- Star a model → select it → send a message → verify "now" appears on worktree row
- Copy branch name from header → verify clipboard
- Open a file tab → press Cmd+W → verify file tab closes, not session
- Start another session on a different worktree → switch away → let it complete
- Switch back → verify context indicator, last-message time, and unread status
- Start a dev server → switch to Run tab → verify Chrome button → click it
- Create a merge conflict → verify CONFLICTS button → click it → verify auto-sent prompt
- Trigger a question → switch away → switch back → verify question dialog persists
- Verify no detached tool results across any tab switches
pnpm lint
pnpm testFix any failures.
- All files modified in Sessions 1-12
- All 10 features work correctly in isolation
- Cross-feature interactions work (context + tab switch, question + tool correlation, etc.)
- No regressions from Phase 14 features (custom icons, drag reorder, dock badge, etc.)
-
pnpm lintpasses with no new warnings -
pnpm testpasses with all Phase 15 tests green - App starts and runs without console errors
- No TypeScript compilation errors
// test/phase-15/session-13/integration-verification.test.ts
describe('Session 13: Integration Verification', () => {
test('all Phase 15 features compile without errors', () => {
// This is validated by `pnpm lint` passing
})
test('all Phase 15 test suites pass', () => {
// This is validated by `pnpm test` passing
})
})