diff --git a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.module.css b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.module.css index 3598053d3c9..5115da914c9 100644 --- a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.module.css +++ b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.module.css @@ -166,6 +166,9 @@ margin: 5px 0; min-width: 0; overflow: hidden; + /* Sizes the timeline ruler's visibility to the panel itself (split + view panes are narrower than the viewport). */ + container-type: inline-size; } .header { @@ -232,3 +235,96 @@ .detail { margin-top: 4px; } + +/* Shared-axis mini timeline: one bar per agent laid out against the + group's combined wall-clock span. */ +.track { + position: relative; + height: 6px; + margin: 1px 0 5px; + border-radius: 999px; + background: color-mix(in srgb, var(--muted-foreground) 14%, transparent); +} + +.bar { + position: absolute; + top: 0; + height: 100%; + border-radius: 999px; + background: color-mix(in srgb, var(--muted-foreground) 60%, transparent); +} + +.barRunning { + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--agent-blue-500) 55%, transparent), + var(--agent-blue-500) + ); +} + +.barRunning::after { + content: ''; + position: absolute; + right: -1px; + top: 50%; + transform: translateY(-50%); + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--agent-blue-500); +} + +@media (prefers-reduced-motion: no-preference) { + .barRunning::after { + animation: bar-pulse 1.6s ease-out infinite; + } +} + +@keyframes bar-pulse { + 0% { + box-shadow: 0 0 0 0 + color-mix(in srgb, var(--agent-blue-500) 55%, transparent); + } + 70% { + box-shadow: 0 0 0 6px transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +.ruler { + position: relative; + height: 15px; + margin-top: 8px; + border-top: 1px solid var(--border); +} + +.tick { + position: absolute; + top: 0; + transform: translateX(-50%); + padding-top: 2px; + font-size: 9.5px; + font-variant-numeric: tabular-nums; + color: var(--muted-foreground); +} + +.tick::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + width: 1px; + height: 3px; + background: var(--border); +} + +/* Narrow panels (split view, mobile): the bars still read — overlap and + relative duration — while each row keeps its absolute numbers, so only + the ruler goes. */ +@container (max-width: 380px) { + .ruler { + display: none; + } +} diff --git a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.test.tsx b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.test.tsx new file mode 100644 index 00000000000..f1fb35e6612 --- /dev/null +++ b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider } from '../../../i18n'; +import type { ACPToolCall } from '../../../adapters/types'; + +// ParallelAgentsGroup renders SubAgentPanel, which pulls in ToolGroup; +// ToolGroup imports App only for CompactModeContext — loading the real +// App module would drag the whole application graph into this unit test. +vi.mock('../../../App', async () => { + const { createContext } = await import('react'); + return { CompactModeContext: createContext(false) }; +}); + +const { computeAgentsTimeline, ParallelAgentsGroup } = await import( + './ParallelAgentsGroup' +); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function agent(partial: Partial): ACPToolCall { + return { + callId: 'a1', + toolName: 'Task', + status: 'completed', + ...partial, + } as ACPToolCall; +} + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +// Render the group and expand it (it starts collapsed) so the per-agent +// timeline is in the DOM. +function renderExpandedGroup(agents: ACPToolCall[]): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + mounted.push({ root, container }); + const summary = container.querySelector('[aria-expanded]') as HTMLElement; + act(() => summary.click()); + return container; +} + +describe('computeAgentsTimeline', () => { + it('returns null for a single agent or missing start times', () => { + expect( + computeAgentsTimeline([agent({ startTime: 0, endTime: 5_000 })], 10_000), + ).toBeNull(); + expect( + computeAgentsTimeline( + [ + agent({ callId: 'a1', startTime: 0, endTime: 5_000 }), + agent({ callId: 'a2' }), + ], + 10_000, + ), + ).toBeNull(); + }); + + it('returns null for a sub-second span (nothing to compare)', () => { + expect( + computeAgentsTimeline( + [ + agent({ callId: 'a1', startTime: 0, endTime: 400 }), + agent({ callId: 'a2', startTime: 100, endTime: 600 }), + ], + 1_000, + ), + ).toBeNull(); + }); + + it('lays out bars against the combined span, running bars ending at now', () => { + const timeline = computeAgentsTimeline( + [ + agent({ callId: 'a1', startTime: 0, endTime: 24_000 }), + agent({ callId: 'a2', startTime: 3_000, status: 'in_progress' }), + ], + 15_000, + )!; + expect(timeline).not.toBeNull(); + + const done = timeline.rows.get('a1')!; + expect(done.leftPct).toBe(0); + expect(done.widthPct).toBe(100); + expect(done.running).toBe(false); + + const running = timeline.rows.get('a2')!; + expect(running.leftPct).toBeCloseTo(12.5); + expect(running.widthPct).toBeCloseTo(50); + expect(running.running).toBe(true); + }); + + it('keeps a visible sliver for near-instant agents, clamped to the edge', () => { + const timeline = computeAgentsTimeline( + [ + agent({ callId: 'a1', startTime: 0, endTime: 10_000 }), + agent({ callId: 'a2', startTime: 10_000, endTime: 10_000 }), + ], + 10_000, + )!; + const sliver = timeline.rows.get('a2')!; + expect(sliver.widthPct).toBe(2); + expect(sliver.leftPct + sliver.widthPct).toBeLessThanOrEqual(100); + }); + + it('picks nice ruler ticks that stop short of the right edge', () => { + const timeline = computeAgentsTimeline( + [ + agent({ callId: 'a1', startTime: 0, endTime: 24_000 }), + agent({ callId: 'a2', startTime: 3_000, endTime: 20_000 }), + ], + 24_000, + )!; + expect(timeline.ticks.map((tick) => tick.label)).toEqual([ + '0s', + '10s', + '20s', + ]); + expect(timeline.ticks[2].leftPct).toBeCloseTo((20_000 / 24_000) * 100); + }); + + it('emits a single tick for a span barely over 1s, so the ruler is dropped', () => { + // Only 0s fits before the 92% cutoff; the component gates the ruler on + // ticks.length >= 2, so this span renders bars without a ruler. + const timeline = computeAgentsTimeline( + [ + agent({ callId: 'a1', startTime: 0, endTime: 1_050 }), + agent({ callId: 'a2', startTime: 0, endTime: 1_050 }), + ], + 1_050, + )!; + expect(timeline).not.toBeNull(); + expect(timeline.ticks.length).toBe(1); + }); +}); + +describe('ParallelAgentsGroup timeline rendering', () => { + it('renders one bar per agent and a ruler when the span is comparable', () => { + const container = renderExpandedGroup([ + agent({ callId: 'a1', startTime: 0, endTime: 24_000 }), + agent({ callId: 'a2', startTime: 3_000, endTime: 20_000 }), + ]); + // The computed geometry actually reaches the DOM: a bar per agent... + expect(container.querySelectorAll('[class*="bar"]').length).toBe(2); + // ...and the ruler with its nice ticks. + expect(container.querySelector('[class*="ruler"]')).not.toBeNull(); + expect(container.textContent).toContain('0s'); + expect(container.textContent).toContain('10s'); + }); + + it('renders the bars but no ruler when the span yields a single tick', () => { + const container = renderExpandedGroup([ + agent({ callId: 'a1', startTime: 0, endTime: 1_050 }), + agent({ callId: 'a2', startTime: 0, endTime: 1_050 }), + ]); + expect(container.querySelectorAll('[class*="bar"]').length).toBe(2); + expect(container.querySelector('[class*="ruler"]')).toBeNull(); + }); + + it('renders no timeline at all when bars would not be comparable', () => { + // A single agent → computeAgentsTimeline returns null → plain list. + const container = renderExpandedGroup([ + agent({ callId: 'a1', startTime: 0, endTime: 24_000 }), + ]); + expect(container.querySelector('[class*="track"]')).toBeNull(); + expect(container.querySelector('[class*="ruler"]')).toBeNull(); + }); +}); diff --git a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx index 88fd94488fd..b6b875c7d17 100644 --- a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx +++ b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx @@ -33,6 +33,76 @@ function formatDuration(ms: number): string { return sec > 0 ? `${min}m ${sec}s` : `${min}m`; } +export interface TimelineRow { + leftPct: number; + widthPct: number; + running: boolean; +} + +export interface TimelineTick { + leftPct: number; + label: string; +} + +export interface AgentsTimeline { + rows: Map; + ticks: TimelineTick[]; +} + +/** + * Geometry for the shared-axis mini timeline: one bar per agent against + * the group's combined wall-clock span, so overlap and relative duration + * read at a glance. Returns null when the bars would not be comparable + * (an agent without a start time) or carry no information (single agent, + * sub-second span) — the list then renders without a timeline. + */ +export function computeAgentsTimeline( + agents: ACPToolCall[], + now: number, +): AgentsTimeline | null { + if (agents.length < 2) return null; + const starts: number[] = []; + for (const agent of agents) { + if (typeof agent.startTime !== 'number') return null; + starts.push(agent.startTime); + } + const ends = agents.map((agent, i) => + agent.status === 'in_progress' + ? Math.max(now, starts[i]) + : Math.max(agent.endTime ?? starts[i], starts[i]), + ); + const t0 = Math.min(...starts); + const span = Math.max(...ends) - t0; + if (span < 1000) return null; + + const rows = new Map(); + agents.forEach((agent, i) => { + // Keep a visible sliver for near-instant agents, clamped so it never + // overflows the right edge. + const width = Math.max((ends[i] - starts[i]) / span, 0.02); + const left = Math.min((starts[i] - t0) / span, 1 - width); + rows.set(agent.callId, { + leftPct: left * 100, + widthPct: width * 100, + running: agent.status === 'in_progress', + }); + }); + + // Ruler at 0 / step / 2·step… with a "nice" step (1-2-5 × 10ᵏ seconds), + // stopping short of the right edge so labels don't collide with it. + const targetSec = Math.max(span / 1000 / 2.2, 1); + const pow = Math.pow(10, Math.floor(Math.log10(targetSec))); + const stepSec = + [5, 2, 1].map((m) => m * pow).find((s) => s <= targetSec) ?? pow; + const ticks: TimelineTick[] = []; + for (let m = 0; ticks.length < 4; m++) { + const atMs = m * stepSec * 1000; + if (atMs > span * 0.92) break; + ticks.push({ leftPct: (atMs / span) * 100, label: formatDuration(atMs) }); + } + return { rows, ticks }; +} + function getAgentStats(agent: ACPToolCall, now: number): string { const parts: string[] = []; const taskExec = getTaskExecutionRecord(agent.rawOutput); @@ -121,6 +191,7 @@ export function ParallelAgentsGroup({ ? agents.find((a) => toolContainsCallId(a, pendingApproval.toolCallId!)) : undefined; const showGroup = groupExpanded || !!approvalAgent; + const timeline = showGroup ? computeAgentsTimeline(agents, now) : null; const summaryStatus = agents.some( (a) => getAgentDisplayStatus(a) === 'failed', ) @@ -174,6 +245,7 @@ export function ParallelAgentsGroup({ const stats = getAgentStats(agent, now); const status = getAgentDisplayStatus(agent); const isExpanded = expandedId === agent.callId; + const track = timeline?.rows.get(agent.callId); return (
{stats && {stats}}
+ {track && ( + + )} {isExpanded && (
@@ -202,6 +289,19 @@ export function ParallelAgentsGroup({ ); })}
+ {timeline && timeline.ticks.length >= 2 && ( + + )}
)} diff --git a/packages/web-shell/client/components/messages/tools/SubAgentPanel.module.css b/packages/web-shell/client/components/messages/tools/SubAgentPanel.module.css index 4ac32fbfd7b..e5418aeae7d 100644 --- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.module.css +++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.module.css @@ -82,8 +82,9 @@ overflow: hidden; } -/* Result and sub-tool list: scroll within the same cap as the live - stream so the enclosing panel never grows past a screen. */ +/* Result (compact mode) and the step list (always): scroll within the + same cap as the live stream so the enclosing panel never grows past a + screen. */ .scrollWindow { max-height: 400px; overflow-y: auto; @@ -104,31 +105,24 @@ color: var(--muted-foreground); } -.tabBar { +/* Hairline-ruled caption separating the conclusion from the step + timeline when both are present. */ +.sectionCap { display: flex; - gap: 0; - border-bottom: 1px solid var(--border); - margin-bottom: 6px; -} - -.tab { - padding: 4px 12px; - font-size: 12px; - color: var(--muted-foreground); - background: none; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - font-family: inherit; -} - -.tab:hover { + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; color: var(--muted-foreground); } -.tabActive { - color: var(--foreground); - border-bottom-color: var(--agent-blue-500); +.sectionCap::after { + content: ''; + height: 1px; + flex: 1; + background: var(--border); } .tools { @@ -141,6 +135,49 @@ border-left-width: 2px; } +/* Step rail: a dot per step plus per-step segments of vertical line, so + the sub-tool list reads as one chronological timeline. Segments (not + one absolute line on the scroll container) because the container's + height is the scrollport, not the content. */ +.step { + position: relative; + padding-left: 16px; +} + +.step::before { + content: ''; + position: absolute; + left: 2px; + top: 7px; + width: 7px; + height: 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--muted-foreground) 55%, transparent); + z-index: 1; +} + +.step:not(:last-child)::after { + content: ''; + position: absolute; + left: 5px; + top: 16px; + bottom: -8px; + width: 1px; + background: var(--border); +} + +.step[data-status='in_progress']::before, +.step[data-status='pending']::before { + background: var(--agent-blue-500); +} + +.step[data-status='failed']::before, +.step[data-status='error']::before, +.step[data-status='cancelled']::before, +.step[data-status='canceled']::before { + background: var(--error-color); +} + /* * Per-sub-tool hover time. Deliberately a *separate* class pair from the * message-level MessageTimestamp (.row/.tip): the Tools list nests inside a diff --git a/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx b/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx index 2fae6bd50dc..2ecf5d66d0d 100644 --- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx +++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx @@ -127,6 +127,70 @@ describe('SubAgentPanel sub-tool timestamps', () => { expect(container.textContent).toContain('second line'); }); + it('shows the conclusion and the step list together when complete', () => { + const container = renderPanel({ + callId: 'agent-1', + toolName: 'Task', + status: 'completed', + rawOutput: { type: 'task_execution', result: 'all references found' }, + subTools: [{ callId: 'sub-1', toolName: 'Grep', status: 'completed' }], + }); + // No tab switching: both sections render at once, captioned. + expect(container.textContent).toContain('all references found'); + expect(container.textContent).toContain('Grep'); + expect(container.textContent).toContain('Result'); + expect(container.textContent).toContain('Tools (1)'); + }); + + it('shows steps and the live stream together while running', () => { + const container = renderPanel({ + callId: 'agent-1', + toolName: 'Task', + status: 'in_progress', + subContent: 'scanning for usages…', + subTools: [{ callId: 'sub-1', toolName: 'Grep', status: 'in_progress' }], + }); + expect(container.textContent).toContain('Grep'); + expect(container.textContent).toContain('scanning for usages…'); + // The live stream renders as a
; while running it must be present.
+    expect(container.querySelector('[class*="stream"]')).not.toBeNull();
+    // The running flow is uncaptioned — no conclusion exists yet.
+    expect(container.textContent).not.toContain('Result');
+  });
+
+  it('renders a completed agent stream text as the conclusion, not the live stream', () => {
+    // The conclusion-first invariant: once complete, subContent is the
+    // conclusion (assistant markdown), never the running 
 stream.
+    const container = renderPanel({
+      callId: 'agent-1',
+      toolName: 'Task',
+      status: 'completed',
+      subContent: 'the final answer',
+    });
+    const conclusion = container.querySelector(
+      '[data-markdown-source="assistant"]',
+    );
+    expect(conclusion).not.toBeNull();
+    expect(conclusion?.textContent).toContain('the final answer');
+    expect(container.querySelector('[class*="stream"]')).toBeNull();
+  });
+
+  it('always scroll-caps the step list, regardless of compactThinking', () => {
+    // The tabs are gone, so the conclusion renders above the steps; the step
+    // list carries the scroll cap unconditionally (previously compact-only)
+    // so a long list can never push the conclusion off-screen. The test runs
+    // with the default (non-compact) customization.
+    const container = renderPanel({
+      callId: 'agent-1',
+      toolName: 'Task',
+      status: 'completed',
+      subTools: [{ callId: 'sub-1', toolName: 'Grep', status: 'completed' }],
+    });
+    const stepWindow = container.querySelector('[class*="scrollWindow"]');
+    expect(stepWindow).not.toBeNull();
+    expect(stepWindow?.className).toContain('tools');
+  });
+
   it('hides non-standard sub-tool summaries until the row is expanded', () => {
     const container = renderPanel(
       makeAgentWithSubTool({
diff --git a/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx b/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx
index ce9ad2714a2..1bddcb0d227 100644
--- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx
+++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx
@@ -151,8 +151,6 @@ function getAgentResultText(tool: ACPToolCall): string {
   return '';
 }
 
-type SubAgentTab = 'result' | 'tools';
-
 /**
  * Live sub-agent stream (thinking + output) shown while the agent runs.
  * With compactThinking enabled it collapses to a 5-line window pinned to
@@ -227,10 +225,11 @@ function SubAgentResult({ content }: { content: string }) {
 }
 
 /**
- * Sub-tool list, capped to the same scrollable window as the result
- * with compactThinking enabled. While the agent is still running the
- * window follows the newest call; once it completes it snaps back to
- * the top for reading.
+ * Step timeline: the sub-tool list in execution order, always capped to
+ * its own scrollable window — with the conclusion rendered above it (no
+ * tabs), an uncapped list would grow the panel past a screen. While the
+ * agent is still running the window follows the newest call; once it
+ * completes it snaps back to the top for reading.
  */
 function SubAgentTools({
   pinTail,
@@ -241,24 +240,16 @@ function SubAgentTools({
   itemCount: number;
   children: ReactNode;
 }) {
-  const { compactThinking } = useWebShellCustomization();
   const windowRef = useRef(null);
 
   useEffect(() => {
     const el = windowRef.current;
-    if (!el || !compactThinking) return;
+    if (!el) return;
     el.scrollTop = pinTail ? el.scrollHeight : 0;
-  }, [compactThinking, pinTail, itemCount]);
+  }, [pinTail, itemCount]);
 
   return (
-    
+
{children}
); @@ -274,7 +265,6 @@ export function SubAgentPanel({ const isComplete = tool.status === 'completed' || tool.status === 'failed'; const displayStatus = getAgentDisplayStatus(tool); const [expanded, setExpanded] = useState(defaultExpanded ?? false); - const [activeTab, setActiveTab] = useState('result'); const taskExec = isTaskExecution(tool.rawOutput) ? tool.rawOutput : null; @@ -302,7 +292,10 @@ export function SubAgentPanel({ (tool.subTools && tool.subTools.length > 0) || (taskToolCalls && taskToolCalls.length > 0) ); - const showTabs = hasResult && hasTools; + // Captions only where they disambiguate: a completed agent showing both + // its conclusion and the steps that produced it. A single section — or + // the live steps+stream flow while running — reads on its own. + const showSectionCaps = isComplete && hasResult && hasTools; return (
@@ -328,56 +321,62 @@ export function SubAgentPanel({ {(expanded || hideHeader) && (
- {showTabs && ( -
- - -
- )} - - {(!showTabs || activeTab === 'result') && hasResult && ( + {/* One chronological story instead of Result/Tools tabs. + Completed: conclusion first, then the steps that produced it + in their own scroll window, so the payoff stays in view. + Running: no conclusion exists yet — the step window pins to + the newest call and the live stream tails it. */} + {isComplete && hasResult && (
- {isComplete ? ( - - ) : ( - tool.subContent && + {showSectionCaps && ( +
{t('subagent.result')}
)} +
)} - {(!showTabs || activeTab === 'tools') && ( - <> - {tool.subTools && tool.subTools.length > 0 && ( - + {t('subagent.tools', { count: subToolCount })} +
+ )} + {tool.subTools && tool.subTools.length > 0 && ( + + {tool.subTools.map((sub) => ( +
- {tool.subTools.map((sub) => ( - - ))} - - )} - {taskToolCalls && taskToolCalls.length > 0 && ( - +
+ ))} +
+ )} + {taskToolCalls && taskToolCalls.length > 0 && ( + + {taskToolCalls.map((tc) => ( +
- {taskToolCalls.map((tc) => ( - - ))} - - )} - + +
+ ))} +
+ )} + + {!isComplete && tool.subContent && ( +
+ +
)}
)}