This document outlines the implementation plan for Hive Phase 7, focusing on project filtering, branch duplication, code review triggers, inline diff viewing, running-process animations, UX polish, and model variant selection.
The implementation is divided into 8 focused sessions, each with:
- Clear objectives
- Definition of done
- Testing criteria for verification
Phase 7 builds upon Phase 6 — all Phase 6 infrastructure (rich tool rendering, context indicator, notifications, queued messages, image attachments, slash commands, tab persistence, session badges) is assumed to be in place.
test/
├── phase-7/
│ ├── session-1/
│ │ └── quick-wins.test.ts
│ ├── session-2/
│ │ └── project-filter.test.ts
│ ├── session-3/
│ │ └── branch-duplication.test.ts
│ ├── session-4/
│ │ └── code-review.test.ts
│ ├── session-5/
│ │ └── inline-diff-viewer.test.ts
│ ├── session-6/
│ │ └── model-variants.test.ts
│ ├── session-7/
│ │ └── integration-polish.test.ts
│ └── session-8/
│ └── e2e-verification.test.ts
// No new dependencies requiredAll features use existing packages: React, Zustand, diff2html, simple-git, lucide-react, sonner.
- Auto-focus the session textarea when entering/switching sessions
- Add a clear button to the run pane output
- Show an ECG pulse animation on worktrees with a live running process
- In
src/renderer/src/components/sessions/SessionView.tsx:- Locate the existing
textareaRefref - Add a
useEffectthat focuses the textarea when the active session changes:useEffect(() => { if (textareaRef.current) { requestAnimationFrame(() => { textareaRef.current?.focus() }) } }, [activeSessionId])
requestAnimationFrameensures the DOM is settled after tab switch animations
- Locate the existing
- In
src/renderer/src/components/layout/RunTab.tsx:- Import
Trash2fromlucide-react - Add a Clear button in the status bar (the
<div>withitems-center justify-between):{runOutput.length > 0 && ( <button onClick={() => clearRunOutput(worktreeId!)} className="flex items-center gap-1 px-2 py-0.5 text-xs rounded hover:bg-accent transition-colors" data-testid="clear-button" > <Trash2 className="h-3 w-3" /> Clear </button> )}
- Place it in the right side button group, before the Run/Stop/Restart buttons
clearRunOutputis already destructured fromuseScriptStore.getState()on line 29
- Import
-
Create
src/renderer/src/components/worktrees/PulseAnimation.tsx:- Small SVG component rendering an ECG-style sine wave:
import { cn } from '@/lib/utils' interface PulseAnimationProps { className?: string } export function PulseAnimation({ className }: PulseAnimationProps): React.JSX.Element { return ( <svg className={cn('overflow-hidden', className)} viewBox="0 0 24 12" width="16" height="12" > <path d="M0,6 Q3,6 4,2 Q5,-2 6,6 Q7,14 8,6 Q9,6 12,6 Q15,6 16,2 Q17,-2 18,6 Q19,14 20,6 Q21,6 24,6" fill="none" stroke="currentColor" strokeWidth="1.5" className="animate-ecg-travel" /> </svg> ) }
- Add the CSS keyframes — either inline via Tailwind
@keyframesin the component or in a global CSS snippet:@keyframes ecg-travel { to { stroke-dashoffset: -24; } } .animate-ecg-travel { stroke-dasharray: 24; stroke-dashoffset: 0; animation: ecg-travel 2s linear infinite; }
- May need to add the animation to Tailwind config or use a
<style>tag
- Small SVG component rendering an ECG-style sine wave:
-
In
src/renderer/src/components/worktrees/WorktreeItem.tsx:- Import
useScriptStoreandPulseAnimation - Subscribe to the run state for this worktree:
const isRunProcessAlive = useScriptStore( (s) => s.scriptStates[worktree.id]?.runRunning ?? false )
- In the icon section (lines 136-142), add a condition for
isRunProcessAlive:{isRunProcessAlive ? ( <PulseAnimation className="h-3.5 w-3.5 text-green-500 shrink-0" /> ) : worktreeStatus === 'working' ? ( <Loader2 className="h-3.5 w-3.5 text-primary shrink-0 animate-spin" /> ) : worktree.is_default ? ( <Folder className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> ) : ( <GitBranch className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> )}
- Run process takes priority over AI working status visually
- Import
src/renderer/src/components/sessions/SessionView.tsx— auto-focussrc/renderer/src/components/layout/RunTab.tsx— clear buttonsrc/renderer/src/components/worktrees/PulseAnimation.tsx— NEWsrc/renderer/src/components/worktrees/WorktreeItem.tsx— pulse integration
- Entering a session auto-focuses the textarea (cursor ready for typing)
- Switching session tabs auto-focuses the new session's textarea
- Clear button appears in the run pane status bar when there is output
- Clear button hidden when there is no output
- Clicking Clear removes all run output from the pane
- ECG pulse animation renders as a smooth traveling sine wave at 60fps
- Pulse animation shown on worktree items where the run process is alive
- Pulse animation disappears when the run process stops
- AI session "working" spinner still shows when no run process is active
-
pnpm lintpasses -
pnpm testpasses
- Auto-focus: Open the app → select a worktree → verify cursor is in the textarea. Switch session tabs → verify cursor is in the new tab's textarea.
- Clear button: Run a project script → verify output appears → click Clear → verify output is gone. Verify Clear button is hidden when no output exists.
- Pulse animation: Configure a run script on a project → run it → verify the worktree icon changes to a pulsing ECG animation. Stop the script → verify the icon returns to the normal branch icon.
// test/phase-7/session-1/quick-wins.test.ts
describe('Session 1: Quick Wins', () => {
describe('Auto-Focus', () => {
test('Textarea focused on session mount', () => {
// Render SessionView with activeSessionId
// Verify textareaRef.current === document.activeElement
})
test('Textarea focused on session switch', () => {
// Render with session-A, then change to session-B
// Verify textarea focused after switch
})
})
describe('Clear Button', () => {
test('Clear button visible when output exists', () => {
// Set runOutput = ['line1', 'line2']
// Render RunTab
// Verify clear-button element present
})
test('Clear button hidden when no output', () => {
// Set runOutput = []
// Render RunTab
// Verify clear-button element NOT present
})
test('Clear button clears output', () => {
// Set runOutput = ['line1']
// Click clear button
// Verify clearRunOutput called with worktreeId
})
})
describe('Pulse Animation', () => {
test('PulseAnimation renders SVG', () => {
// Render PulseAnimation
// Verify SVG element with path present
})
test('Pulse shown when run process alive', () => {
// Set scriptStates[worktreeId].runRunning = true
// Render WorktreeItem
// Verify PulseAnimation component rendered
// Verify Loader2 NOT rendered
})
test('Spinner shown when AI working (no run process)', () => {
// Set runRunning = false, worktreeStatus = 'working'
// Render WorktreeItem
// Verify Loader2 rendered, PulseAnimation NOT rendered
})
test('Normal icon when idle', () => {
// Set runRunning = false, worktreeStatus = null
// Render WorktreeItem
// Verify GitBranch icon rendered
})
})
})- Create a subsequence matching utility that returns matched character indices
- Build a search input in the project sidebar
- Filter projects by name and path with match highlighting
-
Create
src/renderer/src/lib/subsequence-match.ts:export interface SubsequenceMatch { matched: boolean indices: number[] score: number // lower is better (sum of gaps between consecutive matches) } export function subsequenceMatch(query: string, target: string): SubsequenceMatch { const q = query.toLowerCase() const t = target.toLowerCase() const indices: number[] = [] let qi = 0 for (let ti = 0; ti < t.length && qi < q.length; ti++) { if (t[ti] === q[qi]) { indices.push(ti) qi++ } } if (qi < q.length) return { matched: false, indices: [], score: Infinity } let score = 0 for (let i = 1; i < indices.length; i++) { score += indices[i] - indices[i - 1] - 1 } return { matched: true, indices, score } }
-
Create
src/renderer/src/components/projects/HighlightedText.tsx:interface HighlightedTextProps { text: string indices: number[] className?: string } export function HighlightedText({ text, indices, className }: HighlightedTextProps) { const set = new Set(indices) return ( <span className={className}> {text.split('').map((char, i) => set.has(i) ? <span key={i} className="text-primary font-semibold">{char}</span> : <span key={i}>{char}</span> )} </span> ) }
-
Create
src/renderer/src/components/projects/ProjectFilter.tsx:interface ProjectFilterProps { value: string onChange: (value: string) => void }
- Render a text input with a
Searchicon (from lucide) and a clear button (Xicon) - Placeholder: "Filter projects..."
- Pressing Escape clears the input and blurs
- Compact styling:
h-7 text-xs px-2matching the sidebar aesthetic
- Render a text input with a
-
In
src/renderer/src/components/projects/ProjectList.tsx:- Add state:
const [filterQuery, setFilterQuery] = useState('') - Render
<ProjectFilter value={filterQuery} onChange={setFilterQuery} />above the project list - Compute filtered projects:
const filteredProjects = useMemo(() => { if (!filterQuery.trim()) return projects.map(p => ({ project: p, nameMatch: null, pathMatch: null })) return projects .map(project => ({ project, nameMatch: subsequenceMatch(filterQuery, project.name), pathMatch: subsequenceMatch(filterQuery, project.path) })) .filter(({ nameMatch, pathMatch }) => nameMatch.matched || pathMatch.matched) .sort((a, b) => { const aScore = a.nameMatch.matched ? a.nameMatch.score : a.pathMatch.score + 1000 const bScore = b.nameMatch.matched ? b.nameMatch.score : b.pathMatch.score + 1000 return aScore - bScore }) }, [projects, filterQuery])
- Pass match data to
ProjectItem:<ProjectItem key={item.project.id} project={item.project} nameMatchIndices={item.nameMatch?.matched ? item.nameMatch.indices : undefined} pathMatchIndices={item.pathMatch?.matched && !item.nameMatch?.matched ? item.pathMatch.indices : undefined} />
- Add state:
-
In
src/renderer/src/components/projects/ProjectItem.tsx:- Add optional props:
interface ProjectItemProps { project: Project nameMatchIndices?: number[] pathMatchIndices?: number[] }
- When
nameMatchIndicesis provided, render the project name using<HighlightedText>instead of plain text - When
pathMatchIndicesis provided (matched on path but not name), show the path below the name in a small muted font with highlighted characters
- Add optional props:
src/renderer/src/lib/subsequence-match.ts— NEWsrc/renderer/src/components/projects/HighlightedText.tsx— NEWsrc/renderer/src/components/projects/ProjectFilter.tsx— NEWsrc/renderer/src/components/projects/ProjectList.tsx— filter integrationsrc/renderer/src/components/projects/ProjectItem.tsx— highlight rendering
- Search input visible at top of project sidebar
- Empty input shows all projects (no filtering)
- Typing filters projects using subsequence matching (not substring)
- Matched characters highlighted in project name with
text-primary font-semibold - If match is on path only, path shown below name with highlighted characters
- Matching is case-insensitive
- Results sorted by match quality (name matches first, then by contiguity score)
- Pressing Escape clears the filter and blurs the input
- Filter updates immediately per keystroke (no debounce)
- "orders" matches "tedooo-orders" (contiguous subsequence)
- "orders" matches "ordjjrekekqerjskjs" (spread subsequence)
- "xyz" does NOT match "tedooo-orders" (no subsequence match)
- Worktree names/paths are NOT searched
-
pnpm lintpasses
- Open the app with 3+ projects added
- Type a few letters in the filter → verify only matching projects shown
- Type "orders" → verify projects with those letters in order are shown
- Verify highlighted characters in matched names
- Type something that matches a path but not a name → verify path shown below name
- Press Escape → verify filter cleared, all projects shown
- Verify non-matching projects are hidden
// test/phase-7/session-2/project-filter.test.ts
describe('Session 2: Project Filter', () => {
describe('subsequenceMatch', () => {
test('exact match returns indices', () => {
const result = subsequenceMatch('abc', 'abc')
expect(result.matched).toBe(true)
expect(result.indices).toEqual([0, 1, 2])
expect(result.score).toBe(0)
})
test('subsequence match with gaps', () => {
const result = subsequenceMatch('ace', 'abcde')
expect(result.matched).toBe(true)
expect(result.indices).toEqual([0, 2, 4])
expect(result.score).toBe(2) // gaps: (2-0-1) + (4-2-1) = 1+1
})
test('no match returns matched=false', () => {
const result = subsequenceMatch('xyz', 'abcde')
expect(result.matched).toBe(false)
expect(result.indices).toEqual([])
})
test('case insensitive', () => {
const result = subsequenceMatch('ABC', 'abcdef')
expect(result.matched).toBe(true)
})
test('"orders" matches "tedooo-orders"', () => {
const result = subsequenceMatch('orders', 'tedooo-orders')
expect(result.matched).toBe(true)
})
test('"orders" matches "ordjjrekekqerjskjs"', () => {
const result = subsequenceMatch('orders', 'ordjjrekekqerjskjs')
expect(result.matched).toBe(true)
})
test('empty query matches everything', () => {
const result = subsequenceMatch('', 'anything')
expect(result.matched).toBe(true)
})
test('query longer than target does not match', () => {
const result = subsequenceMatch('abcdef', 'abc')
expect(result.matched).toBe(false)
})
})
describe('HighlightedText', () => {
test('renders highlighted characters at correct indices', () => {
// Render HighlightedText with text="hello" indices=[1,3]
// Verify chars at index 1 and 3 have text-primary class
// Verify other chars do NOT have text-primary class
})
test('renders all chars normal when indices empty', () => {
// Render HighlightedText with text="hello" indices=[]
// Verify no chars have text-primary class
})
})
describe('ProjectFilter', () => {
test('renders search input with placeholder', () => {
// Verify input with placeholder "Filter projects..."
})
test('calls onChange on input', () => {
// Type "test", verify onChange called with "test"
})
test('Escape clears input', () => {
// Type "test", press Escape
// Verify onChange called with ""
})
})
describe('ProjectList filtering', () => {
test('all projects shown when filter empty', () => {
// 3 projects, empty filter
// Verify all 3 ProjectItem components rendered
})
test('only matching projects shown', () => {
// Projects: ["alpha", "beta", "gamma"]
// Filter: "al"
// Verify only "alpha" shown
})
test('name match indices passed to ProjectItem', () => {
// Filter matches project name
// Verify ProjectItem receives nameMatchIndices prop
})
test('path match shown when name doesnt match', () => {
// Project name "my-project", path "/users/test/orders-app"
// Filter "orders"
// Verify pathMatchIndices passed, path shown below name
})
test('results sorted by match quality', () => {
// Project A: name match with score 0 (exact)
// Project B: name match with score 5 (gaps)
// Verify A appears before B
})
})
})- Add a
duplicateWorktree()method to the git service that clones a branch with uncommitted state - Implement auto-versioning logic (
-v2,-v3, etc.) - Add "Duplicate" to the worktree context menu
-
In
src/main/services/git-service.ts:- Add
duplicateWorktree()method:async duplicateWorktree( sourceBranch: string, sourceWorktreePath: string, projectName: string ): Promise<CreateWorktreeResult> { // 1. Extract base name (strip -vN suffix) const baseName = sourceBranch.replace(/-v\d+$/, '') // 2. Find next version number const allBranches = await this.getAllBranches() const versionPattern = new RegExp(`^${baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-v(\\d+)$`) let maxVersion = 1 // means first dup will be v2 for (const branch of allBranches) { const match = branch.match(versionPattern) if (match) { maxVersion = Math.max(maxVersion, parseInt(match[1], 10)) } } const newBranchName = `${baseName}-v${maxVersion + 1}` // 3. Create worktree directory const projectWorktreesDir = this.ensureWorktreesDir(projectName) const worktreePath = join(projectWorktreesDir, newBranchName) // 4. Create worktree from source branch await this.git.raw(['worktree', 'add', '-b', newBranchName, worktreePath, sourceBranch]) // 5. Capture uncommitted state via stash create (non-destructive) const sourceGit = simpleGit(sourceWorktreePath) const stashRef = (await sourceGit.raw(['stash', 'create'])).trim() if (stashRef) { // 6. Apply stash in new worktree const newGit = simpleGit(worktreePath) try { await newGit.raw(['stash', 'apply', stashRef]) } catch { // stash apply may fail if changes conflict — log but continue } } // 7. Copy untracked files const untrackedRaw = await sourceGit.raw(['ls-files', '--others', '--exclude-standard']) const untrackedFiles = untrackedRaw.trim().split('\n').filter(Boolean) for (const file of untrackedFiles) { const srcPath = join(sourceWorktreePath, file) const destPath = join(worktreePath, file) // Ensure destination directory exists, then copy mkdirSync(dirname(destPath), { recursive: true }) cpSync(srcPath, destPath) } return { success: true, name: newBranchName, branchName: newBranchName, path: worktreePath } }
- Import
cpSync,mkdirSyncfromfsanddirnamefrompath
- Add
-
In
src/main/ipc/worktree-handlers.ts:- Add
worktree:duplicateIPC handler:ipcMain.handle('worktree:duplicate', async (_, params: { projectId: string projectPath: string projectName: string sourceBranch: string sourceWorktreePath: string }) => { const gitService = createGitService(params.projectPath) const result = await gitService.duplicateWorktree( params.sourceBranch, params.sourceWorktreePath, params.projectName ) if (!result.success) return { success: false, error: result.error } // Create database entry const worktree = getDatabase().createWorktree({ project_id: params.projectId, name: result.name!, branch_name: result.branchName!, path: result.path! }) return { success: true, worktree } })
- Add
-
In
src/preload/index.ts:- Add
duplicatemethod toworktreeOps:duplicate: (params: { projectId: string projectPath: string projectName: string sourceBranch: string sourceWorktreePath: string }) => ipcRenderer.invoke('worktree:duplicate', params)
- Add
-
In
src/preload/index.d.ts:- Add type declaration for
duplicatein theworktreeOpsinterface
- Add type declaration for
-
In
src/renderer/src/stores/useWorktreeStore.ts:- Add
duplicateWorktreeaction:duplicateWorktree: async ( projectId: string, projectPath: string, projectName: string, sourceBranch: string, sourceWorktreePath: string ) => { const result = await window.worktreeOps.duplicate({ projectId, projectPath, projectName, sourceBranch, sourceWorktreePath }) if (result.success && result.worktree) { // Reload worktrees for the project get().loadWorktrees(projectId) } return result }
- Add
-
In
src/renderer/src/components/worktrees/WorktreeItem.tsx:- Add
handleDuplicatecallback:const handleDuplicate = useCallback(async () => { const project = useProjectStore.getState().projects.find(p => p.id === worktree.project_id) if (!project) return const result = await useWorktreeStore.getState().duplicateWorktree( project.id, project.path, project.name, worktree.branch_name, worktree.path ) if (result.success) { toast.success(`Duplicated to ${result.worktree?.name || 'new branch'}`) } else { toast.error(result.error || 'Failed to duplicate worktree') } }, [worktree])
- Add "Duplicate" to both the
DropdownMenuContentandContextMenuContent:- Place it after "Copy Path" and before the separator/Unbranch section
- Use
CopyorGitBranchPlusicon - Only show for non-default worktrees (
!worktree.is_default)
- Add
src/main/services/git-service.ts—duplicateWorktree()methodsrc/main/ipc/worktree-handlers.ts—worktree:duplicatehandlersrc/preload/index.ts— exposeduplicatesrc/preload/index.d.ts— typessrc/renderer/src/stores/useWorktreeStore.ts— store actionsrc/renderer/src/components/worktrees/WorktreeItem.tsx— menu items
- "Duplicate" appears in worktree context menu and dropdown (for non-default worktrees)
- First duplication of
feature-authcreatesfeature-auth-v2 - Second duplication (from any version) creates
feature-auth-v3 - Version number scans all existing branches to find the next number
- Base name extraction strips existing
-vNsuffix correctly - New worktree created at the correct path under the project's worktrees directory
- Uncommitted changes (staged + unstaged) copied to new worktree via
git stash create+git stash apply - Untracked files (not in .gitignore) copied to new worktree
- Database entry created for the new worktree
- Worktree list refreshes after duplication
- Success toast shown with new branch name
- Error toast shown on failure
- Default worktree does not show "Duplicate" option
-
pnpm lintpasses
- Create a project with a worktree
feature-auth - Make some uncommitted changes (edit a file, add a new untracked file)
- Right-click the worktree → select "Duplicate"
- Verify a new worktree
feature-auth-v2appears in the list - Click into
feature-auth-v2→ open in terminal → verify uncommitted changes are present - Verify untracked files are present in the new worktree
- Duplicate again → verify
feature-auth-v3is created (notfeature-auth-v2-v2) - Duplicate from
feature-auth-v2→ verifyfeature-auth-v4is created
// test/phase-7/session-3/branch-duplication.test.ts
describe('Session 3: Branch Duplication', () => {
describe('Version naming', () => {
test('first duplication creates -v2', () => {
// Existing branches: ['feature-auth', 'main']
// Duplicate feature-auth → expect 'feature-auth-v2'
})
test('second duplication creates -v3', () => {
// Existing branches: ['feature-auth', 'feature-auth-v2', 'main']
// Duplicate feature-auth → expect 'feature-auth-v3'
})
test('duplication from versioned branch increments globally', () => {
// Existing branches: ['feature-auth', 'feature-auth-v2', 'main']
// Duplicate feature-auth-v2 → expect 'feature-auth-v3'
})
test('base name extraction strips -vN suffix', () => {
// 'feature-auth-v2' → base name 'feature-auth'
// 'feature-auth-v10' → base name 'feature-auth'
// 'my-v2-project' → base name 'my-v2-project' (v2 not at end)
})
test('handles branch names with special regex chars', () => {
// Branch 'fix/auth+login' → no regex error
})
})
describe('Worktree creation', () => {
test('worktree created from source branch', () => {
// Verify git worktree add called with sourceBranch as start point
})
test('database entry created', () => {
// Verify createWorktree called with correct project_id, name, branch_name, path
})
test('worktree list refreshed after creation', () => {
// Verify loadWorktrees called for the project
})
})
describe('Uncommitted state', () => {
test('stash create called on source worktree', () => {
// Verify git stash create executed in source path
})
test('stash applied in new worktree when stash ref exists', () => {
// stash create returns a ref
// Verify git stash apply called in new worktree with ref
})
test('no stash apply when working tree clean', () => {
// stash create returns empty string
// Verify stash apply NOT called
})
test('untracked files copied to new worktree', () => {
// Source has untracked files: ['new-file.ts', 'src/util.ts']
// Verify files copied to new worktree preserving paths
})
})
describe('UI', () => {
test('Duplicate shown in context menu for non-default worktree', () => {
// Render WorktreeItem with is_default=false
// Verify "Duplicate" in context menu
})
test('Duplicate NOT shown for default worktree', () => {
// Render WorktreeItem with is_default=true
// Verify "Duplicate" NOT in context menu
})
test('success toast shown', () => {
// Duplicate succeeds
// Verify toast.success called
})
test('error toast shown on failure', () => {
// Duplicate fails
// Verify toast.error called
})
})
})- Add a "Review" button to the git status panel header
- Read the review prompt from
prompts/review.md - Create a new session, send the review prompt with file change context
-
In
src/renderer/src/components/git/GitStatusPanel.tsx:- Import
FileSearch(orMessageSquareCode) icon from lucide-react - Add a "Review" button next to the refresh button in the header:
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={handleReview} disabled={!hasChanges || isReviewing} title="Review changes with AI" data-testid="git-review-button" > {isReviewing ? ( <Loader2 className="h-3 w-3 animate-spin" /> ) : ( <FileSearch className="h-3 w-3" /> )} </Button>
- Add
isReviewingstate - Implement
handleReview:const handleReview = async () => { if (!worktreePath) return setIsReviewing(true) try { // 1. Read review prompt template const promptResult = await window.fileOps.readFile( join(/* app root */, 'prompts', 'review.md') ) // Alternatively, use a dedicated IPC handler or hardcode the path // 2. Build file list from current git status const fileList = [...stagedFiles, ...modifiedFiles, ...untrackedFiles] .map(f => `- ${f.status} ${f.relativePath}`) .join('\n') // 3. Construct prompt const prompt = `${promptResult.content || ''}\n\n---\n\nPlease review the following uncommitted changes in this worktree:\n\nChanged files:\n${fileList}\n\nFocus on: bugs, logic errors, and code quality.` // 4. Create new session // (Need worktree info and project info) const worktree = useWorktreeStore.getState()... const session = await useSessionStore.getState().createSession(...) // 5. Send prompt await window.opencodeOps.prompt(worktreePath, session.opencode_session_id, prompt) // 6. Navigate to new session useSessionStore.getState().setActiveSession(session.id) } finally { setIsReviewing(false) } }
- The exact flow depends on how session creation and OpenCode connection work — need to connect first, then prompt
- Use existing patterns from how sessions are created elsewhere in the app
- Import
-
Need to resolve how to read
prompts/review.md— options:- Use
window.fileOps.readFile()with the app's resource path - Add a dedicated IPC handler that reads from the app's
prompts/directory - The path should be relative to the project root, not the worktree
- On dev:
prompts/review.mdrelative to repo root - On production: bundled in app resources
- Use
src/renderer/src/components/git/GitStatusPanel.tsx— review button and handlersrc/renderer/src/stores/useSessionStore.ts— may need a helper for creating review sessions
- "Review" button visible in git panel header next to refresh
- Button disabled when no changes exist
- Button shows spinner while review is being set up
- Clicking creates a new session in the current worktree
- Session name is "Code Review — {branch}"
- Prompt includes contents of
prompts/review.md - Prompt includes list of changed files with their statuses
- New session tab activates automatically
- AI begins reviewing the changes
-
pnpm lintpasses
- Open a worktree with some uncommitted changes
- Verify the "Review" button is visible in the git panel
- Click "Review"
- Verify a new session tab opens with name "Code Review — {branch}"
- Verify the AI starts analyzing the changes
- Verify no button shown when there are zero changes
// test/phase-7/session-4/code-review.test.ts
describe('Session 4: Code Review', () => {
test('Review button rendered when changes exist', () => {
// Render GitStatusPanel with changes
// Verify git-review-button present
})
test('Review button disabled when no changes', () => {
// Render GitStatusPanel with no changes
// Verify button disabled
})
test('Review button shows spinner during creation', () => {
// Click review, during async operation
// Verify Loader2 spinner visible
})
test('New session created with review name', () => {
// Click review
// Verify createSession called with name "Code Review — {branch}"
})
test('Prompt includes review template', () => {
// Click review
// Verify prompt sent includes review.md content
})
test('Prompt includes changed file list', () => {
// Changed files: M App.tsx, A New.tsx, ? util.ts
// Verify prompt contains file list with statuses
})
test('New session becomes active', () => {
// Click review
// Verify setActiveSession called with new session ID
})
})- Replace the diff modal with an inline diff viewer in the main pane
- Add context expansion (show more lines around hunks)
- Add up/down navigation between diff hunks
-
Create
src/renderer/src/components/diff/InlineDiffViewer.tsx:interface InlineDiffViewerProps { worktreePath: string filePath: string fileName: string staged: boolean isUntracked: boolean onClose: () => void }
- Toolbar at top: hunk nav (▲▼), view mode toggle (Unified/Split), copy button, close button
- Main area: renders
DiffViewercomponent - Context expansion: "Show more" buttons between hunks
- Manages
contextLinesstate (default: 3, increases by 10 on each click) - Fetches diff via
window.gitOps.getDiff(worktreePath, filePath, staged, isUntracked, contextLines)
-
Add hunk navigation:
- After diff renders, find all
.d2h-infoor@@marker elements - Store refs to each hunk marker
- ▼ button: scroll to next hunk relative to current scroll position
- ▲ button: scroll to previous hunk
- Keyboard:
Alt+↓/Alt+↑bindings
- After diff renders, find all
-
Add context expansion:
- Maintain
contextLinesstate starting at 3 - "Show more context" button in the toolbar
- On click: increment contextLines by 10, re-fetch diff
- Re-renders the DiffViewer with new data
- Maintain
-
In
src/main/ipc/git-file-handlers.ts:- Update the
getDiffhandler to accept optionalcontextLines:ipcMain.handle('git:getDiff', async (_, { worktreePath, filePath, staged, isUntracked, contextLines }) => { // Use contextLines in the diff command const contextArg = contextLines ? `-U${contextLines}` : '' // Pass to git diff command })
- Update the
-
In
src/preload/index.tsandsrc/preload/index.d.ts:- Update
getDiffsignature to include optionalcontextLines?: numberparameter
- Update
-
In
src/renderer/src/components/git/GitStatusPanel.tsx:- Change
handleViewDiffto open an inline diff tab instead ofDiffModal:- Either use
useFileViewerStoreto set a "diff" tab - Or emit an event that the main pane listens for
- Either use
- Remove the
DiffModalcomponent rendering (or keep as fallback)
- Change
-
In
src/renderer/src/stores/useFileViewerStore.ts:- Add diff tab state:
activeDiff: { worktreePath: string filePath: string fileName: string staged: boolean isUntracked: boolean } | null setActiveDiff: (diff: ...) => void clearActiveDiff: () => void
- Add diff tab state:
src/renderer/src/components/diff/InlineDiffViewer.tsx— NEWsrc/main/ipc/git-file-handlers.ts— contextLines supportsrc/preload/index.ts— update getDiffsrc/preload/index.d.ts— update typessrc/renderer/src/components/git/GitStatusPanel.tsx— open inline diff instead of modalsrc/renderer/src/stores/useFileViewerStore.ts— diff tab state
- Clicking a changed file in git panel opens an inline diff viewer (not a modal)
- Diff viewer renders in the main pane area
- Toolbar shows: ▲▼ nav arrows, Unified/Split toggle, Copy, Close
- ▼ scrolls to next diff hunk, ▲ scrolls to previous
- "Show more context" re-fetches diff with increased
-U{n}context - Default context is 3 lines, each expansion adds 10
- Unified and Split view modes work
- Copy button copies diff to clipboard
- Close button returns to the session view
-
contextLinesparameter passed through IPC to git diff command -
pnpm lintpasses
- Make changes to a file in a worktree
- Click the file in the git status panel
- Verify diff opens inline in the main pane (not as a modal popup)
- Verify hunk navigation: click ▼ to jump to next change, ▲ to go back
- Click "Show more context" → verify more lines appear around the changes
- Toggle between Unified and Split view
- Click Close → verify you return to the session/file view
// test/phase-7/session-5/inline-diff-viewer.test.ts
describe('Session 5: Inline Diff Viewer', () => {
test('InlineDiffViewer renders diff content', () => {
// Provide diff string
// Verify DiffViewer rendered with content
})
test('toolbar shows nav arrows', () => {
// Render InlineDiffViewer
// Verify ▲ and ▼ buttons present
})
test('down arrow scrolls to next hunk', () => {
// Diff with 3 hunks
// Click ▼ → verify scroll position moved to hunk 2
})
test('up arrow scrolls to previous hunk', () => {
// At hunk 3
// Click ▲ → verify scroll position moved to hunk 2
})
test('context expansion increases contextLines', () => {
// Initial contextLines = 3
// Click "Show more context"
// Verify getDiff called with contextLines=13
})
test('contextLines passed to IPC handler', () => {
// Call getDiff with contextLines=20
// Verify git command includes -U20
})
test('unified/split toggle works', () => {
// Click split button
// Verify DiffViewer receives viewMode='split'
})
test('copy button copies diff to clipboard', () => {
// Click copy
// Verify copyToClipboard called with diff string
})
test('close button calls onClose', () => {
// Click close
// Verify onClose callback called
})
test('file click in GitStatusPanel opens inline diff', () => {
// Click a file in GitStatusPanel
// Verify setActiveDiff called (not DiffModal opened)
})
})- Group models by base name (stripping date suffixes) within each provider
- Show variant indicators in the dropdown
- Add Alt+T keyboard shortcut to cycle between variants of the selected model
-
In
src/renderer/src/components/sessions/ModelSelector.tsx:- Add variant grouping utility:
function getBaseName(modelId: string): string { return modelId.replace(/(-\d{8,})$/, '') } function getVariantSuffix(modelId: string): string | null { const match = modelId.match(/(-\d{8,})$/) return match ? match[1].slice(1) : null // e.g. "20251101" } interface ModelGroup { baseName: string displayName: string models: ModelInfo[] // all variants, sorted by date descending providerID: string }
- In
parseProviders, after building the flat model list per provider, group by base name:const groups: ModelGroup[] = [] const groupMap = new Map<string, ModelInfo[]>() for (const model of models) { const base = getBaseName(model.id) if (!groupMap.has(base)) groupMap.set(base, []) groupMap.get(base)!.push(model) } for (const [baseName, variants] of groupMap) { variants.sort((a, b) => b.id.localeCompare(a.id)) // newest first groups.push({ baseName, displayName: baseName, models: variants, providerID }) }
- Update the dropdown rendering:
- For each group, show the base name as the primary item
- If the group has multiple variants, show variant date chips below:
{group.models.length > 1 && isActive(group) && ( <div className="flex gap-1 pl-6 pb-1"> {group.models.map(variant => ( <button key={variant.id} className={cn( 'text-[10px] px-1.5 py-0.5 rounded', isActiveModel(variant) ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-accent' )} onClick={() => handleSelect(variant)} > {getVariantSuffix(variant.id) || variant.id} </button> ))} </div> )}
- Clicking the base name selects the first (latest) variant
- Clicking a variant chip selects that specific variant
- Add variant grouping utility:
-
Add Alt+T keyboard shortcut:
- In
ModelSelector.tsxor viauseKeyboardShortcut:useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.altKey && e.key === 't') { e.preventDefault() cycleVariant() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [selectedModel, providers])
cycleVariant():- Find the current model's base name
- Find all variants for that base name from the same provider
- Find current index, select the next one (wrap around)
- Call
setSelectedModel({ providerID, modelID: nextVariant.id }) - Show toast:
toast.success(\Switched to ${shortenModelName(nextVariant.id)}`)`
- If only one variant exists, do nothing
- In
-
Update the pill display to show a variant indicator when multiple variants exist:
- Show a small
↻icon or "(2 variants)" text when the current model has siblings
- Show a small
src/renderer/src/components/sessions/ModelSelector.tsx— variant grouping, UI, Alt+T
- Models with same base name grouped in dropdown
- Active model group shows variant date chips below
- Clicking base name selects the latest variant
- Clicking a variant chip selects that specific variant
- Active variant highlighted in chips
- Alt+T cycles to next variant of current model
- Alt+T wraps around to first variant after last
- Alt+T does nothing if only one variant exists
- Toast shown on variant switch via Alt+T
- Single-variant models display normally (no chips)
- Pill shows correct display name for selected variant
-
pnpm lintpasses
- Open the model selector dropdown
- Verify models with multiple date variants are grouped (e.g.,
claude-opus-4-5with date chips below) - Click a different date chip → verify model changes
- Close dropdown → press Alt+T → verify model cycles to next variant with a toast
- Press Alt+T again → verify cycles to the next, wrapping around
- Select a model with only one variant → press Alt+T → verify nothing happens
// test/phase-7/session-6/model-variants.test.ts
describe('Session 6: Model Variants', () => {
describe('Grouping', () => {
test('getBaseName strips date suffix', () => {
expect(getBaseName('claude-opus-4-5-20251101')).toBe('claude-opus-4-5')
expect(getBaseName('claude-opus-4-5-20250514')).toBe('claude-opus-4-5')
expect(getBaseName('gpt-4o')).toBe('gpt-4o')
})
test('getVariantSuffix extracts date', () => {
expect(getVariantSuffix('claude-opus-4-5-20251101')).toBe('20251101')
expect(getVariantSuffix('gpt-4o')).toBeNull()
})
test('models grouped by base name', () => {
// Models: ['claude-opus-4-5-20251101', 'claude-opus-4-5-20250514', 'claude-haiku-4-5-20251001']
// Verify 2 groups: claude-opus-4-5 (2 variants), claude-haiku-4-5 (1 variant)
})
test('variants sorted newest first', () => {
// Group with ['20250514', '20251101']
// Verify order: 20251101 first, then 20250514
})
})
describe('Dropdown UI', () => {
test('variant chips shown for active multi-variant group', () => {
// Select claude-opus-4-5-20251101 (group has 2 variants)
// Verify date chips visible
})
test('no chips for single-variant group', () => {
// Select gpt-4o (only 1 variant)
// Verify no chips shown
})
test('clicking base name selects latest variant', () => {
// Click "claude-opus-4-5" group
// Verify selected model is 20251101 (latest)
})
test('clicking variant chip selects that variant', () => {
// Click "20250514" chip
// Verify selected model is claude-opus-4-5-20250514
})
test('active variant chip highlighted', () => {
// Selected: 20251101
// Verify 20251101 chip has primary styling
// Verify 20250514 chip has muted styling
})
})
describe('Alt+T shortcut', () => {
test('Alt+T cycles to next variant', () => {
// Selected: claude-opus-4-5-20251101 (2 variants)
// Press Alt+T
// Verify selected model is claude-opus-4-5-20250514
})
test('Alt+T wraps around', () => {
// Selected: claude-opus-4-5-20250514 (last variant)
// Press Alt+T
// Verify selected model is claude-opus-4-5-20251101 (first)
})
test('Alt+T does nothing for single variant', () => {
// Selected: gpt-4o (only 1 variant)
// Press Alt+T
// Verify model unchanged
})
test('toast shown on variant switch', () => {
// Press Alt+T, variant changes
// Verify toast.success called
})
test('no toast when no change', () => {
// Single variant, press Alt+T
// Verify no toast
})
})
})- Verify all Phase 7 features work correctly together
- Fix edge cases, visual inconsistencies, and regressions
- Run lint and tests
-
Project Filter integration:
- Filter projects → expand a matching project → verify worktrees still show
- Clear filter → verify all projects return
- Filter while a project is selected → verify selection preserved if project still visible
- Test with projects that have special characters in names/paths
-
Branch Duplication integration:
- Duplicate a branch → verify the new branch appears in the sidebar
- Select the duplicated branch → verify it loads correctly
- Verify the run script state is independent (not shared with source)
-
Code Review integration:
- Trigger review → verify session appears in tab list
- Verify AI response streams correctly
- Trigger review when already reviewing → verify graceful handling (button disabled)
-
Inline Diff integration:
- Open a diff → switch to a session tab → switch back → verify diff still shown
- Open a diff → make more changes → refresh git status → verify diff updates
- Open a diff for a staged file vs unstaged file → verify correct diff
-
Pulse Animation integration:
- Start run process → verify pulse appears
- Start AI session while run is active → verify pulse takes priority over spinner
- Stop run process → verify pulse disappears, AI spinner shows if still working
-
Auto-Focus integration:
- Open inline diff → close diff → verify session textarea refocuses
- Open command palette → close → verify textarea refocuses
-
Model Variants integration:
- Switch model → send a message → verify correct model used
- Alt+T during streaming → verify model change applies to next message
-
Run
pnpm lint— fix any errors -
Run
pnpm test— fix any failures -
Check for console errors during normal operation
- All files modified in sessions 1-6
- Focus on cross-cutting concerns
- All 8 features from sessions 1-6 work correctly in isolation
- No regressions in Phase 6 features
- Cross-feature interactions work correctly (listed above)
- No console errors during normal operation
-
pnpm lintpasses -
pnpm testpasses - Performance targets met (filter < 16ms, diff render < 100ms, etc.)
Run through each integration scenario listed in Tasks above. Focus on transitions between features — switching from diff view to session, filtering while working, duplicating while running, etc.
// test/phase-7/session-7/integration-polish.test.ts
describe('Session 7: Integration & Polish', () => {
test('Filter preserves project selection', () => {
// Select project-A, type filter that includes project-A
// Verify project-A still selected and expanded
})
test('Filter hides non-matching and shows matching', () => {
// 3 projects, filter matches 1
// Verify 1 shown, 2 hidden
})
test('Duplicated worktree appears in sidebar', () => {
// Duplicate feature-auth
// Verify feature-auth-v2 in worktree list
})
test('Review session streams correctly', () => {
// Trigger review
// Verify session created and message sent
})
test('Inline diff updates on git changes', () => {
// Open diff, make more changes
// Verify diff reflects new changes after refresh
})
test('Pulse priority over AI spinner', () => {
// runRunning=true, worktreeStatus='working'
// Verify PulseAnimation shown, not Loader2
})
test('No console errors during full workflow', () => {
// Navigate through all features
// Verify zero console.error calls
})
test('Lint passes', () => {
// pnpm lint exit code 0
})
test('Tests pass', () => {
// pnpm test exit code 0
})
})- Full manual verification of all Phase 7 features with real data
- Performance validation against NFR targets
- Final cleanup
-
Full feature walkthrough (with the app running via
pnpm dev):- Add 5+ projects with varying names
- Test project filter with subsequence queries
- Create worktrees, make changes, duplicate them
- Trigger code review on uncommitted changes
- View diffs inline with context expansion
- Run project scripts, verify pulse animation
- Verify auto-focus on session switch
- Test model variant selection with Alt+T
- Clear run output
-
Edge cases:
- Filter with empty projects list
- Duplicate a clean worktree (no uncommitted changes)
- Review when no changes exist (button should be disabled)
- Diff for a deleted file
- Diff for a new (untracked) file
- Alt+T when no model loaded yet
- Pulse animation when switching between worktrees rapidly
-
Performance check:
- Project filter: type rapidly in filter → verify no lag (< 16ms per keystroke)
- Branch duplication: measure time for typical worktree (< 5 seconds)
- Inline diff: open large file diff (> 1000 lines) → verify render < 100ms
- Pulse animation: verify 60fps (no jank in dev tools performance tab)
-
Final
pnpm lintandpnpm test
- All features demonstrated working with real data
- Edge cases handled gracefully
- Performance targets met
-
pnpm lintpasses -
pnpm testpasses - No visual regressions from Phase 6
Follow the full walkthrough in task 1 above. Use Chrome DevTools Performance tab to verify animation FPS and measure render times.
// test/phase-7/session-8/e2e-verification.test.ts
describe('Session 8: E2E Verification', () => {
test('Project filter end-to-end', () => {
// Type in filter, verify results, clear, verify all shown
})
test('Branch duplication end-to-end', () => {
// Duplicate, verify new worktree, verify uncommitted files
})
test('Code review end-to-end', () => {
// Make changes, click review, verify session with review prompt
})
test('Inline diff end-to-end', () => {
// Click file, verify inline diff, expand context, navigate hunks
})
test('Pulse animation end-to-end', () => {
// Run script, verify pulse, stop, verify gone
})
test('Auto-focus end-to-end', () => {
// Switch sessions, verify textarea focused each time
})
test('Model variants end-to-end', () => {
// Open selector, click variant, Alt+T cycle, verify toast
})
test('Clear button end-to-end', () => {
// Run script, produce output, click clear, verify empty
})
})Session 1 (Quick Wins: Auto-Focus, Clear, Pulse)
|
├── Session 2 (Project Filter) ── independent
├── Session 3 (Branch Duplication) ── independent
├── Session 4 (Code Review) ── independent
├── Session 5 (Inline Diff Viewer) ── independent
├── Session 6 (Model Variants) ── independent
|
Session 7 (Integration & Polish) ── requires sessions 1-6
|
Session 8 (E2E Verification) ── requires session 7
After Session 1 (quick wins), Sessions 2-6 can all run in parallel since they touch independent areas:
- Track A: Session 2 — Project sidebar (ProjectList, ProjectItem, new filter components)
- Track B: Session 3 — Worktree operations (git-service, worktree-handlers, WorktreeItem)
- Track C: Session 4 — Git panel (GitStatusPanel, session creation)
- Track D: Session 5 — Diff infrastructure (InlineDiffViewer, git-file-handlers, FileViewerStore)
- Track E: Session 6 — Model selector (ModelSelector only)
Minimum critical path: Session 1 → Session 7 → Session 8
Maximum parallelism: Sessions 2, 3, 4, 5, 6 all in parallel after Session 1.
Session 7 and 8 require all previous sessions complete.
- Rich tool rendering (ReadToolView, EditToolView, etc.)
- Context indicator with token tracking
- Native notifications on session completion
- Queued messages during streaming
- Image/file attachments via 📎 and paste
- Slash command popover
- "+" worktree button on project items
- Tab persistence and session tab badges
- Subagent/reasoning/compaction part rendering
Per PRD Phase 7:
- Fuzzy matching (Levenshtein) — subsequence only
- Filtering worktrees by name
- Branch duplication across projects
- Branch duplication preserving stash entries
- Custom review prompts
- Diff for arbitrary commits (working tree vs HEAD only)
- Diff syntax highlighting beyond diff2html defaults
- Multi-file diff view
- Pulse animation customization
- Model variant sorting preferences
- Model variant pinning/favorites
| Operation | Target |
|---|---|
| Project filter per keystroke | < 16ms |
| Subsequence match per project | < 5ms |
| Branch duplication | < 5 seconds |
| Review session creation | < 500ms |
| Inline diff render | < 100ms (< 5000 lines) |
| Context expansion re-render | < 200ms |
| Hunk navigation scroll | < 16ms |
| Pulse animation | 60fps |
| Auto-focus textarea | < 50ms |
| Model variant cycle (Alt+T) | < 100ms |
- Subsequence matching (not fuzzy/Levenshtein): Simpler, faster, and more predictable. Letters must appear in order. Score favors contiguous matches.
- Branch duplication via
git stash create+git stash apply: Non-destructive to source worktree.stash createdoesn't modify the working tree or stash list. Untracked files copied separately viafs.cpSync. - Review prompt reads
prompts/review.mdat runtime: Allows updating the prompt without rebuilding. Falls back gracefully if file missing. - Inline diff replaces modal (not alongside): Modal blocks interaction. Inline diff as a tab allows side-by-side work with sessions.
- Context expansion via re-fetch with
-U{n}: Simpler than parsing and inserting lines client-side. Slight re-render cost is acceptable. - Pulse animation via SVG + CSS: No JS animation loop needed. CSS handles the traveling wave effect at 60fps with zero layout cost.
- Model variant grouping is client-side only: No backend changes needed. Grouping logic strips date suffixes and compares base names.
- Alt+T is a global keyboard shortcut: Works regardless of focus. Uses
window.addEventListenerfor reliability.