diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx new file mode 100644 index 000000000..5cf8d0527 --- /dev/null +++ b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx @@ -0,0 +1,67 @@ +import React, { useCallback } from 'react' +import { ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' + +type Props = { + childAgentCount?: number + childAgentsExpanded: boolean + onToggleChildAgents?: () => void + reserveDisclosureGutter: boolean +} + +export function DashboardAgentChildDisclosure({ + childAgentCount, + childAgentsExpanded, + onToggleChildAgents, + reserveDisclosureGutter +}: Props) { + const hasChildDisclosure = + typeof childAgentCount === 'number' && + childAgentCount > 0 && + typeof onToggleChildAgents === 'function' + const handleToggleChildren = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + onToggleChildAgents?.() + }, + [onToggleChildAgents] + ) + const stopMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + }, []) + const stopKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation() + } + }, []) + + if (!hasChildDisclosure) { + return reserveDisclosureGutter ? ( + + ) : null + } + + // Why: the chevron owns child disclosure; leaf spacers keep the leading + // state-dot column aligned across the card. + return ( + + ) +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 11ad84131..255775a7a 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -6,6 +6,7 @@ import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' import type { AgentStatusState } from '../../../../shared/agent-status-types' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' @@ -112,6 +113,14 @@ type Props = { hideExpand?: boolean /** Reuse the row's hover tint to show the focused terminal pane's agent. */ isFocusedPane?: boolean + // Why: inline-card orchestration rows fold children under a leading chevron. + childAgentCount?: number + childAgentsExpanded?: boolean + onToggleChildAgents?: () => void + // Why: leaf siblings reserve the chevron gutter so state dots align. + reserveDisclosureGutter?: boolean + // Why: chevron indentation replaces fixed-offset lineage connector art. + hideLineageConnectors?: boolean } const DashboardAgentRow = React.memo(function DashboardAgentRow({ @@ -123,8 +132,17 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ stateDotSize = 'md', hideIdentityIcon = false, hideExpand = false, - isFocusedPane = false + isFocusedPane = false, + childAgentCount, + childAgentsExpanded = false, + onToggleChildAgents, + reserveDisclosureGutter = false, + hideLineageConnectors = false }: Props) { + const hasChildDisclosure = + typeof childAgentCount === 'number' && + childAgentCount > 0 && + typeof onToggleChildAgents === 'function' const [expanded, setExpanded] = useState(false) // Why: stop propagation so clicking the X doesn't also fire the worktree // card's click handler (which navigates away from the dashboard). @@ -243,14 +261,14 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ role={participatesInLineage ? 'treeitem' : undefined} aria-level={participatesInLineage ? (lineage?.depth ?? 0) + 1 : undefined} > - {lineageChildCount > 0 ? ( + {lineageChildCount > 0 && !hideLineageConnectors ? ( ) : null} - {isLineageChild ? ( + {isLineageChild && !hideLineageConnectors ? ( ) : null}
+ {/* Why: state indicator lives in the leading gutter so the user's eye can sweep one column and know which rows are working, waiting, or done at a glance — the list-view convention (Linear, @@ -330,6 +354,18 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ > {displayLabel} + {/* Why: "+N" badge mirrors the leading chevron — without it the + parent row reads identical to a leaf row when collapsed, and the + child count is invisible. Hidden when expanded because the + children are visible directly below. */} + {hasChildDisclosure && !childAgentsExpanded && ( + + +{childAgentCount} + + )} {/* Why: right cluster keeps passive time and dismiss affordance in one place. State belongs in the leading gutter; repeating it here as text makes interrupted rows look like the old badge treatment. */} diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index 25542908d..3d745887e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -1,12 +1,15 @@ import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -let mockAgents = [ +let mockAgents: unknown[] = [ { paneKey: 'tab-1:1', tab: { id: 'tab-1' }, + state: 'working', entry: { - stateStartedAt: 1000 + stateStartedAt: 1000, + orchestration: undefined } } ] @@ -31,9 +34,37 @@ vi.mock('@/components/dashboard/useNow', () => ({ })) vi.mock('@/components/dashboard/DashboardAgentRow', () => ({ - default: ({ agent, isFocusedPane }: { agent: { paneKey: string }; isFocusedPane?: boolean }) => ( -
+ default: ({ + agent, + isFocusedPane, + childAgentCount, + childAgentsExpanded, + onToggleChildAgents + }: { + agent: { paneKey: string } + isFocusedPane?: boolean + childAgentCount?: number + childAgentsExpanded?: boolean + onToggleChildAgents?: () => void + }) => ( +
{agent.paneKey} + {typeof childAgentCount === 'number' && childAgentCount > 0 ? ( + + ) : null}
) })) @@ -42,6 +73,12 @@ vi.mock('./focused-agent-row-highlight', () => ({ useFocusedAgentPaneKey: vi.fn(() => mockFocusedAgentPaneKey) })) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + describe('WorktreeCardAgents', () => { beforeEach(() => { vi.clearAllMocks() @@ -49,15 +86,17 @@ describe('WorktreeCardAgents', () => { { paneKey: 'tab-1:1', tab: { id: 'tab-1' }, + state: 'working', entry: { - stateStartedAt: 1000 + stateStartedAt: 1000, + orchestration: undefined } } ] mockFocusedAgentPaneKey = null }) - it('renders rows in a labeled group without the removed per-card toggle header', async () => { + it('renders ordinary rows in a labeled group without a child disclosure', async () => { const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') const markup = renderToStaticMarkup() @@ -91,8 +130,110 @@ describe('WorktreeCardAgents', () => { const markup = renderToStaticMarkup() - expect(markup).toContain('data-focused="false">tab-1:1') - expect(markup).toContain('data-focused="true">tab-1:2') + expect(markup).toContain('data-focused="false" data-pane-key="tab-1:1"') + expect(markup).toContain('data-focused="true" data-pane-key="tab-1:2"') + }) + + it('collapses orchestration child agent rows behind a parent disclosure by default', async () => { + mockAgents = [ + { + paneKey: 'tab-parent:1', + tab: { id: 'tab-parent' }, + state: 'working', + entry: { + stateStartedAt: 1000, + orchestration: undefined + }, + lineage: { + depth: 0, + isFirstSibling: true, + isLastSibling: true, + childCount: 1 + } + }, + { + paneKey: 'tab-child:1', + tab: { id: 'tab-child' }, + state: 'done', + entry: { + stateStartedAt: 1500, + orchestration: { + parentPaneKey: 'tab-parent:1' + } + }, + lineage: { + depth: 1, + isFirstSibling: true, + isLastSibling: true, + childCount: 0 + } + } + ] + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + const markup = renderToStaticMarkup() + + 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"') + }) + + it('keeps partially cyclic orchestration rows visible as flat roots', async () => { + mockAgents = [ + { + paneKey: 'tab-root:1', + tab: { id: 'tab-root' }, + state: 'working', + entry: { + stateStartedAt: 1000, + orchestration: undefined + } + }, + { + paneKey: 'tab-cycle-a:1', + tab: { id: 'tab-cycle-a' }, + state: 'working', + entry: { + stateStartedAt: 1200, + orchestration: { + parentPaneKey: 'tab-cycle-b:1' + } + }, + lineage: { + depth: 0, + isFirstSibling: true, + isLastSibling: false, + childCount: 1 + } + }, + { + paneKey: 'tab-cycle-b:1', + tab: { id: 'tab-cycle-b' }, + state: 'done', + entry: { + stateStartedAt: 1300, + orchestration: { + parentPaneKey: 'tab-cycle-a:1' + } + }, + lineage: { + depth: 1, + isFirstSibling: false, + isLastSibling: true, + childCount: 1 + } + } + ] + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + const markup = renderToStaticMarkup() + + expect(markup).toContain('data-pane-key="tab-root:1"') + expect(markup).toContain('data-pane-key="tab-cycle-a:1"') + expect(markup).toContain('data-pane-key="tab-cycle-b:1"') + expect(markup).not.toContain('aria-label="Show 1 child agent"') }) it('does not render the labeled wrapper when there are no agent rows', async () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 391bcf212..16fea6e98 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' @@ -47,6 +47,70 @@ type BodyProps = { className?: string } +type AgentLineageModel = { + rootAgents: DashboardAgentRowData[] + childrenByParentPaneKey: Map +} + +function buildAgentLineageModel(agents: DashboardAgentRowData[]): AgentLineageModel { + const agentPaneKeys = new Set(agents.map((agent) => agent.paneKey)) + const childrenByParentPaneKey = new Map() + const childPaneKeys = new Set() + + for (const agent of agents) { + const parentPaneKey = agent.entry.orchestration?.parentPaneKey + if (!parentPaneKey || !agentPaneKeys.has(parentPaneKey)) { + continue + } + childPaneKeys.add(agent.paneKey) + const siblings = childrenByParentPaneKey.get(parentPaneKey) + if (siblings) { + siblings.push(agent) + } else { + childrenByParentPaneKey.set(parentPaneKey, [agent]) + } + } + + const rootAgents = agents.filter((agent) => !childPaneKeys.has(agent.paneKey)) + if (rootAgents.length === 0 && agents.length > 0) { + // Why: malformed orchestration metadata can theoretically form a cycle. + // Keep every row visible instead of recursing forever or hiding the list. + return { rootAgents: agents, childrenByParentPaneKey: new Map() } + } + + const reachablePaneKeys = new Set() + const markReachable = ( + agent: DashboardAgentRowData, + ancestorPaneKeys: ReadonlySet = new Set() + ): void => { + if (reachablePaneKeys.has(agent.paneKey) || ancestorPaneKeys.has(agent.paneKey)) { + return + } + reachablePaneKeys.add(agent.paneKey) + const descendantAncestorPaneKeys = new Set(ancestorPaneKeys) + descendantAncestorPaneKeys.add(agent.paneKey) + for (const childAgent of childrenByParentPaneKey.get(agent.paneKey) ?? []) { + markReachable(childAgent, descendantAncestorPaneKeys) + } + } + for (const rootAgent of rootAgents) { + markReachable(rootAgent) + } + + for (const agent of agents) { + if (reachablePaneKeys.has(agent.paneKey)) { + continue + } + // Why: a partial cycle alongside a valid root has no true root, so it + // would otherwise disappear. Render malformed participants as flat rows + // and drop their child edges, matching the dashboard lineage fallback. + rootAgents.push(agent) + childrenByParentPaneKey.delete(agent.paneKey) + } + + return { rootAgents, childrenByParentPaneKey } +} + const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ worktreeId, agents, @@ -125,46 +189,111 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ // don't pay any timer cost. const now = useNow(30_000) const hasLineage = agents.some((agent) => agent.lineage && agent.lineage.depth > 0) + const { rootAgents, childrenByParentPaneKey } = useMemo( + () => buildAgentLineageModel(agents), + [agents] + ) + const [expandedLineageParents, setExpandedLineageParents] = useState>( + () => new Set() + ) + const toggleLineageParent = useCallback((paneKey: string) => { + setExpandedLineageParents((current) => { + const next = new Set(current) + if (next.has(paneKey)) { + next.delete(paneKey) + } else { + next.add(paneKey) + } + return next + }) + }, []) const stopBubble = useCallback((e: React.MouseEvent) => { e.stopPropagation() }, []) + // Why: when any root row has a disclosure chevron, leaf siblings reserve a + // matching leading spacer so the state-dot column stays aligned across the + // card. Without this, parent rows shift right by the chevron's width while + // leaf rows hug the gutter — visible misalignment when the user sweeps the + // leading column. + const anyRootHasChildren = rootAgents.some( + (agent) => (childrenByParentPaneKey.get(agent.paneKey) ?? []).length > 0 + ) + + const renderAgentBranch = ( + agent: DashboardAgentRowData, + ancestorPaneKeys: ReadonlySet = new Set() + ): React.ReactNode => { + if (ancestorPaneKeys.has(agent.paneKey)) { + // Why: orchestration metadata is external state and can be malformed. + // Bail out of repeated ancestors instead of recursing forever. + return null + } + const childAgents = childrenByParentPaneKey.get(agent.paneKey) ?? [] + const hasChildAgents = childAgents.length > 0 + const expanded = expandedLineageParents.has(agent.paneKey) + const descendantAncestorPaneKeys = new Set(ancestorPaneKeys) + descendantAncestorPaneKeys.add(agent.paneKey) + return ( + + toggleLineageParent(agent.paneKey) : undefined + } + // Why: keep leaf rows aligned with parent rows in the same card — + // see anyRootHasChildren above. + reserveDisclosureGutter={anyRootHasChildren && !hasChildAgents} + isFocusedPane={agent.paneKey === focusedAgentPaneKey} + // Why: the disclosure variant uses chevron + indentation to show + // hierarchy. The legacy L-connector / vertical-trunk decorations + // are pinned to a fixed left offset that doesn't match the + // chevron-shifted column and read as floating fragments. + hideLineageConnectors + /> + {hasChildAgents && expanded + ? childAgents.map((childAgent) => + renderAgentBranch(childAgent, descendantAncestorPaneKeys) + ) + : null} + + ) + } + return ( // Why: swallow bubbling so clicks on the gutter around the agent rows // don't reach WorktreeCard's activate / edit-meta handlers.
- {agents.map((agent) => ( -
- -
- ))} + {rootAgents.map((rootAgent) => renderAgentBranch(rootAgent))}
) })