akseljoonas HF Staff commited on
Commit
8d40f7c
·
1 Parent(s): 1a390ae

Redesign research subagent display with live stats and rolling steps

Browse files

- Add researchStats (toolCount, tokenCount, elapsed) to session state
- Parse tokens:/tools: log events into stats instead of step list
- Show live stats in chip (running · N tools · Xk tokens · Ys)
- Rolling 2-line window of sub-tool calls, hidden on completion
- Format step labels with specifics (dataset name, paper query, etc.)
- Handle truncated JSON args from backend via regex fallback
- Fix stale tool spinners on page reload (coerce to completed)
- Clean activity status bar labels for research tool logs

frontend/src/components/Chat/ActivityStatusBar.tsx CHANGED
@@ -20,11 +20,81 @@ const TOOL_LABELS: Record<string, string> = {
20
  research: 'Researching',
21
  };
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  function statusLabel(status: ActivityStatus): string {
24
  switch (status.type) {
25
  case 'thinking': return 'Thinking';
26
  case 'streaming': return 'Writing';
27
  case 'tool': {
 
 
 
28
  const base = status.description || TOOL_LABELS[status.toolName] || `Running ${status.toolName}`;
29
  if (status.toolName === 'bash' && status.description && /install/i.test(status.description)) {
30
  return `${base} — this can take a few minutes, sit tight`;
 
20
  research: 'Researching',
21
  };
22
 
23
+ /** Format raw research log into a clean status label. */
24
+ function formatResearchStatus(raw: string): string {
25
+ const s = raw.replace(/^▸\s*/, '');
26
+ const jsonStart = s.indexOf('{');
27
+ const toolName = jsonStart > 0 ? s.slice(0, jsonStart).trim() : s.trim();
28
+ let args: Record<string, string> = {};
29
+ if (jsonStart > 0) {
30
+ const jsonStr = s.slice(jsonStart);
31
+ try {
32
+ const parsed = JSON.parse(jsonStr);
33
+ for (const [k, v] of Object.entries(parsed)) {
34
+ if (typeof v === 'string') args[k] = v;
35
+ }
36
+ } catch {
37
+ for (const m of jsonStr.matchAll(/"(\w+)":\s*"([^"]*)"/g)) {
38
+ args[m[1]] = m[2];
39
+ }
40
+ }
41
+ }
42
+
43
+ if (toolName === 'github_find_examples') {
44
+ const d = (args.keyword) || (args.repo);
45
+ return d ? `Finding examples: ${d}` : 'Finding examples';
46
+ }
47
+ if (toolName === 'github_read_file') {
48
+ const f = ((args.path) || '').split('/').pop();
49
+ return f ? `Reading ${f}` : 'Reading file';
50
+ }
51
+ if (toolName === 'explore_hf_docs') {
52
+ const d = (args.endpoint) || (args.query);
53
+ return d ? `Exploring docs: ${d}` : 'Exploring docs';
54
+ }
55
+ if (toolName === 'fetch_hf_docs') {
56
+ const p = ((args.url) || '').split('/').pop()?.replace(/\.md$/, '');
57
+ return p ? `Reading docs: ${p}` : 'Fetching docs';
58
+ }
59
+ if (toolName === 'hf_inspect_dataset') {
60
+ const d = args.dataset as string;
61
+ return d ? `Inspecting dataset: ${d}` : 'Inspecting dataset';
62
+ }
63
+ if (toolName === 'hf_papers') {
64
+ const op = args.operation as string;
65
+ const detail = (args.query) || (args.arxiv_id);
66
+ const opLabels: Record<string, string> = {
67
+ trending: 'Browsing trending papers',
68
+ search: 'Searching papers',
69
+ paper_details: 'Reading paper details',
70
+ read_paper: 'Reading paper',
71
+ find_datasets: 'Finding paper datasets',
72
+ find_models: 'Finding paper models',
73
+ find_collections: 'Finding paper collections',
74
+ find_all_resources: 'Finding paper resources',
75
+ };
76
+ const base = (op && opLabels[op]) || 'Searching papers';
77
+ return detail ? `${base}: ${detail}` : base;
78
+ }
79
+ if (toolName === 'find_hf_api') {
80
+ const d = (args.query) || (args.tag);
81
+ return d ? `Finding API: ${d}` : 'Finding API endpoints';
82
+ }
83
+ if (toolName === 'hf_repo_files') {
84
+ const d = (args.repo_id) || (args.repo);
85
+ return d ? `Reading ${d} files` : 'Reading repo files';
86
+ }
87
+ return 'Researching';
88
+ }
89
+
90
  function statusLabel(status: ActivityStatus): string {
91
  switch (status.type) {
92
  case 'thinking': return 'Thinking';
93
  case 'streaming': return 'Writing';
94
  case 'tool': {
95
+ if (status.toolName === 'research' && status.description) {
96
+ return formatResearchStatus(status.description);
97
+ }
98
  const base = status.description || TOOL_LABELS[status.toolName] || `Running ${status.toolName}`;
99
  if (status.toolName === 'bash' && status.description && /install/i.test(status.description)) {
100
  return `${base} — this can take a few minutes, sit tight`;
frontend/src/components/Chat/ToolCallGroup.tsx CHANGED
@@ -35,37 +35,142 @@ interface ToolCallGroupProps {
35
  // Research sub-steps (inline under the research tool row)
36
  // ---------------------------------------------------------------------------
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  /** Pretty labels for research sub-agent tool calls */
39
- function formatResearchStep(step: string): { icon: string; label: string } {
40
- if (step === 'Starting research sub-agent...') return { icon: '🔍', label: 'Starting research' };
41
- if (step === 'Research complete.') return { icon: '✓', label: 'Research complete' };
42
- if (step.startsWith('github_find_examples')) return { icon: '📂', label: step.replace('github_find_examples', 'Finding examples') };
 
 
 
 
 
43
  if (step.startsWith('github_read_file')) {
44
- const path = step.match(/\(([^)]+)\)/)?.[1] || '';
45
  const filename = path.split('/').pop() || path;
46
- return { icon: '📄', label: `Reading ${filename}` };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
- if (step.startsWith('explore_hf_docs')) return { icon: '📚', label: step.replace('explore_hf_docs', 'Exploring docs') };
49
- if (step.startsWith('fetch_hf_docs')) return { icon: '📖', label: step.replace('fetch_hf_docs', 'Fetching docs') };
50
- if (step.startsWith('hf_inspect_dataset')) return { icon: '🗃️', label: step.replace('hf_inspect_dataset', 'Inspecting dataset') };
51
- if (step.startsWith('hf_papers')) return { icon: '📑', label: 'Searching papers' };
52
- if (step.startsWith('find_hf_api')) return { icon: '🔌', label: 'Finding API endpoints' };
53
- if (step.startsWith('hf_repo_files')) return { icon: '📁', label: 'Reading repo files' };
54
- return { icon: '→', label: step };
55
  }
56
 
 
57
  function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boolean }) {
58
- // Filter out the "Starting..." and "complete" meta-steps for the list
59
- const toolSteps = steps.filter(
60
- s => s !== 'Starting research sub-agent...' && s !== 'Research complete.',
61
- );
62
- if (toolSteps.length === 0) return null;
63
 
64
  return (
65
  <Box sx={{ pl: 4.5, pr: 1.5, pb: 1, pt: 0.25 }}>
66
- {toolSteps.map((step, i) => {
67
- const { icon, label } = formatResearchStep(step);
68
- const isLast = i === toolSteps.length - 1;
69
  return (
70
  <Stack
71
  key={i}
@@ -74,17 +179,16 @@ function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boole
74
  spacing={0.75}
75
  sx={{ py: 0.2 }}
76
  >
77
- <Typography sx={{ fontSize: '0.65rem', lineHeight: 1, width: 14, textAlign: 'center', flexShrink: 0 }}>
78
- {isLast && isRunning ? '' : icon}
79
- </Typography>
80
- {isLast && isRunning && (
81
  <CircularProgress size={10} thickness={5} sx={{ color: 'var(--accent-yellow)', flexShrink: 0 }} />
 
 
82
  )}
83
  <Typography
84
  sx={{
85
  fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
86
  fontSize: '0.68rem',
87
- color: isLast && isRunning ? 'var(--text)' : 'var(--muted-text)',
88
  overflow: 'hidden',
89
  textOverflow: 'ellipsis',
90
  whiteSpace: 'nowrap',
@@ -402,6 +506,12 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
402
  const activeId = s.activeSessionId;
403
  return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined;
404
  }) ?? EMPTY_STEPS;
 
 
 
 
 
 
405
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
406
 
407
  // ── Batch approval state ──────────────────────────────────────────
@@ -680,19 +790,21 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
680
  const clickable =
681
  state === 'output-available' ||
682
  state === 'output-error' ||
683
- !!tool.input;
 
684
  const localDecision = decisions[tool.toolCallId];
685
 
686
  const cancelled = isCancelledTool(tool);
687
  const currentlyHasError = state === 'output-error';
688
  const persistedError = getToolError(tool.toolCallId);
689
-
690
- // Use persisted error OR current error (persisting happens in useEffect)
691
  const hasError = persistedError || currentlyHasError;
692
 
693
- const displayState = isPending && localDecision
694
- ? (localDecision.approved ? 'input-available' : 'output-denied')
695
- : state;
 
 
 
696
  const label = cancelled ? 'cancelled'
697
  : hasError ? 'error'
698
  : statusLabel(displayState as ToolPartState);
@@ -762,25 +874,37 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
762
  </Typography>
763
 
764
  {/* Status chip (non hf_jobs, or hf_jobs without final status) */}
765
- {label && !(tool.toolName === 'hf_jobs' && jobMeta.jobStatus) && (
766
- <Chip
767
- label={label}
768
- size="small"
769
- sx={{
770
- height: 20,
771
- fontSize: '0.65rem',
772
- fontWeight: 600,
773
- bgcolor: cancelled ? 'rgba(255,255,255,0.05)'
774
- : hasError ? 'rgba(224,90,79,0.12)'
775
- : displayState === 'output-denied' ? 'rgba(255,255,255,0.05)'
776
- : 'var(--accent-yellow-weak)',
777
- color: cancelled ? 'var(--muted-text)'
778
- : hasError ? 'var(--accent-red)'
779
- : statusColor(displayState as ToolPartState),
780
- letterSpacing: '0.03em',
781
- }}
782
- />
783
- )}
 
 
 
 
 
 
 
 
 
 
 
 
784
 
785
  {/* HF Jobs: final status chip from job metadata */}
786
  {tool.toolName === 'hf_jobs' && jobMeta.jobStatus && (
@@ -834,11 +958,11 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
834
  )}
835
  </Stack>
836
 
837
- {/* Research sub-agent steps */}
838
- {tool.toolName === 'research' && researchSteps.length > 0 && (
839
  <ResearchSteps
840
  steps={researchSteps}
841
- isRunning={state === 'input-streaming' || state === 'input-available'}
842
  />
843
  )}
844
 
 
35
  // Research sub-steps (inline under the research tool row)
36
  // ---------------------------------------------------------------------------
37
 
38
+ /** Hook that ticks every second while startedAt is set, returning elapsed seconds. */
39
+ function useElapsed(startedAt: number | null): number | null {
40
+ const [elapsed, setElapsed] = useState<number | null>(null);
41
+ useEffect(() => {
42
+ if (startedAt === null) { setElapsed(null); return; }
43
+ setElapsed(Math.round((Date.now() - startedAt) / 1000));
44
+ const id = setInterval(() => setElapsed(Math.round((Date.now() - startedAt) / 1000)), 1000);
45
+ return () => clearInterval(id);
46
+ }, [startedAt]);
47
+ return elapsed;
48
+ }
49
+
50
+ /** Format token count like the CLI: "12.4k" or "800". */
51
+ function formatTokens(tokens: number): string {
52
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
53
+ }
54
+
55
+ /** Format elapsed seconds like the CLI: "18s" or "2m 5s". */
56
+ function formatElapsed(seconds: number): string {
57
+ if (seconds < 60) return `${seconds}s`;
58
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
59
+ }
60
+
61
+ /** Build the research stats chip label. */
62
+ function researchChipLabel(
63
+ stats: { toolCount: number; tokenCount: number; startedAt: number | null; finalElapsed: number | null },
64
+ liveElapsed: number | null,
65
+ ): string | null {
66
+ const elapsed = stats.finalElapsed ?? liveElapsed;
67
+ if (elapsed === null && stats.toolCount === 0) return null;
68
+ const parts: string[] = [];
69
+ if (stats.startedAt !== null) parts.push('running');
70
+ if (stats.toolCount > 0) parts.push(`${stats.toolCount} tools`);
71
+ if (stats.tokenCount > 0) parts.push(`${formatTokens(stats.tokenCount)} tokens`);
72
+ if (elapsed !== null) parts.push(formatElapsed(elapsed));
73
+ return parts.join(' \u00B7 ');
74
+ }
75
+
76
+ /** Parse JSON args from a step string like "tool_name {json}" (may be truncated at 80 chars). */
77
+ function parseStepArgs(step: string): Record<string, string> {
78
+ const jsonStart = step.indexOf('{');
79
+ if (jsonStart < 0) return {};
80
+ const jsonStr = step.slice(jsonStart);
81
+ try {
82
+ const parsed = JSON.parse(jsonStr);
83
+ const result: Record<string, string> = {};
84
+ for (const [k, v] of Object.entries(parsed)) {
85
+ if (typeof v === 'string') result[k] = v;
86
+ }
87
+ return result;
88
+ } catch {
89
+ // JSON likely truncated — extract key-value pairs via regex
90
+ const result: Record<string, string> = {};
91
+ for (const m of jsonStr.matchAll(/"(\w+)":\s*"([^"]*)"/g)) {
92
+ result[m[1]] = m[2];
93
+ }
94
+ return result;
95
+ }
96
+ }
97
+
98
  /** Pretty labels for research sub-agent tool calls */
99
+ function formatResearchStep(raw: string): { label: string } {
100
+ // Backend sends logs like "▸ tool_name {args}" strip the prefix
101
+ const step = raw.replace(/^▸\s*/, '');
102
+ const args = parseStepArgs(step);
103
+
104
+ if (step.startsWith('github_find_examples')) {
105
+ const detail = (args.keyword) || (args.repo);
106
+ return { label: detail ? `Finding examples: ${detail}` : 'Finding examples' };
107
+ }
108
  if (step.startsWith('github_read_file')) {
109
+ const path = (args.path) || '';
110
  const filename = path.split('/').pop() || path;
111
+ return { label: filename ? `Reading ${filename}` : 'Reading file' };
112
+ }
113
+ if (step.startsWith('explore_hf_docs')) {
114
+ const endpoint = (args.endpoint) || (args.query);
115
+ return { label: endpoint ? `Exploring docs: ${endpoint}` : 'Exploring docs' };
116
+ }
117
+ if (step.startsWith('fetch_hf_docs')) {
118
+ const url = (args.url) || '';
119
+ const page = url.split('/').pop()?.replace(/\.md$/, '');
120
+ return { label: page ? `Reading docs: ${page}` : 'Fetching docs' };
121
+ }
122
+ if (step.startsWith('hf_inspect_dataset')) {
123
+ const dataset = (args.dataset);
124
+ return { label: dataset ? `Inspecting dataset: ${dataset}` : 'Inspecting dataset' };
125
+ }
126
+ if (step.startsWith('hf_papers')) {
127
+ const op = args.operation as string;
128
+ const detail = (args.query) || (args.arxiv_id);
129
+ const opLabels: Record<string, string> = {
130
+ trending: 'Browsing trending papers',
131
+ search: 'Searching papers',
132
+ paper_details: 'Reading paper details',
133
+ read_paper: 'Reading paper',
134
+ find_datasets: 'Finding paper datasets',
135
+ find_models: 'Finding paper models',
136
+ find_collections: 'Finding paper collections',
137
+ find_all_resources: 'Finding paper resources',
138
+ };
139
+ const base = (op && opLabels[op]) || 'Searching papers';
140
+ return { label: detail ? `${base}: ${detail}` : base };
141
+ }
142
+ if (step.startsWith('find_hf_api')) {
143
+ const detail = (args.query) || (args.tag);
144
+ return { label: detail ? `Finding API: ${detail}` : 'Finding API endpoints' };
145
+ }
146
+ if (step.startsWith('hf_repo_files')) {
147
+ const repo = (args.repo_id) || (args.repo);
148
+ return { label: repo ? `Reading ${repo} files` : 'Reading repo files' };
149
+ }
150
+ if (step.startsWith('read')) {
151
+ const path = (args.path) || '';
152
+ const filename = path.split('/').pop();
153
+ return { label: filename ? `Reading ${filename}` : 'Reading file' };
154
+ }
155
+ if (step.startsWith('bash')) {
156
+ const cmd = args.command as string;
157
+ const short = cmd && cmd.length > 40 ? cmd.slice(0, 40) + '...' : cmd;
158
+ return { label: short ? `Running: ${short}` : 'Running command' };
159
  }
160
+ return { label: step.replace(/^▸\s*/, '') };
 
 
 
 
 
 
161
  }
162
 
163
+ /** Rolling 2-line display of research sub-tool calls — hidden when complete. */
164
  function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boolean }) {
165
+ if (!isRunning) return null;
166
+ const visible = steps.slice(-2);
167
+ if (visible.length === 0) return null;
 
 
168
 
169
  return (
170
  <Box sx={{ pl: 4.5, pr: 1.5, pb: 1, pt: 0.25 }}>
171
+ {visible.map((step, i) => {
172
+ const { label } = formatResearchStep(step);
173
+ const isLast = i === visible.length - 1;
174
  return (
175
  <Stack
176
  key={i}
 
179
  spacing={0.75}
180
  sx={{ py: 0.2 }}
181
  >
182
+ {isLast ? (
 
 
 
183
  <CircularProgress size={10} thickness={5} sx={{ color: 'var(--accent-yellow)', flexShrink: 0 }} />
184
+ ) : (
185
+ <CheckCircleOutlineIcon sx={{ fontSize: 12, color: 'var(--muted-text)', flexShrink: 0 }} />
186
  )}
187
  <Typography
188
  sx={{
189
  fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
190
  fontSize: '0.68rem',
191
+ color: isLast ? 'var(--text)' : 'var(--muted-text)',
192
  overflow: 'hidden',
193
  textOverflow: 'ellipsis',
194
  whiteSpace: 'nowrap',
 
506
  const activeId = s.activeSessionId;
507
  return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined;
508
  }) ?? EMPTY_STEPS;
509
+ const researchStats = useAgentStore(s => {
510
+ const activeId = s.activeSessionId;
511
+ return activeId ? s.sessionStates[activeId]?.researchStats : undefined;
512
+ }) ?? { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null };
513
+ const liveElapsed = useElapsed(researchStats.startedAt);
514
+ const isProcessing = useAgentStore(s => s.isProcessing);
515
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
516
 
517
  // ── Batch approval state ──────────────────────────────────────────
 
790
  const clickable =
791
  state === 'output-available' ||
792
  state === 'output-error' ||
793
+ !!tool.input ||
794
+ (!isProcessing && (state === 'input-available' || state === 'input-streaming'));
795
  const localDecision = decisions[tool.toolCallId];
796
 
797
  const cancelled = isCancelledTool(tool);
798
  const currentlyHasError = state === 'output-error';
799
  const persistedError = getToolError(tool.toolCallId);
 
 
800
  const hasError = persistedError || currentlyHasError;
801
 
802
+ // Stale in-progress tools after page reload: treat as completed
803
+ const stale = !isProcessing && (state === 'input-available' || state === 'input-streaming');
804
+ const displayState = stale ? 'output-available'
805
+ : isPending && localDecision
806
+ ? (localDecision.approved ? 'input-available' : 'output-denied')
807
+ : state;
808
  const label = cancelled ? 'cancelled'
809
  : hasError ? 'error'
810
  : statusLabel(displayState as ToolPartState);
 
874
  </Typography>
875
 
876
  {/* Status chip (non hf_jobs, or hf_jobs without final status) */}
877
+ {(() => {
878
+ // Research tool: override chip label with live stats (but not if cancelled/done)
879
+ const researchDone = cancelled || state === 'output-available' || state === 'output-error' || state === 'output-denied';
880
+ const researchLabel = tool.toolName === 'research' && !researchDone
881
+ ? researchChipLabel(researchStats, liveElapsed)
882
+ : (tool.toolName === 'research' && researchDone && researchStats.finalElapsed !== null)
883
+ ? researchChipLabel({ ...researchStats, startedAt: null }, null)
884
+ : null;
885
+ const chipLabel = researchLabel || label;
886
+ if (!chipLabel || (tool.toolName === 'hf_jobs' && jobMeta.jobStatus)) return null;
887
+ return (
888
+ <Chip
889
+ label={chipLabel}
890
+ size="small"
891
+ sx={{
892
+ height: 20,
893
+ fontSize: '0.65rem',
894
+ fontWeight: 600,
895
+ bgcolor: cancelled ? 'rgba(255,255,255,0.05)'
896
+ : hasError ? 'rgba(224,90,79,0.12)'
897
+ : displayState === 'output-denied' ? 'rgba(255,255,255,0.05)'
898
+ : (researchLabel && displayState === 'output-available') ? 'rgba(47,204,113,0.12)'
899
+ : 'var(--accent-yellow-weak)',
900
+ color: cancelled ? 'var(--muted-text)'
901
+ : hasError ? 'var(--accent-red)'
902
+ : statusColor(displayState as ToolPartState),
903
+ letterSpacing: '0.03em',
904
+ }}
905
+ />
906
+ );
907
+ })()}
908
 
909
  {/* HF Jobs: final status chip from job metadata */}
910
  {tool.toolName === 'hf_jobs' && jobMeta.jobStatus && (
 
958
  )}
959
  </Stack>
960
 
961
+ {/* Research sub-agent rolling steps (visible only while running) */}
962
+ {tool.toolName === 'research' && !cancelled && state !== 'output-available' && state !== 'output-error' && state !== 'output-denied' && (
963
  <ResearchSteps
964
  steps={researchSteps}
965
+ isRunning={researchStats.startedAt !== null}
966
  />
967
  )}
968
 
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -86,14 +86,39 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
86
  }
87
  },
88
  onToolLog: (tool: string, log: string) => {
89
- // Research sub-agent: accumulate steps + update activity status
90
  if (tool === 'research') {
91
  const sessState = useAgentStore.getState().getSessionState(sessionId);
92
- const steps = [...sessState.researchSteps, log];
93
- updateSession(sessionId, {
94
- researchSteps: steps,
95
- activityStatus: { type: 'tool', toolName: 'research', description: log },
96
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  return;
98
  }
99
 
@@ -235,8 +260,11 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
235
  const updates: Partial<import('@/store/agentStore').PerSessionState> = {
236
  activityStatus: { type: 'tool', toolName, description },
237
  };
238
- // Clear research steps when a new research call starts
239
- if (toolName === 'research') updates.researchSteps = [];
 
 
 
240
  updateSession(sessionId, updates);
241
  },
242
  onInterrupted: () => { /* no-op — handled by stop() caller */ },
 
86
  }
87
  },
88
  onToolLog: (tool: string, log: string) => {
89
+ // Research sub-agent: parse stats vs step logs
90
  if (tool === 'research') {
91
  const sessState = useAgentStore.getState().getSessionState(sessionId);
92
+ const stats = { ...sessState.researchStats };
93
+
94
+ if (log === 'Starting research sub-agent...') {
95
+ updateSession(sessionId, {
96
+ researchSteps: [],
97
+ researchStats: { toolCount: 0, tokenCount: 0, startedAt: Date.now(), finalElapsed: null },
98
+ activityStatus: { type: 'tool', toolName: 'research', description: log },
99
+ });
100
+ } else if (log.startsWith('tokens:')) {
101
+ stats.tokenCount = parseInt(log.slice(7), 10);
102
+ updateSession(sessionId, { researchStats: stats });
103
+ } else if (log.startsWith('tools:')) {
104
+ stats.toolCount = parseInt(log.slice(6), 10);
105
+ updateSession(sessionId, { researchStats: stats });
106
+ } else if (log === 'Research complete.') {
107
+ const elapsed = stats.startedAt
108
+ ? Math.round((Date.now() - stats.startedAt) / 1000)
109
+ : null;
110
+ updateSession(sessionId, {
111
+ researchStats: { ...stats, startedAt: null, finalElapsed: elapsed },
112
+ activityStatus: { type: 'tool', toolName: 'research', description: log },
113
+ });
114
+ } else {
115
+ // Regular tool call step — append
116
+ const steps = [...sessState.researchSteps, log];
117
+ updateSession(sessionId, {
118
+ researchSteps: steps,
119
+ activityStatus: { type: 'tool', toolName: 'research', description: log },
120
+ });
121
+ }
122
  return;
123
  }
124
 
 
260
  const updates: Partial<import('@/store/agentStore').PerSessionState> = {
261
  activityStatus: { type: 'tool', toolName, description },
262
  };
263
+ // Clear research steps + stats when a new research call starts
264
+ if (toolName === 'research') {
265
+ updates.researchSteps = [];
266
+ updates.researchStats = { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null };
267
+ }
268
  updateSession(sessionId, updates);
269
  },
270
  onInterrupted: () => { /* no-op — handled by stop() caller */ },
frontend/src/store/agentStore.ts CHANGED
@@ -63,6 +63,8 @@ export interface PerSessionState {
63
  plan: PlanItem[];
64
  /** Steps completed by the research sub-agent (tool_log events). */
65
  researchSteps: string[];
 
 
66
  }
67
 
68
  const defaultSessionState: PerSessionState = {
@@ -73,6 +75,7 @@ const defaultSessionState: PerSessionState = {
73
  panelEditable: false,
74
  plan: [],
75
  researchSteps: [],
 
76
  };
77
 
78
  interface AgentStore {
@@ -271,6 +274,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
271
  panelEditable: state.panelEditable,
272
  plan: state.plan,
273
  researchSteps: state.sessionStates[state.activeSessionId]?.researchSteps ?? [],
 
274
  };
275
  }
276
 
 
63
  plan: PlanItem[];
64
  /** Steps completed by the research sub-agent (tool_log events). */
65
  researchSteps: string[];
66
+ /** Live stats from the research sub-agent. */
67
+ researchStats: { toolCount: number; tokenCount: number; startedAt: number | null; finalElapsed: number | null };
68
  }
69
 
70
  const defaultSessionState: PerSessionState = {
 
75
  panelEditable: false,
76
  plan: [],
77
  researchSteps: [],
78
+ researchStats: { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null },
79
  };
80
 
81
  interface AgentStore {
 
274
  panelEditable: state.panelEditable,
275
  plan: state.plan,
276
  researchSteps: state.sessionStates[state.activeSessionId]?.researchSteps ?? [],
277
+ researchStats: state.sessionStates[state.activeSessionId]?.researchStats ?? defaultSessionState.researchStats,
278
  };
279
  }
280