mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 08:03:08 +00:00
Show worktree-attributed child agents inline
- Keep orchestration worker rows visible when hook status arrives before the renderer has a tab for the pane. - Show child agent lineage expanded by default with clearer parent/child styling. - Tighten worktree list indentation so grouped and nested rows align correctly.
This commit is contained in:
@@ -877,6 +877,72 @@
|
||||
background: color-mix(in srgb, var(--sidebar-foreground) 8%, var(--sidebar));
|
||||
}
|
||||
|
||||
.worktree-agent-lineage-parent-row {
|
||||
/* Why: inline orchestration trees have two chevron levels; the manager row
|
||||
needs a structural cue that is quieter than the workspace card chrome. */
|
||||
background: color-mix(in srgb, var(--sidebar-foreground) 3%, var(--sidebar));
|
||||
box-shadow:
|
||||
inset 2px 0 0 color-mix(in srgb, var(--sidebar-foreground) 38%, transparent),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--sidebar-foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.worktree-agent-lineage-parent-row.worktree-agent-row-hover:hover {
|
||||
background: color-mix(in srgb, var(--sidebar-foreground) 6%, var(--sidebar));
|
||||
}
|
||||
|
||||
.dark .worktree-agent-lineage-parent-row {
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--sidebar));
|
||||
box-shadow:
|
||||
inset 2px 0 0 color-mix(in srgb, var(--accent) 58%, transparent),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.dark .worktree-agent-lineage-parent-row.worktree-agent-row-hover:hover {
|
||||
background: color-mix(in srgb, var(--accent) 30%, var(--sidebar));
|
||||
}
|
||||
|
||||
.worktree-agent-lineage-child-row {
|
||||
position: relative;
|
||||
background: color-mix(in srgb, var(--sidebar) 74%, var(--background));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-foreground) 7%, transparent);
|
||||
}
|
||||
|
||||
.worktree-agent-lineage-child-row.worktree-agent-row-hover:hover {
|
||||
background: color-mix(in srgb, var(--sidebar-foreground) 4%, var(--sidebar));
|
||||
}
|
||||
|
||||
.worktree-agent-lineage-child-row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.15rem;
|
||||
top: 50%;
|
||||
width: 0.35rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--sidebar-foreground) 20%, transparent);
|
||||
}
|
||||
|
||||
.dark .worktree-agent-lineage-child-row {
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--sidebar));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
|
||||
.dark .worktree-agent-lineage-child-row.worktree-agent-row-hover:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--sidebar));
|
||||
}
|
||||
|
||||
.dark .worktree-agent-lineage-child-row::before {
|
||||
border-top-color: color-mix(in srgb, var(--accent) 42%, transparent);
|
||||
}
|
||||
|
||||
.worktree-agent-lineage-children {
|
||||
margin-left: 0.75rem;
|
||||
padding-left: 0.25rem;
|
||||
border-left: 1px solid color-mix(in srgb, var(--sidebar-foreground) 22%, transparent);
|
||||
}
|
||||
|
||||
.dark .worktree-agent-lineage-children {
|
||||
border-left-color: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
|
||||
.worktree-agent-send-target-button {
|
||||
/* Why: the row remains an ordinary agent row during send-target mode; the
|
||||
action itself carries the AI target outline so the clickable affordance
|
||||
@@ -898,11 +964,12 @@
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 50%, transparent);
|
||||
}
|
||||
|
||||
/* Why: the virtualized card row is height-measured; animating height would
|
||||
make TanStack reposition sibling cards on every frame. */
|
||||
/* Why: the virtualized card row is height-measured; animate the grid track
|
||||
instead of explicit height so the reveal stays CSS-owned and interruptible. */
|
||||
.compact-agent-expansion-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.compact-agent-expansion-grid-expanded {
|
||||
@@ -922,10 +989,14 @@
|
||||
}
|
||||
|
||||
.compact-agent-expansion-grid-expanded .compact-agent-expansion-content {
|
||||
animation: compact-agent-expansion-reveal 150ms ease-out both;
|
||||
animation: compact-agent-expansion-reveal 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.compact-agent-expansion-grid {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.compact-agent-expansion-grid-expanded .compact-agent-expansion-content {
|
||||
animation-duration: 0ms;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DashboardAgentChildDisclosure({
|
||||
onClick={handleToggleChildren}
|
||||
onMouseDown={stopMouseDown}
|
||||
onKeyDown={stopKeyDown}
|
||||
className="-ml-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-sidebar-accent hover:text-foreground"
|
||||
className="-ml-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-sidebar-border/80 bg-sidebar text-foreground/80 shadow-xs hover:bg-sidebar-accent hover:text-foreground"
|
||||
aria-label={`${childAgentsExpanded ? 'Hide' : 'Show'} ${childAgentCount} child ${
|
||||
childAgentCount === 1 ? 'agent' : 'agents'
|
||||
}`}
|
||||
|
||||
@@ -349,4 +349,25 @@ describe('DashboardAgentRow', () => {
|
||||
expect(classTokens(markup)).toContain('left-[13px]')
|
||||
expect(markup).toContain('aria-level="1"')
|
||||
})
|
||||
|
||||
it('marks child-disclosure rows as lineage manager rows', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<DashboardAgentRow
|
||||
agent={makeAgent()}
|
||||
onDismiss={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
now={NOW}
|
||||
hideIdentityIcon
|
||||
hideExpand
|
||||
childAgentCount={2}
|
||||
childAgentsExpanded={false}
|
||||
onToggleChildAgents={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(classTokens(markup)).toContain('worktree-agent-lineage-parent-row')
|
||||
expect(markup).toContain('aria-label="Show 2 child agents"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -296,6 +296,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
|
||||
// their hover wash must stay softer than the parent card highlight.
|
||||
// The focused-pane state reuses the same class via data attribute.
|
||||
'cursor-pointer rounded-sm worktree-agent-row-hover',
|
||||
hasChildDisclosure && 'worktree-agent-lineage-parent-row',
|
||||
isLineageChild && 'worktree-agent-lineage-child-row',
|
||||
sendTargetStatus === 'sending' && 'cursor-progress opacity-75',
|
||||
sendTargetStatus === 'disabled' && 'cursor-default opacity-60'
|
||||
)}
|
||||
|
||||
@@ -607,7 +607,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
// aligned with the pre-inset layout and the repo header hierarchy.
|
||||
const cardStyle = flushSurface
|
||||
? {
|
||||
paddingLeft: contentIndent > 0 ? `calc(${contentIndent}px - 0.125rem)` : '0.125rem'
|
||||
paddingLeft:
|
||||
contentIndent > 0 ? `max(0.125rem, calc(${contentIndent}px - 0.625rem))` : '0.125rem'
|
||||
}
|
||||
: contentIndent > 0
|
||||
? { paddingLeft: `calc(0.125rem + ${contentIndent}px)` }
|
||||
@@ -674,7 +675,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
const cardBody = (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-start gap-1.5 px-1.5 pt-1.5 pb-2 cursor-pointer transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none',
|
||||
'group relative flex items-start gap-0.5 pl-0 pr-1.5 pt-1.5 pb-2 cursor-pointer transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none',
|
||||
flushSurface ? 'ml-1 w-[calc(100%-0.25rem)]' : 'ml-1',
|
||||
isMultiSelected ? 'rounded-sm' : 'rounded-lg',
|
||||
isActiveSurface
|
||||
@@ -725,7 +726,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
) : null}
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden flex flex-col gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col gap-1.5',
|
||||
lineageChildren ? 'overflow-visible' : 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
{/* Header row: Title */}
|
||||
<div className="flex items-center justify-between min-w-0 gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
@@ -977,7 +983,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lineageChildren && <div className="-ml-3 mt-1.5 space-y-1">{lineageChildren}</div>}
|
||||
{lineageChildren && (
|
||||
<div className="-ml-[1.125rem] mt-1.5 w-[calc(100%+1.125rem)] space-y-1">
|
||||
{lineageChildren}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
|
||||
|
||||
type MockAgentOptions = {
|
||||
paneKey?: string
|
||||
@@ -182,7 +183,7 @@ describe('WorktreeCardAgents', () => {
|
||||
expect(markup).toContain('data-pane-key="tab-1:2"')
|
||||
})
|
||||
|
||||
it('collapses orchestration child agent rows behind a parent disclosure by default', async () => {
|
||||
it('shows orchestration child agent rows under their parent by default', async () => {
|
||||
mockAgentActivityDisplayMode = 'full'
|
||||
mockAgents = [
|
||||
mockAgent({
|
||||
@@ -213,9 +214,9 @@ describe('WorktreeCardAgents', () => {
|
||||
|
||||
expect(markup).toContain('role="tree"')
|
||||
expect(markup).toContain('data-pane-key="tab-parent:1"')
|
||||
expect(markup).not.toContain('data-pane-key="tab-child:1"')
|
||||
expect(markup).toContain('aria-label="Show 1 child agent"')
|
||||
expect(markup).toContain('aria-expanded="false"')
|
||||
expect(markup).toContain('data-pane-key="tab-child:1"')
|
||||
expect(markup).toContain('aria-label="Hide 1 child agent"')
|
||||
expect(markup).toContain('aria-expanded="true"')
|
||||
})
|
||||
|
||||
it('keeps partially cyclic orchestration rows visible as flat roots', async () => {
|
||||
@@ -283,6 +284,9 @@ describe('WorktreeCardAgents', () => {
|
||||
|
||||
expect(markup).toContain('All 2 agents done')
|
||||
expect(markup).toContain('Expand All 2 agents done')
|
||||
expect(markup).not.toContain('title="Codex done"')
|
||||
expect(markup).not.toContain('title="Claude done"')
|
||||
expect(markup).not.toContain('>2 done<')
|
||||
expect(markup).not.toContain('First agent')
|
||||
expect(markup).not.toContain('Second agent')
|
||||
expect(markup).not.toContain('data-testid="agent-row"')
|
||||
@@ -365,6 +369,9 @@ describe('WorktreeCardAgents', () => {
|
||||
expect(markup).not.toContain('-space-x-1')
|
||||
expect(markup).toContain('3 agents: 1 waiting, 1 working, 1 done')
|
||||
expect(markup).toContain('Expand 3 agents: 1 waiting, 1 working, 1 done')
|
||||
expect(markup).not.toContain('title="Codex waiting"')
|
||||
expect(markup).not.toContain('title="Claude working"')
|
||||
expect(markup).not.toContain('title="Gemini done"')
|
||||
expect(markup).not.toContain('data-testid="agent-row"')
|
||||
})
|
||||
|
||||
@@ -413,10 +420,51 @@ describe('WorktreeCardAgents', () => {
|
||||
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
|
||||
const iconTitles = [...markup.matchAll(/title="([^"]+)"/g)].map((match) => match[1])
|
||||
|
||||
expect(iconTitles).toEqual(['Codex', 'Gemini', 'Claude'])
|
||||
expect(iconTitles).toEqual([])
|
||||
expect(markup).not.toContain('>5 working<')
|
||||
expect(markup).toContain('>+2<')
|
||||
})
|
||||
|
||||
it('uses a neutral compact summary label while expanded', async () => {
|
||||
const { CompactAgentSummaryButton } = await import('./worktree-card-compact-agents')
|
||||
const agents = [
|
||||
['tab-1:1', 'codex', 'One'],
|
||||
['tab-1:2', 'codex', 'Two'],
|
||||
['tab-1:3', 'codex', 'Three'],
|
||||
['tab-1:4', 'gemini', 'Four'],
|
||||
['tab-1:5', 'claude', 'Five']
|
||||
].map(([paneKey, agentType, prompt]) =>
|
||||
mockAgent({ paneKey, agentType, startedAt: 1000, prompt })
|
||||
) as DashboardAgentRowData[]
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<CompactAgentSummaryButton
|
||||
agents={agents}
|
||||
subjectLabel="5 agents"
|
||||
expanded
|
||||
onToggle={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('aria-expanded="true"')
|
||||
expect(markup).toContain('Collapse 5 agents')
|
||||
expect(markup).toContain('>5 agents<')
|
||||
expect(markup).not.toContain('>+2<')
|
||||
expect(markup).not.toContain('Expand All 5 agents working')
|
||||
})
|
||||
|
||||
it('can slightly indent expanded compact summary content', async () => {
|
||||
const { CompactAgentExpansion } = await import('./worktree-card-compact-agents')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<CompactAgentExpansion expanded contentClassName="pl-1">
|
||||
<div>Agent row</div>
|
||||
</CompactAgentExpansion>
|
||||
)
|
||||
|
||||
expect(markup).toContain('compact-agent-expansion-content flex flex-col gap-0.5 pt-0.5 pl-1')
|
||||
})
|
||||
|
||||
it('summarizes compact lineage by parent rows before revealing children', async () => {
|
||||
mockAgentActivityDisplayMode = 'compact'
|
||||
mockAgents = [
|
||||
@@ -466,6 +514,9 @@ describe('WorktreeCardAgents', () => {
|
||||
|
||||
expect(markup).toContain('role="tree"')
|
||||
expect(markup).toContain('3 parents: 1 waiting, 1 working, 1 done')
|
||||
expect(markup).not.toContain('title="Gemini waiting"')
|
||||
expect(markup).not.toContain('title="Codex working"')
|
||||
expect(markup).not.toContain('title="Codex done"')
|
||||
expect(markup).not.toContain('Parent A')
|
||||
expect(markup).not.toContain('Child A')
|
||||
})
|
||||
|
||||
@@ -255,6 +255,13 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
scrollToBottomIfOutputSinceLastView: true
|
||||
})
|
||||
} else {
|
||||
const liveEntry = useAppStore.getState().agentStatusByPaneKey[paneKey]
|
||||
if (liveEntry?.worktreeId === worktreeId) {
|
||||
// Why: orchestration worker status can be worktree-attributed before
|
||||
// the renderer knows its tab. Keep the visible live row instead of
|
||||
// dismissing it as stale just because it cannot be focused yet.
|
||||
return
|
||||
}
|
||||
dismissStaleAgentRowByKey(paneKey)
|
||||
}
|
||||
},
|
||||
@@ -270,7 +277,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
[agents]
|
||||
)
|
||||
const hasLineage = childrenByParentPaneKey.size > 0
|
||||
const [expandedLineageParents, setExpandedLineageParents] = useState<ReadonlySet<string>>(
|
||||
const [collapsedLineageParents, setCollapsedLineageParents] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [compactRootListExpanded, setCompactRootListExpanded] = useState(false)
|
||||
@@ -285,7 +292,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
}, [agentActivityDisplayMode, compactRootListExpanded])
|
||||
const toggleLineageParent = useCallback((paneKey: string) => {
|
||||
dispatchSuppressScrollAdjustment()
|
||||
setExpandedLineageParents((current) => {
|
||||
setCollapsedLineageParents((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(paneKey)) {
|
||||
next.delete(paneKey)
|
||||
@@ -320,7 +327,9 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
}
|
||||
const childAgents = childrenByParentPaneKey.get(agent.paneKey) ?? []
|
||||
const hasChildAgents = childAgents.length > 0
|
||||
const expanded = expandedLineageParents.has(agent.paneKey)
|
||||
// Why: spawned child agents are actionable work, so they should be visible
|
||||
// as soon as the parent appears; the disclosure remains available to fold noise.
|
||||
const expanded = !collapsedLineageParents.has(agent.paneKey)
|
||||
const sendTarget = isAgentSendTargetModeActive
|
||||
? (sendTargetsByPaneKey.get(agent.paneKey) ?? {
|
||||
status: 'disabled' as const,
|
||||
@@ -371,11 +380,13 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
// chevron-shifted column and read as floating fragments.
|
||||
hideLineageConnectors
|
||||
/>
|
||||
{hasChildAgents && expanded
|
||||
? childAgents.map((childAgent) =>
|
||||
{hasChildAgents && expanded ? (
|
||||
<div className="worktree-agent-lineage-children">
|
||||
{childAgents.map((childAgent) =>
|
||||
renderAgentBranch(childAgent, descendantAncestorPaneKeys)
|
||||
)
|
||||
: null}
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -389,7 +400,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
}
|
||||
const childAgents = childrenByParentPaneKey.get(agent.paneKey) ?? []
|
||||
const hasChildAgents = childAgents.length > 0
|
||||
const expanded = expandedLineageParents.has(agent.paneKey)
|
||||
const expanded = !collapsedLineageParents.has(agent.paneKey)
|
||||
const descendantAncestorPaneKeys = new Set(ancestorPaneKeys)
|
||||
descendantAncestorPaneKeys.add(agent.paneKey)
|
||||
return (
|
||||
@@ -408,9 +419,11 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
/>
|
||||
{hasChildAgents ? (
|
||||
<CompactAgentExpansion expanded={expanded}>
|
||||
{childAgents.map((childAgent) =>
|
||||
renderCompactAgentBranch(childAgent, descendantAncestorPaneKeys)
|
||||
)}
|
||||
<div className="worktree-agent-lineage-children flex flex-col gap-0.5">
|
||||
{childAgents.map((childAgent) =>
|
||||
renderCompactAgentBranch(childAgent, descendantAncestorPaneKeys)
|
||||
)}
|
||||
</div>
|
||||
</CompactAgentExpansion>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
@@ -451,7 +464,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
{!shouldUseSummaryRow ? (
|
||||
rootAgents.map((rootAgent) => renderCompactAgentBranch(rootAgent))
|
||||
) : shouldUseSummaryRow ? (
|
||||
<CompactAgentExpansion expanded={compactRootListExpanded}>
|
||||
<CompactAgentExpansion expanded={compactRootListExpanded} contentClassName="pl-1">
|
||||
{rootAgents.map((rootAgent) => renderCompactAgentBranch(rootAgent))}
|
||||
</CompactAgentExpansion>
|
||||
) : null}
|
||||
|
||||
@@ -191,8 +191,26 @@ function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage {
|
||||
}
|
||||
}
|
||||
|
||||
function setLineageFixtureState(groupBy: 'none' | 'repo' = 'none'): void {
|
||||
const repo = makeRepo()
|
||||
function setLineageFixtureState(
|
||||
groupBy: 'none' | 'repo' = 'none',
|
||||
options: { projectGrouped?: boolean } = {}
|
||||
): void {
|
||||
const projectGroup: ProjectGroup = {
|
||||
id: 'project-group-1',
|
||||
name: 'Personal',
|
||||
parentPath: '/tmp/lineage-order',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const repo = {
|
||||
...makeRepo(),
|
||||
projectGroupId: options.projectGrouped ? projectGroup.id : null
|
||||
}
|
||||
const parent = makeWorktree({
|
||||
id: 'parent',
|
||||
instanceId: 'parent-instance',
|
||||
@@ -233,6 +251,7 @@ function setLineageFixtureState(groupBy: 'none' | 'repo' = 'none'): void {
|
||||
pendingRevealWorktree: null,
|
||||
prCache: {},
|
||||
prVisibleRefreshGeneration: 0,
|
||||
projectGroups: options.projectGrouped ? [projectGroup] : [],
|
||||
ptyIdsByTabId: {},
|
||||
reorderRepos: vi.fn(),
|
||||
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
|
||||
@@ -415,4 +434,16 @@ describe('WorktreeList lineage child card renderer', () => {
|
||||
'<section data-worktree-card-id="parent" data-content-indent="18" data-flush-surface="true">'
|
||||
)
|
||||
})
|
||||
|
||||
it('adds project group depth to workspace card content indentation', async () => {
|
||||
setLineageFixtureState('repo', { projectGrouped: true })
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
const parentRow = markup.match(/<div[^>]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
|
||||
|
||||
expect(parentRow).not.toContain('padding-left')
|
||||
expect(markup).toContain(
|
||||
'<section data-worktree-card-id="parent" data-content-indent="36" data-flush-surface="true">'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -188,6 +188,11 @@ import {
|
||||
type WorktreeSectionActivityState,
|
||||
type WorktreeSectionActivitySummary
|
||||
} from './worktree-section-activity'
|
||||
import {
|
||||
SIDEBAR_TREE_INDENT,
|
||||
getProjectGroupHeaderPaddingLeft,
|
||||
getWorktreeCardContentIndent
|
||||
} from './worktree-list-indentation'
|
||||
|
||||
export {
|
||||
getScrollTopToRevealBounds,
|
||||
@@ -333,12 +338,7 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string {
|
||||
return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees'
|
||||
}
|
||||
|
||||
const LINEAGE_INDENT = 18
|
||||
// Why: top-level worktrees are children of their project header; indent the
|
||||
// group one step so the status dots nest under the folder icon for hierarchy.
|
||||
const WORKTREE_GROUP_INDENT = 18
|
||||
const PROJECT_GROUP_HEADER_BASE_PADDING = 4
|
||||
const PROJECT_GROUP_HEADER_INDENT = 10
|
||||
const LINEAGE_INDENT = SIDEBAR_TREE_INDENT
|
||||
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
|
||||
|
||||
type VirtualizedWorktreeViewportProps = {
|
||||
@@ -2549,9 +2549,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
row.repo && 'overflow-hidden'
|
||||
)}
|
||||
style={{
|
||||
paddingLeft:
|
||||
PROJECT_GROUP_HEADER_BASE_PADDING +
|
||||
Math.min(projectGroupDepth, 6) * PROJECT_GROUP_HEADER_INDENT
|
||||
paddingLeft: getProjectGroupHeaderPaddingLeft(projectGroupDepth)
|
||||
}}
|
||||
onDragOver={
|
||||
isPinnedHeader
|
||||
@@ -2862,10 +2860,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
// Why: child cards render inside the parent card body, so their
|
||||
// first nested level starts flush with that inset.
|
||||
const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth
|
||||
// Why: grouped rows still indent their contents under the header,
|
||||
// but the card surface spans the full sidebar hit/background row.
|
||||
const basePadding = !nested && groupBy !== 'none' ? WORKTREE_GROUP_INDENT : 0
|
||||
const paddingLeft = basePadding + paddingDepth * LINEAGE_INDENT
|
||||
// Why: grouped rows inherit their project/group header depth,
|
||||
// while the card surface still spans the full hit/background row.
|
||||
const paddingLeft = getWorktreeCardContentIndent({
|
||||
isGrouped: !nested && groupBy !== 'none',
|
||||
groupDepth: itemRow.groupDepth,
|
||||
lineageDepth: paddingDepth
|
||||
})
|
||||
const worktreeDragGroupKey = groupKeyByWorktreeId.get(itemRow.worktree.id)
|
||||
const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id)
|
||||
const revealHighlightTone =
|
||||
@@ -2966,11 +2967,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}
|
||||
}
|
||||
const lineageToggleGroupKey = child.lineageGroupKey
|
||||
const childContentIndent = Math.max(0, child.depth - 1) * LINEAGE_INDENT
|
||||
return (
|
||||
<div
|
||||
key={child.worktree.id}
|
||||
style={{ paddingLeft: `${Math.max(0, child.depth - 1) * LINEAGE_INDENT}px` }}
|
||||
>
|
||||
<div key={child.worktree.id}>
|
||||
<WorktreeContextMenu
|
||||
worktree={child.worktree}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
@@ -2984,7 +2983,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
data-worktree-card-surface="true"
|
||||
data-worktree-card-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer items-start gap-1.5 rounded-md border border-transparent px-2 py-1.5 transition-colors',
|
||||
'relative flex w-full cursor-pointer items-start gap-1.5 rounded-lg border border-transparent py-1.5 pr-2 transition-colors',
|
||||
highlightedRevealWorktreeId === child.worktree.id && [
|
||||
'scroll-to-current-workspace-reveal-highlight',
|
||||
revealHighlightTone === 'ai' &&
|
||||
@@ -3000,88 +2999,97 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="mt-[2px] flex w-4 shrink-0 justify-center pt-[2px]">
|
||||
<WorktreeActivityStatusIndicator worktreeId={child.worktree.id} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] leading-tight text-foreground">
|
||||
{child.worktree.displayName}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5">
|
||||
{child.repo && groupBy !== 'repo' ? (
|
||||
<span className="flex h-[16px] shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 text-[10px] font-semibold leading-none text-foreground dark:bg-accent/50 dark:border-border/60">
|
||||
<RepoBadgeMark color={child.repo.badgeColor} />
|
||||
<span className="max-w-[6rem] truncate lowercase">
|
||||
{child.repo.displayName}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate text-[10.5px] leading-none text-muted-foreground">
|
||||
{branchDisplayName(child.worktree.branch)}
|
||||
</span>
|
||||
</div>
|
||||
{child.worktree.linkedIssue || child.worktree.comment ? (
|
||||
<div className="mt-1.5 truncate text-[10.5px] leading-tight text-muted-foreground">
|
||||
{child.worktree.linkedIssue ? (
|
||||
<span className="font-medium text-foreground/80">
|
||||
#{child.worktree.linkedIssue}
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-start gap-1.5 pl-2"
|
||||
style={
|
||||
childContentIndent > 0
|
||||
? { paddingLeft: `calc(0.5rem + ${childContentIndent}px)` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="mt-[2px] flex w-4 shrink-0 justify-center pt-[2px]">
|
||||
<WorktreeActivityStatusIndicator worktreeId={child.worktree.id} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] leading-tight text-foreground">
|
||||
{child.worktree.displayName}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5">
|
||||
{child.repo && groupBy !== 'repo' ? (
|
||||
<span className="flex h-[16px] shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 text-[10px] font-semibold leading-none text-foreground dark:bg-accent/50 dark:border-border/60">
|
||||
<RepoBadgeMark color={child.repo.badgeColor} />
|
||||
<span className="max-w-[6rem] truncate lowercase">
|
||||
{child.repo.displayName}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
|
||||
{child.worktree.comment}
|
||||
<span className="truncate text-[10.5px] leading-none text-muted-foreground">
|
||||
{branchDisplayName(child.worktree.branch)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{showInlineAgentCards ? (
|
||||
// Why: nested lineage children use this lightweight
|
||||
// renderer instead of WorktreeCard, so their inline
|
||||
// agent rows must be mounted here explicitly.
|
||||
<WorktreeCardAgents
|
||||
worktreeId={child.worktree.id}
|
||||
className="mt-1 divide-y-0"
|
||||
/>
|
||||
) : null}
|
||||
{child.lineageChildCount > 0 && lineageToggleGroupKey ? (
|
||||
<div className="mt-1.5 flex min-w-0 justify-start">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-[18px] max-w-[8rem] gap-1 rounded-md border border-sidebar-border bg-sidebar px-1.5 text-[10px] font-medium leading-none text-muted-foreground shadow-none hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-sidebar-ring"
|
||||
aria-label={`${child.lineageCollapsed ? 'Show' : 'Hide'} ${
|
||||
child.lineageChildCount
|
||||
} child ${
|
||||
child.lineageChildCount === 1 ? 'workspace' : 'workspaces'
|
||||
}`}
|
||||
aria-expanded={!child.lineageCollapsed}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
|
||||
}}
|
||||
>
|
||||
<Workflow className="size-2.5" />
|
||||
<span className="truncate">
|
||||
{child.lineageChildCount}{' '}
|
||||
{child.lineageChildCount === 1 ? 'child' : 'children'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-2.5 transition-transform',
|
||||
child.lineageCollapsed && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{child.lineageCollapsed
|
||||
? 'Show child workspaces'
|
||||
: 'Hide child workspaces'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
{child.worktree.linkedIssue || child.worktree.comment ? (
|
||||
<div className="mt-1.5 truncate text-[10.5px] leading-tight text-muted-foreground">
|
||||
{child.worktree.linkedIssue ? (
|
||||
<span className="font-medium text-foreground/80">
|
||||
#{child.worktree.linkedIssue}
|
||||
</span>
|
||||
) : null}
|
||||
{child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
|
||||
{child.worktree.comment}
|
||||
</div>
|
||||
) : null}
|
||||
{showInlineAgentCards ? (
|
||||
// Why: nested lineage children use this lightweight
|
||||
// renderer instead of WorktreeCard, so their inline
|
||||
// agent rows must be mounted here explicitly.
|
||||
<WorktreeCardAgents
|
||||
worktreeId={child.worktree.id}
|
||||
className="mt-1 divide-y-0"
|
||||
/>
|
||||
) : null}
|
||||
{child.lineageChildCount > 0 && lineageToggleGroupKey ? (
|
||||
<div className="mt-1.5 flex min-w-0 justify-start">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-[18px] max-w-[8rem] gap-1 rounded-md border border-sidebar-border bg-sidebar px-1.5 text-[10px] font-medium leading-none text-muted-foreground shadow-none hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-sidebar-ring"
|
||||
aria-label={`${child.lineageCollapsed ? 'Show' : 'Hide'} ${
|
||||
child.lineageChildCount
|
||||
} child ${
|
||||
child.lineageChildCount === 1 ? 'workspace' : 'workspaces'
|
||||
}`}
|
||||
aria-expanded={!child.lineageCollapsed}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
|
||||
}}
|
||||
>
|
||||
<Workflow className="size-2.5" />
|
||||
<span className="truncate">
|
||||
{child.lineageChildCount}{' '}
|
||||
{child.lineageChildCount === 1 ? 'child' : 'children'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-2.5 transition-transform',
|
||||
child.lineageCollapsed && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{child.lineageCollapsed
|
||||
? 'Show child workspaces'
|
||||
: 'Hide child workspaces'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorktreeContextMenu>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentStatusEntry,
|
||||
MigrationUnsupportedPtyEntry
|
||||
@@ -10,13 +10,6 @@ import {
|
||||
type FocusedAgentRowHighlightState
|
||||
} from './focused-agent-row-highlight'
|
||||
|
||||
vi.mock('@/lib/agent-status', () => ({
|
||||
isExplicitAgentStatusFresh: vi.fn(
|
||||
(entry: AgentStatusEntry, now: number, staleAfterMs: number) =>
|
||||
now - entry.updatedAt <= staleAfterMs
|
||||
)
|
||||
}))
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/worktree'
|
||||
const OTHER_WORKTREE_ID = 'repo-1::/other'
|
||||
const TAB_ID = 'tab-1'
|
||||
@@ -89,14 +82,26 @@ function makeState(
|
||||
}
|
||||
|
||||
describe('getFocusedAgentPaneKeyForWorktree', () => {
|
||||
it('returns the focused pane key when that pane has a fresh live agent status', () => {
|
||||
it('returns the focused pane key when that pane has a live agent status', () => {
|
||||
const state = makeState({
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY]: makeAgentStatusEntry(PANE_KEY)
|
||||
}
|
||||
})
|
||||
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBe(PANE_KEY)
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBe(PANE_KEY)
|
||||
})
|
||||
|
||||
it('highlights a focused row whose status has decayed past freshness', () => {
|
||||
// Why: the inline card keeps decayed agents visible; clicking one must still
|
||||
// color the row even though its status is no longer fresh.
|
||||
const state = makeState({
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY]: makeAgentStatusEntry(PANE_KEY, 1)
|
||||
}
|
||||
})
|
||||
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBe(PANE_KEY)
|
||||
})
|
||||
|
||||
it('does not return another split pane in the same terminal tab', () => {
|
||||
@@ -106,7 +111,7 @@ describe('getFocusedAgentPaneKeyForWorktree', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBeNull()
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not highlight while another surface type is active', () => {
|
||||
@@ -117,7 +122,7 @@ describe('getFocusedAgentPaneKeyForWorktree', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBeNull()
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns retained agent row pane keys for the focused pane', () => {
|
||||
@@ -134,7 +139,7 @@ describe('getFocusedAgentPaneKeyForWorktree', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBe(PANE_KEY)
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBe(PANE_KEY)
|
||||
})
|
||||
|
||||
it('returns migration-unsupported agent row pane keys for the focused pane', () => {
|
||||
@@ -144,6 +149,6 @@ describe('getFocusedAgentPaneKeyForWorktree', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID, 2_000)).toBe(PANE_KEY)
|
||||
expect(getFocusedAgentPaneKeyForWorktree(state, WORKTREE_ID)).toBe(PANE_KEY)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
|
||||
export type FocusedAgentRowHighlightState = Pick<
|
||||
@@ -21,8 +16,7 @@ export type FocusedAgentRowHighlightState = Pick<
|
||||
|
||||
export function getFocusedAgentPaneKeyForWorktree(
|
||||
state: FocusedAgentRowHighlightState,
|
||||
worktreeId: string,
|
||||
now = Date.now()
|
||||
worktreeId: string
|
||||
): string | null {
|
||||
if (state.activeWorktreeId !== worktreeId || state.activeTabType !== 'terminal') {
|
||||
return null
|
||||
@@ -46,8 +40,11 @@ export function getFocusedAgentPaneKeyForWorktree(
|
||||
}
|
||||
|
||||
const activePaneKey = makePaneKey(activeTabId, activeLeafId)
|
||||
const liveEntry = state.agentStatusByPaneKey[activePaneKey]
|
||||
if (liveEntry && isFreshLiveAgent(liveEntry, now)) {
|
||||
// Why: the inline card lists every agent attributed to this worktree, even
|
||||
// after its status decays to idle. Highlight whichever displayed row matches
|
||||
// the focused pane — gating on freshness left clicked-into stale rows with no
|
||||
// selection coloring.
|
||||
if (state.agentStatusByPaneKey[activePaneKey]) {
|
||||
return activePaneKey
|
||||
}
|
||||
|
||||
@@ -64,7 +61,3 @@ export function getFocusedAgentPaneKeyForWorktree(
|
||||
export function useFocusedAgentPaneKey(worktreeId: string): string | null {
|
||||
return useAppStore((state) => getFocusedAgentPaneKeyForWorktree(state, worktreeId))
|
||||
}
|
||||
|
||||
function isFreshLiveAgent(entry: AgentStatusEntry, now: number): boolean {
|
||||
return isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
|
||||
}
|
||||
|
||||
@@ -110,6 +110,31 @@ describe('buildWorktreeAgentRows', () => {
|
||||
expect(working?.state).toBe('idle')
|
||||
expect(done?.state).toBe('done')
|
||||
})
|
||||
|
||||
it('renders live worktree-attributed entries even when their tab is absent', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [],
|
||||
entries: [
|
||||
makeEntry(PANE_KEY_1, 1000, {
|
||||
state: 'working',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
prompt: 'child agent'
|
||||
})
|
||||
],
|
||||
retained: [],
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1])
|
||||
expect(rows[0]).toMatchObject({
|
||||
state: 'working',
|
||||
tab: {
|
||||
id: 'tab-1',
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAgentRowLineage', () => {
|
||||
|
||||
@@ -129,6 +129,26 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => {
|
||||
expect(secondWt2).not.toBe(firstWt2)
|
||||
expect(secondWt2[0]?.prompt).toBe('updated prompt preview')
|
||||
})
|
||||
|
||||
it('uses worktree attribution when the status tab is not in the renderer tab list', () => {
|
||||
const childEntry = makeEntry(PANE_KEY_1, 1000, {
|
||||
state: 'working',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1'
|
||||
})
|
||||
const state = {
|
||||
tabsByWorktree: {
|
||||
'wt-1': []
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY_1]: childEntry
|
||||
},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
retainedAgentsByPaneKey: {}
|
||||
}
|
||||
|
||||
expect(selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')).toEqual([childEntry])
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectRetainedAgentEntriesForWorktree', () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map<string, Ag
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
const worktreeId = tabIdToWorktreeId.get(parsed.tabId)
|
||||
const worktreeId = tabIdToWorktreeId.get(parsed.tabId) ?? entry.worktreeId
|
||||
if (!worktreeId) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -9,6 +9,23 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
|
||||
import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows'
|
||||
|
||||
function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | null {
|
||||
const parsed = parsePaneKey(entry.paneKey)
|
||||
if (!parsed || !entry.worktreeId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: parsed.tabId,
|
||||
ptyId: null,
|
||||
worktreeId: entry.worktreeId,
|
||||
title: entry.terminalTitle ?? 'Agent',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: Number.MAX_SAFE_INTEGER,
|
||||
createdAt: entry.stateStartedAt
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorktreeAgentRows(args: {
|
||||
tabs: TerminalTab[]
|
||||
entries: AgentStatusEntry[]
|
||||
@@ -56,6 +73,32 @@ export function buildWorktreeAgentRows(args: {
|
||||
|
||||
rows.push(...buildTitleDerivedAgentRows({ ...args, seenPaneKeys }))
|
||||
|
||||
// Why: orchestration workers can be attributed to a worktree by main before
|
||||
// their tab is present in this renderer. Keep those live rows visible in the
|
||||
// worktree card instead of waiting for tab membership that may never arrive.
|
||||
for (const entry of args.entries) {
|
||||
if (seenPaneKeys.has(entry.paneKey)) {
|
||||
continue
|
||||
}
|
||||
const tab = tabFromAttributedStatusEntry(entry)
|
||||
if (!tab) {
|
||||
continue
|
||||
}
|
||||
const isFresh = isExplicitAgentStatusFresh(entry, args.now, AGENT_STATUS_STALE_AFTER_MS)
|
||||
const shouldDecay =
|
||||
!isFresh &&
|
||||
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
|
||||
rows.push({
|
||||
paneKey: entry.paneKey,
|
||||
entry,
|
||||
tab,
|
||||
agentType: entry.agentType ?? 'unknown',
|
||||
state: shouldDecay ? 'idle' : entry.state,
|
||||
startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt
|
||||
})
|
||||
seenPaneKeys.add(entry.paneKey)
|
||||
}
|
||||
|
||||
for (const ra of args.retained) {
|
||||
if (seenPaneKeys.has(ra.entry.paneKey)) {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { AgentDotState } from '@/components/AgentStateDot'
|
||||
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
|
||||
import { formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
|
||||
export type SummaryAgentGroup = {
|
||||
state: AgentDotState
|
||||
agents: DashboardAgentRowData[]
|
||||
}
|
||||
|
||||
const SUMMARY_STATE_ORDER: AgentDotState[] = [
|
||||
'waiting',
|
||||
'blocked',
|
||||
'interrupted',
|
||||
'working',
|
||||
'done',
|
||||
'idle'
|
||||
]
|
||||
|
||||
function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
case 'blocked':
|
||||
case 'waiting':
|
||||
case 'done':
|
||||
case 'idle':
|
||||
return state
|
||||
}
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState {
|
||||
return agent.entry.interrupted === true ? 'interrupted' : asDotState(agent.state)
|
||||
}
|
||||
|
||||
export function formatSummaryStateLabel(state: AgentDotState): string {
|
||||
switch (state) {
|
||||
case 'waiting':
|
||||
return 'waiting'
|
||||
case 'blocked':
|
||||
return 'blocked'
|
||||
case 'interrupted':
|
||||
return 'interrupted'
|
||||
case 'working':
|
||||
return 'working'
|
||||
case 'done':
|
||||
return 'done'
|
||||
case 'idle':
|
||||
return 'idle'
|
||||
case 'permission':
|
||||
return 'needs attention'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSummaryAgentGroups(agents: DashboardAgentRowData[]): SummaryAgentGroup[] {
|
||||
const groups = new Map<AgentDotState, DashboardAgentRowData[]>()
|
||||
for (const agent of agents) {
|
||||
const dotState = getAgentDotState(agent)
|
||||
const group = groups.get(dotState)
|
||||
if (group) {
|
||||
group.push(agent)
|
||||
} else {
|
||||
groups.set(dotState, [agent])
|
||||
}
|
||||
}
|
||||
return SUMMARY_STATE_ORDER.flatMap((state) => {
|
||||
const groupAgents = groups.get(state)
|
||||
return groupAgents ? [{ state, agents: groupAgents }] : []
|
||||
})
|
||||
}
|
||||
|
||||
export function formatSummaryGroupLabel(group: SummaryAgentGroup): string {
|
||||
const label = formatSummaryStateLabel(group.state)
|
||||
return group.agents.length === 1 ? label : `${group.agents.length} ${label}`
|
||||
}
|
||||
|
||||
export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string {
|
||||
const counts = new Map<AgentDotState, number>()
|
||||
for (const agent of agents) {
|
||||
const dotState = getAgentDotState(agent)
|
||||
counts.set(dotState, (counts.get(dotState) ?? 0) + 1)
|
||||
}
|
||||
const parts = SUMMARY_STATE_ORDER.flatMap((state) => {
|
||||
const count = counts.get(state) ?? 0
|
||||
if (count === 0) {
|
||||
return []
|
||||
}
|
||||
const label = formatSummaryStateLabel(state)
|
||||
return `${count} ${label}`
|
||||
})
|
||||
if (parts.length === 1) {
|
||||
const onlyStatusLabel = parts[0].replace(/^\d+\s+/, '')
|
||||
return agents.length === 1
|
||||
? `${subjectLabel} ${onlyStatusLabel}`
|
||||
: `All ${subjectLabel} ${onlyStatusLabel}`
|
||||
}
|
||||
return `${subjectLabel}: ${parts.join(', ')}`
|
||||
}
|
||||
|
||||
export function summarizeAgentIdentities(agents: DashboardAgentRowData[]): string {
|
||||
return agents
|
||||
.map((agent) => {
|
||||
const agentLabel = formatAgentTypeLabel(agent.agentType)
|
||||
const stateLabel = formatSummaryStateLabel(getAgentDotState(agent))
|
||||
return `${agentLabel} ${stateLabel}`
|
||||
})
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
export function selectSummaryGroupIconAgents(
|
||||
agents: DashboardAgentRowData[],
|
||||
maxCount: number
|
||||
): DashboardAgentRowData[] {
|
||||
const groups = new Map<string, { agents: DashboardAgentRowData[]; firstIndex: number }>()
|
||||
agents.forEach((agent, index) => {
|
||||
const key = agent.agentType ?? 'unknown'
|
||||
const group = groups.get(key)
|
||||
if (group) {
|
||||
group.agents.push(agent)
|
||||
} else {
|
||||
groups.set(key, { agents: [agent], firstIndex: index })
|
||||
}
|
||||
})
|
||||
const sortedGroups = [...groups.values()].sort(
|
||||
(a, b) => b.agents.length - a.agents.length || a.firstIndex - b.firstIndex
|
||||
)
|
||||
const selected: DashboardAgentRowData[] = []
|
||||
for (const group of sortedGroups) {
|
||||
if (selected.length >= maxCount) {
|
||||
break
|
||||
}
|
||||
selected.push(group.agents[0])
|
||||
}
|
||||
return selected
|
||||
}
|
||||
@@ -1,31 +1,21 @@
|
||||
import React, { useCallback, useRef } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
|
||||
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
|
||||
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import CommentMarkdown from './CommentMarkdown'
|
||||
import {
|
||||
buildSummaryAgentGroups,
|
||||
getAgentDotState,
|
||||
selectSummaryGroupIconAgents,
|
||||
summarizeAgentIdentities,
|
||||
summarizeAgents
|
||||
} from './worktree-card-agent-summary'
|
||||
|
||||
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]\n]*\]\([^)]+\)/
|
||||
|
||||
function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
case 'blocked':
|
||||
case 'waiting':
|
||||
case 'done':
|
||||
case 'idle':
|
||||
return state
|
||||
}
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function getAgentDotState(agent: DashboardAgentRowData): AgentDotState {
|
||||
return agent.entry.interrupted === true ? 'interrupted' : asDotState(agent.state)
|
||||
}
|
||||
|
||||
function formatShortTimeAgo(ts: number, now: number): string {
|
||||
const delta = now - ts
|
||||
if (delta < 60_000) {
|
||||
@@ -86,15 +76,6 @@ function getCompactAgentTime(agent: DashboardAgentRowData, now: number): string
|
||||
return startedAt > 0 ? formatShortTimeAgo(startedAt, now) : null
|
||||
}
|
||||
|
||||
const SUMMARY_STATE_ORDER: AgentDotState[] = [
|
||||
'waiting',
|
||||
'blocked',
|
||||
'interrupted',
|
||||
'working',
|
||||
'done',
|
||||
'idle'
|
||||
]
|
||||
|
||||
function stopActivationKeyPropagation(e: React.KeyboardEvent): void {
|
||||
// Why: the surrounding worktree list handles Enter/Space as row activation.
|
||||
// Focused nested buttons need those keys to stay local.
|
||||
@@ -103,77 +84,6 @@ function stopActivationKeyPropagation(e: React.KeyboardEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string {
|
||||
const counts = new Map<AgentDotState, number>()
|
||||
for (const agent of agents) {
|
||||
const dotState = getAgentDotState(agent)
|
||||
counts.set(dotState, (counts.get(dotState) ?? 0) + 1)
|
||||
}
|
||||
const parts = SUMMARY_STATE_ORDER.flatMap((state) => {
|
||||
const count = counts.get(state) ?? 0
|
||||
if (count === 0) {
|
||||
return []
|
||||
}
|
||||
const label =
|
||||
state === 'waiting'
|
||||
? 'waiting'
|
||||
: state === 'blocked'
|
||||
? 'blocked'
|
||||
: state === 'interrupted'
|
||||
? 'interrupted'
|
||||
: state === 'working'
|
||||
? 'working'
|
||||
: state === 'done'
|
||||
? 'done'
|
||||
: 'idle'
|
||||
return `${count} ${label}`
|
||||
})
|
||||
if (parts.length === 1) {
|
||||
const onlyStatusLabel = parts[0].replace(/^\d+\s+/, '')
|
||||
return agents.length === 1
|
||||
? `${subjectLabel} ${onlyStatusLabel}`
|
||||
: `All ${subjectLabel} ${onlyStatusLabel}`
|
||||
}
|
||||
return `${subjectLabel}: ${parts.join(', ')}`
|
||||
}
|
||||
|
||||
function selectSummaryIconAgents(
|
||||
agents: DashboardAgentRowData[],
|
||||
maxCount: number
|
||||
): DashboardAgentRowData[] {
|
||||
const groups = new Map<string, { agents: DashboardAgentRowData[]; firstIndex: number }>()
|
||||
agents.forEach((agent, index) => {
|
||||
const key = agent.agentType ?? 'unknown'
|
||||
const group = groups.get(key)
|
||||
if (group) {
|
||||
group.agents.push(agent)
|
||||
} else {
|
||||
groups.set(key, { agents: [agent], firstIndex: index })
|
||||
}
|
||||
})
|
||||
const sortedGroups = [...groups.values()].sort(
|
||||
(a, b) => b.agents.length - a.agents.length || a.firstIndex - b.firstIndex
|
||||
)
|
||||
const selected: DashboardAgentRowData[] = []
|
||||
for (const group of sortedGroups) {
|
||||
if (selected.length >= maxCount) {
|
||||
break
|
||||
}
|
||||
selected.push(group.agents[0])
|
||||
}
|
||||
// Why: once every visible agent kind is represented, duplicate slots should
|
||||
// reflect the largest groups instead of arbitrary list order.
|
||||
for (const group of sortedGroups) {
|
||||
for (const agent of group.agents.slice(1)) {
|
||||
if (selected.length >= maxCount) {
|
||||
return selected
|
||||
}
|
||||
selected.push(agent)
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
type CompactAgentSummaryButtonProps = {
|
||||
agents: DashboardAgentRowData[]
|
||||
subjectLabel: string
|
||||
@@ -183,11 +93,13 @@ type CompactAgentSummaryButtonProps = {
|
||||
|
||||
type CompactAgentExpansionProps = {
|
||||
expanded: boolean
|
||||
contentClassName?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function CompactAgentExpansion({
|
||||
expanded,
|
||||
contentClassName,
|
||||
children
|
||||
}: CompactAgentExpansionProps): React.JSX.Element {
|
||||
const hasRenderedChildrenRef = useRef(expanded)
|
||||
@@ -209,7 +121,12 @@ export function CompactAgentExpansion({
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
{shouldRenderChildren && (
|
||||
<div className="compact-agent-expansion-content flex flex-col gap-0.5 pt-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
'compact-agent-expansion-content flex flex-col gap-0.5 pt-0.5',
|
||||
contentClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -225,8 +142,12 @@ export function CompactAgentSummaryButton({
|
||||
onToggle
|
||||
}: CompactAgentSummaryButtonProps): React.JSX.Element {
|
||||
const summary = summarizeAgents(agents, subjectLabel)
|
||||
const iconAgents = selectSummaryIconAgents(agents, 3)
|
||||
const hiddenIconAgentCount = Math.max(0, agents.length - iconAgents.length)
|
||||
const groups = buildSummaryAgentGroups(agents)
|
||||
const visibleGroups = groups.slice(0, 3)
|
||||
const hiddenGroupAgentCount = groups
|
||||
.slice(visibleGroups.length)
|
||||
.reduce((count, group) => count + group.agents.length, 0)
|
||||
const agentIdentitySummary = summarizeAgentIdentities(agents)
|
||||
const stopPointerPropagation = useCallback((e: React.SyntheticEvent) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
@@ -243,11 +164,13 @@ export function CompactAgentSummaryButton({
|
||||
type="button"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
'group/agent-summary flex h-6 w-full min-w-0 items-center gap-1.5 rounded-sm border border-sidebar-border/70',
|
||||
'bg-sidebar-accent/35 px-1.5 text-left text-[11px] leading-none text-muted-foreground',
|
||||
'group/agent-summary flex h-6 w-full min-w-0 items-center gap-1 rounded-sm border border-sidebar-border/70',
|
||||
'bg-sidebar-accent/35 px-1 text-left text-[11px] leading-none text-muted-foreground',
|
||||
'hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring'
|
||||
)}
|
||||
aria-label={`${expanded ? 'Collapse' : 'Expand'} ${summary}`}
|
||||
aria-label={
|
||||
expanded ? `Collapse ${subjectLabel}` : `Expand ${summary}. ${agentIdentitySummary}`
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={stopActivationKeyPropagation}
|
||||
@@ -255,22 +178,47 @@ export function CompactAgentSummaryButton({
|
||||
onPointerDown={stopPointerPropagation}
|
||||
onDragStart={stopPointerPropagation}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-0.5" aria-hidden>
|
||||
{iconAgents.map((agent) => (
|
||||
<span
|
||||
key={agent.paneKey}
|
||||
className="inline-flex size-4 items-center justify-center rounded-full border border-sidebar bg-sidebar"
|
||||
title={formatAgentTypeLabel(agent.agentType)}
|
||||
>
|
||||
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={12} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{summary}</span>
|
||||
{hiddenIconAgentCount > 0 && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
|
||||
+{hiddenIconAgentCount}
|
||||
{expanded ? (
|
||||
<span className="min-w-0 flex-1 truncate px-1 font-medium text-muted-foreground">
|
||||
{subjectLabel}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden" aria-hidden>
|
||||
{visibleGroups.map((group) => {
|
||||
const iconAgents = selectSummaryGroupIconAgents(group.agents, 3)
|
||||
const hiddenIconCount = Math.max(0, group.agents.length - iconAgents.length)
|
||||
return (
|
||||
<span
|
||||
key={group.state}
|
||||
className="inline-flex min-w-0 shrink-0 items-center gap-0.5 rounded-sm bg-sidebar/70 px-1 py-0.5"
|
||||
>
|
||||
<AgentStateDot state={group.state} size="sm" />
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5">
|
||||
{iconAgents.map((agent) => (
|
||||
<span
|
||||
key={agent.paneKey}
|
||||
className="inline-flex size-3.5 items-center justify-center rounded-full border border-sidebar-border/70 bg-sidebar"
|
||||
>
|
||||
<AgentIcon agent={agentTypeToIconAgent(agent.agentType)} size={10} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{hiddenIconCount > 0 && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
|
||||
+{hiddenIconCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
{hiddenGroupAgentCount > 0 && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
|
||||
+{hiddenGroupAgentCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChevronRight
|
||||
className={cn('size-3 shrink-0 transition-transform duration-150', expanded && 'rotate-90')}
|
||||
@@ -311,6 +259,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
|
||||
const primary = getCompactAgentPrimary(agent)
|
||||
const assistantMessage = agent.entry.lastAssistantMessage?.trim() ?? ''
|
||||
const hasAssistantImage = MARKDOWN_IMAGE_PATTERN.test(assistantMessage)
|
||||
const isLineageChild = agent.lineage?.depth === 1
|
||||
const secondary = hasAssistantImage
|
||||
? formatAgentTypeLabel(agent.agentType)
|
||||
: getCompactAgentSecondary(agent)
|
||||
@@ -337,7 +286,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
|
||||
{hasChildDisclosure ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-sm border border-sidebar-border/80 bg-sidebar text-foreground/80 shadow-xs hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
|
||||
aria-label={`${childAgentsExpanded ? 'Hide' : 'Show'} ${childAgentCount} child ${
|
||||
childAgentCount === 1 ? 'agent' : 'agents'
|
||||
}`}
|
||||
@@ -385,6 +334,8 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
|
||||
className={cn(
|
||||
'group/compact-agent-row min-w-0 cursor-pointer rounded-sm px-1 text-[11px] leading-none',
|
||||
'text-muted-foreground worktree-agent-row-hover',
|
||||
hasChildDisclosure && 'worktree-agent-lineage-parent-row',
|
||||
isLineageChild && 'worktree-agent-lineage-child-row',
|
||||
hasAssistantImage ? 'flex flex-col py-0.5' : 'flex h-6 items-center gap-1',
|
||||
isFocusedPane && 'bg-sidebar-accent'
|
||||
)}
|
||||
|
||||
@@ -986,6 +986,10 @@ describe('project groups', () => {
|
||||
expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual(
|
||||
[0, 1, 2]
|
||||
)
|
||||
expect(rows.find((row) => row.type === 'item')).toMatchObject({
|
||||
type: 'item',
|
||||
groupDepth: 2
|
||||
})
|
||||
expect(rows[0]).toMatchObject({ count: 1 })
|
||||
})
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export type WorktreeRow = {
|
||||
worktree: Worktree
|
||||
repo: Repo | undefined
|
||||
depth: number
|
||||
groupDepth: number
|
||||
lineageTrail: boolean[]
|
||||
isLastLineageChild: boolean
|
||||
lineageChildCount: number
|
||||
@@ -246,7 +247,7 @@ function emitPinnedGroup(
|
||||
const lastPinnedIndexByRepoId = new Map<string, number>()
|
||||
pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index))
|
||||
for (const [index, worktree] of pinned.entries()) {
|
||||
result.push(buildWorktreeRow(worktree, repoMap, 0, [], false, 0, false))
|
||||
result.push(buildWorktreeRow(worktree, repoMap, 0, 0, [], false, 0, false))
|
||||
const candidate = importedWorktreesByRepo.get(worktree.repoId)
|
||||
if (
|
||||
candidate &&
|
||||
@@ -277,6 +278,7 @@ function buildWorktreeRow(
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
depth: number,
|
||||
groupDepth: number,
|
||||
lineageTrail: boolean[],
|
||||
isLastLineageChild: boolean,
|
||||
lineageChildCount: number,
|
||||
@@ -287,6 +289,7 @@ function buildWorktreeRow(
|
||||
worktree,
|
||||
repo: repoMap.get(worktree.repoId),
|
||||
depth,
|
||||
groupDepth,
|
||||
lineageTrail,
|
||||
isLastLineageChild,
|
||||
lineageChildCount,
|
||||
@@ -304,12 +307,13 @@ function appendWorktreeRows(
|
||||
options: {
|
||||
nestLineage: boolean
|
||||
collapsedGroups: Set<string>
|
||||
groupDepth: number
|
||||
}
|
||||
): void {
|
||||
const { nestLineage, collapsedGroups } = options
|
||||
const { nestLineage, collapsedGroups, groupDepth } = options
|
||||
if (!nestLineage) {
|
||||
for (const worktree of worktrees) {
|
||||
result.push(buildWorktreeRow(worktree, repoMap, 0, [], false, 0, false))
|
||||
result.push(buildWorktreeRow(worktree, repoMap, 0, groupDepth, [], false, 0, false))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -347,6 +351,7 @@ function appendWorktreeRows(
|
||||
worktree,
|
||||
repoMap,
|
||||
depth,
|
||||
groupDepth,
|
||||
lineageTrail,
|
||||
isLastChild,
|
||||
children.length,
|
||||
@@ -445,7 +450,8 @@ export function buildRows(
|
||||
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
|
||||
appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
collapsedGroups
|
||||
collapsedGroups,
|
||||
groupDepth: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -606,7 +612,8 @@ export function buildRows(
|
||||
const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items
|
||||
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
collapsedGroups
|
||||
collapsedGroups,
|
||||
groupDepth: projectGroupDepth
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const makeWorktreeRow = (id: string): Extract<Row, { type: 'item' }> => ({
|
||||
worktree: makeWorktree(id),
|
||||
repo,
|
||||
depth: 0,
|
||||
groupDepth: 0,
|
||||
lineageTrail: [],
|
||||
isLastLineageChild: false,
|
||||
lineageChildCount: 0
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getProjectGroupHeaderPaddingLeft,
|
||||
getWorktreeCardContentIndent
|
||||
} from './worktree-list-indentation'
|
||||
|
||||
describe('worktree list indentation', () => {
|
||||
it('keeps ungrouped workspaces flush with the list', () => {
|
||||
expect(getWorktreeCardContentIndent({ isGrouped: false, groupDepth: 4, lineageDepth: 0 })).toBe(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('indents workspace content one step deeper than its containing project header', () => {
|
||||
expect(getWorktreeCardContentIndent({ isGrouped: true, groupDepth: 0, lineageDepth: 0 })).toBe(
|
||||
18
|
||||
)
|
||||
expect(getWorktreeCardContentIndent({ isGrouped: true, groupDepth: 1, lineageDepth: 0 })).toBe(
|
||||
36
|
||||
)
|
||||
})
|
||||
|
||||
it('adds lineage depth after project/group depth', () => {
|
||||
expect(getWorktreeCardContentIndent({ isGrouped: true, groupDepth: 1, lineageDepth: 2 })).toBe(
|
||||
72
|
||||
)
|
||||
})
|
||||
|
||||
it('caps header indentation separately from workspace content indentation', () => {
|
||||
expect(getProjectGroupHeaderPaddingLeft(100)).toBe(70)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
export const SIDEBAR_TREE_INDENT = 18
|
||||
export const PROJECT_GROUP_HEADER_BASE_PADDING = 10
|
||||
export const PROJECT_GROUP_HEADER_INDENT = 10
|
||||
export const MAX_PROJECT_GROUP_HEADER_DEPTH = 6
|
||||
|
||||
function clampDepth(depth: number): number {
|
||||
return Math.max(0, Math.floor(Number.isFinite(depth) ? depth : 0))
|
||||
}
|
||||
|
||||
export function getProjectGroupHeaderPaddingLeft(depth: number): number {
|
||||
return (
|
||||
PROJECT_GROUP_HEADER_BASE_PADDING +
|
||||
Math.min(clampDepth(depth), MAX_PROJECT_GROUP_HEADER_DEPTH) * PROJECT_GROUP_HEADER_INDENT
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorktreeCardContentIndent(args: {
|
||||
isGrouped: boolean
|
||||
groupDepth: number
|
||||
lineageDepth: number
|
||||
}): number {
|
||||
const groupSteps = args.isGrouped ? clampDepth(args.groupDepth) + 1 : 0
|
||||
return (groupSteps + clampDepth(args.lineageDepth)) * SIDEBAR_TREE_INDENT
|
||||
}
|
||||
@@ -51,6 +51,7 @@ const makeWorktreeRow = (id: string): Extract<Row, { type: 'item' }> => ({
|
||||
worktree: makeWorktree(id),
|
||||
repo,
|
||||
depth: 0,
|
||||
groupDepth: 0,
|
||||
lineageTrail: [],
|
||||
isLastLineageChild: false,
|
||||
lineageChildCount: 0
|
||||
|
||||
@@ -2204,7 +2204,7 @@ export function useIpcEvents(): void {
|
||||
if (!payload) {
|
||||
return 'dropped'
|
||||
}
|
||||
const {
|
||||
let {
|
||||
exists,
|
||||
title,
|
||||
identityTitle,
|
||||
@@ -2212,6 +2212,19 @@ export function useIpcEvents(): void {
|
||||
repoConnectionResolved,
|
||||
owningWorktreeId
|
||||
} = resolvePaneKey(store, data.paneKey)
|
||||
if (!exists && data.worktreeId) {
|
||||
// Why: orchestration worker hooks can carry main-side worktree
|
||||
// attribution before this renderer has a terminal tab for the pane.
|
||||
// Accept those only when the worktree is known, then keep the normal
|
||||
// repo connection check below for SSH/local ownership.
|
||||
const fallbackOwnership = resolveWorktreeConnection(store, data.worktreeId)
|
||||
if (fallbackOwnership.worktreeExists) {
|
||||
owningWorktreeId = data.worktreeId
|
||||
repoConnectionId = fallbackOwnership.repoConnectionId
|
||||
repoConnectionResolved = fallbackOwnership.repoConnectionResolved
|
||||
exists = true
|
||||
}
|
||||
}
|
||||
if (!exists) {
|
||||
// Why: empty paneKeys are dropped in main before IPC fanout. Reaching
|
||||
// this branch means a non-empty paneKey escaped without a matching
|
||||
@@ -2293,12 +2306,21 @@ export function useIpcEvents(): void {
|
||||
return 'dropped'
|
||||
}
|
||||
const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
|
||||
store.setAgentStatus(data.paneKey, statusPayload, terminalTitle, {
|
||||
updatedAt: data.receivedAt,
|
||||
stateStartedAt: data.stateStartedAt
|
||||
})
|
||||
applyResolvedAgentTerminalTitleToTab(store, data.paneKey, title, terminalTitle)
|
||||
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
|
||||
store.setAgentStatus(
|
||||
data.paneKey,
|
||||
statusPayload,
|
||||
terminalTitle,
|
||||
{
|
||||
updatedAt: data.receivedAt,
|
||||
stateStartedAt: data.stateStartedAt
|
||||
},
|
||||
{
|
||||
tabId: data.tabId,
|
||||
worktreeId: statusWorktreeId
|
||||
}
|
||||
)
|
||||
applyResolvedAgentTerminalTitleToTab(store, data.paneKey, title, terminalTitle)
|
||||
if (options?.replay !== true && statusWorktreeId) {
|
||||
// Why: local Codex/Claude hooks arrive through this main-process IPC
|
||||
// path, not the PTY OSC fallback, so task-complete notifications must
|
||||
@@ -2683,6 +2705,26 @@ function resolvePaneKey(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorktreeConnection(
|
||||
store: ReturnType<typeof useAppStore.getState>,
|
||||
worktreeId: string
|
||||
): {
|
||||
worktreeExists: boolean
|
||||
repoConnectionId: string | null
|
||||
repoConnectionResolved: boolean
|
||||
} {
|
||||
const worktree = getWorktreeMapFromState(store).get(worktreeId)
|
||||
if (!worktree) {
|
||||
return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false }
|
||||
}
|
||||
const repo = getRepoMapFromState(store).get(worktree.repoId)
|
||||
return {
|
||||
worktreeExists: true,
|
||||
repoConnectionId: repo?.connectionId ?? null,
|
||||
repoConnectionResolved: repo !== undefined
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHookPayloadAgentType(
|
||||
payload: ParsedAgentStatusPayload,
|
||||
terminalTitle: string | undefined
|
||||
|
||||
@@ -107,6 +107,34 @@ describe('agent status freshness expiry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent status routing attribution', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('stores worktree and tab attribution from accepted hook events', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = createTestStore()
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
'tab-child:11111111-1111-4111-8111-111111111111',
|
||||
{ state: 'working', prompt: 'child agent', agentType: 'codex' },
|
||||
undefined,
|
||||
undefined,
|
||||
{ tabId: 'tab-child', worktreeId: 'wt-1' }
|
||||
)
|
||||
|
||||
expect(
|
||||
store.getState().agentStatusByPaneKey['tab-child:11111111-1111-4111-8111-111111111111']
|
||||
).toMatchObject({
|
||||
tabId: 'tab-child',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent status tool + assistant fields', () => {
|
||||
// Why: setAgentStatus schedules a real 30-minute freshness setTimeout via
|
||||
// queueMicrotask. Without fake timers those handles leak into the test
|
||||
|
||||
@@ -61,7 +61,8 @@ export type AgentStatusSlice = {
|
||||
paneKey: string,
|
||||
payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext },
|
||||
terminalTitle?: string,
|
||||
timing?: { updatedAt?: number; stateStartedAt?: number }
|
||||
timing?: { updatedAt?: number; stateStartedAt?: number },
|
||||
routing?: { tabId?: string; worktreeId?: string }
|
||||
) => void
|
||||
|
||||
setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void
|
||||
@@ -200,7 +201,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
retainedAgentsByPaneKey: {},
|
||||
retentionSuppressedPaneKeys: {},
|
||||
|
||||
setAgentStatus: (paneKey, payload, terminalTitle, timing) => {
|
||||
setAgentStatus: (paneKey, payload, terminalTitle, timing, routing) => {
|
||||
const updatedAt = timing?.updatedAt ?? Date.now()
|
||||
let completionRefreshWorktreeId: string | null = null
|
||||
let suppressedInheritedTerminalStatus = false
|
||||
@@ -288,6 +289,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
stateStartedAt,
|
||||
agentType: identity.agentType,
|
||||
paneKey,
|
||||
worktreeId:
|
||||
routing?.worktreeId ??
|
||||
existing?.worktreeId ??
|
||||
findAgentPaneWorktreeId(s, paneKey) ??
|
||||
undefined,
|
||||
tabId: routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined,
|
||||
terminalTitle: effectiveTitle,
|
||||
stateHistory: history,
|
||||
toolName: payload.toolName,
|
||||
|
||||
@@ -82,6 +82,13 @@ export type AgentStatusEntry = {
|
||||
agentType?: AgentType
|
||||
/** Composite key: `${tabId}:${leafId}` where leafId is a stable UUID layout leaf. */
|
||||
paneKey: string
|
||||
/** Worktree attribution stamped by main when a hook can be resolved there.
|
||||
* Why: orchestration workers can report status before their terminal tab is
|
||||
* present in a renderer; retaining this lets worktree-level UI still show
|
||||
* the live child agent instead of dropping it as unattributed. */
|
||||
worktreeId?: string
|
||||
/** Tab attribution from the hook IPC payload, when available. */
|
||||
tabId?: string
|
||||
terminalTitle?: string
|
||||
/** Rolling log of previous states. Each entry records a state the agent was in
|
||||
* before transitioning to the current one. Capped at AGENT_STATE_HISTORY_MAX. */
|
||||
|
||||
Reference in New Issue
Block a user