diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 05ffc660732..7f555bf724d 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1626,6 +1626,31 @@ html.native-shell .app-layout { animation: compact-agent-expansion-reveal 180ms cubic-bezier(0.16, 1, 0.3, 1) both; } +/* Keep the disclosure in the timestamp slot without moving adjacent content. */ +.agent-child-disclosure-time { + visibility: hidden; +} + +@media (hover: hover) { + .agent-child-disclosure[data-expanded='false'] .agent-child-disclosure-time { + visibility: visible; + } + + .agent-child-disclosure[data-expanded='false'] [data-agent-child-disclosure-button] { + opacity: 0; + } + + .agent-disclosure-row:hover .agent-child-disclosure-time, + .agent-disclosure-row:focus-within .agent-child-disclosure-time { + visibility: hidden; + } + + .agent-disclosure-row:hover [data-agent-child-disclosure-button], + .agent-disclosure-row:focus-within [data-agent-child-disclosure-button] { + opacity: 1; + } +} + /* Why: the collapsed summary still uses pill spacing, while expanded compact agents read as a quiet tree inside the existing worktree card. */ .compact-agent-summary-panel { diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx new file mode 100644 index 00000000000..6d0994eb760 --- /dev/null +++ b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx @@ -0,0 +1,157 @@ +/** @vitest-environment happy-dom */ +import { fireEvent, render, screen, cleanup } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CompactAgentRow } from '../sidebar/worktree-card-compact-agent-row' +import { TooltipProvider } from '../ui/tooltip' +import DashboardAgentRow from './DashboardAgentRow' +import type { DashboardAgentRow as AgentRow } from './useDashboardData' +import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' + +vi.mock('./use-agent-row-conversation-name', () => ({ useAgentRowConversationName: () => null })) +vi.mock('../sidebar/CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +afterEach(cleanup) + +const agent: AgentRow = { + paneKey: 'tab:leaf', + agentType: 'claude', + state: 'working', + startedAt: 60000, + entry: { + paneKey: 'tab:leaf', + state: 'working', + prompt: 'Review the change', + updatedAt: 60000, + stateStartedAt: 60000, + stateHistory: [] + }, + tab: { + id: 'tab', + ptyId: null, + worktreeId: 'workspace', + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('agent child disclosure', () => { + it('toggles children without activating the surrounding agent or workspace', () => { + const activate = vi.fn() + const toggle = vi.fn() + const pointerDown = vi.fn() + const keyDown = vi.fn() + render( +
+ +
+ ) + const button = screen.getByRole('button', { name: 'Show 2 child agents' }) + fireEvent.pointerDown(button) + fireEvent.keyDown(button, { key: 'Enter' }) + fireEvent.keyDown(button, { key: ' ' }) + fireEvent.click(button) + expect(toggle).toHaveBeenCalledTimes(1) + expect(activate).not.toHaveBeenCalled() + expect(pointerDown).not.toHaveBeenCalled() + expect(keyDown).not.toHaveBeenCalled() + }) + + it('updates the accessible action and retains the timestamp when expanded', () => { + const toggle = vi.fn() + const { rerender } = render( + + ) + expect( + screen.getByRole('button', { name: 'Show 1 child agent' }).getAttribute('aria-expanded') + ).toBe('false') + rerender( + + ) + expect( + screen.getByRole('button', { name: 'Hide 1 child agent' }).getAttribute('aria-expanded') + ).toBe('true') + expect(screen.getByText('5m')).toBeTruthy() + }) + + it('does not offer a disclosure without children or a toggle action', () => { + const { rerender } = render( + + ) + expect(screen.queryByRole('button')).toBeNull() + rerender() + expect(screen.queryByRole('button')).toBeNull() + }) +}) + +describe.each(['compact', 'full'] as const)('%s row disclosure actions', (mode) => { + it.each([undefined, 'eligible'] as const)( + 'keeps child expansion separate from row actions in %s send mode', + (sendTargetStatus) => { + const onActivate = vi.fn(), + onSendTargetClick = vi.fn(), + onDismiss = vi.fn(), + onToggleChildAgents = vi.fn() + const props = { + agent, + now: 120000, + childAgentCount: 2, + childAgentsExpanded: false, + onToggleChildAgents, + onActivate, + sendTargetStatus, + onSendTargetClick + } + render( + + {mode === 'compact' ? ( + + ) : ( + + )} + + ) + fireEvent.click(screen.getByRole('button', { name: 'Show 2 child agents' })) + expect(onToggleChildAgents).toHaveBeenCalledTimes(1) + expect(onActivate).not.toHaveBeenCalled() + expect(onSendTargetClick).not.toHaveBeenCalled() + expect(onDismiss).not.toHaveBeenCalled() + fireEvent.click(screen.getByText('Review the change')) + if (sendTargetStatus) { + expect(onSendTargetClick).toHaveBeenCalledWith(agent.paneKey) + expect(onActivate).not.toHaveBeenCalled() + } else { + expect(onActivate).toHaveBeenCalledWith(agent.tab.id, agent.paneKey) + if (mode === 'full') { + fireEvent.click(screen.getByRole('button', { name: 'Dismiss agent' })) + expect(onDismiss).toHaveBeenCalledWith(agent.paneKey) + expect(onActivate).toHaveBeenCalledTimes(1) + } + } + } + ) +}) diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx index fb8e45b4216..4e225a287dd 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx @@ -1,18 +1,21 @@ import React, { useCallback } from 'react' -import { ChevronRight } from 'lucide-react' +import { ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' type Props = { childAgentCount?: number childAgentsExpanded: boolean onToggleChildAgents?: () => void + timestamp?: React.ReactNode } export function DashboardAgentChildDisclosure({ childAgentCount, childAgentsExpanded, - onToggleChildAgents + onToggleChildAgents, + timestamp }: Props) { const hasChildDisclosure = typeof childAgentCount === 'number' && @@ -40,29 +43,46 @@ export function DashboardAgentChildDisclosure({ } return ( - + aria-expanded={childAgentsExpanded} + > + + + ) } diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 4b38d8d1b0d..1b64ec6af83 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -4,7 +4,6 @@ import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' import { DashboardAgentRowMessage } from './DashboardAgentRowMessage' import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls' import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' @@ -56,12 +55,10 @@ 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. + // Why: inline-card orchestration rows can fold their child agents. childAgentCount?: number childAgentsExpanded?: boolean onToggleChildAgents?: () => void - // Why: a top-level chevron hangs in the card gutter so the state dot keeps the column of chevron-less rows. - disclosureInGutter?: boolean // Why: chevron indentation replaces fixed-offset lineage connector art. hideLineageConnectors?: boolean // Why: send-popover target mode makes row clicks send/no-op instead of navigating. @@ -83,7 +80,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ childAgentCount, childAgentsExpanded = false, onToggleChildAgents, - disclosureInGutter = false, hideLineageConnectors = false, sendTargetStatus, sendTargetDisabledReason, @@ -185,8 +181,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ onClick={handleActivate} className={cn( // Why: named group scopes the X-reveal to this row, not every row in the card. - 'group/agent-row relative flex flex-col py-1', - hasChildDisclosure && disclosureInGutter ? '-ml-7' : '-ml-2', + 'agent-disclosure-row group/agent-row relative -ml-2 flex flex-col py-1', isLineageChild ? 'pl-5 pr-2' : 'px-2', // Why: hover wash stays softer than the enclosing card's highlight. 'cursor-pointer rounded-sm worktree-agent-row-hover', @@ -229,11 +224,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ ) : null}
- {/* Why: state dot sits in the leading gutter so the eye can scan one column for row state. */} @@ -288,6 +278,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ void paneKey: string relativeTimestamp: string | null expanded: boolean @@ -18,6 +22,9 @@ type DashboardAgentRowTrailingControlsProps = { } export function DashboardAgentRowTrailingControls({ + childAgentCount, + childAgentsExpanded = false, + onToggleChildAgents, paneKey, relativeTimestamp, expanded, @@ -64,8 +71,15 @@ export function DashboardAgentRowTrailingControls({ [onSendTargetClick, paneKey, sendTargetStatus] ) + const hasChildDisclosure = Boolean(childAgentCount && onToggleChildAgents) + return ( - + {(sendTargetStatus === 'eligible' || sendTargetStatus === 'sending') && ( )} - {!sendTargetStatus && hideDismiss && relativeTimestamp !== null && ( + {!hasChildDisclosure && !sendTargetStatus && hideDismiss && relativeTimestamp !== null && ( )} - {!sendTargetStatus && !hideDismiss && relativeTimestamp === null && ( + {!sendTargetStatus && !hideDismiss && (hasChildDisclosure || relativeTimestamp === null) && ( )} + {hasChildDisclosure && ( + + {relativeTimestamp} + + } + /> + )} {!hideExpand && ( - ) : null} {/* Why: the row's actionable disabled reason must win on every hit area. */} )} {cacheTimer && } - {shortTime && ( - - {shortTime} - + {hasChildDisclosure ? ( + + ) : ( + timestamp )} ) @@ -293,11 +257,9 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({