This document outlines the implementation plan for Hive Phase 11, focusing on session title simplification (server-side titles), automatic and manual branch renaming, worktree UX improvements (auto-start, create from branch), file sidebar redesign, and streaming/tool-call correctness bugfixes.
The implementation is divided into 12 focused sessions, each with:
- Clear objectives
- Definition of done
- Testing criteria for verification
Phase 11 builds upon Phase 10 — all Phase 10 infrastructure is assumed to be in place.
test/
├── phase-11/
│ ├── session-1/
│ │ └── remove-haiku-naming.test.ts
│ ├── session-2/
│ │ └── server-title-events.test.ts
│ ├── session-3/
│ │ └── branch-rename-infra.test.ts
│ ├── session-4/
│ │ └── auto-rename-branch.test.ts
│ ├── session-5/
│ │ └── manual-branch-rename.test.ts
│ ├── session-6/
│ │ └── auto-start-session.test.ts
│ ├── session-7/
│ │ └── worktree-from-branch.test.ts
│ ├── session-8/
│ │ └── streaming-bugfixes.test.ts
│ ├── session-9/
│ │ └── file-sidebar-tabs.test.ts
│ ├── session-10/
│ │ └── changes-view.test.ts
│ ├── session-11/
│ │ └── ui-text-changes.test.ts
│ └── session-12/
│ └── integration-verification.test.ts
# No new dependencies — all features use existing packages:
# - @opencode-ai/sdk (session.patch, session status already available)
# - simple-git (branch -m for rename — already installed)
# - zustand (stores — already installed)
# - lucide-react (icons — already installed)
# - @tanstack/react-virtual (ChangesView can reuse — already installed)- Remove the entire custom title generation system (Haiku calls, naming callbacks, temporary sessions, fire-and-forget triggers)
- Change the default session title format to match the server expectation
- Ensure the app still functions with the old timestamp-based titles until server titles arrive
In src/main/services/opencode-service.ts:
- Delete the
NamingCallbackinterface (lines 141-147) - Delete the
namingCallbacksMap declaration (line 155) - Delete all naming callback event routing in
handleEvent()(lines 1017-1049) — the block that checks if an event's session ID is innamingCallbacks, collects text deltas, and resolves onsession.idle - Delete the
generateSessionName()method entirely (lines 1159-1231)
In src/main/ipc/opencode-handlers.ts:
- Delete the
opencode:generateSessionNamehandler (lines 142-163)
In src/preload/index.ts:
- Remove the
generateSessionNamemethod from theopencodeOpsnamespace (lines 675-679)
In src/preload/index.d.ts:
- Remove the
generateSessionNamedeclaration from theopencodeOpsinterface (line 310)
In src/renderer/src/components/sessions/SessionView.tsx:
- Delete the
hasTriggeredNamingRefdeclaration - Delete the fire-and-forget naming block that calls
window.opencodeOps.generateSessionName()(lines 1474-1502) - Remove any imports related to the naming flow
In src/renderer/src/stores/useSessionStore.ts:
- Delete the
generateSessionName()utility function (lines 57-63 — the one that produces"Session HH:MM") - Change the
createSessionmethod's name value (line 152) fromgenerateSessionName()to:
name: `New session - ${new Date().toISOString()}`This format matches the server's default title regex so the server's ensureTitle guard recognizes it as a placeholder and will auto-generate a proper title.
Search the codebase for any remaining references to generateSessionName, namingCallbacks, hasTriggeredNamingRef, or NamingCallback and remove them.
src/main/services/opencode-service.ts— remove naming infrastructuresrc/main/ipc/opencode-handlers.ts— remove IPC handlersrc/preload/index.ts— remove preload methodsrc/preload/index.d.ts— remove type declarationsrc/renderer/src/components/sessions/SessionView.tsx— remove naming triggersrc/renderer/src/stores/useSessionStore.ts— remove utility, update default title
-
NamingCallbackinterface andnamingCallbacksMap no longer exist -
generateSessionName()method removed from opencode-service -
opencode:generateSessionNameIPC handler removed -
generateSessionNameremoved from preload and type declarations -
hasTriggeredNamingRefand fire-and-forget naming block removed from SessionView - Local
generateSessionName()utility removed from session store - New sessions are created with title format
"New session - <ISO date>" - No console errors when sending the first message (no calls to removed APIs)
-
pnpm lintpasses -
pnpm testpasses
- Start the app, create a new session
- Verify the tab shows a title like
"New session - 2026-02-10T..." - Send a message — verify NO Haiku API call is made (check main process logs for absence of
generateSessionName) - Verify no console errors related to missing
generateSessionName - (Titles won't auto-update yet — that comes in Session 2)
// test/phase-11/session-1/remove-haiku-naming.test.ts
describe('Session 1: Remove Haiku Naming', () => {
test('createSession uses ISO date format title', () => {
// Mock window.db.session.create
// Call useSessionStore.getState().createSession(worktreeId, projectId)
// Verify name matches /^New session - \d{4}-\d{2}-\d{2}T/
})
test('generateSessionName no longer exists on window.opencodeOps', () => {
expect(window.opencodeOps.generateSessionName).toBeUndefined()
})
test('no naming-related refs in SessionView', () => {
// Verify hasTriggeredNamingRef is not present in component
// (source-level verification)
})
})- Handle
session.updatedSSE events in both the main process (persist to DB) and renderer (update store) - Add
renameSessionmethod for manual title changes via the OpenCode PATCH API - Wire up the full IPC chain for
renameSession
In src/main/services/opencode-service.ts, in the handleEvent() method, add handling for session.updated events. When the event contains a title, persist it to the DB:
if (eventType === 'session.updated') {
const sessionData = event.properties
const opencodeSessionId = sessionData?.id || sessionData?.sessionID
if (opencodeSessionId) {
const hiveSessionId = this.getMappedHiveSessionId(opencodeSessionId)
if (hiveSessionId && sessionData?.title) {
try {
db.updateSession(hiveSessionId, { name: sessionData.title })
} catch (err) {
log.warn('Failed to persist session title from server', { err })
}
}
}
// Continue to forward event to renderer (existing generic forwarding handles this)
}Ensure this block runs before the generic event forwarding so the DB is updated before the renderer processes it.
In src/renderer/src/components/sessions/SessionView.tsx, in the stream event handler, add a branch for session.updated:
if (event.type === 'session.updated') {
const sessionData = event.data
if (sessionData?.title) {
useSessionStore.getState().updateSessionName(sessionId, sessionData.title)
}
return
}Place this before the existing message.part.updated branch.
In src/main/services/opencode-service.ts, add a new public method:
async renameSession(
opencodeSessionId: string,
title: string,
worktreePath?: string
): Promise<void> {
const instance = await this.getOrCreateInstance()
await instance.client.session.patch({
path: { sessionID: opencodeSessionId },
query: worktreePath ? { directory: worktreePath } : undefined,
body: { title }
})
}In src/main/ipc/opencode-handlers.ts:
ipcMain.handle(
'opencode:renameSession',
async (
_event,
{
opencodeSessionId,
title,
worktreePath
}: { opencodeSessionId: string; title: string; worktreePath?: string }
) => {
log.info('IPC: opencode:renameSession', { opencodeSessionId, title })
try {
await openCodeService.renameSession(opencodeSessionId, title, worktreePath)
return { success: true }
} catch (error) {
log.error('IPC: opencode:renameSession failed', { error })
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
)In src/preload/index.ts, add to opencodeOps:
renameSession: (
opencodeSessionId: string,
title: string,
worktreePath?: string
): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('opencode:renameSession', { opencodeSessionId, title, worktreePath }),In src/preload/index.d.ts, add to the opencodeOps interface:
renameSession: (opencodeSessionId: string, title: string, worktreePath?: string) =>
Promise<{ success: boolean; error?: string }>src/main/services/opencode-service.ts—session.updatedhandling,renameSession()methodsrc/main/ipc/opencode-handlers.ts—opencode:renameSessionhandlersrc/preload/index.ts— preload bridgesrc/preload/index.d.ts— type declarationssrc/renderer/src/components/sessions/SessionView.tsx—session.updatedstream handler
-
session.updatedevents with a title field update the session name in the DB (main process) -
session.updatedevents update the session name in the renderer store - Session tab title updates within seconds of sending the first message
- Session history list reflects the new title
-
renameSession()callsclient.session.patch()with the title -
opencode:renameSessionIPC handler registered - Preload exposes
renameSession()onwindow.opencodeOps -
pnpm lintpasses -
pnpm testpasses
- Create a new session, send a message like "help me debug the auth module"
- Wait 3-5 seconds — verify the tab title changes from
"New session - ..."to a descriptive title (e.g., "Auth module debugging") - Open session history — verify the title is also updated there
- Test
renameSessionfrom devtools: callwindow.opencodeOps.renameSession(opencodeSessionId, 'My Custom Title', worktreePath)— verify the tab updates
// test/phase-11/session-2/server-title-events.test.ts
describe('Session 2: Server Title Events', () => {
test('session.updated event updates session name in store', () => {
const updateSessionName = vi.fn()
// Mock useSessionStore.getState().updateSessionName
// Simulate stream event: { type: 'session.updated', sessionId: 'hive-1', data: { title: 'Auth debugging' } }
// Verify updateSessionName called with ('hive-1', 'Auth debugging')
})
test('session.updated without title is ignored', () => {
// Simulate event with no title field
// Verify updateSessionName NOT called
})
test('renameSession IPC calls session.patch', () => {
// Mock openCodeService.renameSession
// Invoke 'opencode:renameSession' handler
// Verify renameSession called with correct args
})
test('preload exposes renameSession', () => {
expect(window.opencodeOps.renameSession).toBeDefined()
})
})- Add the
canonicalizeBranchName()utility - Add the
renameBranch()method to git-service - Wire up the IPC channel for branch renaming
- Add the
branch_renamedcolumn to the worktrees table via DB migration - Export the city names list for checking auto-generated names
In src/main/services/git-service.ts, add as an exported function:
/**
* Convert a session title into a safe git branch name.
*/
export function canonicalizeBranchName(title: string): string {
return title
.toLowerCase()
.replace(/[\s_]+/g, '-') // spaces and underscores → dashes
.replace(/[^a-z0-9\-/.]/g, '') // remove invalid chars
.replace(/-{2,}/g, '-') // collapse consecutive dashes
.replace(/^-+|-+$/g, '') // strip leading/trailing dashes
.slice(0, 50) // truncate
.replace(/-+$/, '') // strip trailing dashes after truncation
}In src/main/services/git-service.ts, add:
/**
* Rename a branch in a worktree directory.
*/
async renameBranch(
worktreePath: string,
oldBranch: string,
newBranch: string
): Promise<{ success: boolean; error?: string }> {
try {
const git = simpleGit(worktreePath)
await git.branch(['-m', oldBranch, newBranch])
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}In src/main/services/city-names.ts, ensure the city names array is exported:
export const CITY_NAMES = [
/* existing list */
]If it's currently const cityNames = [...] and only used internally, rename and export it.
In src/main/db/schema.ts, bump CURRENT_SCHEMA_VERSION and add a new migration:
{
version: CURRENT_SCHEMA_VERSION,
up: (db) => {
db.exec('ALTER TABLE worktrees ADD COLUMN branch_renamed INTEGER NOT NULL DEFAULT 0')
}
}In src/preload/index.d.ts, add to the Worktree interface:
branch_renamed?: number // 0 = auto-named (city), 1 = user/auto renamedIn src/main/ipc/worktree-handlers.ts:
ipcMain.handle(
'worktree:renameBranch',
async (
_event,
{
worktreeId,
worktreePath,
oldBranch,
newBranch
}: { worktreeId: string; worktreePath: string; oldBranch: string; newBranch: string }
) => {
log.info('IPC: worktree:renameBranch', { worktreePath, oldBranch, newBranch })
try {
const result = await gitService.renameBranch(worktreePath, oldBranch, newBranch)
if (result.success) {
db.updateWorktree(worktreeId, { branch: newBranch, branch_renamed: 1 })
}
return result
} catch (error) {
log.error('IPC: worktree:renameBranch failed', { error })
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
)In src/preload/index.ts, add to worktreeOps:
renameBranch: (
worktreeId: string,
worktreePath: string,
oldBranch: string,
newBranch: string
): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('worktree:renameBranch', { worktreeId, worktreePath, oldBranch, newBranch }),In src/preload/index.d.ts, add to worktreeOps:
renameBranch: (worktreeId: string, worktreePath: string, oldBranch: string, newBranch: string) =>
Promise<{ success: boolean; error?: string }>In src/renderer/src/stores/useWorktreeStore.ts, add:
updateWorktreeBranch: (worktreeId: string, newBranch: string) => {
set((state) => ({
worktrees: state.worktrees.map((w) => (w.id === worktreeId ? { ...w, branch: newBranch } : w))
}))
}src/main/services/git-service.ts—canonicalizeBranchName(),renameBranch()src/main/services/city-names.ts— exportCITY_NAMESsrc/main/db/schema.ts— migration forbranch_renamedsrc/main/ipc/worktree-handlers.ts—worktree:renameBranchhandlersrc/preload/index.ts— preload bridgesrc/preload/index.d.ts— type declarations,Worktreetype updatesrc/renderer/src/stores/useWorktreeStore.ts—updateWorktreeBranch
-
canonicalizeBranchName('Auth Refresh Token Support')returns'auth-refresh-token-support' -
canonicalizeBranchNamehandles edge cases: double spaces, special chars, >50 char strings, empty input -
renameBranch()callsgit branch -m oldBranch newBranch - DB migration adds
branch_renamedcolumn to worktrees table -
Worktreetype includesbranch_renamed -
worktree:renameBranchIPC handler renames branch and updates DB -
updateWorktreeBranchupdates the store so the sidebar reflects changes -
pnpm lintpasses -
pnpm testpasses
- Test
canonicalizeBranchNamewith various inputs from devtools - Test
renameBranchvia IPC from devtools: pick a worktree, callwindow.worktreeOps.renameBranch(id, path, 'old', 'new'), verifygit branchshows new name - Verify
branch_renamedcolumn exists in DB after migration
// test/phase-11/session-3/branch-rename-infra.test.ts
describe('Session 3: Branch Rename Infrastructure', () => {
describe('canonicalizeBranchName', () => {
test('converts spaces to dashes and lowercases', () => {
expect(canonicalizeBranchName('Auth Refresh Token')).toBe('auth-refresh-token')
})
test('removes special characters', () => {
expect(canonicalizeBranchName('Fix #123: Bug!')).toBe('fix-123-bug')
})
test('collapses consecutive dashes', () => {
expect(canonicalizeBranchName('fix -- double spaces')).toBe('fix-double-spaces')
})
test('truncates to 50 characters', () => {
const long = 'a'.repeat(60)
expect(canonicalizeBranchName(long).length).toBeLessThanOrEqual(50)
})
test('strips trailing dashes after truncation', () => {
const input = 'a'.repeat(49) + '-b'
const result = canonicalizeBranchName(input)
expect(result.endsWith('-')).toBe(false)
})
test('returns empty string for empty input', () => {
expect(canonicalizeBranchName('')).toBe('')
})
test('preserves dots and slashes', () => {
expect(canonicalizeBranchName('feature/auth.v2')).toBe('feature/auth.v2')
})
test('converts underscores to dashes', () => {
expect(canonicalizeBranchName('fix_the_bug')).toBe('fix-the-bug')
})
})
describe('renameBranch IPC', () => {
test('worktree:renameBranch handler exists', () => {
// Source verification
})
test('preload exposes renameBranch on worktreeOps', () => {
expect(window.worktreeOps.renameBranch).toBeDefined()
})
})
})- When a server-generated title arrives for a session, auto-rename the worktree branch from the city name to a canonicalized version of the title
- Only rename if the branch is still an original city name (not manually renamed)
- Only rename once per worktree (use
branch_renamedflag)
In src/main/services/opencode-service.ts, extend the session.updated handler from Session 2. After persisting the title, check if the branch should be auto-renamed:
// After: db.updateSession(hiveSessionId, { name: sessionData.title })
// Auto-rename branch if still a city name
const worktree = db.getWorktreeBySessionId(hiveSessionId)
if (worktree && !worktree.branch_renamed) {
const isCityName = CITY_NAMES.some((city) => city.toLowerCase() === worktree.branch.toLowerCase())
if (isCityName) {
const newBranch = canonicalizeBranchName(sessionData.title)
if (newBranch && newBranch !== worktree.branch.toLowerCase()) {
try {
const renameResult = await gitService.renameBranch(
worktree.path,
worktree.branch,
newBranch
)
if (renameResult.success) {
db.updateWorktree(worktree.id, { branch: newBranch, branch_renamed: 1 })
// Notify renderer to update the sidebar
this.sendToRenderer('worktree:branchRenamed', {
worktreeId: worktree.id,
newBranch
})
}
} catch (err) {
log.warn('Failed to auto-rename branch', { err })
}
}
}
}If db.getWorktreeBySessionId() doesn't exist, add it to the DB service:
getWorktreeBySessionId(sessionId: string): Worktree | undefined {
const session = this.db.prepare('SELECT worktree_id FROM sessions WHERE id = ?').get(sessionId)
if (!session) return undefined
return this.db.prepare('SELECT * FROM worktrees WHERE id = ?').get(session.worktree_id)
}In the renderer, set up a listener (in useWorktreeStore.ts or in a global listener hook) for the worktree:branchRenamed event:
window.worktreeOps?.onBranchRenamed?.((data) => {
const { worktreeId, newBranch } = data
useWorktreeStore.getState().updateWorktreeBranch(worktreeId, newBranch)
})Use the preload's typed namespace pattern similar to how onStream works.
Add imports for CITY_NAMES, canonicalizeBranchName, and gitService at the top of the file where they'll be used in the event handler.
src/main/services/opencode-service.ts— auto-rename insession.updatedhandlersrc/main/db/—getWorktreeBySessionIdhelper (if needed)src/renderer/src/stores/useWorktreeStore.ts— listen forworktree:branchRenamedsrc/preload/index.ts— exposeworktree:branchRenamedlistener (if needed)
- When first title arrives and branch is a city name, branch is renamed to canonicalized title
-
branch_renamedflag set to 1 after rename - Second title update does NOT trigger another rename
- If branch was already manually renamed (not a city name), no rename occurs
- Sidebar immediately reflects the new branch name
- Git branch is actually renamed on disk (
git branchshows new name) -
pnpm lintpasses -
pnpm testpasses
- Create a new worktree — verify it gets a city name (e.g., "tokyo")
- Create a session and send "help me set up authentication"
- Wait for the title to arrive — verify the branch name changes from "tokyo" to something like "auth-setup" in the sidebar
- Verify
git branchin the worktree directory shows the new branch name - Send another message — verify the branch is NOT renamed again
- Create another worktree, manually rename its branch (Session 5), then send a message — verify the branch is NOT auto-renamed
// test/phase-11/session-4/auto-rename-branch.test.ts
describe('Session 4: Auto-Rename Branch', () => {
test('branch renamed when city name and first title arrives', () => {
// Mock: worktree with branch 'tokyo', branch_renamed: 0
// Simulate session.updated with title 'Auth Setup Guide'
// Verify renameBranch called with ('tokyo', 'auth-setup-guide')
// Verify DB updated with branch_renamed: 1
})
test('branch NOT renamed when already renamed', () => {
// Mock: worktree with branch 'custom-name', branch_renamed: 1
// Simulate session.updated with title 'New Title'
// Verify renameBranch NOT called
})
test('branch NOT renamed when name is not a city', () => {
// Mock: worktree with branch 'my-feature', branch_renamed: 0
// Simulate session.updated with title 'New Title'
// Verify renameBranch NOT called (branch not in CITY_NAMES)
})
test('renderer receives branchRenamed event', () => {
// Mock worktree:branchRenamed IPC event
// Verify updateWorktreeBranch called in store
})
})- Add a "Rename Branch" option to the worktree context menu and dropdown menu
- Show an inline text input for the new branch name
- On submit, call the rename IPC and update the store
In src/renderer/src/components/worktrees/WorktreeItem.tsx, add state:
const [isRenamingBranch, setIsRenamingBranch] = useState(false)
const [branchNameInput, setBranchNameInput] = useState('')Add to both the dropdown menu (lines 193-232) and the context menu (lines 238-277), after "Copy Path" and before "Duplicate":
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setBranchNameInput(worktree.branch)
setIsRenamingBranch(true)
}}
>
<Pencil className="h-3.5 w-3.5 mr-2" />
Rename Branch
</DropdownMenuItem>Only show for non-default worktrees (same guard as Duplicate).
When isRenamingBranch is true, replace the branch name display area with an input field:
{isRenamingBranch ? (
<input
autoFocus
value={branchNameInput}
onChange={(e) => setBranchNameInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleBranchRename()
if (e.key === 'Escape') setIsRenamingBranch(false)
}}
onBlur={() => setIsRenamingBranch(false)}
className="bg-background border border-border rounded px-1.5 py-0.5 text-xs w-full"
/>
) : (
// existing branch name display
)}const handleBranchRename = async () => {
const trimmed = branchNameInput.trim()
if (!trimmed || trimmed === worktree.branch) {
setIsRenamingBranch(false)
return
}
// Canonicalize for safety
const newBranch = trimmed
.toLowerCase()
.replace(/[\s_]+/g, '-')
.replace(/[^a-z0-9\-/.]/g, '')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50)
.replace(/-+$/, '')
if (!newBranch) {
toast.error('Invalid branch name')
setIsRenamingBranch(false)
return
}
const result = await window.worktreeOps.renameBranch(
worktree.id,
worktree.path,
worktree.branch,
newBranch
)
if (result.success) {
useWorktreeStore.getState().updateWorktreeBranch(worktree.id, newBranch)
toast.success(`Branch renamed to ${newBranch}`)
} else {
toast.error(result.error || 'Failed to rename branch')
}
setIsRenamingBranch(false)
}Add Pencil to the lucide-react import at the top of the file if not already imported.
src/renderer/src/components/worktrees/WorktreeItem.tsx— menu item, inline input, handler
- "Rename Branch" appears in both the dropdown and context menu for non-default worktrees
- Clicking it shows an inline text input pre-filled with the current branch name
- Pressing Enter submits the rename
- Pressing Escape or clicking away cancels
- The input value is canonicalized before sending
- Git branch is renamed on success
- DB record updated with
branch_renamed: 1 - Sidebar immediately reflects the new name
- Toast notification on success/failure
- Invalid branch names show an error toast
-
pnpm lintpasses -
pnpm testpasses
- Right-click a non-default worktree in the sidebar
- Click "Rename Branch" — verify input appears with current branch name
- Type a new name like "my-feature-branch", press Enter
- Verify the sidebar updates, toast shows success
- Verify
git branchshows the new name - Try renaming with invalid chars (e.g., "my branch!!!") — verify it gets canonicalized
- Press Escape instead of Enter — verify rename is cancelled
- Verify "Rename Branch" does NOT appear for the default worktree
// test/phase-11/session-5/manual-branch-rename.test.ts
describe('Session 5: Manual Branch Rename', () => {
test('Rename Branch menu item renders for non-default worktree', () => {
// Render WorktreeItem with is_default: false
// Open context menu
// Verify "Rename Branch" item exists
})
test('Rename Branch menu item NOT rendered for default worktree', () => {
// Render WorktreeItem with is_default: true
// Open context menu
// Verify "Rename Branch" item does NOT exist
})
test('clicking Rename Branch shows input', () => {
// Click "Rename Branch"
// Verify input element appears with current branch name
})
test('Enter submits rename', () => {
// Mock window.worktreeOps.renameBranch returning success
// Show input, change value, press Enter
// Verify renameBranch called with correct args
})
test('Escape cancels rename', () => {
// Show input, press Escape
// Verify renameBranch NOT called
// Verify input disappears
})
test('invalid input shows error', () => {
// Show input, set value to '' (empty after canonicalization)
// Submit
// Verify error toast
})
})- Simplify the auto-start logic from project-wide to per-worktree
- When entering a worktree with 0 sessions, automatically create the first one
In src/renderer/src/components/sessions/SessionTabs.tsx, replace the auto-start effect (lines 198-237):
useEffect(() => {
if (!selectedWorktreeId) return
if (!project) return
if (isLoading) return
if (!autoStartSession) return
const sessions = useSessionStore.getState().getSessionsByWorktree(selectedWorktreeId)
if (sessions.length > 0) return
if (autoStartedRef.current === selectedWorktreeId) return
autoStartedRef.current = selectedWorktreeId
useSessionStore.getState().createSession(selectedWorktreeId, project.id)
}, [selectedWorktreeId, project, isLoading, autoStartSession])This removes:
- The async
window.db.session.getByProject(project.id)call - The project-wide "any active session?" guard
- The re-check after the async call
The setting toggle in SettingsGeneral.tsx should continue to work — when disabled, no auto-start occurs.
src/renderer/src/components/sessions/SessionTabs.tsx— simplify auto-start effect
- Entering a worktree with 0 sessions auto-creates a session (when setting enabled)
- Entering a worktree with existing sessions does NOT auto-create
- Auto-start works even when other worktrees in the project have active sessions
- Disabling the
autoStartSessionsetting prevents auto-creation - Auto-start only fires once per worktree selection (no duplicates)
- The "No sessions yet. Click + to create one." message is never shown when setting is enabled
-
pnpm lintpasses -
pnpm testpasses
- Create a new worktree — verify a session is automatically created
- Switch to a different worktree that has sessions, then switch back — verify no duplicate session created
- Create another new worktree while the first has an active session — verify auto-start still works for the new worktree
- Go to Settings, disable "Auto-start session" — create a new worktree — verify no auto-start, see "Click + to create one."
- Re-enable the setting — create another new worktree — verify auto-start resumes
// test/phase-11/session-6/auto-start-session.test.ts
describe('Session 6: Auto-Start Session', () => {
test('auto-creates session when worktree has 0 sessions', () => {
// Mock: selectedWorktreeId set, sessions empty, autoStartSession true
// Verify createSession called with (worktreeId, projectId)
})
test('does NOT create when worktree has existing sessions', () => {
// Mock: sessions.length > 0
// Verify createSession NOT called
})
test('does NOT create when setting disabled', () => {
// Mock: autoStartSession false
// Verify createSession NOT called
})
test('does NOT create duplicate on re-render', () => {
// Trigger effect twice with same worktreeId
// Verify createSession called only once
})
test('creates for new worktree even when other worktrees have sessions', () => {
// Mock: worktree A has sessions, switch to worktree B with 0 sessions
// Verify createSession called for worktree B
})
})- Add
createWorktreeFromBranch()andlistBranchesWithStatus()to git-service - Wire up IPC handlers
- Build the branch picker dialog
- Add "New Workspace From..." to the project context menu
In src/main/services/git-service.ts:
async listBranchesWithStatus(): Promise<
Array<{
name: string
isRemote: boolean
isCheckedOut: boolean
worktreePath?: string
}>
> {
const [branchSummary, worktreeList] = await Promise.all([
this.git.branch(['-a']),
this.git.raw(['worktree', 'list', '--porcelain'])
])
const checkedOut = new Map<string, string>()
const blocks = worktreeList.split('\n\n').filter(Boolean)
for (const block of blocks) {
const lines = block.split('\n')
const wtPath = lines.find((l) => l.startsWith('worktree '))?.replace('worktree ', '')
const branch = lines.find((l) => l.startsWith('branch '))?.replace('branch refs/heads/', '')
if (wtPath && branch) checkedOut.set(branch, wtPath)
}
return Object.entries(branchSummary.branches).map(([name, info]) => ({
name: info.name,
isRemote: name.startsWith('remotes/'),
isCheckedOut: checkedOut.has(info.name),
worktreePath: checkedOut.get(info.name)
}))
}In src/main/services/git-service.ts:
async createWorktreeFromBranch(
projectName: string,
branchName: string
): Promise<CreateWorktreeResult> {
// Check if branch is already checked out
const worktreeList = await this.git.raw(['worktree', 'list', '--porcelain'])
const blocks = worktreeList.split('\n\n').filter(Boolean)
for (const block of blocks) {
const lines = block.split('\n')
const branch = lines.find((l) => l.startsWith('branch '))?.replace('branch refs/heads/', '')
const wtPath = lines.find((l) => l.startsWith('worktree '))?.replace('worktree ', '')
if (branch === branchName && wtPath) {
// Already checked out — duplicate it
return this.duplicateWorktree(branchName, wtPath, projectName)
}
}
// Not checked out — create worktree using existing branch
const dirName = branchName
.replace(/[/\\]/g, '-')
.replace(/[^a-zA-Z0-9-]/g, '')
.toLowerCase()
const worktreeBase = path.join(os.homedir(), '.hive-worktrees', projectName)
const worktreePath = path.join(worktreeBase, dirName)
await fs.mkdir(worktreeBase, { recursive: true })
await this.git.raw(['worktree', 'add', worktreePath, branchName])
return { path: worktreePath, branch: branchName, name: dirName }
}In src/main/ipc/worktree-handlers.ts:
ipcMain.handle(
'worktree:createFromBranch',
async (
_event,
{
projectId,
projectPath,
projectName,
branchName
}: { projectId: string; projectPath: string; projectName: string; branchName: string }
) => {
log.info('IPC: worktree:createFromBranch', { projectName, branchName })
try {
const gitSvc = new GitService(projectPath)
const result = await gitSvc.createWorktreeFromBranch(projectName, branchName)
const worktree = db.createWorktree({
project_id: projectId,
name: result.name,
path: result.path,
branch: result.branch,
is_default: false
})
return { success: true, worktree }
} catch (error) {
log.error('IPC: worktree:createFromBranch failed', { error })
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }
}
}
)
ipcMain.handle(
'git:listBranchesWithStatus',
async (_event, { projectPath }: { projectPath: string }) => {
try {
const gitSvc = new GitService(projectPath)
const branches = await gitSvc.listBranchesWithStatus()
return { success: true, branches }
} catch (error) {
return {
success: false,
branches: [],
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
)In src/preload/index.ts, add to worktreeOps:
createFromBranch: (
projectId: string,
projectPath: string,
projectName: string,
branchName: string
): Promise<{ success: boolean; worktree?: Worktree; error?: string }> =>
ipcRenderer.invoke('worktree:createFromBranch', { projectId, projectPath, projectName, branchName }),Add to gitOps:
listBranchesWithStatus: (
projectPath: string
): Promise<{
success: boolean
branches: Array<{ name: string; isRemote: boolean; isCheckedOut: boolean; worktreePath?: string }>
error?: string
}> => ipcRenderer.invoke('git:listBranchesWithStatus', { projectPath }),Add type declarations in src/preload/index.d.ts.
Create src/renderer/src/components/worktrees/BranchPickerDialog.tsx:
- Uses the shadcn
Dialogcomponent - Filter input at the top (debounced)
- Scrollable list of branches
- Each branch shows: name, "(remote)" badge if remote, "(active)" badge if checked out
- Clicking a branch calls
onSelect(branchName)and closes the dialog - Loading spinner while fetching branches
In src/renderer/src/components/projects/ProjectItem.tsx:
- Add state:
const [branchPickerOpen, setBranchPickerOpen] = useState(false) - Add menu item after existing items (before the separator):
<ContextMenuItem onClick={() => setBranchPickerOpen(true)}>
<GitBranch className="h-3.5 w-3.5 mr-2" />
New Workspace From...
</ContextMenuItem>- Add the dialog at the component's JSX root level:
<BranchPickerDialog
open={branchPickerOpen}
onOpenChange={setBranchPickerOpen}
projectPath={project.path}
onSelect={handleBranchSelect}
/>- Implement
handleBranchSelect:
const handleBranchSelect = async (branchName: string) => {
setBranchPickerOpen(false)
const result = await window.worktreeOps.createFromBranch(
project.id,
project.path,
project.name,
branchName
)
if (result.success && result.worktree) {
useWorktreeStore.getState().addWorktree(result.worktree)
useWorktreeStore.getState().selectWorktree(result.worktree.id)
toast.success(`Workspace created from ${branchName}`)
} else {
toast.error(result.error || 'Failed to create workspace')
}
}src/main/services/git-service.ts—createWorktreeFromBranch(),listBranchesWithStatus()src/main/ipc/worktree-handlers.ts— IPC handlerssrc/preload/index.ts— preload bridgesrc/preload/index.d.ts— type declarationssrc/renderer/src/components/worktrees/BranchPickerDialog.tsx— NEWsrc/renderer/src/components/projects/ProjectItem.tsx— menu item, dialog, handler
-
listBranchesWithStatusreturns all local and remote branches with checkout status -
createWorktreeFromBranchcreates a worktree from an un-checked-out branch -
createWorktreeFromBranchduplicates when the branch is already checked out - Branch picker dialog shows all branches with filter
- Branches show remote/active badges
- Selecting a branch creates a worktree and selects it
- "New Workspace From..." appears in the project context menu
-
pnpm lintpasses -
pnpm testpasses
- Right-click a project in the sidebar — verify "New Workspace From..." appears
- Click it — verify dialog opens with branch list
- Type in filter — verify list filters
- Click a branch that's NOT checked out — verify worktree created, sidebar shows it, auto-selected
- Click a branch that IS already checked out — verify a duplicate is created (e.g.,
main-v2) - Verify the new worktree directory exists on disk with the correct branch checked out
// test/phase-11/session-7/worktree-from-branch.test.ts
describe('Session 7: Worktree from Branch', () => {
test('listBranchesWithStatus returns branches with status', () => {
// Mock git.branch and git.raw for worktree list
// Verify output has name, isRemote, isCheckedOut fields
})
test('createWorktreeFromBranch creates from unchecked branch', () => {
// Mock git.raw for worktree list (branch not checked out)
// Verify git worktree add called with correct path and branch
})
test('createWorktreeFromBranch duplicates checked-out branch', () => {
// Mock git.raw for worktree list (branch IS checked out)
// Verify duplicateWorktree called
})
test('BranchPickerDialog renders branches', () => {
// Mock gitOps.listBranchesWithStatus
// Open dialog
// Verify branches rendered
})
test('BranchPickerDialog filters branches', () => {
// Type in filter input
// Verify list updates
})
test('ProjectItem shows New Workspace From... menu item', () => {
// Render ProjectItem, open context menu
// Verify menu item exists
})
})- Fix session loading state being cleared when switching tabs to a streaming session
- Fix streaming content bleeding across tabs
- Fix tool call results detaching after session switch during tool execution
In src/renderer/src/components/sessions/SessionView.tsx, in the initializeSession effect:
Problem: resetStreamingState() is called early (line ~758), which sets isStreaming = false. This kills the loading indicator when switching to a tab that's actively streaming.
Fix: Replace the full resetStreamingState() with a partial clear that only resets display data but NOT the isStreaming flag:
// BEFORE:
resetStreamingState()
// AFTER:
// Partial clear — reset display data but preserve streaming status
streamingPartsRef.current = []
streamingContentRef.current = ''
childToSubtaskIndexRef.current = new Map()
setStreamingParts([])
setStreamingContent('')
hasFinalizedCurrentResponseRef.current = false
// NOTE: Do NOT set isStreaming = false here
// Let the stream subscription's session.status events control itThe session.status event handler already correctly sets isStreaming based on whether the session is busy or idle.
Problem: When multiple SessionView instances are mounted (during tab transitions), stale closures in stream handlers can process events for the wrong session.
Fix: Add a generation counter ref to invalidate stale closures:
const streamGenerationRef = useRef(0)In the stream subscription effect:
useEffect(() => {
streamGenerationRef.current += 1
const currentGeneration = streamGenerationRef.current
// Partial clear for new session
streamingPartsRef.current = []
streamingContentRef.current = ''
setStreamingParts([])
setStreamingContent('')
// ... setup code ...
const unsubscribe = window.opencodeOps.onStream((event) => {
// Guard 1: session ID check (existing)
if (event.sessionId !== sessionId) return
// Guard 2: generation check (NEW — prevents stale closure)
if (streamGenerationRef.current !== currentGeneration) return
// ... existing event processing ...
})
return () => {
unsubscribe()
}
}, [sessionId])Problem: When switching away from a session with a running tool call and switching back, streamingPartsRef is empty. When the tool result arrives, it can't find the matching callID and creates a new detached entry.
Fix: On remount during an active session, initialize streamingPartsRef from the last persisted assistant message's parts:
// In initializeSession, after loading messages from DB:
if (messages.length > 0) {
const lastMsg = messages[messages.length - 1]
if (lastMsg.role === 'assistant' && lastMsg.opencode_parts_json) {
try {
const persistedParts = JSON.parse(lastMsg.opencode_parts_json)
if (Array.isArray(persistedParts) && persistedParts.length > 0) {
streamingPartsRef.current = persistedParts.map(convertPersistedPartToStreamingPart)
setStreamingParts([...streamingPartsRef.current])
const textParts = persistedParts.filter((p: any) => p.type === 'text')
if (textParts.length > 0) {
const content = textParts.map((p: any) => p.content || p.text || '').join('')
streamingContentRef.current = content
setStreamingContent(content)
}
}
} catch {
// Fall through to empty state
}
}
}Add a helper function convertPersistedPartToStreamingPart that maps the DB-stored part format to the StreamingPart format, preserving callID, tool name, status, etc.
src/renderer/src/components/sessions/SessionView.tsx— all three fixes
- Switching to a tab with an actively streaming session preserves the spinning indicator
- Switching to a tab with an idle session correctly shows idle state
- Streaming content from session A never appears in session B's tab
- Starting a tool call, switching away, switching back → tool result appears merged into the original tool card
- No stale closures processing events for wrong sessions
-
pnpm lintpasses -
pnpm testpasses
Loading state fix:
- Create two sessions in a worktree
- In session A, send a long prompt that will stream for a while
- While streaming, switch to session B tab
- Switch back to session A — verify the loading/streaming indicator is still showing
- Wait for streaming to finish — verify it correctly transitions to idle
Cross-tab bleed fix:
- Create two sessions in a worktree
- Start streaming in session A
- Switch to session B tab — verify NO streaming content appears
- Switch back to session A — verify streaming content is there
- Repeat rapidly switching between tabs during streaming
Tool call detach fix:
- Send a prompt that triggers a slow tool call (e.g., a bash command that takes time)
- While the tool is running (spinner showing), switch to another session tab
- Wait a moment, then switch back
- When the tool result arrives, verify it appears inside the original tool card (not as a separate block)
// test/phase-11/session-8/streaming-bugfixes.test.ts
describe('Session 8: Streaming Bugfixes', () => {
describe('Loading state preservation', () => {
test('partial clear does not reset isStreaming', () => {
// Simulate: isStreaming = true, then partial clear runs
// Verify isStreaming remains true
})
test('session.status busy sets isStreaming true after remount', () => {
// Simulate remount + session.status { type: 'busy' }
// Verify isStreaming set to true
})
})
describe('Cross-tab bleed prevention', () => {
test('generation counter increments on session change', () => {
// Change sessionId prop
// Verify streamGenerationRef incremented
})
test('stale closure events are rejected', () => {
// Subscribe with generation N
// Increment generation to N+1
// Send event with session ID matching
// Verify event is NOT processed (generation mismatch)
})
})
describe('Tool call result reconciliation', () => {
test('streaming parts restored from DB on remount', () => {
// Mock: last message has opencode_parts_json with a pending tool call
// Remount SessionView
// Verify streamingPartsRef populated with the tool call
})
test('tool result merges into restored tool call', () => {
// Restore parts from DB with callID 'abc123'
// Receive tool result event for callID 'abc123'
// Verify result merged, not detached
})
})
})- Create the
FileSidebarcomponent with two tabs: "Changes" and "Files" - Wire it into the layout replacing the current
FileTreeusage - The "Files" tab renders the existing
FileTreewith git indicators hidden
Create src/renderer/src/components/file-tree/FileSidebar.tsx:
import { useState } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { FileTree } from './FileTree'
import { ChangesView } from './ChangesView'
interface FileSidebarProps {
worktreePath: string
onClose: () => void
onFileClick: (filePath: string) => void
}
export function FileSidebar({
worktreePath,
onClose,
onFileClick
}: FileSidebarProps): React.JSX.Element {
const [activeTab, setActiveTab] = useState<'changes' | 'files'>('changes')
return (
<div className="flex flex-col h-full">
<div className="flex items-center border-b border-border px-2 pt-1.5 pb-0">
<button
className={cn(
'px-3 py-1.5 text-xs font-medium transition-colors relative',
activeTab === 'changes'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setActiveTab('changes')}
>
Changes
{activeTab === 'changes' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
className={cn(
'px-3 py-1.5 text-xs font-medium transition-colors relative',
activeTab === 'files'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setActiveTab('files')}
>
Files
{activeTab === 'files' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<div className="flex-1" />
<button
onClick={onClose}
className="p-1 text-muted-foreground hover:text-foreground rounded"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-hidden">
{activeTab === 'changes' ? (
<ChangesView worktreePath={worktreePath} onFileClick={onFileClick} />
) : (
<FileTree
worktreePath={worktreePath}
onClose={onClose}
onFileClick={onFileClick}
hideHeader
hideGitIndicators
hideGitContextActions
/>
)}
</div>
</div>
)
}In src/renderer/src/components/file-tree/FileTree.tsx, add props:
interface FileTreeProps {
worktreePath: string
onClose: () => void
onFileClick: (filePath: string) => void
hideHeader?: boolean // NEW — hide the built-in header
hideGitIndicators?: boolean // NEW — suppress git status badges
hideGitContextActions?: boolean // NEW — suppress git context menu items
}- When
hideHeaderis true, skip renderingFileTreeHeader - Pass
hideGitIndicatorstoFileTreeNode(suppressGitStatusIndicator) - Pass
hideGitContextActionstoFileContextMenu(suppress stage/unstage/discard items)
In src/renderer/src/components/file-tree/FileTreeNode.tsx:
- Accept
hideGitIndicators?: booleanprop - When true, don't render the
GitStatusIndicatorcomponent
In src/renderer/src/components/file-tree/FileContextMenu.tsx:
- Accept
hideGitContextActions?: booleanprop - When true, don't render stage/unstage/discard/gitignore menu items
Find where FileTree is rendered in the layout (likely MainPane.tsx or a sidebar component) and replace with FileSidebar:
// BEFORE:
<FileTree worktreePath={worktreePath} onClose={handleClose} onFileClick={handleFileClick} />
// AFTER:
<FileSidebar worktreePath={worktreePath} onClose={handleClose} onFileClick={handleFileClick} />In src/renderer/src/components/file-tree/index.ts:
export { FileSidebar } from './FileSidebar'
export { ChangesView } from './ChangesView'Create src/renderer/src/components/file-tree/ChangesView.tsx with a minimal placeholder (full implementation in Session 10):
interface ChangesViewProps {
worktreePath: string
onFileClick: (filePath: string) => void
}
export function ChangesView({ worktreePath }: ChangesViewProps): React.JSX.Element {
return (
<div className="p-4 text-sm text-muted-foreground">
Changes view — coming next session
</div>
)
}src/renderer/src/components/file-tree/FileSidebar.tsx— NEWsrc/renderer/src/components/file-tree/ChangesView.tsx— NEW (placeholder)src/renderer/src/components/file-tree/FileTree.tsx— add optional propssrc/renderer/src/components/file-tree/FileTreeNode.tsx— respecthideGitIndicatorssrc/renderer/src/components/file-tree/FileContextMenu.tsx— respecthideGitContextActionssrc/renderer/src/components/file-tree/index.ts— exports- Layout component that renders the sidebar — swap to
FileSidebar
-
FileSidebarrenders with two tabs: "Changes" and "Files" - Clicking tabs switches between views
- "Files" tab shows the file tree without git status indicators
- "Files" tab shows the file tree without git context menu items (stage/unstage/discard)
- "Files" tab includes filter field and standard navigation
- "Changes" tab shows placeholder (implemented in Session 10)
- Close button (X) works
- Tab active state styled with underline indicator
-
pnpm lintpasses -
pnpm testpasses
- Open a worktree with changed files
- Verify the sidebar shows two tabs: "Changes" and "Files"
- Default tab is "Changes" — shows placeholder
- Click "Files" — verify file tree appears without git status badges (no M/A/D indicators)
- Right-click a file in the "Files" tab — verify no stage/unstage/discard options
- Verify the filter field works in the "Files" tab
- Click the X button — verify sidebar closes
// test/phase-11/session-9/file-sidebar-tabs.test.ts
describe('Session 9: File Sidebar Tabs', () => {
test('renders two tabs', () => {
render(<FileSidebar worktreePath="/test" onClose={vi.fn()} onFileClick={vi.fn()} />)
expect(screen.getByText('Changes')).toBeInTheDocument()
expect(screen.getByText('Files')).toBeInTheDocument()
})
test('defaults to Changes tab', () => {
render(<FileSidebar worktreePath="/test" onClose={vi.fn()} onFileClick={vi.fn()} />)
// Verify Changes tab has active styling
})
test('switches to Files tab on click', () => {
render(<FileSidebar worktreePath="/test" onClose={vi.fn()} onFileClick={vi.fn()} />)
fireEvent.click(screen.getByText('Files'))
// Verify FileTree is rendered
})
test('Files tab hides git indicators', () => {
// Render FileTree with hideGitIndicators={true}
// Verify GitStatusIndicator not rendered
})
test('Files tab hides git context actions', () => {
// Render FileContextMenu with hideGitContextActions={true}
// Open context menu
// Verify stage/unstage/discard items not present
})
test('close button calls onClose', () => {
const onClose = vi.fn()
render(<FileSidebar worktreePath="/test" onClose={onClose} onFileClick={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /close/i }))
expect(onClose).toHaveBeenCalled()
})
})- Implement the full
ChangesViewcomponent with staged/unstaged/untracked file groups - Add bulk actions: Stage All, Unstage All, Discard All
- Support individual file actions via context menu
Replace the placeholder in src/renderer/src/components/file-tree/ChangesView.tsx:
import { useState, useMemo } from 'react'
import { ChevronDown, ChevronRight, Plus, Minus, Undo2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useGitStore } from '@/stores'
import { FileIcon } from './FileIcon'
import { GitStatusIndicator } from './GitStatusIndicator'
interface ChangesViewProps {
worktreePath: string
onFileClick: (filePath: string) => void
}The component should:
- Subscribe to
useGitStorefor the current worktree's git status - Group files into three categories:
- Staged — files with
indexstatus (not' 'or'?') - Unstaged — files with
working_dirstatus (not' 'or'?') - Untracked — files with
'?'status
- Staged — files with
- Each group is collapsible with a count badge
- Each file row shows: icon, file name (relative path), git status indicator
- Clicking a file calls
onFileClickto open the diff viewer
Each group has a clickable header that toggles collapse:
<button
onClick={() => toggleGroup('staged')}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
{collapsed.has('staged') ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
Staged ({staged.length})
</button>At the bottom of the view, add action buttons:
<div className="flex items-center gap-2 px-3 py-2 border-t border-border">
<button
onClick={handleStageAll}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
title="Stage All"
>
<Plus className="h-3 w-3" /> Stage All
</button>
<button
onClick={handleUnstageAll}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
title="Unstage All"
>
<Minus className="h-3 w-3" /> Unstage All
</button>
<button
onClick={handleDiscardAll}
className="text-xs text-destructive/70 hover:text-destructive flex items-center gap-1"
title="Discard All Changes"
>
<Undo2 className="h-3 w-3" /> Discard
</button>
</div>Wire these to the existing git operations: window.gitOps.stageFile, window.gitOps.unstageFile, window.gitOps.discardFile (or their bulk equivalents if they exist).
Right-clicking a file in any group shows a context menu with:
- Staged files: Unstage, Open Diff
- Unstaged files: Stage, Discard, Open Diff
- Untracked files: Stage, Delete
When there are no changes, show: "No changes" message.
src/renderer/src/components/file-tree/ChangesView.tsx— full implementation
- Staged, Unstaged, and Untracked groups displayed with correct file counts
- Groups are collapsible
- File rows show icon, relative path, and git status badge
- Clicking a file opens the diff viewer
- "Stage All" stages all unstaged + untracked files
- "Unstage All" unstages all staged files
- "Discard All" discards all unstaged changes (with confirmation)
- Right-click context menu provides per-file actions
- Empty state shown when no changes
- File list updates when git status changes (via store subscription)
-
pnpm lintpasses -
pnpm testpasses
- Make changes to files in a worktree (modify, add new, stage some)
- Open the sidebar, verify "Changes" tab shows grouped files
- Verify staged files appear under "Staged", modified under "Unstaged", new under "Untracked"
- Click group headers to collapse/expand
- Click "Stage All" — verify all files move to Staged group
- Click "Unstage All" — verify all files move back
- Right-click a file → Stage → verify it moves to Staged
- Discard a change — verify file disappears from the list
- With no changes, verify "No changes" message
// test/phase-11/session-10/changes-view.test.ts
describe('Session 10: Changes View', () => {
test('groups files by git status', () => {
// Mock useGitStore with staged, unstaged, and untracked files
// Render ChangesView
// Verify three groups rendered with correct counts
})
test('collapsing a group hides its files', () => {
// Click the Staged header
// Verify staged files are hidden
})
test('clicking a file calls onFileClick', () => {
const onFileClick = vi.fn()
// Render with files, click one
// Verify onFileClick called with file path
})
test('empty state shows message', () => {
// Mock useGitStore with no changes
// Verify "No changes" message
})
test('Stage All calls gitOps for all unstaged files', () => {
// Mock window.gitOps.stageFile
// Click Stage All
// Verify stageFile called for each unstaged and untracked file
})
})- Remove the "Streaming..." blue text from
AssistantCanvas - Rename "Task" → "Agent" in
ToolCardcollapsed header - Update
TaskToolViewfallback text
In src/renderer/src/components/sessions/AssistantCanvas.tsx, delete lines 266-268:
// DELETE:
{isStreaming && (
<span className="block text-[10px] text-blue-500 animate-pulse mt-2">Streaming...</span>
)}In src/renderer/src/components/sessions/ToolCard.tsx, line 385:
// BEFORE:
<span className="font-medium text-foreground shrink-0">Task</span>
// AFTER:
<span className="font-medium text-foreground shrink-0">Agent</span>In src/renderer/src/components/sessions/tools/TaskToolView.tsx, line 43:
// BEFORE:
{
description || 'Agent Task'
}
// AFTER:
{
description || 'Sub-agent'
}src/renderer/src/components/sessions/AssistantCanvas.tsx— remove "Streaming..." spansrc/renderer/src/components/sessions/ToolCard.tsx— rename "Task" → "Agent"src/renderer/src/components/sessions/tools/TaskToolView.tsx— update fallback
- No "Streaming..." blue text appears anywhere during streaming
- The streaming cursor still appears (it's a separate component)
- Tool calls using the
tasktool show "Agent" in the collapsed header -
TaskToolViewexpanded view shows "Sub-agent" when no description -
pnpm lintpasses -
pnpm testpasses
- Send a message and observe streaming — verify no "Streaming..." text, but the pulsing cursor is still visible
- Send a prompt that triggers a
tasktool call (e.g., something that causes the AI to dispatch a sub-agent) — verify the collapsed tool card shows "Agent" instead of "Task" - Expand the tool card — verify fallback text is "Sub-agent" if no description
// test/phase-11/session-11/ui-text-changes.test.ts
describe('Session 11: UI Text Changes', () => {
test('AssistantCanvas does not render Streaming text', () => {
// Render AssistantCanvas with isStreaming={true}
// Verify no element with text 'Streaming...'
})
test('ToolCard renders Agent instead of Task', () => {
// Render ToolCard with name='task'
// Verify text 'Agent' present, 'Task' absent in collapsed header
})
test('TaskToolView shows Sub-agent as fallback', () => {
// Render TaskToolView without description
// Verify text 'Sub-agent' present
})
})- Verify all Phase 11 features work correctly together
- Test cross-feature interactions
- Run lint and tests
- Fix any edge cases or regressions
- Create a new worktree (city name branch) → create session → send first message → verify title updates → verify branch auto-renames → verify sidebar shows new branch name
- Send another message → verify no second rename
- After a branch was auto-renamed, right-click → Rename Branch → enter new name → verify it sticks
- Send another message with new title → verify branch is NOT renamed again (branch_renamed = 1)
- Create a new worktree → verify session auto-creates → send a message → verify title and branch update correctly
- Create a worktree from a specific branch → verify auto-start creates a session → send a message → verify title appears
- Start streaming → open file sidebar → switch between Changes and Files tabs → verify no glitches
- Switch sessions during streaming → verify no content bleed
- Run a tool call → switch tabs → switch back → verify tool result merges
- Stream a response → verify no "Streaming..." text
- Stream triggers a sub-agent → verify "Agent" label shown
Walk through the complete flow:
- Open app → select project → "New Workspace From..." → pick a branch → worktree created → session auto-starts
- Send a message → title updates in tab → branch auto-renames from city name
- Right-click worktree → Rename Branch → set custom name → verify branch renamed
- Open file sidebar → verify Changes tab shows git changes → switch to Files tab → verify clean tree
- Send a prompt that triggers streaming with tool calls → switch to another session tab → switch back → verify no content bleed and tool results merge correctly
- Verify no "Streaming..." text, "Agent" label on task tool calls
pnpm lint
pnpm testFix any failures.
- All files modified in sessions 1–11
- All 10 features work correctly in isolation
- Cross-feature interactions work correctly
- No regressions in Phase 10 features (questions, scroll FAB, slash commands, etc.)
- 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:
- Title arrival timing and branch rename race conditions
- Auto-start with various worktree states
- Branch picker with many branches (100+)
- Streaming state during rapid tab switching
// test/phase-11/session-12/integration-verification.test.ts
describe('Session 12: Integration & Verification', () => {
test('title event triggers branch auto-rename', () => {
// End-to-end: session.updated with title → branch renamed from city name
})
test('manual rename prevents future auto-rename', () => {
// Manual rename sets branch_renamed = 1
// New title event arrives → no rename
})
test('auto-start creates session in new worktree from branch', () => {
// Create worktree from branch → auto-start fires → session created
})
test('streaming state preserved across tab switches', () => {
// Start streaming in session A
// Switch to session B
// Switch back to session A
// Verify isStreaming still true
})
test('tool call result merges after tab switch', () => {
// Start tool call in session A
// Switch to session B
// Switch back to session A
// Receive tool result
// Verify merged into original card
})
test('no streaming content in wrong tab', () => {
// Stream in session A
// Switch to session B
// Verify no streaming content in session B
})
test('file sidebar tabs work during streaming', () => {
// Start streaming
// Open file sidebar
// Switch between Changes and Files tabs
// Verify no errors
})
test('lint passes', () => {
// pnpm lint exit code 0
})
test('tests pass', () => {
// pnpm test exit code 0
})
})Session 1 (Remove Haiku Naming) ── foundational cleanup
|
└──► Session 2 (Server Title Events) ── depends on Session 1 (naming removed first)
|
└──► Session 4 (Auto-Rename Branch) ── depends on Sessions 2+3
Session 3 (Branch Rename Infra) ── independent infrastructure
|
├──► Session 4 (Auto-Rename Branch) ── depends on Session 3 (needs renameBranch + canonicalize)
└──► Session 5 (Manual Branch Rename) ── depends on Session 3 (needs renameBranch IPC)
Session 6 (Auto-Start Session) ── independent
Session 7 (Worktree from Branch) ── independent
Session 8 (Streaming Bugfixes) ── independent
Session 9 (File Sidebar Tabs) ── independent
|
└──► Session 10 (Changes View) ── depends on Session 9 (needs FileSidebar wrapper)
Session 11 (UI Text Changes) ── independent
Session 12 (Integration) ── requires sessions 1-11
┌────────────────────────────────────────────────────────────────────────────┐
│ Time → │
│ │
│ Track A: [S1: Remove Naming] → [S2: Title Events] ──┐ │
│ Track B: [S3: Branch Infra] ─────────────────────────┼─► [S4: Auto-Rename]│
│ └──► [S5: Manual Rename] │
│ Track C: [S6: Auto-Start] │
│ Track D: [S7: Worktree from Branch] │
│ Track E: [S8: Streaming Bugfixes] │
│ Track F: [S9: Sidebar Tabs] → [S10: Changes View] │
│ Track G: [S11: UI Text] │
│ │
│ All ──────────────────────────────────────────────► [S12: Integration] │
└────────────────────────────────────────────────────────────────────────────┘
Maximum parallelism: Tracks A–G are largely independent. Track A (S1→S2) must complete before S4 can start. Track B (S3) must complete before S4 and S5.
Critical path: S1 → S2 → S4 (title system → server events → auto-rename) and S3 → S4 (infra → auto-rename). S4 is the convergence point.
Minimum total: 5 rounds:
- (S1, S3, S6, S7, S8, S9, S11 in parallel)
- (S2, S5, S10 in parallel)
- (S4)
- (S12)
- Interactive question prompts (QuestionPrompt, QuestionStore, IPC)
- Scroll FAB fix (userHasScrolledUpRef)
- Write tool view (WriteToolView)
- Show in Finder (QuickActions)
- Slash command execution (SDK command endpoint, mode switching)
Per PRD Phase 11:
- Per-message summary titles (only session-level titles implemented)
- Custom title model selection (rely on server defaults)
- Branch rename with remote tracking update
- Worktree directory rename to match new branch name
- Remote branch checkout with tracking setup
- Merge conflict resolution in Changes view
- File staging/unstaging animations
- Drag-and-drop file staging
- File diff inline in Changes view (opens separate viewer)
- Session tab rename UI (manual rename via API only, no inline tab editing)
- Branch protection rules (preventing rename of main/master)
| Operation | Target |
|---|---|
| Title update from server event | < 100ms from SSE event to title visible in tab |
| Branch auto-rename after title | < 500ms from title event to branch renamed and UI updated |
| Manual branch rename round-trip | < 300ms from Enter key to rename complete |
| Auto-start session | < 200ms from worktree selection to session created |
| Branch picker dialog load | < 500ms from menu click to branch list rendered |
| Session loading state preservation | 0 false-idle states |
| File sidebar tab switch | < 50ms to swap between tabs |
| Tool call result reconciliation | 100% results merge into original tool card |
| Stream content isolation | 0 cross-tab content leaks |
-
Remove custom naming entirely rather than patching it: The server already provides title generation. Keeping the Haiku system alongside server titles would cause race conditions (two sources of truth) and double the LLM costs. Clean removal is the correct approach.
-
Auto-rename in main process rather than renderer: The main process has direct access to git-service, city-names, and the DB. Doing it in the renderer would require additional IPC round-trips and importing Node.js-only modules. The main process also receives the SSE events first, so it can rename before notifying the renderer.
-
branch_renamedDB flag over city-name-list checking alone: While checking if the current branch is a city name works for the initial rename, it doesn't prevent re-renaming after a subsequent title change. The DB flag is a definitive "stop" signal. -
Per-worktree auto-start over project-wide: The project-wide check was overly conservative. Users expect each worktree to be independent. If worktree A is active, that shouldn't prevent worktree B from having its own session.
-
Generation counter for stream isolation over component-key remounting: React's key-based remounting would lose all component state. A generation counter preserves state while preventing stale closures from processing events.
-
Partial state clear on tab switch over full reset: A full
resetStreamingState()kills the loading indicator. Clearing only display data (parts, content) while preservingisStreaminglets the stream events control the loading state correctly. -
Restoring streaming parts from DB on remount: The main process already persists parts via
mergeUpdatedPart(). Reading them back on remount gives the stream handler the context it needs to merge tool results into their original calls. -
Two-tab sidebar over mixed tree: Separating changes from files reduces cognitive load. Users looking at changes want git-focused actions; users browsing files want navigation. Mixing both creates UI clutter.