Track two-agent setup from agent hooks

This commit is contained in:
brennanb2025
2026-05-30 10:08:56 -07:00
parent 9e06a18edf
commit 096a63b4a0
6 changed files with 232 additions and 145 deletions
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import type { JSX, ReactNode } from 'react'
import { Briefcase, FolderGit2, House } from 'lucide-react'
import { FolderGit2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ClaudeIcon } from '../status-bar/icons'
import { CodexInlineIcon, WorkingSpinner } from './feature-tour-preview-glyphs'
@@ -9,29 +9,32 @@ type RepoFocus = 'personal' | 'work' | 'both'
type RepoVisual = {
id: Exclude<RepoFocus, 'both'>
label: string
name: string
worktree: string
prompt: string
action: string
agent: 'Claude Code' | 'Codex'
icon: ReactNode
agentIcon: ReactNode
}
const REPOS: readonly RepoVisual[] = [
{
id: 'personal',
label: 'Personal',
name: 'recipe-box',
worktree: 'weekend polish',
prompt: 'refine recipe search',
action: 'Editing search filters',
agent: 'Claude Code',
icon: <House className="size-3.5" />
agentIcon: <ClaudeIcon size={12} />
},
{
id: 'work',
label: 'Work',
name: 'billing-app',
worktree: 'checkout fix',
prompt: 'fix checkout bug',
action: 'Updating checkout tests',
agent: 'Codex',
icon: <Briefcase className="size-3.5" />
agentIcon: <CodexInlineIcon />
}
]
@@ -62,59 +65,107 @@ function RepoProjectCard(props: {
return (
<section
className={cn(
'flex min-w-0 flex-col rounded-lg border bg-background transition-[border-color,box-shadow,background-color] duration-500',
props.active ? 'border-primary/35 bg-primary/5 shadow-xs' : 'border-border'
'flex min-w-0 flex-col gap-2 rounded-lg border border-sidebar-border bg-sidebar p-2 text-sidebar-foreground transition-[border-color,box-shadow] duration-500',
props.active ? 'shadow-xs' : null
)}
>
<div className="flex h-10 items-center gap-2 border-b border-border px-3">
<span
className={cn(
'flex size-5 items-center justify-center rounded-md border border-border bg-card transition-colors',
props.active ? 'text-primary' : 'text-muted-foreground'
)}
>
{props.repo.icon}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-none text-foreground">
{props.repo.name}
</div>
<div className="mt-1 text-[10px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{props.repo.label}
</div>
</div>
</div>
<ProjectSidebarRow name={props.repo.name} />
<div className="flex flex-1 flex-col gap-3 p-3">
<div className="rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
<FolderGit2 className="size-3.5" />
</span>
<span className="min-w-0 truncate text-xs font-semibold text-foreground">
{props.repo.worktree}
</span>
</div>
<div className="mt-3 rounded-md border border-border bg-background p-2.5 font-mono text-[11px]">
<TerminalLine>
<Prompt>&gt;</Prompt>
{props.repo.id === 'personal' ? 'refine recipe search' : 'fix checkout bug'}
</TerminalLine>
<TerminalLine muted>
<WorkingSpinner size="xs" reducedMotion={props.reducedMotion} />
{props.repo.agent} working
</TerminalLine>
<TerminalLine muted>
{props.repo.agent === 'Codex' ? <CodexInlineIcon /> : <ClaudeIcon size={12} />}
{props.repo.id === 'personal' ? 'Updating UI' : 'Editing tests'}
</TerminalLine>
</div>
<ProjectWorktreeRow
repo={props.repo}
active={props.active}
reducedMotion={props.reducedMotion}
/>
<div className="min-w-0 overflow-hidden rounded-lg border border-border bg-background text-foreground">
<VisualTitlebar title={`${props.repo.worktree} - ${props.repo.agent}`} />
<div className="space-y-1.5 p-2.5 font-mono text-[11px]">
<TerminalLine>
<Prompt>&gt;</Prompt>
{props.repo.prompt}
</TerminalLine>
<TerminalLine muted>
{props.repo.agentIcon}
{props.repo.agent} session ready
</TerminalLine>
<TerminalLine muted>
<WorkingSpinner size="xs" reducedMotion={props.reducedMotion} />
{props.repo.action}
</TerminalLine>
</div>
</div>
</section>
)
}
function ProjectSidebarRow(props: { name: string }): JSX.Element {
return (
<div
aria-hidden
className="relative flex h-8 min-w-0 items-center gap-1.5 rounded-md px-1.5 text-sidebar-foreground"
>
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<FolderGit2 className="size-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold leading-none">
{props.name}
</span>
<span className="min-w-[3.75rem] rounded-full bg-sidebar-accent px-1.5 py-0.5 text-center text-[9px] font-medium leading-none text-muted-foreground">
1 worktree
</span>
</div>
)
}
function ProjectWorktreeRow(props: {
repo: RepoVisual
active: boolean
reducedMotion: boolean
}): JSX.Element {
return (
<div
className={cn(
'rounded-md border border-sidebar-border px-2.5 py-2 transition-colors',
props.active ? 'bg-sidebar-accent' : 'bg-sidebar'
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
props.active ? 'bg-emerald-500' : 'bg-muted-foreground/35'
)}
/>
<span className="truncate text-xs font-medium text-sidebar-foreground">
{props.repo.worktree}
</span>
</div>
<div className="mt-2 grid grid-cols-[8px_14px_minmax(0,1fr)] items-center gap-1.5">
<WorkingSpinner size="xs" reducedMotion={props.reducedMotion} />
<span className="flex size-3.5 items-center justify-center text-sidebar-foreground/65">
{props.repo.agentIcon}
</span>
<span className="truncate font-mono text-[11px] text-sidebar-foreground/65">
{props.repo.prompt}
</span>
</div>
</div>
)
}
function VisualTitlebar(props: { title: string }): JSX.Element {
return (
<div className="flex h-6 items-center gap-1.5 border-b border-border bg-muted/40 px-2">
<span className="size-2 rounded-full bg-foreground/15" />
<span className="size-2 rounded-full bg-foreground/15" />
<span className="size-2 rounded-full bg-foreground/15" />
<span className="ml-1 truncate font-mono text-[11px] text-muted-foreground">
{props.title}
</span>
</div>
)
}
function TerminalLine(props: { children: ReactNode; muted?: boolean }): JSX.Element {
return (
<div
@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useRef, useState, type JSX } from 'react'
import { Check, GitBranch } from 'lucide-react'
import { GitBranch } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ClaudeIcon } from '../status-bar/icons'
import { FeatureWallClickRing } from './FeatureWallClickRing'
@@ -166,20 +166,10 @@ function AgentWorktreeTerminal(props: {
<span className="truncate">{WORKTREE_LABEL}</span>
</span>
</div>
<div className="space-y-2.5 p-3 font-mono text-[10.5px] leading-snug">
<ClaudePrompt
active={props.phase === 'inspect'}
done={props.targetVisible}
text="approve the note in my app"
/>
<div className="space-y-2 p-3 font-mono text-[10.5px] leading-snug">
<TerminalRequest text="approve the note in my app" />
<CliCommandBlock
command={
<>
<span className="text-muted-foreground">orca computer</span>{' '}
<span className="text-foreground">get-app-state</span>
</>
}
arg='--app "Notes"'
commandLines={['orca computer get-app-state', '--app "Notes"']}
active={props.phase === 'inspect'}
done={props.targetVisible}
output={
@@ -191,31 +181,19 @@ function AgentWorktreeTerminal(props: {
}
/>
<CliCommandBlock
command={
<>
<span className="text-muted-foreground">orca computer</span>{' '}
<span className="text-foreground">click</span>
</>
}
arg="--element-index 7"
commandLines={['orca computer click', '--element-index 7']}
active={props.phase === 'target' || props.phase === 'click'}
done={props.clicked}
output={props.clicked ? <>click sent</> : null}
/>
<CliCommandBlock
command={
<>
<span className="text-muted-foreground">orca computer</span>{' '}
<span className="text-foreground">get-app-state</span>
</>
}
arg='--app "Notes" --json'
commandLines={['orca computer get-app-state', '--app "Notes" --json']}
active={props.phase === 'click'}
done={props.verified}
output={
props.verified ? (
<>
state: <span className="text-foreground">Approved</span>
<span aria-hidden>→</span> <span className="text-foreground">computer</span>
</>
) : null
}
@@ -225,62 +203,38 @@ function AgentWorktreeTerminal(props: {
)
}
function ClaudePrompt(props: { text: string; active: boolean; done: boolean }): JSX.Element {
function TerminalRequest(props: { text: string }): JSX.Element {
return (
<div className="flex min-w-0 items-start gap-1.5">
<span className="shrink-0 text-amber-600">&gt;</span>
<span
className={cn(
'truncate',
props.active || props.done ? 'text-foreground' : 'text-muted-foreground'
)}
>
{props.text}
</span>
<div className="min-w-0 truncate text-muted-foreground">
<span>{props.text}</span>
</div>
)
}
function CliCommandBlock(props: {
command: JSX.Element
arg: string
commandLines: readonly string[]
active: boolean
done: boolean
output: JSX.Element | null
}): JSX.Element {
const commandTone = props.active || props.done ? 'text-foreground' : 'text-muted-foreground/65'
return (
<div className="space-y-0.5">
<div className="flex items-start gap-1.5">
<span
aria-hidden
className={cn(
'mt-px shrink-0',
props.active || props.done ? 'text-foreground' : 'text-muted-foreground/60'
)}
>
$
</span>
<div className="min-w-0 flex-1">
<div
className={cn('truncate', props.active ? 'text-foreground' : 'text-muted-foreground')}
>
{props.command}
<div className={cn('text-[11px]', commandTone)}>$</div>
<div className={cn('min-w-0 space-y-0.5 pl-2', commandTone)}>
{props.commandLines.map((line) => (
<div key={line} className="truncate">
{line}
</div>
<div className="truncate pl-3 text-muted-foreground">{props.arg}</div>
</div>
))}
</div>
<div
className={cn(
'flex min-h-[14px] items-center gap-1.5 pl-3.5 text-muted-foreground transition-opacity duration-200',
'min-h-[14px] truncate pl-2 text-muted-foreground transition-opacity duration-200',
props.output ? 'opacity-100' : 'opacity-0'
)}
>
{props.done ? (
<Check className="size-2.5 text-primary" strokeWidth={3} />
) : (
<span aria-hidden>→</span>
)}
<span className="truncate">{props.output ?? ' '}</span>
{props.output ?? ' '}
</div>
</div>
)
@@ -18,7 +18,7 @@ function makeInput(
gitRepoCount: 0,
worktreesByRepo: {},
tabsByWorktree: {},
runtimePaneTitlesByTabId: {},
agentStatusByPaneKey: {},
hasSetupScript: false,
...overrides
}
@@ -65,7 +65,7 @@ describe('getFeatureWallSetupProgress', () => {
expect(progress.stepDone['two-agents']).toBe(false)
})
it('marks two agents complete once two agent sessions exist in one worktree', () => {
it('does not mark two agents complete from terminal titles alone', () => {
const progress = getFeatureWallSetupProgress(
makeInput({
worktreesByRepo: { 'repo-1': [makeWorktree('worktree-1')] },
@@ -78,9 +78,82 @@ describe('getFeatureWallSetupProgress', () => {
})
)
expect(progress.stepDone['two-agents']).toBe(false)
})
it('marks two agents complete once two hook-reported agent sessions exist in one worktree', () => {
const progress = getFeatureWallSetupProgress(
makeInput({
worktreesByRepo: { 'repo-1': [makeWorktree('worktree-1')] },
tabsByWorktree: {
'worktree-1': [
{ id: 'tab-1', title: 'Terminal' },
{ id: 'tab-2', title: 'Terminal' }
] as never
},
agentStatusByPaneKey: {
'tab-1:00000000-0000-4000-8000-000000000001': {
paneKey: 'tab-1:00000000-0000-4000-8000-000000000001',
state: 'working',
prompt: 'first task',
updatedAt: 1,
stateStartedAt: 1,
agentType: 'claude',
stateHistory: []
},
'tab-2:00000000-0000-4000-8000-000000000002': {
paneKey: 'tab-2:00000000-0000-4000-8000-000000000002',
state: 'waiting',
prompt: 'second task',
updatedAt: 2,
stateStartedAt: 2,
agentType: 'codex',
stateHistory: []
}
}
})
)
expect(progress.stepDone['two-agents']).toBe(true)
})
it('does not mark two agents complete when hook-reported agents are in separate worktrees', () => {
const progress = getFeatureWallSetupProgress(
makeInput({
worktreesByRepo: {
'repo-1': [makeWorktree('worktree-1')],
'repo-2': [makeWorktree('worktree-2')]
},
tabsByWorktree: {
'worktree-1': [{ id: 'tab-1', title: 'Terminal' }] as never,
'worktree-2': [{ id: 'tab-2', title: 'Terminal' }] as never
},
agentStatusByPaneKey: {
'tab-1:00000000-0000-4000-8000-000000000001': {
paneKey: 'tab-1:00000000-0000-4000-8000-000000000001',
state: 'working',
prompt: 'first task',
updatedAt: 1,
stateStartedAt: 1,
agentType: 'claude',
stateHistory: []
},
'tab-2:00000000-0000-4000-8000-000000000002': {
paneKey: 'tab-2:00000000-0000-4000-8000-000000000002',
state: 'working',
prompt: 'second task',
updatedAt: 2,
stateStartedAt: 2,
agentType: 'codex',
stateHistory: []
}
}
})
)
expect(progress.stepDone['two-agents']).toBe(false)
})
it('marks worktrees complete once two worktrees exist', () => {
expect(
getFeatureWallSetupProgress(
@@ -4,7 +4,8 @@ import {
type FeatureWallSetupStepId
} from '../../../../shared/feature-wall-setup-steps'
import type { GlobalSettings, TerminalTab, Worktree } from '../../../../shared/types'
import { getAgentLabel } from '../../lib/agent-status'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
export type FeatureWallSetupProgressInput = {
settings: GlobalSettings | null
@@ -17,7 +18,7 @@ export type FeatureWallSetupProgressInput = {
gitRepoCount: number
worktreesByRepo: Record<string, Worktree[]>
tabsByWorktree: Record<string, TerminalTab[]>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
agentStatusByPaneKey: Record<string, AgentStatusEntry>
hasSetupScript: boolean
}
@@ -27,30 +28,38 @@ export type FeatureWallSetupProgress = {
coreTotal: number
}
function hasTwoAgentSessionsInOneWorktree(input: FeatureWallSetupProgressInput): boolean {
function hasTwoHookReportedAgentsInOneWorktree(input: FeatureWallSetupProgressInput): boolean {
const validWorktreeIds = new Set(
Object.values(input.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
)
const tabIdToWorktreeId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(input.tabsByWorktree)) {
if (
!Object.values(input.worktreesByRepo).some((worktrees) =>
worktrees.some((w) => w.id === worktreeId)
)
) {
if (!validWorktreeIds.has(worktreeId)) {
continue
}
let agentSessionCount = 0
for (const tab of tabs) {
const paneTitles = input.runtimePaneTitlesByTabId[tab.id]
if (paneTitles && Object.keys(paneTitles).length > 0) {
agentSessionCount += Object.values(paneTitles).filter((title) =>
getAgentLabel(title)
).length
} else if (getAgentLabel(tab.title)) {
agentSessionCount += 1
}
if (agentSessionCount >= 2) {
return true
}
tabIdToWorktreeId.set(tab.id, worktreeId)
}
}
const agentCountsByWorktree = new Map<string, number>()
for (const paneKey of Object.keys(input.agentStatusByPaneKey)) {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
continue
}
const worktreeId = tabIdToWorktreeId.get(parsed.tabId)
if (!worktreeId) {
continue
}
const count = (agentCountsByWorktree.get(worktreeId) ?? 0) + 1
if (count >= 2) {
return true
}
agentCountsByWorktree.set(worktreeId, count)
}
return false
}
@@ -73,7 +82,7 @@ export function getFeatureWallSetupProgress(
notifications:
input.settings?.notifications.enabled === true &&
input.settings.notifications.agentTaskComplete === true,
'two-agents': hasTwoAgentSessionsInOneWorktree(input),
'two-agents': hasTwoHookReportedAgentsInOneWorktree(input),
'three-workspaces': countWorkspaces(input.worktreesByRepo) >= 2,
'task-sources': input.hasConnectedTaskSource,
'agent-capabilities': agentCapabilitiesDone,
@@ -26,7 +26,7 @@ export function useSetupGuideProgress(
const featureInteractions = useAppStore((s) => s.featureInteractions)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
@@ -168,7 +168,7 @@ export function useSetupGuideProgress(
gitRepoCount,
worktreesByRepo,
tabsByWorktree,
runtimePaneTitlesByTabId,
agentStatusByPaneKey,
hasSetupScript
}),
[
@@ -178,11 +178,11 @@ export function useSetupGuideProgress(
detectedBrowserUseSkillInstalled,
detectedOrchestrationSkillInstalled,
featureInteractions,
agentStatusByPaneKey,
gitRepoCount,
hasConnectedTaskSource,
hasSetupScript,
orchestrationSkillInstalled,
runtimePaneTitlesByTabId,
settings,
tabsByWorktree,
worktreesByRepo
+2 -2
View File
@@ -48,8 +48,8 @@ export const FEATURE_WALL_SETUP_STEPS: readonly FeatureWallSetupStep[] = [
},
{
id: 'agent-capabilities',
name: 'Enable advanced agent capabilities',
subtitle: 'Enable advanced agent capabilities',
name: 'Install CLI & Skills',
subtitle: 'Install CLI & Skills',
description: 'Enable the tools that let agents work more independently.'
},
{