akseljoonas HF Staff commited on
Commit
ccfb504
·
1 Parent(s): 84d6321

Persist research subtool steps across page refresh

Browse files

Save research steps and stats to localStorage on every update,
restore atomically with isProcessing on mount so the rolling
display survives page refresh during active research.

frontend/src/components/Chat/ToolCallGroup.tsx CHANGED
@@ -10,6 +10,7 @@ import BlockIcon from '@mui/icons-material/Block';
10
  import { useAgentStore } from '@/store/agentStore';
11
  import { useLayoutStore } from '@/store/layoutStore';
12
  import { logger } from '@/utils/logger';
 
13
  import type { UIMessage } from 'ai';
14
 
15
  // ---------------------------------------------------------------------------
@@ -163,7 +164,7 @@ function formatResearchStep(raw: string): { label: string } {
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(-4);
167
  if (visible.length === 0) return null;
168
 
169
  return (
 
10
  import { useAgentStore } from '@/store/agentStore';
11
  import { useLayoutStore } from '@/store/layoutStore';
12
  import { logger } from '@/utils/logger';
13
+ import { RESEARCH_MAX_STEPS } from '@/lib/research-store';
14
  import type { UIMessage } from 'ai';
15
 
16
  // ---------------------------------------------------------------------------
 
164
  /** Rolling 2-line display of research sub-tool calls — hidden when complete. */
165
  function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boolean }) {
166
  if (!isRunning) return null;
167
+ const visible = steps.slice(-RESEARCH_MAX_STEPS);
168
  if (visible.length === 0) return null;
169
 
170
  return (
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -12,6 +12,7 @@ import { useChat } from '@ai-sdk/react';
12
  import { type UIMessage, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
13
  import { SSEChatTransport, type SideChannelCallbacks } from '@/lib/sse-chat-transport';
14
  import { loadMessages, saveMessages } from '@/lib/chat-message-store';
 
15
  import { llmMessagesToUIMessages } from '@/lib/convert-llm-messages';
16
  import { apiFetch } from '@/utils/api';
17
  import { useAgentStore } from '@/store/agentStore';
@@ -92,32 +93,39 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
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
  }
@@ -372,9 +380,25 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
372
  // results make tools look "done" even when the agent is still
373
  // mid-turn and about to call more tools.
374
  if (backendIsProcessing) {
375
- updateSession(sessionId, { isProcessing: true, activityStatus: { type: 'thinking' } });
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  } else if (pendingIds && pendingIds.size > 0) {
377
  updateSession(sessionId, { activityStatus: { type: 'waiting-approval' } });
 
 
 
378
  }
379
  } catch {
380
  /* backend unreachable -- localStorage fallback is fine */
 
12
  import { type UIMessage, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
13
  import { SSEChatTransport, type SideChannelCallbacks } from '@/lib/sse-chat-transport';
14
  import { loadMessages, saveMessages } from '@/lib/chat-message-store';
15
+ import { saveResearch, loadResearch, clearResearch, RESEARCH_MAX_STEPS } from '@/lib/research-store';
16
  import { llmMessagesToUIMessages } from '@/lib/convert-llm-messages';
17
  import { apiFetch } from '@/utils/api';
18
  import { useAgentStore } from '@/store/agentStore';
 
93
  const stats = { ...sessState.researchStats };
94
 
95
  if (log === 'Starting research sub-agent...') {
96
+ const newStats = { toolCount: 0, tokenCount: 0, startedAt: Date.now(), finalElapsed: null };
97
  updateSession(sessionId, {
98
  researchSteps: [],
99
+ researchStats: newStats,
100
  activityStatus: { type: 'tool', toolName: 'research', description: log },
101
  });
102
+ saveResearch(sessionId, [], newStats);
103
  } else if (log.startsWith('tokens:')) {
104
  stats.tokenCount = parseInt(log.slice(7), 10);
105
  updateSession(sessionId, { researchStats: stats });
106
+ saveResearch(sessionId, sessState.researchSteps, stats);
107
  } else if (log.startsWith('tools:')) {
108
  stats.toolCount = parseInt(log.slice(6), 10);
109
  updateSession(sessionId, { researchStats: stats });
110
+ saveResearch(sessionId, sessState.researchSteps, stats);
111
  } else if (log === 'Research complete.') {
112
  const elapsed = stats.startedAt
113
  ? Math.round((Date.now() - stats.startedAt) / 1000)
114
  : null;
115
+ const doneStats = { ...stats, startedAt: null, finalElapsed: elapsed };
116
  updateSession(sessionId, {
117
+ researchStats: doneStats,
118
  activityStatus: { type: 'tool', toolName: 'research', description: log },
119
  });
120
+ clearResearch(sessionId);
121
  } else {
122
+ // Regular tool call step — append (trim to max)
123
+ const steps = [...sessState.researchSteps, log].slice(-RESEARCH_MAX_STEPS);
124
  updateSession(sessionId, {
125
  researchSteps: steps,
126
  activityStatus: { type: 'tool', toolName: 'research', description: log },
127
  });
128
+ saveResearch(sessionId, steps, stats);
129
  }
130
  return;
131
  }
 
380
  // results make tools look "done" even when the agent is still
381
  // mid-turn and about to call more tools.
382
  if (backendIsProcessing) {
383
+ // Restore research sub-agent state alongside isProcessing in one
384
+ // atomic update so the UI never sees isProcessing=false with stale
385
+ // tool states (which would coerce them to 'output-available').
386
+ const savedResearch = loadResearch(sessionId);
387
+ updateSession(sessionId, {
388
+ isProcessing: true,
389
+ activityStatus: savedResearch?.stats.startedAt
390
+ ? { type: 'tool', toolName: 'research', description: 'Resuming research...' }
391
+ : { type: 'thinking' },
392
+ ...(savedResearch && {
393
+ researchSteps: savedResearch.steps,
394
+ researchStats: savedResearch.stats,
395
+ }),
396
+ });
397
  } else if (pendingIds && pendingIds.size > 0) {
398
  updateSession(sessionId, { activityStatus: { type: 'waiting-approval' } });
399
+ clearResearch(sessionId);
400
+ } else {
401
+ clearResearch(sessionId);
402
  }
403
  } catch {
404
  /* backend unreachable -- localStorage fallback is fine */
frontend/src/lib/research-store.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Persist research sub-agent state (steps + stats) per session.
3
+ * Survives page refresh so the rolling display isn't lost mid-research.
4
+ */
5
+ import type { PerSessionState } from '@/store/agentStore';
6
+
7
+ /** Max steps to keep in storage and display. Single source of truth. */
8
+ export const RESEARCH_MAX_STEPS = 40;
9
+
10
+ const STORAGE_KEY = 'hf-agent-research';
11
+
12
+ type ResearchState = {
13
+ steps: string[];
14
+ stats: PerSessionState['researchStats'];
15
+ };
16
+
17
+ type ResearchMap = Record<string, ResearchState>;
18
+
19
+ function readAll(): ResearchMap {
20
+ try {
21
+ const raw = localStorage.getItem(STORAGE_KEY);
22
+ return raw ? JSON.parse(raw) : {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ function writeAll(map: ResearchMap): void {
29
+ try {
30
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
31
+ } catch { /* quota exceeded — ignore */ }
32
+ }
33
+
34
+ export function saveResearch(
35
+ sessionId: string,
36
+ steps: string[],
37
+ stats: PerSessionState['researchStats'],
38
+ ): void {
39
+ const map = readAll();
40
+ map[sessionId] = {
41
+ steps: steps.slice(-RESEARCH_MAX_STEPS),
42
+ stats,
43
+ };
44
+ writeAll(map);
45
+ }
46
+
47
+ export function loadResearch(sessionId: string): ResearchState | null {
48
+ const map = readAll();
49
+ return map[sessionId] ?? null;
50
+ }
51
+
52
+ export function clearResearch(sessionId: string): void {
53
+ const map = readAll();
54
+ delete map[sessionId];
55
+ writeAll(map);
56
+ }