tfrere HF Staff Cursor commited on
Commit
7eefa0d
·
1 Parent(s): 3dd281d

feat: migrate frontend to Vercel AI SDK with robust WebSocket transport

Browse files

Replace custom useAgentWebSocket + agentStore message management with
Vercel AI SDK's useChat hook and a custom WebSocketChatTransport.

Key changes:
- Add ws-chat-transport.ts: bridges backend WS events to UIMessageChunk
- Add useAgentChat.ts: central hook wiring useChat + transport + side-channel
- Add chat-message-store.ts: localStorage persistence for messages
- Restore message persistence and timestamps after SDK migration
- Add stop button (abort signal wiring + backend interrupt)
- Fix stream race conditions (generation counter, awaitingProcessing flag)
- Fix WS cycling on rapid session changes (connect timeout guard)
- Robust undo: syncs useChat state + localStorage on undo_complete
- Replace inline session-expired banner with debounced Snackbar toast
- Refactor ToolCallGroup to use AI SDK DynamicToolPart types
- Simplify agentStore (remove message/trace management, keep UI state)

Co-authored-by: Cursor <cursoragent@cursor.com>

agent/core/agent_loop.py CHANGED
@@ -115,6 +115,42 @@ def _needs_approval(
115
  class Handlers:
116
  """Handler functions for each operation type"""
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  @staticmethod
119
  @observe(name="run_agent")
120
  async def run_agent(
@@ -130,6 +166,11 @@ class Handlers:
130
 
131
  Laminar.set_trace_session_id(session_id=session.session_id)
132
 
 
 
 
 
 
133
  # Add user message to history only if there's actual content
134
  if text:
135
  user_msg = Message(role="user", content=text)
@@ -553,14 +594,19 @@ class Handlers:
553
 
554
  # Execute all approved tools concurrently
555
  async def execute_tool(tc, tool_name, tool_args):
556
- """Execute a single tool and return its result"""
 
 
 
 
 
557
  await session.send_event(
558
  Event(
559
- event_type="tool_call",
560
  data={
561
- "tool": tool_name,
562
- "arguments": tool_args,
563
  "tool_call_id": tc.id,
 
 
564
  },
565
  )
566
  )
 
115
  class Handlers:
116
  """Handler functions for each operation type"""
117
 
118
+ @staticmethod
119
+ async def _abandon_pending_approval(session: Session) -> None:
120
+ """Cancel pending approval tools when the user continues the conversation.
121
+
122
+ Injects rejection tool-result messages into the LLM context (so the
123
+ history stays valid) and notifies the frontend that those tools were
124
+ abandoned.
125
+ """
126
+ tool_calls = session.pending_approval.get("tool_calls", [])
127
+ for tc in tool_calls:
128
+ tool_name = tc.function.name
129
+ abandon_msg = "Task abandoned — user continued the conversation without approving."
130
+
131
+ # Keep LLM context valid: every tool_call needs a tool result
132
+ tool_msg = Message(
133
+ role="tool",
134
+ content=abandon_msg,
135
+ tool_call_id=tc.id,
136
+ name=tool_name,
137
+ )
138
+ session.context_manager.add_message(tool_msg)
139
+
140
+ await session.send_event(
141
+ Event(
142
+ event_type="tool_state_change",
143
+ data={
144
+ "tool_call_id": tc.id,
145
+ "tool": tool_name,
146
+ "state": "abandoned",
147
+ },
148
+ )
149
+ )
150
+
151
+ session.pending_approval = None
152
+ logger.info("Abandoned %d pending approval tool(s)", len(tool_calls))
153
+
154
  @staticmethod
155
  @observe(name="run_agent")
156
  async def run_agent(
 
166
 
167
  Laminar.set_trace_session_id(session_id=session.session_id)
168
 
169
+ # If there's a pending approval and the user sent a new message,
170
+ # abandon the pending tools so the LLM context stays valid.
171
+ if text and session.pending_approval:
172
+ await Handlers._abandon_pending_approval(session)
173
+
174
  # Add user message to history only if there's actual content
175
  if text:
176
  user_msg = Message(role="user", content=text)
 
594
 
595
  # Execute all approved tools concurrently
596
  async def execute_tool(tc, tool_name, tool_args):
597
+ """Execute a single tool and return its result.
598
+
599
+ The TraceLog already exists on the frontend (created by
600
+ approval_required), so we send tool_state_change instead of
601
+ tool_call to avoid creating a duplicate.
602
+ """
603
  await session.send_event(
604
  Event(
605
+ event_type="tool_state_change",
606
  data={
 
 
607
  "tool_call_id": tc.id,
608
+ "tool": tool_name,
609
+ "state": "running",
610
  },
611
  )
612
  )
configs/main_agent_config.json CHANGED
@@ -3,7 +3,7 @@
3
  "save_sessions": true,
4
  "session_dataset_repo": "akseljoonas/hf-agent-sessions",
5
  "yolo_mode": false,
6
- "confirm_cpu_jobs": false,
7
  "auto_file_upload": true,
8
  "mcpServers": {
9
  "hf-mcp-server": {
 
3
  "save_sessions": true,
4
  "session_dataset_repo": "akseljoonas/hf-agent-sessions",
5
  "yolo_mode": false,
6
+ "confirm_cpu_jobs": true,
7
  "auto_file_upload": true,
8
  "mcpServers": {
9
  "hf-mcp-server": {
frontend/package-lock.json CHANGED
@@ -8,10 +8,12 @@
8
  "name": "hf-agent-frontend",
9
  "version": "1.0.0",
10
  "dependencies": {
 
11
  "@emotion/react": "^11.13.0",
12
  "@emotion/styled": "^11.13.0",
13
  "@mui/icons-material": "^6.1.0",
14
  "@mui/material": "^6.1.0",
 
15
  "react": "^18.3.1",
16
  "react-dom": "^18.3.1",
17
  "react-markdown": "^9.0.1",
@@ -34,6 +36,70 @@
34
  "vite": "^5.4.10"
35
  }
36
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  "node_modules/@babel/code-frame": {
38
  "version": "7.28.6",
39
  "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -1348,6 +1414,15 @@
1348
  }
1349
  }
1350
  },
 
 
 
 
 
 
 
 
 
1351
  "node_modules/@popperjs/core": {
1352
  "version": "2.11.8",
1353
  "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1715,6 +1790,12 @@
1715
  "win32"
1716
  ]
1717
  },
 
 
 
 
 
 
1718
  "node_modules/@types/babel__core": {
1719
  "version": "7.20.5",
1720
  "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2155,6 +2236,15 @@
2155
  "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
2156
  "license": "ISC"
2157
  },
 
 
 
 
 
 
 
 
 
2158
  "node_modules/@vitejs/plugin-react": {
2159
  "version": "4.7.0",
2160
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2200,6 +2290,24 @@
2200
  "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
2201
  }
2202
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2203
  "node_modules/ajv": {
2204
  "version": "6.12.6",
2205
  "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2848,6 +2956,15 @@
2848
  "node": ">=0.10.0"
2849
  }
2850
  },
 
 
 
 
 
 
 
 
 
2851
  "node_modules/extend": {
2852
  "version": "3.0.2",
2853
  "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -3356,6 +3473,12 @@
3356
  "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
3357
  "license": "MIT"
3358
  },
 
 
 
 
 
 
3359
  "node_modules/json-schema-traverse": {
3360
  "version": "0.4.1",
3361
  "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -5052,6 +5175,31 @@
5052
  "url": "https://github.com/sponsors/ljharb"
5053
  }
5054
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5055
  "node_modules/tinyglobby": {
5056
  "version": "0.2.15",
5057
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -5282,6 +5430,16 @@
5282
  "punycode": "^2.1.0"
5283
  }
5284
  },
 
 
 
 
 
 
 
 
 
 
5285
  "node_modules/vfile": {
5286
  "version": "6.0.3",
5287
  "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -5426,6 +5584,16 @@
5426
  "url": "https://github.com/sponsors/sindresorhus"
5427
  }
5428
  },
 
 
 
 
 
 
 
 
 
 
5429
  "node_modules/zustand": {
5430
  "version": "5.0.10",
5431
  "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
 
8
  "name": "hf-agent-frontend",
9
  "version": "1.0.0",
10
  "dependencies": {
11
+ "@ai-sdk/react": "^3.0.93",
12
  "@emotion/react": "^11.13.0",
13
  "@emotion/styled": "^11.13.0",
14
  "@mui/icons-material": "^6.1.0",
15
  "@mui/material": "^6.1.0",
16
+ "ai": "^6.0.91",
17
  "react": "^18.3.1",
18
  "react-dom": "^18.3.1",
19
  "react-markdown": "^9.0.1",
 
36
  "vite": "^5.4.10"
37
  }
38
  },
39
+ "node_modules/@ai-sdk/gateway": {
40
+ "version": "3.0.50",
41
+ "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.50.tgz",
42
+ "integrity": "sha512-Jdd1a8VgbD7l7r+COj0h5SuaYRfPvOJ/AO6l0OrmTPEcI2MUQPr3C4JttfpNkcheEN+gOdy0CtZWuG17bW2fjw==",
43
+ "license": "Apache-2.0",
44
+ "dependencies": {
45
+ "@ai-sdk/provider": "3.0.8",
46
+ "@ai-sdk/provider-utils": "4.0.15",
47
+ "@vercel/oidc": "3.1.0"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "peerDependencies": {
53
+ "zod": "^3.25.76 || ^4.1.8"
54
+ }
55
+ },
56
+ "node_modules/@ai-sdk/provider": {
57
+ "version": "3.0.8",
58
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
59
+ "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
60
+ "license": "Apache-2.0",
61
+ "dependencies": {
62
+ "json-schema": "^0.4.0"
63
+ },
64
+ "engines": {
65
+ "node": ">=18"
66
+ }
67
+ },
68
+ "node_modules/@ai-sdk/provider-utils": {
69
+ "version": "4.0.15",
70
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz",
71
+ "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==",
72
+ "license": "Apache-2.0",
73
+ "dependencies": {
74
+ "@ai-sdk/provider": "3.0.8",
75
+ "@standard-schema/spec": "^1.1.0",
76
+ "eventsource-parser": "^3.0.6"
77
+ },
78
+ "engines": {
79
+ "node": ">=18"
80
+ },
81
+ "peerDependencies": {
82
+ "zod": "^3.25.76 || ^4.1.8"
83
+ }
84
+ },
85
+ "node_modules/@ai-sdk/react": {
86
+ "version": "3.0.93",
87
+ "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.93.tgz",
88
+ "integrity": "sha512-FY1HmeAfCpiAGLhIZh2QR8QFzHFZfhjMmkA9D5KC/O3eGqPeY7CwBABLkzRH+5Gkf+MfxXnEm4VF0MpmvDMjpg==",
89
+ "license": "Apache-2.0",
90
+ "dependencies": {
91
+ "@ai-sdk/provider-utils": "4.0.15",
92
+ "ai": "6.0.91",
93
+ "swr": "^2.2.5",
94
+ "throttleit": "2.1.0"
95
+ },
96
+ "engines": {
97
+ "node": ">=18"
98
+ },
99
+ "peerDependencies": {
100
+ "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
101
+ }
102
+ },
103
  "node_modules/@babel/code-frame": {
104
  "version": "7.28.6",
105
  "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
 
1414
  }
1415
  }
1416
  },
1417
+ "node_modules/@opentelemetry/api": {
1418
+ "version": "1.9.0",
1419
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
1420
+ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
1421
+ "license": "Apache-2.0",
1422
+ "engines": {
1423
+ "node": ">=8.0.0"
1424
+ }
1425
+ },
1426
  "node_modules/@popperjs/core": {
1427
  "version": "2.11.8",
1428
  "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
 
1790
  "win32"
1791
  ]
1792
  },
1793
+ "node_modules/@standard-schema/spec": {
1794
+ "version": "1.1.0",
1795
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1796
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1797
+ "license": "MIT"
1798
+ },
1799
  "node_modules/@types/babel__core": {
1800
  "version": "7.20.5",
1801
  "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
 
2236
  "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
2237
  "license": "ISC"
2238
  },
2239
+ "node_modules/@vercel/oidc": {
2240
+ "version": "3.1.0",
2241
+ "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
2242
+ "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
2243
+ "license": "Apache-2.0",
2244
+ "engines": {
2245
+ "node": ">= 20"
2246
+ }
2247
+ },
2248
  "node_modules/@vitejs/plugin-react": {
2249
  "version": "4.7.0",
2250
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
 
2290
  "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
2291
  }
2292
  },
2293
+ "node_modules/ai": {
2294
+ "version": "6.0.91",
2295
+ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.91.tgz",
2296
+ "integrity": "sha512-k1/8BusZMhYVxxLZt0BUZzm9HVDCCh117nyWfWUx5xjR2+tWisJbXgysL7EBMq2lgyHwgpA1jDR3tVjWSdWZXw==",
2297
+ "license": "Apache-2.0",
2298
+ "dependencies": {
2299
+ "@ai-sdk/gateway": "3.0.50",
2300
+ "@ai-sdk/provider": "3.0.8",
2301
+ "@ai-sdk/provider-utils": "4.0.15",
2302
+ "@opentelemetry/api": "1.9.0"
2303
+ },
2304
+ "engines": {
2305
+ "node": ">=18"
2306
+ },
2307
+ "peerDependencies": {
2308
+ "zod": "^3.25.76 || ^4.1.8"
2309
+ }
2310
+ },
2311
  "node_modules/ajv": {
2312
  "version": "6.12.6",
2313
  "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
 
2956
  "node": ">=0.10.0"
2957
  }
2958
  },
2959
+ "node_modules/eventsource-parser": {
2960
+ "version": "3.0.6",
2961
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
2962
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
2963
+ "license": "MIT",
2964
+ "engines": {
2965
+ "node": ">=18.0.0"
2966
+ }
2967
+ },
2968
  "node_modules/extend": {
2969
  "version": "3.0.2",
2970
  "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
 
3473
  "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
3474
  "license": "MIT"
3475
  },
3476
+ "node_modules/json-schema": {
3477
+ "version": "0.4.0",
3478
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
3479
+ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
3480
+ "license": "(AFL-2.1 OR BSD-3-Clause)"
3481
+ },
3482
  "node_modules/json-schema-traverse": {
3483
  "version": "0.4.1",
3484
  "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
 
5175
  "url": "https://github.com/sponsors/ljharb"
5176
  }
5177
  },
5178
+ "node_modules/swr": {
5179
+ "version": "2.4.0",
5180
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.0.tgz",
5181
+ "integrity": "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==",
5182
+ "license": "MIT",
5183
+ "dependencies": {
5184
+ "dequal": "^2.0.3",
5185
+ "use-sync-external-store": "^1.6.0"
5186
+ },
5187
+ "peerDependencies": {
5188
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5189
+ }
5190
+ },
5191
+ "node_modules/throttleit": {
5192
+ "version": "2.1.0",
5193
+ "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
5194
+ "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
5195
+ "license": "MIT",
5196
+ "engines": {
5197
+ "node": ">=18"
5198
+ },
5199
+ "funding": {
5200
+ "url": "https://github.com/sponsors/sindresorhus"
5201
+ }
5202
+ },
5203
  "node_modules/tinyglobby": {
5204
  "version": "0.2.15",
5205
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
 
5430
  "punycode": "^2.1.0"
5431
  }
5432
  },
5433
+ "node_modules/use-sync-external-store": {
5434
+ "version": "1.6.0",
5435
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
5436
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
5437
+ "license": "MIT",
5438
+ "peer": true,
5439
+ "peerDependencies": {
5440
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5441
+ }
5442
+ },
5443
  "node_modules/vfile": {
5444
  "version": "6.0.3",
5445
  "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
 
5584
  "url": "https://github.com/sponsors/sindresorhus"
5585
  }
5586
  },
5587
+ "node_modules/zod": {
5588
+ "version": "4.3.6",
5589
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
5590
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
5591
+ "license": "MIT",
5592
+ "peer": true,
5593
+ "funding": {
5594
+ "url": "https://github.com/sponsors/colinhacks"
5595
+ }
5596
+ },
5597
  "node_modules/zustand": {
5598
  "version": "5.0.10",
5599
  "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
frontend/package.json CHANGED
@@ -10,10 +10,12 @@
10
  "preview": "vite preview"
11
  },
12
  "dependencies": {
 
13
  "@emotion/react": "^11.13.0",
14
  "@emotion/styled": "^11.13.0",
15
  "@mui/icons-material": "^6.1.0",
16
  "@mui/material": "^6.1.0",
 
17
  "react": "^18.3.1",
18
  "react-dom": "^18.3.1",
19
  "react-markdown": "^9.0.1",
 
10
  "preview": "vite preview"
11
  },
12
  "dependencies": {
13
+ "@ai-sdk/react": "^3.0.93",
14
  "@emotion/react": "^11.13.0",
15
  "@emotion/styled": "^11.13.0",
16
  "@mui/icons-material": "^6.1.0",
17
  "@mui/material": "^6.1.0",
18
+ "ai": "^6.0.91",
19
  "react": "^18.3.1",
20
  "react-dom": "^18.3.1",
21
  "react-markdown": "^9.0.1",
frontend/src/components/Chat/AssistantMessage.tsx CHANGED
@@ -1,54 +1,66 @@
 
1
  import { Box, Stack, Typography } from '@mui/material';
2
  import MarkdownContent from './MarkdownContent';
3
  import ToolCallGroup from './ToolCallGroup';
4
- import type { Message } from '@/types/agent';
 
5
 
6
  interface AssistantMessageProps {
7
- message: Message;
8
- /** True when this message is actively receiving streaming chunks. */
9
  isStreaming?: boolean;
 
10
  }
11
 
12
- export default function AssistantMessage({ message, isStreaming = false }: AssistantMessageProps) {
13
- const renderSegments = () => {
14
- if (message.segments && message.segments.length > 0) {
15
- // Find the index of the last text segment (that's the one being streamed)
16
- let lastTextIdx = -1;
17
- for (let i = message.segments.length - 1; i >= 0; i--) {
18
- if (message.segments[i].type === 'text') {
19
- lastTextIdx = i;
20
- break;
21
- }
22
- }
23
 
24
- return message.segments.map((segment, idx) => {
25
- if (segment.type === 'text' && segment.content) {
26
- return (
27
- <MarkdownContent
28
- key={idx}
29
- content={segment.content}
30
- isStreaming={isStreaming && idx === lastTextIdx}
31
- />
32
- );
33
- }
34
- if (segment.type === 'tools' && segment.tools && segment.tools.length > 0) {
35
- return <ToolCallGroup key={idx} tools={segment.tools} />;
36
- }
37
- return null;
38
- });
39
- }
40
 
41
- // Fallback: render raw content
42
- if (message.content) {
43
- return <MarkdownContent content={message.content} isStreaming={isStreaming} />;
 
 
 
 
 
 
 
 
 
 
44
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- return null;
47
- };
 
 
 
 
48
 
49
  return (
50
  <Box sx={{ minWidth: 0 }}>
51
- {/* Role label + timestamp */}
52
  <Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
53
  <Typography
54
  variant="caption"
@@ -62,19 +74,13 @@ export default function AssistantMessage({ message, isStreaming = false }: Assis
62
  >
63
  Assistant
64
  </Typography>
65
- <Typography
66
- variant="caption"
67
- sx={{
68
- fontSize: '0.66rem',
69
- color: 'var(--muted-text)',
70
- opacity: 0.6,
71
- }}
72
- >
73
- {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
74
- </Typography>
75
  </Stack>
76
 
77
- {/* Message bubble */}
78
  <Box
79
  sx={{
80
  maxWidth: { xs: '95%', md: '85%' },
@@ -86,7 +92,27 @@ export default function AssistantMessage({ message, isStreaming = false }: Assis
86
  border: '1px solid var(--border)',
87
  }}
88
  >
89
- {renderSegments()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  </Box>
91
  </Box>
92
  );
 
1
+ import { useMemo } from 'react';
2
  import { Box, Stack, Typography } from '@mui/material';
3
  import MarkdownContent from './MarkdownContent';
4
  import ToolCallGroup from './ToolCallGroup';
5
+ import type { UIMessage } from 'ai';
6
+ import type { MessageMeta } from '@/types/agent';
7
 
8
  interface AssistantMessageProps {
9
+ message: UIMessage;
 
10
  isStreaming?: boolean;
11
+ approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
12
  }
13
 
14
+ /**
15
+ * Groups consecutive tool parts together so they render as a single
16
+ * ToolCallGroup (visually identical to the old segments approach).
17
+ */
18
+ type DynamicToolPart = Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>;
 
 
 
 
 
 
19
 
20
+ function groupParts(parts: UIMessage['parts']) {
21
+ const groups: Array<
22
+ | { kind: 'text'; text: string; idx: number }
23
+ | { kind: 'tools'; tools: DynamicToolPart[]; idx: number }
24
+ > = [];
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ for (let i = 0; i < parts.length; i++) {
27
+ const part = parts[i];
28
+
29
+ if (part.type === 'text') {
30
+ groups.push({ kind: 'text', text: part.text, idx: i });
31
+ } else if (part.type === 'dynamic-tool') {
32
+ const toolPart = part as DynamicToolPart;
33
+ const last = groups[groups.length - 1];
34
+ if (last?.kind === 'tools') {
35
+ last.tools.push(toolPart);
36
+ } else {
37
+ groups.push({ kind: 'tools', tools: [toolPart], idx: i });
38
+ }
39
  }
40
+ // step-start, step-end, etc. are ignored visually
41
+ }
42
+
43
+ return groups;
44
+ }
45
+
46
+ export default function AssistantMessage({ message, isStreaming = false, approveTools }: AssistantMessageProps) {
47
+ const groups = useMemo(() => groupParts(message.parts), [message.parts]);
48
+
49
+ // Find the last text group index for streaming cursor
50
+ let lastTextIdx = -1;
51
+ for (let i = groups.length - 1; i >= 0; i--) {
52
+ if (groups[i].kind === 'text') { lastTextIdx = i; break; }
53
+ }
54
 
55
+ const meta = message.metadata as MessageMeta | undefined;
56
+ const timeStr = meta?.createdAt
57
+ ? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
58
+ : null;
59
+
60
+ if (groups.length === 0) return null;
61
 
62
  return (
63
  <Box sx={{ minWidth: 0 }}>
 
64
  <Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
65
  <Typography
66
  variant="caption"
 
74
  >
75
  Assistant
76
  </Typography>
77
+ {timeStr && (
78
+ <Typography variant="caption" sx={{ color: 'var(--muted-text)', fontSize: '0.7rem' }}>
79
+ {timeStr}
80
+ </Typography>
81
+ )}
 
 
 
 
 
82
  </Stack>
83
 
 
84
  <Box
85
  sx={{
86
  maxWidth: { xs: '95%', md: '85%' },
 
92
  border: '1px solid var(--border)',
93
  }}
94
  >
95
+ {groups.map((group, i) => {
96
+ if (group.kind === 'text' && group.text) {
97
+ return (
98
+ <MarkdownContent
99
+ key={group.idx}
100
+ content={group.text}
101
+ isStreaming={isStreaming && i === lastTextIdx}
102
+ />
103
+ );
104
+ }
105
+ if (group.kind === 'tools' && group.tools.length > 0) {
106
+ return (
107
+ <ToolCallGroup
108
+ key={group.idx}
109
+ tools={group.tools}
110
+ approveTools={approveTools}
111
+ />
112
+ );
113
+ }
114
+ return null;
115
+ })}
116
  </Box>
117
  </Box>
118
  );
frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
2
  import { Box, TextField, IconButton, CircularProgress, Typography, Menu, MenuItem, ListItemIcon, ListItemText, Chip } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
 
4
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
5
  import { apiFetch } from '@/utils/api';
6
 
@@ -58,23 +59,34 @@ const findModelByPath = (path: string): ModelOption | undefined => {
58
 
59
  interface ChatInputProps {
60
  onSend: (text: string) => void;
 
61
  disabled?: boolean;
 
62
  }
63
 
64
- export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
65
  const [input, setInput] = useState('');
66
  const inputRef = useRef<HTMLTextAreaElement>(null);
67
- const [selectedModelId, setSelectedModelId] = useState<string>(MODEL_OPTIONS[0].id);
 
 
 
 
 
 
68
  const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
69
 
70
- // Sync with backend on mount
71
  useEffect(() => {
72
  fetch('/api/config/model')
73
  .then((res) => (res.ok ? res.json() : null))
74
  .then((data) => {
75
  if (data?.current) {
76
  const model = findModelByPath(data.current);
77
- if (model) setSelectedModelId(model.id);
 
 
 
78
  }
79
  })
80
  .catch(() => { /* ignore */ });
@@ -123,6 +135,7 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
123
  });
124
  if (res.ok) {
125
  setSelectedModelId(model.id);
 
126
  }
127
  } catch { /* ignore */ }
128
  };
@@ -189,26 +202,44 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
189
  }
190
  }}
191
  />
192
- <IconButton
193
- onClick={handleSend}
194
- disabled={disabled || !input.trim()}
195
- sx={{
196
- mt: 1,
197
- p: 1,
198
- borderRadius: '10px',
199
- color: 'var(--muted-text)',
200
- transition: 'all 0.2s',
201
- '&:hover': {
202
  color: 'var(--accent-yellow)',
203
- bgcolor: 'var(--hover-bg)',
204
- },
205
- '&.Mui-disabled': {
206
- opacity: 0.3,
207
- },
208
- }}
209
- >
210
- {disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
211
- </IconButton>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  </Box>
213
 
214
  {/* Powered By Badge */}
 
1
  import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
2
  import { Box, TextField, IconButton, CircularProgress, Typography, Menu, MenuItem, ListItemIcon, ListItemText, Chip } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
4
+ import StopIcon from '@mui/icons-material/Stop';
5
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
6
  import { apiFetch } from '@/utils/api';
7
 
 
59
 
60
  interface ChatInputProps {
61
  onSend: (text: string) => void;
62
+ onStop?: () => void;
63
  disabled?: boolean;
64
+ isStreaming?: boolean;
65
  }
66
 
67
+ export default function ChatInput({ onSend, onStop, disabled = false, isStreaming = false }: ChatInputProps) {
68
  const [input, setInput] = useState('');
69
  const inputRef = useRef<HTMLTextAreaElement>(null);
70
+ const [selectedModelId, setSelectedModelId] = useState<string>(() => {
71
+ try {
72
+ const stored = localStorage.getItem('hf-agent-model');
73
+ if (stored && MODEL_OPTIONS.some(m => m.id === stored)) return stored;
74
+ } catch { /* localStorage unavailable */ }
75
+ return MODEL_OPTIONS[0].id;
76
+ });
77
  const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
78
 
79
+ // Sync with backend on mount (backend is source of truth, localStorage is just a cache)
80
  useEffect(() => {
81
  fetch('/api/config/model')
82
  .then((res) => (res.ok ? res.json() : null))
83
  .then((data) => {
84
  if (data?.current) {
85
  const model = findModelByPath(data.current);
86
+ if (model) {
87
+ setSelectedModelId(model.id);
88
+ try { localStorage.setItem('hf-agent-model', model.id); } catch { /* ignore */ }
89
+ }
90
  }
91
  })
92
  .catch(() => { /* ignore */ });
 
135
  });
136
  if (res.ok) {
137
  setSelectedModelId(model.id);
138
+ try { localStorage.setItem('hf-agent-model', model.id); } catch { /* ignore */ }
139
  }
140
  } catch { /* ignore */ }
141
  };
 
202
  }
203
  }}
204
  />
205
+ {isStreaming ? (
206
+ <IconButton
207
+ onClick={onStop}
208
+ sx={{
209
+ mt: 1,
210
+ p: 1,
211
+ borderRadius: '10px',
 
 
 
212
  color: 'var(--accent-yellow)',
213
+ transition: 'all 0.2s',
214
+ '&:hover': {
215
+ bgcolor: 'var(--hover-bg)',
216
+ },
217
+ }}
218
+ >
219
+ <StopIcon fontSize="small" />
220
+ </IconButton>
221
+ ) : (
222
+ <IconButton
223
+ onClick={handleSend}
224
+ disabled={disabled || !input.trim()}
225
+ sx={{
226
+ mt: 1,
227
+ p: 1,
228
+ borderRadius: '10px',
229
+ color: 'var(--muted-text)',
230
+ transition: 'all 0.2s',
231
+ '&:hover': {
232
+ color: 'var(--accent-yellow)',
233
+ bgcolor: 'var(--hover-bg)',
234
+ },
235
+ '&.Mui-disabled': {
236
+ opacity: 0.3,
237
+ },
238
+ }}
239
+ >
240
+ {disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
241
+ </IconButton>
242
+ )}
243
  </Box>
244
 
245
  {/* Powered By Badge */}
frontend/src/components/Chat/MessageBubble.tsx CHANGED
@@ -1,36 +1,24 @@
1
  import UserMessage from './UserMessage';
2
  import AssistantMessage from './AssistantMessage';
3
- import type { Message } from '@/types/agent';
4
 
5
  interface MessageBubbleProps {
6
- message: Message;
7
- /** True if this is the user message that starts the last turn. */
8
  isLastTurn?: boolean;
9
- /** Callback to undo (remove) the last turn. */
10
  onUndoTurn?: () => void;
11
- /** Whether the agent is currently processing. */
12
  isProcessing?: boolean;
13
- /** True when this message is actively receiving streaming chunks. */
14
  isStreaming?: boolean;
 
15
  }
16
 
17
- /**
18
- * Thin dispatcher — routes each message to the correct
19
- * specialised component based on its role / content.
20
- */
21
  export default function MessageBubble({
22
  message,
23
  isLastTurn = false,
24
  onUndoTurn,
25
  isProcessing = false,
26
  isStreaming = false,
 
27
  }: MessageBubbleProps) {
28
- // Legacy approval-only messages (from old localStorage data) — skip them.
29
- // Approvals are now rendered inline within ToolCallGroup.
30
- if (message.approval && !message.content && !message.segments?.length) {
31
- return null;
32
- }
33
-
34
  if (message.role === 'user') {
35
  return (
36
  <UserMessage
@@ -43,9 +31,14 @@ export default function MessageBubble({
43
  }
44
 
45
  if (message.role === 'assistant') {
46
- return <AssistantMessage message={message} isStreaming={isStreaming} />;
 
 
 
 
 
 
47
  }
48
 
49
- // Fallback (tool messages, etc.)
50
  return null;
51
  }
 
1
  import UserMessage from './UserMessage';
2
  import AssistantMessage from './AssistantMessage';
3
+ import type { UIMessage } from 'ai';
4
 
5
  interface MessageBubbleProps {
6
+ message: UIMessage;
 
7
  isLastTurn?: boolean;
 
8
  onUndoTurn?: () => void;
 
9
  isProcessing?: boolean;
 
10
  isStreaming?: boolean;
11
+ approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
12
  }
13
 
 
 
 
 
14
  export default function MessageBubble({
15
  message,
16
  isLastTurn = false,
17
  onUndoTurn,
18
  isProcessing = false,
19
  isStreaming = false,
20
+ approveTools,
21
  }: MessageBubbleProps) {
 
 
 
 
 
 
22
  if (message.role === 'user') {
23
  return (
24
  <UserMessage
 
31
  }
32
 
33
  if (message.role === 'assistant') {
34
+ return (
35
+ <AssistantMessage
36
+ message={message}
37
+ isStreaming={isStreaming}
38
+ approveTools={approveTools}
39
+ />
40
+ );
41
  }
42
 
 
43
  return null;
44
  }
frontend/src/components/Chat/MessageList.tsx CHANGED
@@ -1,16 +1,15 @@
1
- import { useEffect, useRef, useMemo, useCallback } from 'react';
2
  import { Box, Stack, Typography } from '@mui/material';
3
  import MessageBubble from './MessageBubble';
4
  import ThinkingIndicator from './ThinkingIndicator';
5
  import { useAgentStore } from '@/store/agentStore';
6
- import { useSessionStore } from '@/store/sessionStore';
7
- import { apiFetch } from '@/utils/api';
8
- import { logger } from '@/utils/logger';
9
- import type { Message } from '@/types/agent';
10
 
11
  interface MessageListProps {
12
- messages: Message[];
13
  isProcessing: boolean;
 
 
14
  }
15
 
16
  function getGreeting(): string {
@@ -20,7 +19,6 @@ function getGreeting(): string {
20
  return 'Evening';
21
  }
22
 
23
- /** Minimal greeting shown when the conversation is empty. */
24
  function WelcomeGreeting() {
25
  const { user } = useAgentStore();
26
  const firstName = user?.name?.split(' ')[0] || user?.username;
@@ -58,58 +56,40 @@ function WelcomeGreeting() {
58
  );
59
  }
60
 
61
- export default function MessageList({ messages, isProcessing }: MessageListProps) {
62
  const scrollContainerRef = useRef<HTMLDivElement>(null);
63
  const stickToBottom = useRef(true);
64
- const { activeSessionId } = useSessionStore();
65
- const { removeLastTurn, currentTurnMessageId } = useAgentStore();
66
 
67
- // ── Scroll-to-bottom helper ─────────────────────────────────────
68
  const scrollToBottom = useCallback(() => {
69
  const el = scrollContainerRef.current;
70
  if (el) el.scrollTop = el.scrollHeight;
71
  }, []);
72
 
73
- // ── Track user scroll intent ────────────────────────────────────
74
  useEffect(() => {
75
  const el = scrollContainerRef.current;
76
  if (!el) return;
77
-
78
  const onScroll = () => {
79
  const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
80
  stickToBottom.current = distFromBottom < 80;
81
  };
82
-
83
  el.addEventListener('scroll', onScroll, { passive: true });
84
  return () => el.removeEventListener('scroll', onScroll);
85
  }, []);
86
 
87
- // ── Auto-scroll on new messages / state changes ─────────────────
88
  useEffect(() => {
89
  if (stickToBottom.current) scrollToBottom();
90
  }, [messages, isProcessing, scrollToBottom]);
91
 
92
- // ── Auto-scroll on DOM mutations (streaming content growth) ─────
93
  useEffect(() => {
94
  const el = scrollContainerRef.current;
95
  if (!el) return;
96
-
97
  const observer = new MutationObserver(() => {
98
- if (stickToBottom.current) {
99
- el.scrollTop = el.scrollHeight;
100
- }
101
- });
102
-
103
- observer.observe(el, {
104
- childList: true,
105
- subtree: true,
106
- characterData: true,
107
  });
108
-
109
  return () => observer.disconnect();
110
  }, []);
111
 
112
- // Find the index of the last user message (start of the last turn)
113
  const lastUserMsgId = useMemo(() => {
114
  for (let i = messages.length - 1; i >= 0; i--) {
115
  if (messages[i].role === 'user') return messages[i].id;
@@ -117,15 +97,13 @@ export default function MessageList({ messages, isProcessing }: MessageListProps
117
  return null;
118
  }, [messages]);
119
 
120
- const handleUndoLastTurn = useCallback(async () => {
121
- if (!activeSessionId) return;
122
- try {
123
- await apiFetch(`/api/undo/${activeSessionId}`, { method: 'POST' });
124
- removeLastTurn(activeSessionId);
125
- } catch (e) {
126
- logger.error('Undo failed:', e);
127
  }
128
- }, [activeSessionId, removeLastTurn]);
 
129
 
130
  return (
131
  <Box
@@ -156,17 +134,18 @@ export default function MessageList({ messages, isProcessing }: MessageListProps
156
  key={msg.id}
157
  message={msg}
158
  isLastTurn={msg.id === lastUserMsgId}
159
- onUndoTurn={handleUndoLastTurn}
160
  isProcessing={isProcessing}
161
- isStreaming={isProcessing && msg.id === currentTurnMessageId}
 
162
  />
163
  ))
164
  )}
165
 
166
- {/* Show thinking dots only when processing but no streaming message yet */}
167
- {isProcessing && !currentTurnMessageId && <ThinkingIndicator />}
 
168
 
169
- {/* Sentinel — keeps scroll anchor at the bottom */}
170
  <div />
171
  </Stack>
172
  </Box>
 
1
+ import { useCallback, useEffect, useRef, useMemo } from 'react';
2
  import { Box, Stack, Typography } from '@mui/material';
3
  import MessageBubble from './MessageBubble';
4
  import ThinkingIndicator from './ThinkingIndicator';
5
  import { useAgentStore } from '@/store/agentStore';
6
+ import type { UIMessage } from 'ai';
 
 
 
7
 
8
  interface MessageListProps {
9
+ messages: UIMessage[];
10
  isProcessing: boolean;
11
+ approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
12
+ onUndoLastTurn: () => void | Promise<void>;
13
  }
14
 
15
  function getGreeting(): string {
 
19
  return 'Evening';
20
  }
21
 
 
22
  function WelcomeGreeting() {
23
  const { user } = useAgentStore();
24
  const firstName = user?.name?.split(' ')[0] || user?.username;
 
56
  );
57
  }
58
 
59
+ export default function MessageList({ messages, isProcessing, approveTools, onUndoLastTurn }: MessageListProps) {
60
  const scrollContainerRef = useRef<HTMLDivElement>(null);
61
  const stickToBottom = useRef(true);
 
 
62
 
 
63
  const scrollToBottom = useCallback(() => {
64
  const el = scrollContainerRef.current;
65
  if (el) el.scrollTop = el.scrollHeight;
66
  }, []);
67
 
 
68
  useEffect(() => {
69
  const el = scrollContainerRef.current;
70
  if (!el) return;
 
71
  const onScroll = () => {
72
  const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
73
  stickToBottom.current = distFromBottom < 80;
74
  };
 
75
  el.addEventListener('scroll', onScroll, { passive: true });
76
  return () => el.removeEventListener('scroll', onScroll);
77
  }, []);
78
 
 
79
  useEffect(() => {
80
  if (stickToBottom.current) scrollToBottom();
81
  }, [messages, isProcessing, scrollToBottom]);
82
 
 
83
  useEffect(() => {
84
  const el = scrollContainerRef.current;
85
  if (!el) return;
 
86
  const observer = new MutationObserver(() => {
87
+ if (stickToBottom.current) el.scrollTop = el.scrollHeight;
 
 
 
 
 
 
 
 
88
  });
89
+ observer.observe(el, { childList: true, subtree: true, characterData: true });
90
  return () => observer.disconnect();
91
  }, []);
92
 
 
93
  const lastUserMsgId = useMemo(() => {
94
  for (let i = messages.length - 1; i >= 0; i--) {
95
  if (messages[i].role === 'user') return messages[i].id;
 
97
  return null;
98
  }, [messages]);
99
 
100
+ // The last assistant message is "streaming" when we're processing
101
+ const lastAssistantId = useMemo(() => {
102
+ for (let i = messages.length - 1; i >= 0; i--) {
103
+ if (messages[i].role === 'assistant') return messages[i].id;
 
 
 
104
  }
105
+ return null;
106
+ }, [messages]);
107
 
108
  return (
109
  <Box
 
134
  key={msg.id}
135
  message={msg}
136
  isLastTurn={msg.id === lastUserMsgId}
137
+ onUndoTurn={onUndoLastTurn}
138
  isProcessing={isProcessing}
139
+ isStreaming={isProcessing && msg.id === lastAssistantId}
140
+ approveTools={approveTools}
141
  />
142
  ))
143
  )}
144
 
145
+ {isProcessing && (!lastAssistantId || messages.every(m => m.id !== lastAssistantId || m.parts.length === 0)) && (
146
+ <ThinkingIndicator />
147
+ )}
148
 
 
149
  <div />
150
  </Stack>
151
  </Box>
frontend/src/components/Chat/ToolCallGroup.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from 'react';
2
  import { Box, Stack, Typography, Chip, Button, TextField, IconButton, Link } from '@mui/material';
3
  import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
4
  import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
@@ -7,54 +7,40 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew';
7
  import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
8
  import LaunchIcon from '@mui/icons-material/Launch';
9
  import SendIcon from '@mui/icons-material/Send';
 
10
  import { useAgentStore } from '@/store/agentStore';
11
  import { useLayoutStore } from '@/store/layoutStore';
12
- import { useSessionStore } from '@/store/sessionStore';
13
- import { apiFetch } from '@/utils/api';
14
  import { logger } from '@/utils/logger';
15
- import type { TraceLog, ToolState } from '@/types/agent';
 
 
 
 
 
 
 
16
 
17
  interface ToolCallGroupProps {
18
- tools: TraceLog[];
 
19
  }
20
 
21
- const TOOL_TIMEOUT_MS = 5 * 60 * 1000;
22
-
23
- /**
24
- * Resolve the effective state of a TraceLog.
25
- * Uses `state` field if present, otherwise infers from legacy fields
26
- * (backward compat with data persisted before the state refactor).
27
- */
28
- function resolveState(log: TraceLog): ToolState {
29
- if (log.state) return log.state;
30
- // Legacy inference
31
- if (log.approvalStatus === 'pending') return 'pending_approval';
32
- if (log.approvalStatus === 'rejected') return 'rejected';
33
- if (log.completed && log.success === false) return 'failed';
34
- if (log.completed) return 'completed';
35
- // Check timeout
36
- const elapsed = Date.now() - new Date(log.timestamp).getTime();
37
- if (elapsed > TOOL_TIMEOUT_MS) return 'timed_out';
38
- if (log.approvalStatus === 'approved') return 'running';
39
- return 'calling';
40
- }
41
 
42
- // ── Status icon based on resolved state ──────────────────────────────
43
- function StatusIcon({ state }: { state: ToolState }) {
44
  switch (state) {
45
- case 'pending_approval':
46
  return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
47
- case 'approved':
48
- return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-green)', opacity: 0.7 }} />;
49
- case 'rejected':
50
- case 'failed':
51
- return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
52
- case 'timed_out':
53
- return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
54
- case 'completed':
55
  return <CheckCircleOutlineIcon sx={{ fontSize: 16, color: 'success.main' }} />;
56
- case 'calling':
57
- case 'running':
 
 
 
 
58
  default:
59
  return (
60
  <MoreHorizIcon
@@ -72,58 +58,61 @@ function StatusIcon({ state }: { state: ToolState }) {
72
  }
73
  }
74
 
75
- // ── Status chip label ───────────────────────────────────────────────
76
- function statusLabel(state: ToolState): string | null {
77
  switch (state) {
78
- case 'pending_approval': return 'awaiting approval';
79
- case 'approved': return 'approved';
80
- case 'rejected': return 'rejected';
81
- case 'timed_out': return 'timed out';
82
- case 'calling':
83
- case 'running': return 'running';
84
  default: return null;
85
  }
86
  }
87
 
88
- function statusColor(state: ToolState): string {
89
  switch (state) {
90
- case 'pending_approval': return 'var(--accent-yellow)';
91
- case 'approved': return 'var(--accent-green)';
92
- case 'rejected':
93
- case 'failed': return 'var(--accent-red)';
94
- case 'timed_out': return 'var(--muted-text)';
95
  default: return 'var(--accent-yellow)';
96
  }
97
  }
98
 
99
- // ── Inline approval UI ──────────────────────────────────────────────
 
 
 
100
  function InlineApproval({
101
- log,
 
 
102
  onResolve,
103
  }: {
104
- log: TraceLog;
 
 
105
  onResolve: (toolCallId: string, approved: boolean, feedback?: string) => void;
106
  }) {
107
  const [feedback, setFeedback] = useState('');
 
108
 
109
  return (
110
  <Box sx={{ px: 1.5, py: 1.5, borderTop: '1px solid var(--tool-border)' }}>
111
- {/* Tool description */}
112
- {log.tool === 'hf_jobs' && log.args && (
113
  <Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.75rem', mb: 1.5 }}>
114
- Execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{log.tool}</Box> on{' '}
115
  <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
116
- {String(log.args.hardware_flavor || 'default')}
117
  </Box>
118
- {!!log.args.timeout && (
119
  <> with timeout <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
120
- {String(log.args.timeout)}
121
  </Box></>
122
  )}
123
  </Typography>
124
  )}
125
 
126
- {/* Feedback + buttons */}
127
  <Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
128
  <TextField
129
  fullWidth
@@ -141,7 +130,7 @@ function InlineApproval({
141
  }}
142
  />
143
  <IconButton
144
- onClick={() => onResolve(log.toolCallId || '', false, feedback || 'Rejected by user')}
145
  disabled={!feedback}
146
  size="small"
147
  sx={{
@@ -159,7 +148,7 @@ function InlineApproval({
159
  <Box sx={{ display: 'flex', gap: 1 }}>
160
  <Button
161
  size="small"
162
- onClick={() => onResolve(log.toolCallId || '', false, feedback || 'Rejected by user')}
163
  sx={{
164
  flex: 1,
165
  textTransform: 'none',
@@ -175,7 +164,7 @@ function InlineApproval({
175
  </Button>
176
  <Button
177
  size="small"
178
- onClick={() => onResolve(log.toolCallId || '', true)}
179
  sx={{
180
  flex: 1,
181
  textTransform: 'none',
@@ -194,94 +183,129 @@ function InlineApproval({
194
  );
195
  }
196
 
197
- // ── Main component ──────────────────────────────────────────────────
198
- export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
199
- const { showToolOutput, setPanelTab, setActivePanelTab, clearPanelTabs } = useAgentStore();
 
 
 
200
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
201
- const { activeSessionId } = useSessionStore();
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  const handleClick = useCallback(
204
- (log: TraceLog) => {
205
- // For hf_jobs with scripts, use tab system
206
- if (log.tool === 'hf_jobs' && log.args?.script) {
 
207
  clearPanelTabs();
208
- setPanelTab({
209
- id: 'script',
210
- title: 'Script',
211
- content: String(log.args.script),
212
- language: 'python',
213
- });
214
- if (log.output) {
215
- setPanelTab({
216
- id: 'output',
217
- title: 'Output',
218
- content: log.output,
219
- language: 'markdown',
220
- });
221
  }
222
- if (log.jobLogs) {
223
- setPanelTab({
224
- id: 'logs',
225
- title: 'Logs',
226
- content: log.jobLogs,
227
- language: 'text',
228
- });
229
- }
230
- // Default to output if it exists (most useful), otherwise script
231
- setActivePanelTab(log.output ? 'output' : 'script');
232
  setRightPanelOpen(true);
233
  setLeftSidebarOpen(false);
234
  return;
235
  }
236
 
237
- // Show output if completed/failed, or args if still running
238
- const s = resolveState(log);
239
- if ((s === 'completed' || s === 'failed') && log.output) {
240
- showToolOutput(log);
241
- } else if (log.args) {
242
- const content = JSON.stringify(log.args, null, 2);
243
- showToolOutput({ ...log, output: content });
244
- } else {
245
- return;
246
- }
247
- setRightPanelOpen(true);
248
- },
249
- [showToolOutput, setRightPanelOpen, setLeftSidebarOpen, clearPanelTabs, setPanelTab, setActivePanelTab],
250
- );
251
 
252
- const handleApprovalResolve = useCallback(
253
- async (toolCallId: string, approved: boolean, feedback?: string) => {
254
- if (!activeSessionId) return;
255
- try {
256
- const res = await apiFetch('/api/approve', {
257
- method: 'POST',
258
- body: JSON.stringify({
259
- session_id: activeSessionId,
260
- approvals: [{
261
- tool_call_id: toolCallId,
262
- approved,
263
- feedback: approved ? null : feedback || 'Rejected by user',
264
- }],
265
- }),
266
- });
267
-
268
- if (res.ok) {
269
- // Optimistic update with proper state transitions
270
- const { updateTraceLog, updateCurrentTurnTrace, setProcessing } = useAgentStore.getState();
271
- updateTraceLog(toolCallId, '', {
272
- state: approved ? 'approved' : 'rejected',
273
- approvalStatus: approved ? 'approved' : 'rejected', // legacy compat
274
- });
275
- updateCurrentTurnTrace(activeSessionId);
276
- if (approved) setProcessing(true);
277
- }
278
- } catch (e) {
279
- logger.error('Approval failed:', e);
280
  }
281
  },
282
- [activeSessionId],
283
  );
284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  return (
286
  <Box
287
  sx={{
@@ -292,30 +316,100 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
292
  my: 1,
293
  }}
294
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  <Stack divider={<Box sx={{ borderBottom: '1px solid var(--tool-border)' }} />}>
296
- {tools.map((log) => {
297
- const state = resolveState(log);
298
- const clickable = state === 'completed' || state === 'failed' || !!log.args;
299
- const label = statusLabel(state);
300
- const isPendingApproval = state === 'pending_approval';
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
  return (
303
- <Box key={log.id}>
304
  {/* Main tool row */}
305
  <Stack
306
  direction="row"
307
  alignItems="center"
308
  spacing={1}
309
- onClick={() => !isPendingApproval && handleClick(log)}
310
  sx={{
311
  px: 1.5,
312
  py: 1,
313
- cursor: isPendingApproval ? 'default' : clickable ? 'pointer' : 'default',
314
  transition: 'background-color 0.15s',
315
- '&:hover': clickable && !isPendingApproval ? { bgcolor: 'var(--hover-bg)' } : {},
316
  }}
317
  >
318
- <StatusIcon state={state} />
319
 
320
  <Typography
321
  variant="body2"
@@ -331,7 +425,7 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
331
  whiteSpace: 'nowrap',
332
  }}
333
  >
334
- {log.tool}
335
  </Typography>
336
 
337
  {label && (
@@ -343,19 +437,19 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
343
  fontSize: '0.65rem',
344
  fontWeight: 600,
345
  bgcolor: 'var(--accent-yellow-weak)',
346
- color: statusColor(state),
347
  letterSpacing: '0.03em',
348
  }}
349
  />
350
  )}
351
 
352
- {clickable && !isPendingApproval && (
353
  <OpenInNewIcon sx={{ fontSize: 14, color: 'var(--muted-text)', opacity: 0.6 }} />
354
  )}
355
  </Stack>
356
 
357
- {/* Job status + link row */}
358
- {(log.jobUrl || log.jobStatus) && (
359
  <Box
360
  sx={{
361
  display: 'flex',
@@ -366,21 +460,21 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
366
  borderTop: '1px solid var(--tool-border)',
367
  }}
368
  >
369
- {log.jobStatus && (
370
  <Typography
371
  variant="caption"
372
  sx={{
373
- color: log.success === false ? 'var(--accent-red)' : 'var(--accent-green)',
374
  fontSize: '0.7rem',
375
  fontWeight: 600,
376
  }}
377
  >
378
- {log.jobStatus}
379
  </Typography>
380
  )}
381
- {log.jobUrl && (
382
  <Link
383
- href={log.jobUrl}
384
  target="_blank"
385
  rel="noopener noreferrer"
386
  onClick={(e) => e.stopPropagation()}
@@ -401,9 +495,48 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
401
  </Box>
402
  )}
403
 
404
- {/* Inline approval UI (only when pending) */}
405
- {isPendingApproval && (
406
- <InlineApproval log={log} onResolve={handleApprovalResolve} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  )}
408
  </Box>
409
  );
 
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
  import { Box, Stack, Typography, Chip, Button, TextField, IconButton, Link } from '@mui/material';
3
  import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
4
  import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
 
7
  import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
8
  import LaunchIcon from '@mui/icons-material/Launch';
9
  import SendIcon from '@mui/icons-material/Send';
10
+ import BlockIcon from '@mui/icons-material/Block';
11
  import { useAgentStore } from '@/store/agentStore';
12
  import { useLayoutStore } from '@/store/layoutStore';
 
 
13
  import { logger } from '@/utils/logger';
14
+ import type { UIMessage } from 'ai';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Type helpers — extract the dynamic-tool part type from UIMessage
18
+ // ---------------------------------------------------------------------------
19
+ type DynamicToolPart = Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>;
20
+
21
+ type ToolPartState = DynamicToolPart['state'];
22
 
23
  interface ToolCallGroupProps {
24
+ tools: DynamicToolPart[];
25
+ approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
26
  }
27
 
28
+ // ---------------------------------------------------------------------------
29
+ // Visual helpers
30
+ // ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
+ function StatusIcon({ state }: { state: ToolPartState }) {
 
33
  switch (state) {
34
+ case 'approval-requested':
35
  return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
36
+ case 'output-available':
 
 
 
 
 
 
 
37
  return <CheckCircleOutlineIcon sx={{ fontSize: 16, color: 'success.main' }} />;
38
+ case 'output-error':
39
+ return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
40
+ case 'output-denied':
41
+ return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
42
+ case 'input-streaming':
43
+ case 'input-available':
44
  default:
45
  return (
46
  <MoreHorizIcon
 
58
  }
59
  }
60
 
61
+ function statusLabel(state: ToolPartState): string | null {
 
62
  switch (state) {
63
+ case 'approval-requested': return 'awaiting approval';
64
+ case 'input-streaming':
65
+ case 'input-available': return 'running';
66
+ case 'output-denied': return 'denied';
67
+ case 'output-error': return 'error';
 
68
  default: return null;
69
  }
70
  }
71
 
72
+ function statusColor(state: ToolPartState): string {
73
  switch (state) {
74
+ case 'approval-requested': return 'var(--accent-yellow)';
75
+ case 'output-available': return 'var(--accent-green)';
76
+ case 'output-error': return 'var(--accent-red)';
77
+ case 'output-denied': return 'var(--muted-text)';
 
78
  default: return 'var(--accent-yellow)';
79
  }
80
  }
81
 
82
+ // ---------------------------------------------------------------------------
83
+ // Inline approval UI (per-tool)
84
+ // ---------------------------------------------------------------------------
85
+
86
  function InlineApproval({
87
+ toolCallId,
88
+ toolName,
89
+ input,
90
  onResolve,
91
  }: {
92
+ toolCallId: string;
93
+ toolName: string;
94
+ input: unknown;
95
  onResolve: (toolCallId: string, approved: boolean, feedback?: string) => void;
96
  }) {
97
  const [feedback, setFeedback] = useState('');
98
+ const args = input as Record<string, unknown> | undefined;
99
 
100
  return (
101
  <Box sx={{ px: 1.5, py: 1.5, borderTop: '1px solid var(--tool-border)' }}>
102
+ {toolName === 'hf_jobs' && args && (
 
103
  <Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.75rem', mb: 1.5 }}>
104
+ Execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{toolName}</Box> on{' '}
105
  <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
106
+ {String(args.hardware_flavor || 'default')}
107
  </Box>
108
+ {!!args.timeout && (
109
  <> with timeout <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
110
+ {String(args.timeout)}
111
  </Box></>
112
  )}
113
  </Typography>
114
  )}
115
 
 
116
  <Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
117
  <TextField
118
  fullWidth
 
130
  }}
131
  />
132
  <IconButton
133
+ onClick={() => onResolve(toolCallId, false, feedback || 'Rejected by user')}
134
  disabled={!feedback}
135
  size="small"
136
  sx={{
 
148
  <Box sx={{ display: 'flex', gap: 1 }}>
149
  <Button
150
  size="small"
151
+ onClick={() => onResolve(toolCallId, false, feedback || 'Rejected by user')}
152
  sx={{
153
  flex: 1,
154
  textTransform: 'none',
 
164
  </Button>
165
  <Button
166
  size="small"
167
+ onClick={() => onResolve(toolCallId, true)}
168
  sx={{
169
  flex: 1,
170
  textTransform: 'none',
 
183
  );
184
  }
185
 
186
+ // ---------------------------------------------------------------------------
187
+ // Main component
188
+ // ---------------------------------------------------------------------------
189
+
190
+ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
191
+ const { setPanelTab, setActivePanelTab, clearPanelTabs } = useAgentStore();
192
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
 
193
 
194
+ // ── Batch approval state ──────────────────────────────────────────
195
+ const pendingTools = useMemo(
196
+ () => tools.filter(t => t.state === 'approval-requested'),
197
+ [tools],
198
+ );
199
+ const hasPendingApprovals = pendingTools.length > 0;
200
+
201
+ const [decisions, setDecisions] = useState<Record<string, { approved: boolean; feedback?: string }>>({});
202
+ const [isSubmitting, setIsSubmitting] = useState(false);
203
+ const submittingRef = useRef(false);
204
+
205
+ // ── Send all decisions as a single batch ──────────────────────────
206
+ const sendBatch = useCallback(
207
+ async (batch: Record<string, { approved: boolean; feedback?: string }>) => {
208
+ if (submittingRef.current) return;
209
+ submittingRef.current = true;
210
+ setIsSubmitting(true);
211
+
212
+ const approvals = Object.entries(batch).map(([toolCallId, d]) => ({
213
+ tool_call_id: toolCallId,
214
+ approved: d.approved,
215
+ feedback: d.approved ? null : (d.feedback || 'Rejected by user'),
216
+ }));
217
+
218
+ const ok = await approveTools(approvals);
219
+ if (!ok) {
220
+ logger.error('Batch approval failed');
221
+ submittingRef.current = false;
222
+ setIsSubmitting(false);
223
+ }
224
+ },
225
+ [approveTools],
226
+ );
227
+
228
+ const handleApproveAll = useCallback(() => {
229
+ const batch: Record<string, { approved: boolean }> = {};
230
+ for (const t of pendingTools) batch[t.toolCallId] = { approved: true };
231
+ sendBatch(batch);
232
+ }, [pendingTools, sendBatch]);
233
+
234
+ const handleRejectAll = useCallback(() => {
235
+ const batch: Record<string, { approved: boolean }> = {};
236
+ for (const t of pendingTools) batch[t.toolCallId] = { approved: false };
237
+ sendBatch(batch);
238
+ }, [pendingTools, sendBatch]);
239
+
240
+ const handleIndividualDecision = useCallback(
241
+ (toolCallId: string, approved: boolean, feedback?: string) => {
242
+ const next = { ...decisions, [toolCallId]: { approved, feedback } };
243
+ setDecisions(next);
244
+ if (pendingTools.every(t => next[t.toolCallId])) {
245
+ sendBatch(next);
246
+ }
247
+ },
248
+ [decisions, pendingTools, sendBatch],
249
+ );
250
+
251
+ const undoDecision = useCallback((toolCallId: string) => {
252
+ setDecisions(prev => {
253
+ const next = { ...prev };
254
+ delete next[toolCallId];
255
+ return next;
256
+ });
257
+ }, []);
258
+
259
+ // ── Panel click handler ───────────────────────────────────────────
260
  const handleClick = useCallback(
261
+ (tool: DynamicToolPart) => {
262
+ const args = tool.input as Record<string, unknown> | undefined;
263
+
264
+ if (tool.toolName === 'hf_jobs' && args?.script) {
265
  clearPanelTabs();
266
+ setPanelTab({ id: 'script', title: 'Script', content: String(args.script), language: 'python' });
267
+ if (tool.state === 'output-available' && tool.output) {
268
+ setPanelTab({ id: 'output', title: 'Output', content: String(tool.output), language: 'markdown' });
 
 
 
 
 
 
 
 
 
 
269
  }
270
+ setActivePanelTab(tool.state === 'output-available' ? 'output' : 'script');
 
 
 
 
 
 
 
 
 
271
  setRightPanelOpen(true);
272
  setLeftSidebarOpen(false);
273
  return;
274
  }
275
 
276
+ if ((tool.state === 'output-available' || tool.state === 'output-error') && tool.output) {
277
+ let language = 'text';
278
+ const content = String(tool.output);
279
+ if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
280
+ else if (content.includes('```')) language = 'markdown';
 
 
 
 
 
 
 
 
 
281
 
282
+ useAgentStore.getState().setPanelTab({ id: 'tool_output', title: tool.toolName, content, language });
283
+ useAgentStore.getState().setActivePanelTab('tool_output');
284
+ setRightPanelOpen(true);
285
+ } else if (args) {
286
+ const content = JSON.stringify(args, null, 2);
287
+ useAgentStore.getState().setPanelTab({ id: 'tool_output', title: tool.toolName, content, language: 'json' });
288
+ useAgentStore.getState().setActivePanelTab('tool_output');
289
+ setRightPanelOpen(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  }
291
  },
292
+ [clearPanelTabs, setPanelTab, setActivePanelTab, setRightPanelOpen, setLeftSidebarOpen],
293
  );
294
 
295
+ // ── Parse hf_jobs metadata from output ────────────────────────────
296
+ function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } {
297
+ if (typeof output !== 'string') return {};
298
+ const urlMatch = output.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
299
+ const statusMatch = output.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
300
+ return {
301
+ jobUrl: urlMatch?.[1],
302
+ jobStatus: statusMatch?.[1]?.trim(),
303
+ };
304
+ }
305
+
306
+ // ── Render ────────────────────────────────────────────────────────
307
+ const decidedCount = pendingTools.filter(t => decisions[t.toolCallId]).length;
308
+
309
  return (
310
  <Box
311
  sx={{
 
316
  my: 1,
317
  }}
318
  >
319
+ {/* Batch approval header */}
320
+ {hasPendingApprovals && !isSubmitting && (
321
+ <Box
322
+ sx={{
323
+ display: 'flex',
324
+ alignItems: 'center',
325
+ gap: 1,
326
+ px: 1.5,
327
+ py: 1,
328
+ borderBottom: '1px solid var(--tool-border)',
329
+ }}
330
+ >
331
+ <Typography
332
+ variant="body2"
333
+ sx={{ fontSize: '0.72rem', color: 'var(--muted-text)', mr: 'auto', whiteSpace: 'nowrap' }}
334
+ >
335
+ {decidedCount > 0
336
+ ? `${decidedCount}/${pendingTools.length} decided`
337
+ : `${pendingTools.length} tool${pendingTools.length > 1 ? 's' : ''} pending`}
338
+ </Typography>
339
+ <Button
340
+ size="small"
341
+ onClick={handleRejectAll}
342
+ sx={{
343
+ textTransform: 'none',
344
+ color: 'var(--accent-red)',
345
+ border: '1px solid rgba(255,255,255,0.05)',
346
+ fontSize: '0.72rem',
347
+ py: 0.5,
348
+ px: 1.5,
349
+ borderRadius: '8px',
350
+ '&:hover': { bgcolor: 'rgba(224,90,79,0.05)', borderColor: 'var(--accent-red)' },
351
+ }}
352
+ >
353
+ Reject all
354
+ </Button>
355
+ <Button
356
+ size="small"
357
+ onClick={handleApproveAll}
358
+ sx={{
359
+ textTransform: 'none',
360
+ color: 'var(--accent-green)',
361
+ border: '1px solid var(--accent-green)',
362
+ fontSize: '0.72rem',
363
+ fontWeight: 600,
364
+ py: 0.5,
365
+ px: 1.5,
366
+ borderRadius: '8px',
367
+ '&:hover': { bgcolor: 'rgba(47,204,113,0.1)' },
368
+ }}
369
+ >
370
+ Approve all{pendingTools.length > 1 ? ` (${pendingTools.length})` : ''}
371
+ </Button>
372
+ </Box>
373
+ )}
374
+
375
+ {/* Tool list */}
376
  <Stack divider={<Box sx={{ borderBottom: '1px solid var(--tool-border)' }} />}>
377
+ {tools.map((tool) => {
378
+ const state = tool.state;
379
+ const isPending = state === 'approval-requested';
380
+ const clickable =
381
+ state === 'output-available' ||
382
+ state === 'output-error' ||
383
+ !!tool.input;
384
+ const localDecision = decisions[tool.toolCallId];
385
+
386
+ const displayState = isPending && localDecision
387
+ ? (localDecision.approved ? 'input-available' : 'output-denied')
388
+ : state;
389
+ const label = statusLabel(displayState as ToolPartState);
390
+
391
+ // Parse job metadata from hf_jobs output
392
+ const jobMeta = tool.toolName === 'hf_jobs' && tool.state === 'output-available'
393
+ ? parseJobMeta(tool.output)
394
+ : {};
395
 
396
  return (
397
+ <Box key={tool.toolCallId}>
398
  {/* Main tool row */}
399
  <Stack
400
  direction="row"
401
  alignItems="center"
402
  spacing={1}
403
+ onClick={() => !isPending && handleClick(tool)}
404
  sx={{
405
  px: 1.5,
406
  py: 1,
407
+ cursor: isPending ? 'default' : clickable ? 'pointer' : 'default',
408
  transition: 'background-color 0.15s',
409
+ '&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
410
  }}
411
  >
412
+ <StatusIcon state={displayState as ToolPartState} />
413
 
414
  <Typography
415
  variant="body2"
 
425
  whiteSpace: 'nowrap',
426
  }}
427
  >
428
+ {tool.toolName}
429
  </Typography>
430
 
431
  {label && (
 
437
  fontSize: '0.65rem',
438
  fontWeight: 600,
439
  bgcolor: 'var(--accent-yellow-weak)',
440
+ color: statusColor(displayState as ToolPartState),
441
  letterSpacing: '0.03em',
442
  }}
443
  />
444
  )}
445
 
446
+ {clickable && !isPending && (
447
  <OpenInNewIcon sx={{ fontSize: 14, color: 'var(--muted-text)', opacity: 0.6 }} />
448
  )}
449
  </Stack>
450
 
451
+ {/* Job status + link */}
452
+ {(jobMeta.jobUrl || jobMeta.jobStatus) && (
453
  <Box
454
  sx={{
455
  display: 'flex',
 
460
  borderTop: '1px solid var(--tool-border)',
461
  }}
462
  >
463
+ {jobMeta.jobStatus && (
464
  <Typography
465
  variant="caption"
466
  sx={{
467
+ color: tool.state === 'output-error' ? 'var(--accent-red)' : 'var(--accent-green)',
468
  fontSize: '0.7rem',
469
  fontWeight: 600,
470
  }}
471
  >
472
+ {jobMeta.jobStatus}
473
  </Typography>
474
  )}
475
+ {jobMeta.jobUrl && (
476
  <Link
477
+ href={jobMeta.jobUrl}
478
  target="_blank"
479
  rel="noopener noreferrer"
480
  onClick={(e) => e.stopPropagation()}
 
495
  </Box>
496
  )}
497
 
498
+ {/* Per-tool approval: undecided */}
499
+ {isPending && !localDecision && !isSubmitting && (
500
+ <InlineApproval
501
+ toolCallId={tool.toolCallId}
502
+ toolName={tool.toolName}
503
+ input={tool.input}
504
+ onResolve={handleIndividualDecision}
505
+ />
506
+ )}
507
+
508
+ {/* Per-tool approval: locally decided (undo available) */}
509
+ {isPending && localDecision && !isSubmitting && (
510
+ <Box
511
+ sx={{
512
+ display: 'flex',
513
+ alignItems: 'center',
514
+ justifyContent: 'space-between',
515
+ px: 1.5,
516
+ py: 0.75,
517
+ borderTop: '1px solid var(--tool-border)',
518
+ }}
519
+ >
520
+ <Typography variant="body2" sx={{ fontSize: '0.72rem', color: 'var(--muted-text)' }}>
521
+ {localDecision.approved
522
+ ? 'Marked for approval'
523
+ : `Marked for rejection${localDecision.feedback ? `: ${localDecision.feedback}` : ''}`}
524
+ </Typography>
525
+ <Button
526
+ size="small"
527
+ onClick={() => undoDecision(tool.toolCallId)}
528
+ sx={{
529
+ textTransform: 'none',
530
+ fontSize: '0.7rem',
531
+ color: 'var(--muted-text)',
532
+ minWidth: 'auto',
533
+ px: 1,
534
+ '&:hover': { color: 'var(--text)' },
535
+ }}
536
+ >
537
+ Undo
538
+ </Button>
539
+ </Box>
540
  )}
541
  </Box>
542
  );
frontend/src/components/Chat/UserMessage.tsx CHANGED
@@ -1,17 +1,22 @@
1
  import { Box, Stack, Typography, IconButton, Tooltip } from '@mui/material';
2
  import CloseIcon from '@mui/icons-material/Close';
3
- import type { Message } from '@/types/agent';
 
4
 
5
  interface UserMessageProps {
6
- message: Message;
7
- /** True if this message starts the last turn. */
8
  isLastTurn?: boolean;
9
- /** Callback to remove the last turn. */
10
  onUndoTurn?: () => void;
11
- /** Whether the agent is currently processing (disables undo). */
12
  isProcessing?: boolean;
13
  }
14
 
 
 
 
 
 
 
 
15
  export default function UserMessage({
16
  message,
17
  isLastTurn = false,
@@ -19,7 +24,11 @@ export default function UserMessage({
19
  isProcessing = false,
20
  }: UserMessageProps) {
21
  const showUndo = isLastTurn && !isProcessing && !!onUndoTurn;
22
-
 
 
 
 
23
  return (
24
  <Stack
25
  direction="row"
@@ -27,7 +36,6 @@ export default function UserMessage({
27
  justifyContent="flex-end"
28
  alignItems="flex-start"
29
  sx={{
30
- // Show the undo button when hovering the entire row
31
  '& .undo-btn': {
32
  opacity: 0,
33
  transition: 'opacity 0.15s ease',
@@ -37,7 +45,6 @@ export default function UserMessage({
37
  },
38
  }}
39
  >
40
- {/* Undo button — visible on hover, left of the bubble */}
41
  {showUndo && (
42
  <Box className="undo-btn" sx={{ display: 'flex', alignItems: 'center', mt: 0.75 }}>
43
  <Tooltip title="Remove this turn" placement="left">
@@ -81,22 +88,17 @@ export default function UserMessage({
81
  wordBreak: 'break-word',
82
  }}
83
  >
84
- {message.content}
85
  </Typography>
86
 
87
- <Typography
88
- variant="caption"
89
- sx={{
90
- display: 'block',
91
- textAlign: 'right',
92
- mt: 1,
93
- fontSize: '0.68rem',
94
- color: 'var(--muted-text)',
95
- opacity: 0.7,
96
- }}
97
- >
98
- {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
99
- </Typography>
100
  </Box>
101
  </Stack>
102
  );
 
1
  import { Box, Stack, Typography, IconButton, Tooltip } from '@mui/material';
2
  import CloseIcon from '@mui/icons-material/Close';
3
+ import type { UIMessage } from 'ai';
4
+ import type { MessageMeta } from '@/types/agent';
5
 
6
  interface UserMessageProps {
7
+ message: UIMessage;
 
8
  isLastTurn?: boolean;
 
9
  onUndoTurn?: () => void;
 
10
  isProcessing?: boolean;
11
  }
12
 
13
+ function extractText(message: UIMessage): string {
14
+ return message.parts
15
+ .filter((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text')
16
+ .map(p => p.text)
17
+ .join('');
18
+ }
19
+
20
  export default function UserMessage({
21
  message,
22
  isLastTurn = false,
 
24
  isProcessing = false,
25
  }: UserMessageProps) {
26
  const showUndo = isLastTurn && !isProcessing && !!onUndoTurn;
27
+ const text = extractText(message);
28
+ const meta = message.metadata as MessageMeta | undefined;
29
+ const timeStr = meta?.createdAt
30
+ ? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
31
+ : null;
32
  return (
33
  <Stack
34
  direction="row"
 
36
  justifyContent="flex-end"
37
  alignItems="flex-start"
38
  sx={{
 
39
  '& .undo-btn': {
40
  opacity: 0,
41
  transition: 'opacity 0.15s ease',
 
45
  },
46
  }}
47
  >
 
48
  {showUndo && (
49
  <Box className="undo-btn" sx={{ display: 'flex', alignItems: 'center', mt: 0.75 }}>
50
  <Tooltip title="Remove this turn" placement="left">
 
88
  wordBreak: 'break-word',
89
  }}
90
  >
91
+ {text}
92
  </Typography>
93
 
94
+ {timeStr && (
95
+ <Typography
96
+ variant="caption"
97
+ sx={{ color: 'var(--muted-text)', mt: 0.5, display: 'block', textAlign: 'right', fontSize: '0.7rem' }}
98
+ >
99
+ {timeStr}
100
+ </Typography>
101
+ )}
 
 
 
 
 
102
  </Box>
103
  </Stack>
104
  );
frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, useEffect } from 'react';
2
  import {
3
  Avatar,
4
  Box,
@@ -7,6 +7,7 @@ import {
7
  IconButton,
8
  Alert,
9
  AlertTitle,
 
10
  useMediaQuery,
11
  useTheme,
12
  } from '@mui/material';
@@ -20,20 +21,19 @@ import { logger } from '@/utils/logger';
20
  import { useSessionStore } from '@/store/sessionStore';
21
  import { useAgentStore } from '@/store/agentStore';
22
  import { useLayoutStore } from '@/store/layoutStore';
23
- import { useAgentWebSocket } from '@/hooks/useAgentWebSocket';
24
  import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
25
  import CodePanel from '@/components/CodePanel/CodePanel';
26
  import ChatInput from '@/components/Chat/ChatInput';
27
  import MessageList from '@/components/Chat/MessageList';
28
  import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
29
  import { apiFetch } from '@/utils/api';
30
- import type { Message } from '@/types/agent';
31
 
32
  const DRAWER_WIDTH = 260;
33
 
34
  export default function AppLayout() {
35
  const { sessions, activeSessionId, deleteSession, updateSessionTitle } = useSessionStore();
36
- const { isConnected, isProcessing, getMessages, addMessage, setProcessing, llmHealthError, setLlmHealthError, user } = useAgentStore();
37
  const {
38
  isLeftSidebarOpen,
39
  isRightPanelOpen,
@@ -48,6 +48,9 @@ export default function AppLayout() {
48
  const theme = useTheme();
49
  const isMobile = useMediaQuery(theme.breakpoints.down('md'));
50
 
 
 
 
51
  const isResizing = useRef(false);
52
 
53
  const handleMouseMove = useCallback((e: MouseEvent) => {
@@ -105,10 +108,9 @@ export default function AppLayout() {
105
  return () => { cancelled = true; };
106
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
107
 
108
- const messages = activeSessionId ? getMessages(activeSessionId) : [];
109
  const hasAnySessions = sessions.length > 0;
110
 
111
- useAgentWebSocket({
112
  sessionId: activeSessionId,
113
  onReady: () => logger.log('Agent ready'),
114
  onError: (error) => logger.error('Agent error:', error),
@@ -118,24 +120,29 @@ export default function AppLayout() {
118
  },
119
  });
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  const handleSendMessage = useCallback(
122
  async (text: string) => {
123
  if (!activeSessionId || !text.trim() || isProcessing) return;
124
-
125
- // Lock input immediately to prevent double-sends
126
- setProcessing(true);
127
 
128
- const userMsg: Message = {
129
- id: `user_${Date.now()}`,
130
- role: 'user',
131
- content: text.trim(),
132
- timestamp: new Date().toISOString(),
133
- };
134
- addMessage(activeSessionId, userMsg);
135
 
136
  // Auto-title the session from the first user message (async, non-blocking)
137
- const currentMessages = getMessages(activeSessionId);
138
- const isFirstMessage = currentMessages.filter((m) => m.role === 'user').length <= 1;
139
  if (isFirstMessage) {
140
  const sessionId = activeSessionId;
141
  apiFetch('/api/title', {
@@ -151,20 +158,8 @@ export default function AppLayout() {
151
  updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '…' : raw);
152
  });
153
  }
154
-
155
- try {
156
- await apiFetch('/api/submit', {
157
- method: 'POST',
158
- body: JSON.stringify({
159
- session_id: activeSessionId,
160
- text: text.trim(),
161
- }),
162
- });
163
- } catch (e) {
164
- logger.error('Send failed:', e);
165
- }
166
  },
167
- [activeSessionId, addMessage, getMessages, updateSessionTitle, isProcessing, setProcessing]
168
  );
169
 
170
  // Close sidebar on mobile after selecting a session
@@ -363,28 +358,12 @@ export default function AppLayout() {
363
  >
364
  {activeSessionId ? (
365
  <>
366
- <MessageList messages={messages} isProcessing={isProcessing} />
367
- {!isConnected && messages.length > 0 && (
368
- <Box sx={{
369
- display: 'flex',
370
- alignItems: 'center',
371
- justifyContent: 'center',
372
- gap: 1,
373
- py: 1,
374
- px: { xs: 1, md: 2 },
375
- mb: 1,
376
- borderRadius: 'var(--radius-md)',
377
- bgcolor: 'rgba(255, 171, 0, 0.08)',
378
- border: '1px solid rgba(255, 171, 0, 0.2)',
379
- }}>
380
- <Typography variant="body2" sx={{ color: 'var(--accent-yellow)', fontFamily: 'monospace', fontSize: { xs: '0.7rem', md: '0.8rem' } }}>
381
- Session expired — create a new session to continue.
382
- </Typography>
383
- </Box>
384
- )}
385
  <ChatInput
386
  onSend={handleSendMessage}
 
387
  disabled={isProcessing || !isConnected}
 
388
  />
389
  </>
390
  ) : (
@@ -466,6 +445,20 @@ export default function AppLayout() {
466
  <CodePanel />
467
  </Drawer>
468
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  </Box>
470
  );
471
  }
 
1
+ import { useCallback, useRef, useEffect, useState } from 'react';
2
  import {
3
  Avatar,
4
  Box,
 
7
  IconButton,
8
  Alert,
9
  AlertTitle,
10
+ Snackbar,
11
  useMediaQuery,
12
  useTheme,
13
  } from '@mui/material';
 
21
  import { useSessionStore } from '@/store/sessionStore';
22
  import { useAgentStore } from '@/store/agentStore';
23
  import { useLayoutStore } from '@/store/layoutStore';
24
+ import { useAgentChat } from '@/hooks/useAgentChat';
25
  import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
26
  import CodePanel from '@/components/CodePanel/CodePanel';
27
  import ChatInput from '@/components/Chat/ChatInput';
28
  import MessageList from '@/components/Chat/MessageList';
29
  import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
30
  import { apiFetch } from '@/utils/api';
 
31
 
32
  const DRAWER_WIDTH = 260;
33
 
34
  export default function AppLayout() {
35
  const { sessions, activeSessionId, deleteSession, updateSessionTitle } = useSessionStore();
36
+ const { isConnected, isProcessing, setProcessing, llmHealthError, setLlmHealthError, user } = useAgentStore();
37
  const {
38
  isLeftSidebarOpen,
39
  isRightPanelOpen,
 
48
  const theme = useTheme();
49
  const isMobile = useMediaQuery(theme.breakpoints.down('md'));
50
 
51
+ const [showExpiredToast, setShowExpiredToast] = useState(false);
52
+ const disconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
53
+
54
  const isResizing = useRef(false);
55
 
56
  const handleMouseMove = useCallback((e: MouseEvent) => {
 
108
  return () => { cancelled = true; };
109
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
110
 
 
111
  const hasAnySessions = sessions.length > 0;
112
 
113
+ const { messages, sendMessage, stop, undoLastTurn, approveTools } = useAgentChat({
114
  sessionId: activeSessionId,
115
  onReady: () => logger.log('Agent ready'),
116
  onError: (error) => logger.error('Agent error:', error),
 
120
  },
121
  });
122
 
123
+ // Debounced "session expired" toast — only fires after 2s of sustained disconnect
124
+ useEffect(() => {
125
+ if (!isConnected && messages.length > 0 && activeSessionId) {
126
+ disconnectTimer.current = setTimeout(() => setShowExpiredToast(true), 2000);
127
+ } else {
128
+ if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
129
+ disconnectTimer.current = null;
130
+ setShowExpiredToast(false);
131
+ }
132
+ return () => {
133
+ if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
134
+ };
135
+ }, [isConnected, messages.length, activeSessionId]);
136
+
137
  const handleSendMessage = useCallback(
138
  async (text: string) => {
139
  if (!activeSessionId || !text.trim() || isProcessing) return;
 
 
 
140
 
141
+ setProcessing(true);
142
+ sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
 
 
 
 
 
143
 
144
  // Auto-title the session from the first user message (async, non-blocking)
145
+ const isFirstMessage = messages.filter((m) => m.role === 'user').length <= 1;
 
146
  if (isFirstMessage) {
147
  const sessionId = activeSessionId;
148
  apiFetch('/api/title', {
 
158
  updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '…' : raw);
159
  });
160
  }
 
 
 
 
 
 
 
 
 
 
 
 
161
  },
162
+ [activeSessionId, sendMessage, messages, updateSessionTitle, isProcessing, setProcessing],
163
  );
164
 
165
  // Close sidebar on mobile after selecting a session
 
358
  >
359
  {activeSessionId ? (
360
  <>
361
+ <MessageList messages={messages} isProcessing={isProcessing} approveTools={approveTools} onUndoLastTurn={undoLastTurn} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  <ChatInput
363
  onSend={handleSendMessage}
364
+ onStop={stop}
365
  disabled={isProcessing || !isConnected}
366
+ isStreaming={isProcessing}
367
  />
368
  </>
369
  ) : (
 
445
  <CodePanel />
446
  </Drawer>
447
  )}
448
+ <Snackbar
449
+ open={showExpiredToast}
450
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
451
+ onClose={() => setShowExpiredToast(false)}
452
+ >
453
+ <Alert
454
+ severity="warning"
455
+ variant="filled"
456
+ onClose={() => setShowExpiredToast(false)}
457
+ sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}
458
+ >
459
+ Session expired — create a new session to continue.
460
+ </Alert>
461
+ </Snackbar>
462
  </Box>
463
  );
464
  }
frontend/src/hooks/useAgentChat.ts ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Central hook wiring the Vercel AI SDK's useChat with our custom
3
+ * WebSocketChatTransport. Replaces the old useAgentWebSocket + agentStore
4
+ * message management.
5
+ */
6
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
7
+ import { useChat } from '@ai-sdk/react';
8
+ import type { UIMessage } from 'ai';
9
+ import { WebSocketChatTransport, type SideChannelCallbacks } from '@/lib/ws-chat-transport';
10
+ import { loadMessages, saveMessages } from '@/lib/chat-message-store';
11
+ import { apiFetch } from '@/utils/api';
12
+ import { useAgentStore } from '@/store/agentStore';
13
+ import { useSessionStore } from '@/store/sessionStore';
14
+ import { useLayoutStore } from '@/store/layoutStore';
15
+ import { logger } from '@/utils/logger';
16
+
17
+ interface UseAgentChatOptions {
18
+ sessionId: string | null;
19
+ onReady?: () => void;
20
+ onError?: (error: string) => void;
21
+ onSessionDead?: (sessionId: string) => void;
22
+ }
23
+
24
+ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: UseAgentChatOptions) {
25
+ const callbacksRef = useRef({ onReady, onError, onSessionDead });
26
+ callbacksRef.current = { onReady, onError, onSessionDead };
27
+
28
+ const {
29
+ setProcessing,
30
+ setConnected,
31
+ setError,
32
+ setPanelTab,
33
+ setActivePanelTab,
34
+ clearPanelTabs,
35
+ } = useAgentStore();
36
+
37
+ const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
38
+ const { setSessionActive } = useSessionStore();
39
+
40
+ // ── Build side-channel callbacks (stable ref) ────────────────────
41
+ const sideChannel = useMemo<SideChannelCallbacks>(
42
+ () => ({
43
+ onReady: () => {
44
+ setConnected(true);
45
+ setProcessing(false);
46
+ if (sessionId) setSessionActive(sessionId, true);
47
+ callbacksRef.current.onReady?.();
48
+ },
49
+ onShutdown: () => {
50
+ setConnected(false);
51
+ setProcessing(false);
52
+ },
53
+ onError: (error: string) => {
54
+ setError(error);
55
+ setProcessing(false);
56
+ callbacksRef.current.onError?.(error);
57
+ },
58
+ onProcessing: () => {
59
+ setProcessing(true);
60
+ },
61
+ onProcessingDone: () => {
62
+ setProcessing(false);
63
+ },
64
+ onUndoComplete: () => {
65
+ setProcessing(false);
66
+ // Remove the last turn (user msg + assistant response) from useChat state
67
+ const setMsgs = chatActionsRef.current.setMessages;
68
+ const msgs = chatActionsRef.current.messages;
69
+ if (setMsgs && msgs.length > 0) {
70
+ let lastUserIdx = -1;
71
+ for (let i = msgs.length - 1; i >= 0; i--) {
72
+ if (msgs[i].role === 'user') { lastUserIdx = i; break; }
73
+ }
74
+ const updated = lastUserIdx > 0 ? msgs.slice(0, lastUserIdx) : [];
75
+ setMsgs(updated);
76
+ if (sessionId) saveMessages(sessionId, updated);
77
+ }
78
+ },
79
+ onCompacted: (oldTokens: number, newTokens: number) => {
80
+ logger.log(`Context compacted: ${oldTokens} → ${newTokens} tokens`);
81
+ },
82
+ onPlanUpdate: (plan) => {
83
+ useAgentStore.getState().setPlan(plan as Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }>);
84
+ if (!useLayoutStore.getState().isRightPanelOpen) {
85
+ setRightPanelOpen(true);
86
+ }
87
+ },
88
+ onToolLog: (tool: string, log: string) => {
89
+ if (tool === 'hf_jobs') {
90
+ const currentTabs = useAgentStore.getState().panelTabs;
91
+ const logsTab = currentTabs.find(t => t.id === 'logs');
92
+ const newContent = logsTab
93
+ ? logsTab.content + '\n' + log
94
+ : '--- Job execution started ---\n' + log;
95
+
96
+ setPanelTab({ id: 'logs', title: 'Logs', content: newContent, language: 'text' });
97
+ setActivePanelTab('logs');
98
+
99
+ if (!useLayoutStore.getState().isRightPanelOpen) {
100
+ setRightPanelOpen(true);
101
+ }
102
+ }
103
+ },
104
+ onConnectionChange: (connected: boolean) => {
105
+ setConnected(connected);
106
+ },
107
+ onSessionDead: (deadSessionId: string) => {
108
+ logger.warn(`Session ${deadSessionId} dead, removing`);
109
+ callbacksRef.current.onSessionDead?.(deadSessionId);
110
+ },
111
+ onApprovalRequired: (tools) => {
112
+ if (!tools.length) return;
113
+ const firstTool = tools[0];
114
+ const args = firstTool.arguments as Record<string, string | undefined>;
115
+
116
+ clearPanelTabs();
117
+
118
+ if (firstTool.tool === 'hf_jobs' && args.script) {
119
+ setPanelTab({ id: 'script', title: 'Script', content: args.script, language: 'python', parameters: firstTool.arguments });
120
+ setActivePanelTab('script');
121
+ } else if (firstTool.tool === 'hf_repo_files' && args.content) {
122
+ const filename = args.path || 'file';
123
+ setPanelTab({
124
+ id: 'content',
125
+ title: filename.split('/').pop() || 'Content',
126
+ content: args.content,
127
+ language: filename.endsWith('.py') ? 'python' : 'text',
128
+ parameters: firstTool.arguments,
129
+ });
130
+ setActivePanelTab('content');
131
+ } else {
132
+ setPanelTab({
133
+ id: 'args',
134
+ title: firstTool.tool,
135
+ content: JSON.stringify(firstTool.arguments, null, 2),
136
+ language: 'json',
137
+ parameters: firstTool.arguments,
138
+ });
139
+ setActivePanelTab('args');
140
+ }
141
+
142
+ setRightPanelOpen(true);
143
+ setLeftSidebarOpen(false);
144
+ },
145
+ onToolCallPanel: (toolName: string, args: Record<string, unknown>) => {
146
+ if (toolName === 'hf_jobs' && args.operation && args.script) {
147
+ clearPanelTabs();
148
+ setPanelTab({
149
+ id: 'script',
150
+ title: 'Script',
151
+ content: String(args.script),
152
+ language: 'python',
153
+ parameters: args,
154
+ });
155
+ setActivePanelTab('script');
156
+ setRightPanelOpen(true);
157
+ setLeftSidebarOpen(false);
158
+ } else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
159
+ useAgentStore.getState().setPanelContent({
160
+ title: `File Upload: ${String(args.path || 'unnamed')}`,
161
+ content: String(args.content),
162
+ parameters: args,
163
+ language: String(args.path || '').endsWith('.py') ? 'python' : undefined,
164
+ });
165
+ setRightPanelOpen(true);
166
+ setLeftSidebarOpen(false);
167
+ }
168
+ },
169
+ onToolOutputPanel: (toolName: string, _toolCallId: string, output: string, success: boolean) => {
170
+ if (toolName === 'hf_jobs' && output) {
171
+ setPanelTab({ id: 'output', title: 'Output', content: output, language: 'markdown' });
172
+ if (!success) setActivePanelTab('output');
173
+ }
174
+ },
175
+ }),
176
+ // Zustand setters are stable
177
+ // eslint-disable-next-line react-hooks/exhaustive-deps
178
+ [sessionId],
179
+ );
180
+
181
+ // ── Create transport (single stable instance for the lifetime of this hook) ──
182
+ const transportRef = useRef<WebSocketChatTransport | null>(null);
183
+ if (!transportRef.current) {
184
+ transportRef.current = new WebSocketChatTransport({ sideChannel });
185
+ }
186
+
187
+ // Keep side-channel callbacks in sync (they capture sessionId)
188
+ useEffect(() => {
189
+ transportRef.current?.updateSideChannel(sideChannel);
190
+ }, [sideChannel]);
191
+
192
+ // Connect / disconnect WebSocket when session changes
193
+ useEffect(() => {
194
+ transportRef.current?.connectToSession(sessionId);
195
+ return () => {
196
+ transportRef.current?.connectToSession(null);
197
+ };
198
+ }, [sessionId]);
199
+
200
+ // ── Restore persisted messages for this session ─────────────────
201
+ const initialMessages = useMemo(
202
+ () => (sessionId ? loadMessages(sessionId) : []),
203
+ [sessionId],
204
+ );
205
+
206
+ // ── Ref for chat actions (used by sideChannel callbacks created before chat) ──
207
+ const chatActionsRef = useRef<{
208
+ setMessages: ((msgs: UIMessage[]) => void) | null;
209
+ messages: UIMessage[];
210
+ }>({ setMessages: null, messages: [] });
211
+
212
+ // ── useChat from Vercel AI SDK ───────────────────────────────────
213
+ const chat = useChat({
214
+ id: sessionId || '__no_session__',
215
+ messages: initialMessages,
216
+ transport: transportRef.current!,
217
+ experimental_throttle: 80,
218
+ onFinish: ({ messages, isAbort, isError }) => {
219
+ if (isAbort || isError) return;
220
+ if (sessionId && messages.length > 0) {
221
+ saveMessages(sessionId, messages);
222
+ }
223
+ },
224
+ onError: (error) => {
225
+ logger.error('useChat error:', error);
226
+ setError(error.message);
227
+ setProcessing(false);
228
+ },
229
+ });
230
+
231
+ // Keep chatActionsRef in sync every render
232
+ chatActionsRef.current.setMessages = chat.setMessages;
233
+ chatActionsRef.current.messages = chat.messages;
234
+
235
+ // ── Persist messages on every user send (onFinish covers assistant turns) ──
236
+ const prevLenRef = useRef(initialMessages.length);
237
+ useEffect(() => {
238
+ if (!sessionId || chat.messages.length === 0) return;
239
+ if (chat.messages.length !== prevLenRef.current) {
240
+ prevLenRef.current = chat.messages.length;
241
+ saveMessages(sessionId, chat.messages);
242
+ }
243
+ }, [sessionId, chat.messages]);
244
+
245
+ // ── Undo last turn (calls backend + syncs useChat + localStorage) ──
246
+ const undoLastTurn = useCallback(async () => {
247
+ if (!sessionId) return;
248
+ try {
249
+ const res = await apiFetch(`/api/undo/${sessionId}`, { method: 'POST' });
250
+ if (!res.ok) {
251
+ logger.error('Undo API returned', res.status);
252
+ return;
253
+ }
254
+ } catch (e) {
255
+ logger.error('Undo failed:', e);
256
+ }
257
+ // Backend will also send undo_complete, but we apply optimistically
258
+ // so the UI updates immediately.
259
+ }, [sessionId]);
260
+
261
+ // ── Convenience: approve tools via transport ─────────────────────
262
+ const approveTools = useCallback(
263
+ async (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => {
264
+ if (!sessionId || !transportRef.current) return false;
265
+ const ok = await transportRef.current.approveTools(sessionId, approvals);
266
+ if (ok) {
267
+ const hasApproved = approvals.some(a => a.approved);
268
+ if (hasApproved) setProcessing(true);
269
+ }
270
+ return ok;
271
+ },
272
+ [sessionId, setProcessing],
273
+ );
274
+
275
+ return {
276
+ messages: chat.messages,
277
+ sendMessage: chat.sendMessage,
278
+ stop: chat.stop,
279
+ status: chat.status,
280
+ undoLastTurn,
281
+ approveTools,
282
+ transport: transportRef.current,
283
+ };
284
+ }
frontend/src/hooks/useAgentWebSocket.ts DELETED
@@ -1,636 +0,0 @@
1
- import { useCallback, useEffect, useRef } from 'react';
2
- import { useAgentStore, type PlanItem } from '@/store/agentStore';
3
- import { useSessionStore } from '@/store/sessionStore';
4
- import { useLayoutStore } from '@/store/layoutStore';
5
- import { getWebSocketUrl } from '@/utils/api';
6
- import { logger } from '@/utils/logger';
7
- import type { AgentEvent } from '@/types/events';
8
- import type { Message, TraceLog } from '@/types/agent';
9
-
10
- const WS_RECONNECT_DELAY = 1000;
11
- const WS_MAX_RECONNECT_DELAY = 30000;
12
- const WS_MAX_RETRIES = 5;
13
-
14
- interface UseAgentWebSocketOptions {
15
- sessionId: string | null;
16
- onReady?: () => void;
17
- onError?: (error: string) => void;
18
- onSessionDead?: (sessionId: string) => void;
19
- }
20
-
21
- export function useAgentWebSocket({
22
- sessionId,
23
- onReady,
24
- onError,
25
- onSessionDead,
26
- }: UseAgentWebSocketOptions) {
27
- const wsRef = useRef<WebSocket | null>(null);
28
- const reconnectTimeoutRef = useRef<number | null>(null);
29
- const reconnectDelayRef = useRef(WS_RECONNECT_DELAY);
30
- const retriesRef = useRef(0);
31
-
32
- const {
33
- addMessage,
34
- updateMessage,
35
- appendToMessage,
36
- setProcessing,
37
- setConnected,
38
- setError,
39
- addTraceLog,
40
- updateTraceLog,
41
- clearTraceLogs,
42
- setPanelContent,
43
- setPanelTab,
44
- setActivePanelTab,
45
- clearPanelTabs,
46
- setPlan,
47
- setCurrentTurnMessageId,
48
- updateCurrentTurnTrace,
49
- removeLastTurn,
50
- } = useAgentStore();
51
-
52
- const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
53
-
54
- const { setSessionActive } = useSessionStore();
55
-
56
- const handleEvent = useCallback(
57
- (event: AgentEvent) => {
58
- if (!sessionId) return;
59
-
60
- switch (event.event_type) {
61
- case 'ready':
62
- setConnected(true);
63
- setProcessing(false);
64
- setSessionActive(sessionId, true);
65
- onReady?.();
66
- break;
67
-
68
- case 'processing':
69
- setProcessing(true);
70
- clearTraceLogs();
71
- // Don't clear panel tabs here - they should persist during approval flow
72
- // Tabs will be cleared when a new tool_call sets up new content
73
- setCurrentTurnMessageId(null); // Start a new turn
74
- break;
75
-
76
- // ── Streaming: individual token chunks ──────────────────
77
- case 'assistant_chunk': {
78
- const delta = (event.data?.content as string) || '';
79
- if (!delta) break;
80
-
81
- const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
82
-
83
- if (currentTurnMsgId) {
84
- // Append delta to the existing streaming message
85
- appendToMessage(sessionId, currentTurnMsgId, delta);
86
- } else {
87
- // First chunk — create the message (with pending traces if any)
88
- const currentTrace = useAgentStore.getState().traceLogs;
89
- const messageId = `msg_${Date.now()}`;
90
- const segments: Array<{ type: 'text' | 'tools'; content?: string; tools?: typeof currentTrace }> = [];
91
-
92
- if (currentTrace.length > 0) {
93
- segments.push({ type: 'tools', tools: [...currentTrace] });
94
- clearTraceLogs();
95
- }
96
- segments.push({ type: 'text', content: delta });
97
-
98
- const message: Message = {
99
- id: messageId,
100
- role: 'assistant',
101
- content: delta,
102
- timestamp: new Date().toISOString(),
103
- segments,
104
- };
105
- addMessage(sessionId, message);
106
- setCurrentTurnMessageId(messageId);
107
- }
108
- break;
109
- }
110
-
111
- // ── Streaming ended (text is already rendered via chunks) ─
112
- case 'assistant_stream_end':
113
- // Nothing to do — chunks already built the message.
114
- // This event is just a signal that the stream is complete.
115
- break;
116
-
117
- // ── Legacy non-streaming full message (kept for backwards compat)
118
- case 'assistant_message': {
119
- const content = (event.data?.content as string) || '';
120
- const currentTrace = useAgentStore.getState().traceLogs;
121
- const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
122
-
123
- if (currentTurnMsgId) {
124
- // Update existing message - add segments chronologically
125
- const messages = useAgentStore.getState().getMessages(sessionId);
126
- const existingMsg = messages.find(m => m.id === currentTurnMsgId);
127
-
128
- if (existingMsg) {
129
- const segments = existingMsg.segments ? [...existingMsg.segments] : [];
130
-
131
- // If there are pending traces, add them as a tools segment first
132
- if (currentTrace.length > 0) {
133
- segments.push({ type: 'tools', tools: [...currentTrace] });
134
- clearTraceLogs();
135
- }
136
-
137
- // Add the new text segment
138
- if (content) {
139
- segments.push({ type: 'text', content });
140
- }
141
-
142
- updateMessage(sessionId, currentTurnMsgId, {
143
- content: existingMsg.content + '\n\n' + content,
144
- segments,
145
- });
146
- }
147
- } else {
148
- // Create new message
149
- const messageId = `msg_${Date.now()}`;
150
- const segments: Array<{ type: 'text' | 'tools'; content?: string; tools?: typeof currentTrace }> = [];
151
-
152
- // Add any pending traces first
153
- if (currentTrace.length > 0) {
154
- segments.push({ type: 'tools', tools: [...currentTrace] });
155
- clearTraceLogs();
156
- }
157
-
158
- // Add the text
159
- if (content) {
160
- segments.push({ type: 'text', content });
161
- }
162
-
163
- const message: Message = {
164
- id: messageId,
165
- role: 'assistant',
166
- content,
167
- timestamp: new Date().toISOString(),
168
- segments,
169
- };
170
- addMessage(sessionId, message);
171
- setCurrentTurnMessageId(messageId);
172
- }
173
- break;
174
- }
175
-
176
- case 'tool_call': {
177
- const toolName = (event.data?.tool as string) || 'unknown';
178
- const toolCallId = (event.data?.tool_call_id as string) || '';
179
- const args = (event.data?.arguments as Record<string, string | undefined>) || {};
180
-
181
- // Don't display plan_tool in trace logs (it shows up elsewhere in the UI)
182
- if (toolName !== 'plan_tool') {
183
- const log: TraceLog = {
184
- id: `tool_${Date.now()}_${toolCallId}`,
185
- toolCallId,
186
- type: 'call',
187
- text: `Agent is executing ${toolName}...`,
188
- tool: toolName,
189
- timestamp: new Date().toISOString(),
190
- state: 'running',
191
- completed: false, // legacy compat
192
- args,
193
- };
194
- addTraceLog(log);
195
-
196
- // If no assistant message exists for this turn, create one now
197
- // so the ToolCallGroup renders immediately in the chat flow.
198
- const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
199
- if (!currentTurnMsgId) {
200
- const messageId = `msg_${Date.now()}`;
201
- const currentTrace = useAgentStore.getState().traceLogs;
202
- addMessage(sessionId, {
203
- id: messageId,
204
- role: 'assistant',
205
- content: '',
206
- timestamp: new Date().toISOString(),
207
- segments: [{ type: 'tools', tools: [...currentTrace] }],
208
- });
209
- setCurrentTurnMessageId(messageId);
210
- clearTraceLogs();
211
- } else {
212
- updateCurrentTurnTrace(sessionId);
213
- }
214
- }
215
-
216
- // Auto-expand Right Panel for specific tools
217
- if (toolName === 'hf_jobs' && (args.operation === 'run' || args.operation === 'scheduled run') && args.script) {
218
- // Clear any existing tabs from previous jobs before setting new script
219
- clearPanelTabs();
220
- // Use tab system for jobs - add script tab immediately
221
- setPanelTab({
222
- id: 'script',
223
- title: 'Script',
224
- content: args.script,
225
- language: 'python',
226
- parameters: args
227
- });
228
- setActivePanelTab('script');
229
- setRightPanelOpen(true);
230
- setLeftSidebarOpen(false);
231
- } else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
232
- setPanelContent({
233
- title: `File Upload: ${args.path || 'unnamed'}`,
234
- content: args.content,
235
- parameters: args,
236
- language: args.path?.endsWith('.py') ? 'python' : undefined
237
- });
238
- setRightPanelOpen(true);
239
- setLeftSidebarOpen(false);
240
- }
241
-
242
- logger.log('Tool call:', toolName, args);
243
- break;
244
- }
245
-
246
- case 'tool_output': {
247
- const toolName = (event.data?.tool as string) || 'unknown';
248
- const toolCallId = (event.data?.tool_call_id as string) || '';
249
- const output = (event.data?.output as string) || '';
250
- const success = event.data?.success as boolean;
251
-
252
- // Mark the tool as completed/failed with its output
253
- updateTraceLog(toolCallId, toolName, {
254
- state: success ? 'completed' : 'failed',
255
- completed: true, // legacy
256
- output,
257
- success,
258
- approvalStatus: 'approved', // legacy: if we got output, it was approved/auto
259
- });
260
- updateCurrentTurnTrace(sessionId);
261
-
262
- // For hf_jobs: parse job output and enrich the TraceLog with job info
263
- if (toolName === 'hf_jobs' && output) {
264
- const updates: Partial<TraceLog> = { approvalStatus: 'approved' as const };
265
-
266
- // Parse job URL
267
- const urlMatch = output.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
268
- if (urlMatch) updates.jobUrl = urlMatch[1];
269
-
270
- // Parse job status
271
- const statusMatch = output.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
272
- if (statusMatch) updates.jobStatus = statusMatch[1].trim();
273
-
274
- // Parse logs
275
- if (output.includes('**Logs:**')) {
276
- const parts = output.split('**Logs:**');
277
- if (parts.length > 1) {
278
- const codeBlockMatch = parts[1].trim().match(/```([\s\S]*?)```/);
279
- if (codeBlockMatch) updates.jobLogs = codeBlockMatch[1].trim();
280
- }
281
- }
282
-
283
- updateTraceLog(toolCallId, toolName, updates);
284
- updateCurrentTurnTrace(sessionId);
285
-
286
- // Add output tab so the user can see results (especially errors)
287
- setPanelTab({
288
- id: 'output',
289
- title: 'Output',
290
- content: output,
291
- language: 'markdown',
292
- });
293
- // Auto-switch to output tab on failure so errors are immediately visible
294
- if (!success) {
295
- setActivePanelTab('output');
296
- }
297
- }
298
-
299
- // Don't create message bubbles for tool outputs - they only show in trace logs
300
- logger.log('Tool output:', toolName, success);
301
- break;
302
- }
303
-
304
- case 'tool_log': {
305
- const toolName = (event.data?.tool as string) || 'unknown';
306
- const log = (event.data?.log as string) || '';
307
-
308
- if (toolName === 'hf_jobs') {
309
- const currentTabs = useAgentStore.getState().panelTabs;
310
- const logsTab = currentTabs.find(t => t.id === 'logs');
311
-
312
- // Append to existing logs tab or create new one
313
- const newContent = logsTab
314
- ? logsTab.content + '\n' + log
315
- : '--- Job execution started ---\n' + log;
316
-
317
- setPanelTab({
318
- id: 'logs',
319
- title: 'Logs',
320
- content: newContent,
321
- language: 'text'
322
- });
323
-
324
- // Auto-switch to logs tab when logs start streaming
325
- setActivePanelTab('logs');
326
-
327
- if (!useLayoutStore.getState().isRightPanelOpen) {
328
- setRightPanelOpen(true);
329
- }
330
- }
331
- break;
332
- }
333
-
334
- case 'plan_update': {
335
- const plan = (event.data?.plan as PlanItem[]) || [];
336
- setPlan(plan);
337
- if (!useLayoutStore.getState().isRightPanelOpen) {
338
- setRightPanelOpen(true);
339
- }
340
- break;
341
- }
342
-
343
- case 'approval_required': {
344
- const tools = event.data?.tools as Array<{
345
- tool: string;
346
- arguments: Record<string, unknown>;
347
- tool_call_id: string;
348
- }>;
349
-
350
- // Create or update trace logs for approval tools.
351
- // The backend only sends tool_call events for non-approval tools,
352
- // so we must create TraceLogs here for approval-requiring tools.
353
- if (tools) {
354
- for (const t of tools) {
355
- // Check if a TraceLog already exists (shouldn't, but be safe)
356
- const existing = useAgentStore.getState().traceLogs.find(
357
- (log) => log.toolCallId === t.tool_call_id
358
- );
359
- if (!existing) {
360
- addTraceLog({
361
- id: `tool_${Date.now()}_${t.tool_call_id}`,
362
- toolCallId: t.tool_call_id,
363
- type: 'call',
364
- text: `Approval required for ${t.tool}`,
365
- tool: t.tool,
366
- timestamp: new Date().toISOString(),
367
- state: 'pending_approval',
368
- completed: false, // legacy
369
- args: t.arguments as Record<string, unknown>,
370
- approvalStatus: 'pending', // legacy
371
- });
372
- } else {
373
- updateTraceLog(t.tool_call_id, t.tool, {
374
- state: 'pending_approval',
375
- approvalStatus: 'pending', // legacy
376
- args: t.arguments as Record<string, unknown>,
377
- });
378
- }
379
- }
380
-
381
- // Ensure there's a message to render the approval UI in
382
- const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
383
- if (!currentTurnMsgId) {
384
- const messageId = `msg_${Date.now()}`;
385
- const currentTrace = useAgentStore.getState().traceLogs;
386
- addMessage(sessionId, {
387
- id: messageId,
388
- role: 'assistant',
389
- content: '',
390
- timestamp: new Date().toISOString(),
391
- segments: [{ type: 'tools', tools: [...currentTrace] }],
392
- });
393
- setCurrentTurnMessageId(messageId);
394
- clearTraceLogs();
395
- } else {
396
- updateCurrentTurnTrace(sessionId);
397
- }
398
- }
399
-
400
- // Show the first tool's content in the panel
401
- if (tools && tools.length > 0) {
402
- const firstTool = tools[0];
403
- const args = firstTool.arguments as Record<string, string | undefined>;
404
-
405
- clearPanelTabs();
406
-
407
- if (firstTool.tool === 'hf_jobs' && args.script) {
408
- setPanelTab({
409
- id: 'script',
410
- title: 'Script',
411
- content: args.script,
412
- language: 'python',
413
- parameters: args
414
- });
415
- setActivePanelTab('script');
416
- } else if (firstTool.tool === 'hf_repo_files' && args.content) {
417
- const filename = args.path || 'file';
418
- const isPython = filename.endsWith('.py');
419
- setPanelTab({
420
- id: 'content',
421
- title: filename.split('/').pop() || 'Content',
422
- content: args.content,
423
- language: isPython ? 'python' : 'text',
424
- parameters: args
425
- });
426
- setActivePanelTab('content');
427
- } else {
428
- setPanelTab({
429
- id: 'args',
430
- title: firstTool.tool,
431
- content: JSON.stringify(args, null, 2),
432
- language: 'json',
433
- parameters: args
434
- });
435
- setActivePanelTab('args');
436
- }
437
-
438
- setRightPanelOpen(true);
439
- setLeftSidebarOpen(false);
440
- }
441
-
442
- setProcessing(false);
443
- break;
444
- }
445
-
446
- // ── Tool state change (sent by backend after approval decisions) ──
447
- case 'tool_state_change': {
448
- const tcId = (event.data?.tool_call_id as string) || '';
449
- const tcTool = (event.data?.tool as string) || '';
450
- const newState = (event.data?.state as string) || '';
451
-
452
- if (tcId && newState) {
453
- updateTraceLog(tcId, tcTool, {
454
- state: newState as import('@/types/agent').ToolState,
455
- // Legacy compat
456
- ...(newState === 'approved' ? { approvalStatus: 'approved' as const } : {}),
457
- ...(newState === 'rejected' ? { approvalStatus: 'rejected' as const, completed: true } : {}),
458
- });
459
- if (sessionId) updateCurrentTurnTrace(sessionId);
460
- }
461
- break;
462
- }
463
-
464
- case 'turn_complete':
465
- setProcessing(false);
466
- setCurrentTurnMessageId(null);
467
- break;
468
-
469
- case 'compacted': {
470
- const oldTokens = event.data?.old_tokens as number;
471
- const newTokens = event.data?.new_tokens as number;
472
- logger.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
473
- break;
474
- }
475
-
476
- case 'error': {
477
- const errorMsg = (event.data?.error as string) || 'Unknown error';
478
- setError(errorMsg);
479
- setProcessing(false);
480
- onError?.(errorMsg);
481
- break;
482
- }
483
-
484
- case 'shutdown':
485
- setConnected(false);
486
- setProcessing(false);
487
- break;
488
-
489
- case 'interrupted':
490
- setProcessing(false);
491
- break;
492
-
493
- case 'undo_complete':
494
- if (sessionId) {
495
- removeLastTurn(sessionId);
496
- }
497
- setProcessing(false);
498
- break;
499
-
500
- default:
501
- logger.log('Unknown event:', event);
502
- }
503
- },
504
- // Zustand setters are stable, so we don't need them in deps
505
- // eslint-disable-next-line react-hooks/exhaustive-deps
506
- [sessionId, onReady, onError, onSessionDead]
507
- );
508
-
509
- const connect = useCallback(() => {
510
- if (!sessionId) return;
511
-
512
- // Don't connect if already connected or connecting
513
- if (wsRef.current?.readyState === WebSocket.OPEN ||
514
- wsRef.current?.readyState === WebSocket.CONNECTING) {
515
- return;
516
- }
517
-
518
- // Build WebSocket URL (centralized in utils/api.ts)
519
- const wsUrl = getWebSocketUrl(sessionId);
520
-
521
- logger.log('Connecting to WebSocket:', wsUrl);
522
- const ws = new WebSocket(wsUrl);
523
-
524
- ws.onopen = () => {
525
- logger.log('WebSocket connected');
526
- setConnected(true);
527
- reconnectDelayRef.current = WS_RECONNECT_DELAY;
528
- retriesRef.current = 0; // Reset retry counter on successful connect
529
- };
530
-
531
- ws.onmessage = (event) => {
532
- try {
533
- const data = JSON.parse(event.data) as AgentEvent;
534
- handleEvent(data);
535
- } catch (e) {
536
- logger.error('Failed to parse WebSocket message:', e);
537
- }
538
- };
539
-
540
- ws.onerror = (error) => {
541
- logger.error('WebSocket error:', error);
542
- };
543
-
544
- ws.onclose = (event) => {
545
- logger.log('WebSocket closed', event.code, event.reason);
546
- setConnected(false);
547
-
548
- // Don't reconnect if:
549
- // - Normal closure (1000)
550
- // - Session not found (4004) — session was deleted or backend restarted
551
- // - Auth failed (4001) or access denied (4003) — won't succeed on retry
552
- // - No session ID
553
- const noRetryCodes = [1000, 4001, 4003, 4004];
554
- if (!noRetryCodes.includes(event.code) && sessionId) {
555
- retriesRef.current += 1;
556
- if (retriesRef.current > WS_MAX_RETRIES) {
557
- logger.warn(`WebSocket: max retries (${WS_MAX_RETRIES}) reached, giving up.`);
558
- onSessionDead?.(sessionId);
559
- return;
560
- }
561
- // Attempt to reconnect with exponential backoff
562
- if (reconnectTimeoutRef.current) {
563
- clearTimeout(reconnectTimeoutRef.current);
564
- }
565
- reconnectTimeoutRef.current = window.setTimeout(() => {
566
- reconnectDelayRef.current = Math.min(
567
- reconnectDelayRef.current * 2,
568
- WS_MAX_RECONNECT_DELAY
569
- );
570
- connect();
571
- }, reconnectDelayRef.current);
572
- } else if (event.code === 4004 && sessionId) {
573
- // Session not found — remove it from the store (lazy cleanup)
574
- logger.warn(`Session ${sessionId} no longer exists on backend, removing.`);
575
- onSessionDead?.(sessionId);
576
- } else if (noRetryCodes.includes(event.code) && event.code !== 1000) {
577
- logger.warn(`WebSocket permanently closed: ${event.code} ${event.reason}`);
578
- }
579
- };
580
-
581
- wsRef.current = ws;
582
- }, [sessionId, handleEvent]);
583
-
584
- const disconnect = useCallback(() => {
585
- if (reconnectTimeoutRef.current) {
586
- clearTimeout(reconnectTimeoutRef.current);
587
- reconnectTimeoutRef.current = null;
588
- }
589
- if (wsRef.current) {
590
- wsRef.current.close();
591
- wsRef.current = null;
592
- }
593
- setConnected(false);
594
- }, []);
595
-
596
- const sendPing = useCallback(() => {
597
- if (wsRef.current?.readyState === WebSocket.OPEN) {
598
- wsRef.current.send(JSON.stringify({ type: 'ping' }));
599
- }
600
- }, []);
601
-
602
- // Connect when sessionId changes (with a small delay to ensure session is ready)
603
- useEffect(() => {
604
- if (!sessionId) {
605
- disconnect();
606
- return;
607
- }
608
-
609
- // Reset retry state for new session
610
- retriesRef.current = 0;
611
- reconnectDelayRef.current = WS_RECONNECT_DELAY;
612
-
613
- // Small delay to ensure session is fully created on backend
614
- const timeoutId = setTimeout(() => {
615
- connect();
616
- }, 100);
617
-
618
- return () => {
619
- clearTimeout(timeoutId);
620
- disconnect();
621
- };
622
- // eslint-disable-next-line react-hooks/exhaustive-deps
623
- }, [sessionId]);
624
-
625
- // Heartbeat
626
- useEffect(() => {
627
- const interval = setInterval(sendPing, 30000);
628
- return () => clearInterval(interval);
629
- }, [sendPing]);
630
-
631
- return {
632
- isConnected: wsRef.current?.readyState === WebSocket.OPEN,
633
- connect,
634
- disconnect,
635
- };
636
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/lib/chat-message-store.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Lightweight localStorage persistence for UIMessage arrays,
3
+ * keyed by session ID.
4
+ *
5
+ * Uses the same storage namespace (`hf-agent-messages`) that the
6
+ * old Zustand-based store used, so existing data is compatible.
7
+ */
8
+ import type { UIMessage } from 'ai';
9
+ import { logger } from '@/utils/logger';
10
+
11
+ const STORAGE_KEY = 'hf-agent-messages';
12
+ const MAX_SESSIONS = 50;
13
+
14
+ type MessagesMap = Record<string, UIMessage[]>;
15
+
16
+ function readAll(): MessagesMap {
17
+ try {
18
+ const raw = localStorage.getItem(STORAGE_KEY);
19
+ if (!raw) return {};
20
+ const parsed = JSON.parse(raw);
21
+ // Legacy format was { messagesBySession: {...} }
22
+ if (parsed.messagesBySession) return parsed.messagesBySession;
23
+ // New flat format
24
+ if (typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
25
+ return {};
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function writeAll(map: MessagesMap): void {
32
+ try {
33
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
34
+ } catch (e) {
35
+ logger.warn('Failed to persist messages:', e);
36
+ }
37
+ }
38
+
39
+ export function loadMessages(sessionId: string): UIMessage[] {
40
+ const map = readAll();
41
+ return map[sessionId] ?? [];
42
+ }
43
+
44
+ export function saveMessages(sessionId: string, messages: UIMessage[]): void {
45
+ const map = readAll();
46
+ map[sessionId] = messages;
47
+
48
+ // Evict oldest sessions if we exceed the cap
49
+ const keys = Object.keys(map);
50
+ if (keys.length > MAX_SESSIONS) {
51
+ const toRemove = keys.slice(0, keys.length - MAX_SESSIONS);
52
+ for (const k of toRemove) delete map[k];
53
+ }
54
+
55
+ writeAll(map);
56
+ }
57
+
58
+ export function deleteMessages(sessionId: string): void {
59
+ const map = readAll();
60
+ delete map[sessionId];
61
+ writeAll(map);
62
+ }
frontend/src/lib/ws-chat-transport.ts ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Custom ChatTransport that bridges our WebSocket-based backend protocol
3
+ * to the Vercel AI SDK's UIMessageChunk streaming interface.
4
+ *
5
+ * The backend stays unchanged — this adapter translates WebSocket events
6
+ * into the chunk types that useChat() expects.
7
+ */
8
+ import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from 'ai';
9
+ import { apiFetch, getWebSocketUrl } from '@/utils/api';
10
+ import { logger } from '@/utils/logger';
11
+ import type { AgentEvent } from '@/types/events';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Side-channel callback interface (non-chat events forwarded to the store)
15
+ // ---------------------------------------------------------------------------
16
+ export interface SideChannelCallbacks {
17
+ onReady: () => void;
18
+ onShutdown: () => void;
19
+ onError: (error: string) => void;
20
+ onProcessing: () => void;
21
+ onProcessingDone: () => void;
22
+ onUndoComplete: () => void;
23
+ onCompacted: (oldTokens: number, newTokens: number) => void;
24
+ onPlanUpdate: (plan: Array<{ id: string; content: string; status: string }>) => void;
25
+ onToolLog: (tool: string, log: string) => void;
26
+ onConnectionChange: (connected: boolean) => void;
27
+ onSessionDead: (sessionId: string) => void;
28
+ /** Called when approval_required arrives — lets the store manage panels */
29
+ onApprovalRequired: (tools: Array<{ tool: string; arguments: Record<string, unknown>; tool_call_id: string }>) => void;
30
+ /** Called when a tool_call arrives with panel-relevant args */
31
+ onToolCallPanel: (tool: string, args: Record<string, unknown>) => void;
32
+ /** Called when tool_output arrives with panel-relevant data */
33
+ onToolOutputPanel: (tool: string, toolCallId: string, output: string, success: boolean) => void;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Transport options
38
+ // ---------------------------------------------------------------------------
39
+ export interface WebSocketChatTransportOptions {
40
+ sideChannel: SideChannelCallbacks;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Constants
45
+ // ---------------------------------------------------------------------------
46
+ const WS_RECONNECT_DELAY = 1000;
47
+ const WS_MAX_RECONNECT_DELAY = 30000;
48
+ const WS_MAX_RETRIES = 5;
49
+ const WS_PING_INTERVAL = 30000;
50
+
51
+ let partIdCounter = 0;
52
+ function nextPartId(prefix: string): string {
53
+ return `${prefix}-${Date.now()}-${++partIdCounter}`;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Transport implementation
58
+ // ---------------------------------------------------------------------------
59
+ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
60
+ private ws: WebSocket | null = null;
61
+ private currentSessionId: string | null = null;
62
+ private sideChannel: SideChannelCallbacks;
63
+
64
+ private streamController: ReadableStreamDefaultController<UIMessageChunk> | null = null;
65
+ private streamGeneration = 0;
66
+ private textPartId: string | null = null;
67
+ private awaitingProcessing = false;
68
+
69
+ private connectTimeout: ReturnType<typeof setTimeout> | null = null;
70
+ private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
71
+ private reconnectDelay = WS_RECONNECT_DELAY;
72
+ private retries = 0;
73
+ private pingInterval: ReturnType<typeof setInterval> | null = null;
74
+
75
+ constructor({ sideChannel }: WebSocketChatTransportOptions) {
76
+ this.sideChannel = sideChannel;
77
+ }
78
+
79
+ /** Update side-channel callbacks (e.g. when sessionId changes). */
80
+ updateSideChannel(sideChannel: SideChannelCallbacks): void {
81
+ this.sideChannel = sideChannel;
82
+ }
83
+
84
+ // ── Public API ──────────────────────────────────────────────────────
85
+
86
+ /** Connect (or reconnect) to a session's WebSocket. */
87
+ connectToSession(sessionId: string | null): void {
88
+ if (this.connectTimeout) {
89
+ clearTimeout(this.connectTimeout);
90
+ this.connectTimeout = null;
91
+ }
92
+ this.disconnectWebSocket();
93
+ this.currentSessionId = sessionId;
94
+ if (sessionId) {
95
+ this.retries = 0;
96
+ this.reconnectDelay = WS_RECONNECT_DELAY;
97
+ this.connectTimeout = setTimeout(() => {
98
+ this.connectTimeout = null;
99
+ if (this.currentSessionId === sessionId) {
100
+ this.createWebSocket(sessionId);
101
+ }
102
+ }, 100);
103
+ }
104
+ }
105
+
106
+ /** Approve / reject tools. Called directly from the UI. */
107
+ async approveTools(
108
+ sessionId: string,
109
+ approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>,
110
+ ): Promise<boolean> {
111
+ try {
112
+ const res = await apiFetch('/api/approve', {
113
+ method: 'POST',
114
+ body: JSON.stringify({ session_id: sessionId, approvals }),
115
+ });
116
+ return res.ok;
117
+ } catch (e) {
118
+ logger.error('Approval request failed:', e);
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /** Clean up everything. */
124
+ destroy(): void {
125
+ if (this.connectTimeout) {
126
+ clearTimeout(this.connectTimeout);
127
+ this.connectTimeout = null;
128
+ }
129
+ this.disconnectWebSocket();
130
+ this.closeActiveStream();
131
+ }
132
+
133
+ // ── ChatTransport interface ─────────────────────────────────────────
134
+
135
+ async sendMessages(
136
+ options: {
137
+ trigger: 'submit-message' | 'regenerate-message';
138
+ chatId: string;
139
+ messageId: string | undefined;
140
+ messages: UIMessage[];
141
+ abortSignal: AbortSignal | undefined;
142
+ } & ChatRequestOptions,
143
+ ): Promise<ReadableStream<UIMessageChunk>> {
144
+ const sessionId = options.chatId;
145
+
146
+ // Close any previously active stream (e.g. user sent new msg during approval)
147
+ this.closeActiveStream();
148
+ this.awaitingProcessing = false;
149
+
150
+ // Track generation to protect against late cancel from a stale stream
151
+ const gen = ++this.streamGeneration;
152
+
153
+ // Wire up abort signal to interrupt the backend and close the stream
154
+ if (options.abortSignal) {
155
+ const onAbort = () => {
156
+ if (this.streamGeneration !== gen) return;
157
+ logger.log('Stream aborted by user');
158
+ this.interruptBackend(sessionId);
159
+ this.endTextPart();
160
+ if (this.streamController) {
161
+ this.enqueue({ type: 'finish-step' });
162
+ this.enqueue({ type: 'finish', finishReason: 'stop' });
163
+ this.closeActiveStream();
164
+ }
165
+ this.awaitingProcessing = true;
166
+ this.sideChannel.onProcessingDone();
167
+ };
168
+ if (options.abortSignal.aborted) {
169
+ onAbort();
170
+ } else {
171
+ options.abortSignal.addEventListener('abort', onAbort, { once: true });
172
+ }
173
+ }
174
+
175
+ // Create the stream BEFORE the POST so WebSocket events arriving
176
+ // while the HTTP request is in-flight are captured immediately.
177
+ const stream = new ReadableStream<UIMessageChunk>({
178
+ start: (controller) => {
179
+ this.streamController = controller;
180
+ this.textPartId = null;
181
+ },
182
+ cancel: () => {
183
+ if (this.streamGeneration === gen) {
184
+ this.streamController = null;
185
+ this.textPartId = null;
186
+ }
187
+ },
188
+ });
189
+
190
+ // Extract the latest user text from the messages array
191
+ const lastUserMsg = [...options.messages].reverse().find(m => m.role === 'user');
192
+ const text = lastUserMsg
193
+ ? lastUserMsg.parts
194
+ .filter((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text')
195
+ .map(p => p.text)
196
+ .join('')
197
+ : '';
198
+
199
+ // POST to the existing backend endpoint
200
+ try {
201
+ await apiFetch('/api/submit', {
202
+ method: 'POST',
203
+ body: JSON.stringify({ session_id: sessionId, text }),
204
+ });
205
+ } catch (e) {
206
+ logger.error('Submit failed:', e);
207
+ this.enqueue({ type: 'error', errorText: 'Failed to send message' });
208
+ this.closeActiveStream();
209
+ }
210
+
211
+ return stream;
212
+ }
213
+
214
+ async reconnectToStream(): Promise<ReadableStream<UIMessageChunk> | null> {
215
+ return null;
216
+ }
217
+
218
+ /** Ask the backend to interrupt the current generation. Fire-and-forget. */
219
+ private interruptBackend(sessionId: string): void {
220
+ apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch((e) =>
221
+ logger.warn('Interrupt request failed:', e),
222
+ );
223
+ }
224
+
225
+ // ── WebSocket lifecycle ─────────────────────────────────────────────
226
+
227
+ private createWebSocket(sessionId: string): void {
228
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
229
+ return;
230
+ }
231
+
232
+ const wsUrl = getWebSocketUrl(sessionId);
233
+ logger.log('WS transport connecting:', wsUrl);
234
+ const ws = new WebSocket(wsUrl);
235
+
236
+ ws.onopen = () => {
237
+ logger.log('WS transport connected');
238
+ this.sideChannel.onConnectionChange(true);
239
+ this.reconnectDelay = WS_RECONNECT_DELAY;
240
+ this.retries = 0;
241
+ this.startPing();
242
+ };
243
+
244
+ ws.onmessage = (evt) => {
245
+ try {
246
+ const raw = JSON.parse(evt.data);
247
+ if (raw.type === 'pong') return;
248
+ this.handleEvent(raw as AgentEvent);
249
+ } catch (e) {
250
+ logger.error('WS parse error:', e);
251
+ }
252
+ };
253
+
254
+ ws.onerror = (err) => logger.error('WS error:', err);
255
+
256
+ ws.onclose = (evt) => {
257
+ logger.log('WS closed', evt.code, evt.reason);
258
+ this.sideChannel.onConnectionChange(false);
259
+ this.stopPing();
260
+
261
+ const noRetry = [1000, 4001, 4003, 4004];
262
+ if (evt.code === 4004 && sessionId) {
263
+ this.sideChannel.onSessionDead(sessionId);
264
+ return;
265
+ }
266
+ if (!noRetry.includes(evt.code) && this.currentSessionId === sessionId) {
267
+ this.retries += 1;
268
+ if (this.retries > WS_MAX_RETRIES) {
269
+ logger.warn('WS max retries reached');
270
+ this.sideChannel.onSessionDead(sessionId);
271
+ return;
272
+ }
273
+ this.reconnectTimeout = setTimeout(() => {
274
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, WS_MAX_RECONNECT_DELAY);
275
+ this.createWebSocket(sessionId);
276
+ }, this.reconnectDelay);
277
+ }
278
+ };
279
+
280
+ this.ws = ws;
281
+ }
282
+
283
+ private disconnectWebSocket(): void {
284
+ if (this.reconnectTimeout) {
285
+ clearTimeout(this.reconnectTimeout);
286
+ this.reconnectTimeout = null;
287
+ }
288
+ this.stopPing();
289
+ if (this.ws) {
290
+ this.ws.close();
291
+ this.ws = null;
292
+ }
293
+ this.sideChannel.onConnectionChange(false);
294
+ }
295
+
296
+ private startPing(): void {
297
+ this.stopPing();
298
+ this.pingInterval = setInterval(() => {
299
+ if (this.ws?.readyState === WebSocket.OPEN) {
300
+ this.ws.send(JSON.stringify({ type: 'ping' }));
301
+ }
302
+ }, WS_PING_INTERVAL);
303
+ }
304
+
305
+ private stopPing(): void {
306
+ if (this.pingInterval) {
307
+ clearInterval(this.pingInterval);
308
+ this.pingInterval = null;
309
+ }
310
+ }
311
+
312
+ // ── Stream helpers ──────────────────────────────────────────────────
313
+
314
+ private closeActiveStream(): void {
315
+ if (this.streamController) {
316
+ try {
317
+ this.streamController.close();
318
+ } catch {
319
+ // already closed
320
+ }
321
+ this.streamController = null;
322
+ this.textPartId = null;
323
+ }
324
+ }
325
+
326
+ private enqueue(chunk: UIMessageChunk): void {
327
+ try {
328
+ this.streamController?.enqueue(chunk);
329
+ } catch {
330
+ // stream already closed
331
+ }
332
+ }
333
+
334
+ private endTextPart(): void {
335
+ if (this.textPartId) {
336
+ this.enqueue({ type: 'text-end', id: this.textPartId });
337
+ this.textPartId = null;
338
+ }
339
+ }
340
+
341
+ // ── Event → UIMessageChunk mapping ──────────────────────────────────
342
+
343
+ private static readonly STREAM_EVENTS = new Set([
344
+ 'assistant_chunk', 'assistant_stream_end', 'assistant_message',
345
+ 'tool_call', 'tool_output', 'approval_required', 'tool_state_change',
346
+ 'turn_complete', 'error',
347
+ ]);
348
+
349
+ private handleEvent(event: AgentEvent): void {
350
+ // After an abort, ignore stale stream events until the next 'processing'
351
+ if (this.awaitingProcessing && WebSocketChatTransport.STREAM_EVENTS.has(event.event_type)) {
352
+ return;
353
+ }
354
+
355
+ switch (event.event_type) {
356
+ // ── Side-channel only events ────────────────────────────────
357
+ case 'ready':
358
+ this.sideChannel.onReady();
359
+ break;
360
+
361
+ case 'shutdown':
362
+ this.sideChannel.onShutdown();
363
+ this.closeActiveStream();
364
+ break;
365
+
366
+ case 'interrupted':
367
+ this.sideChannel.onProcessingDone();
368
+ this.closeActiveStream();
369
+ break;
370
+
371
+ case 'undo_complete':
372
+ this.endTextPart();
373
+ this.closeActiveStream();
374
+ this.sideChannel.onUndoComplete();
375
+ break;
376
+
377
+ case 'compacted':
378
+ this.sideChannel.onCompacted(
379
+ (event.data?.old_tokens as number) || 0,
380
+ (event.data?.new_tokens as number) || 0,
381
+ );
382
+ break;
383
+
384
+ case 'plan_update':
385
+ this.sideChannel.onPlanUpdate(
386
+ (event.data?.plan as Array<{ id: string; content: string; status: string }>) || [],
387
+ );
388
+ break;
389
+
390
+ case 'tool_log':
391
+ this.sideChannel.onToolLog(
392
+ (event.data?.tool as string) || '',
393
+ (event.data?.log as string) || '',
394
+ );
395
+ break;
396
+
397
+ // ── Chat stream events ──────────────────────────────────────
398
+ case 'processing':
399
+ this.awaitingProcessing = false;
400
+ this.sideChannel.onProcessing();
401
+ if (this.streamController) {
402
+ this.enqueue({
403
+ type: 'start',
404
+ messageMetadata: { createdAt: new Date().toISOString() },
405
+ });
406
+ this.enqueue({ type: 'start-step' });
407
+ }
408
+ break;
409
+
410
+ case 'assistant_chunk': {
411
+ const delta = (event.data?.content as string) || '';
412
+ if (!delta || !this.streamController) break;
413
+
414
+ if (!this.textPartId) {
415
+ this.textPartId = nextPartId('text');
416
+ this.enqueue({ type: 'text-start', id: this.textPartId });
417
+ }
418
+ this.enqueue({ type: 'text-delta', id: this.textPartId, delta });
419
+ break;
420
+ }
421
+
422
+ case 'assistant_stream_end':
423
+ this.endTextPart();
424
+ break;
425
+
426
+ case 'assistant_message': {
427
+ const content = (event.data?.content as string) || '';
428
+ if (!content || !this.streamController) break;
429
+ const id = nextPartId('text');
430
+ this.enqueue({ type: 'text-start', id });
431
+ this.enqueue({ type: 'text-delta', id, delta: content });
432
+ this.enqueue({ type: 'text-end', id });
433
+ break;
434
+ }
435
+
436
+ case 'tool_call': {
437
+ if (!this.streamController) break;
438
+ const toolName = (event.data?.tool as string) || 'unknown';
439
+ const toolCallId = (event.data?.tool_call_id as string) || '';
440
+ const args = (event.data?.arguments as Record<string, unknown>) || {};
441
+
442
+ if (toolName === 'plan_tool') break;
443
+
444
+ this.endTextPart();
445
+ this.enqueue({ type: 'tool-input-start', toolCallId, toolName, dynamic: true });
446
+ this.enqueue({ type: 'tool-input-available', toolCallId, toolName, input: args, dynamic: true });
447
+
448
+ this.sideChannel.onToolCallPanel(toolName, args as Record<string, unknown>);
449
+ break;
450
+ }
451
+
452
+ case 'tool_output': {
453
+ if (!this.streamController) break;
454
+ const toolCallId = (event.data?.tool_call_id as string) || '';
455
+ const output = (event.data?.output as string) || '';
456
+ const success = event.data?.success as boolean;
457
+ const toolName = (event.data?.tool as string) || '';
458
+
459
+ if (success) {
460
+ this.enqueue({ type: 'tool-output-available', toolCallId, output, dynamic: true });
461
+ } else {
462
+ this.enqueue({ type: 'tool-output-error', toolCallId, errorText: output, dynamic: true });
463
+ }
464
+
465
+ this.sideChannel.onToolOutputPanel(toolName, toolCallId, output, success);
466
+ break;
467
+ }
468
+
469
+ case 'approval_required': {
470
+ const tools = event.data?.tools as Array<{
471
+ tool: string;
472
+ arguments: Record<string, unknown>;
473
+ tool_call_id: string;
474
+ }>;
475
+ if (!tools || !this.streamController) break;
476
+
477
+ this.endTextPart();
478
+
479
+ for (const t of tools) {
480
+ this.enqueue({ type: 'tool-input-start', toolCallId: t.tool_call_id, toolName: t.tool, dynamic: true });
481
+ this.enqueue({ type: 'tool-input-available', toolCallId: t.tool_call_id, toolName: t.tool, input: t.arguments, dynamic: true });
482
+ this.enqueue({ type: 'tool-approval-request', approvalId: `approval-${t.tool_call_id}`, toolCallId: t.tool_call_id });
483
+ }
484
+
485
+ this.sideChannel.onApprovalRequired(tools);
486
+ this.sideChannel.onProcessingDone();
487
+ break;
488
+ }
489
+
490
+ case 'tool_state_change': {
491
+ if (!this.streamController) break;
492
+ const tcId = (event.data?.tool_call_id as string) || '';
493
+ const state = (event.data?.state as string) || '';
494
+
495
+ if (state === 'rejected' || state === 'abandoned') {
496
+ this.enqueue({ type: 'tool-output-denied', toolCallId: tcId });
497
+ }
498
+ // 'approved' and 'running' are transient — results will follow via tool_output
499
+ break;
500
+ }
501
+
502
+ case 'turn_complete':
503
+ this.endTextPart();
504
+ if (this.streamController) {
505
+ this.enqueue({ type: 'finish-step' });
506
+ this.enqueue({ type: 'finish', finishReason: 'stop' });
507
+ this.closeActiveStream();
508
+ }
509
+ this.sideChannel.onProcessingDone();
510
+ break;
511
+
512
+ case 'error': {
513
+ const errorMsg = (event.data?.error as string) || 'Unknown error';
514
+ this.sideChannel.onError(errorMsg);
515
+ if (this.streamController) {
516
+ this.enqueue({ type: 'error', errorText: errorMsg });
517
+ }
518
+ this.sideChannel.onProcessingDone();
519
+ break;
520
+ }
521
+
522
+ default:
523
+ logger.log('WS transport: unknown event', event);
524
+ }
525
+ }
526
+ }
frontend/src/store/agentStore.ts CHANGED
@@ -1,6 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
1
  import { create } from 'zustand';
2
- import { persist } from 'zustand/middleware';
3
- import type { Message, User, TraceLog } from '@/types/agent';
4
 
5
  export interface PlanItem {
6
  id: string;
@@ -23,408 +33,140 @@ export interface LLMHealthError {
23
  }
24
 
25
  interface AgentStore {
26
- // State per session (keyed by session ID)
27
- messagesBySession: Record<string, Message[]>;
28
  isProcessing: boolean;
29
  isConnected: boolean;
30
  user: User | null;
31
  error: string | null;
32
  llmHealthError: LLMHealthError | null;
33
- traceLogs: TraceLog[];
 
34
  panelContent: { title: string; content: string; language?: string; parameters?: Record<string, unknown> } | null;
35
  panelTabs: PanelTab[];
36
  activePanelTab: string | null;
 
 
37
  plan: PlanItem[];
38
- currentTurnMessageId: string | null; // Track the current turn's assistant message
39
- editedScripts: Record<string, string>; // tool_call_id -> edited content
 
40
 
41
  // Actions
42
- addMessage: (sessionId: string, message: Message) => void;
43
- updateMessage: (sessionId: string, messageId: string, updates: Partial<Message>) => void;
44
- clearMessages: (sessionId: string) => void;
45
  setProcessing: (isProcessing: boolean) => void;
46
  setConnected: (isConnected: boolean) => void;
47
  setUser: (user: User | null) => void;
48
  setError: (error: string | null) => void;
49
- getMessages: (sessionId: string) => Message[];
50
- addTraceLog: (log: TraceLog) => void;
51
- updateTraceLog: (toolCallId: string, toolName: string, updates: Partial<TraceLog>) => void;
52
- clearTraceLogs: () => void;
53
  setPanelContent: (content: { title: string; content: string; language?: string; parameters?: Record<string, unknown> } | null) => void;
54
  setPanelTab: (tab: PanelTab) => void;
55
  updatePanelTabContent: (tabId: string, content: string) => void;
56
  setActivePanelTab: (tabId: string) => void;
57
  clearPanelTabs: () => void;
58
  removePanelTab: (tabId: string) => void;
 
 
59
  setPlan: (plan: PlanItem[]) => void;
60
- setCurrentTurnMessageId: (id: string | null) => void;
61
- updateCurrentTurnTrace: (sessionId: string) => void;
62
- showToolOutput: (log: TraceLog) => void;
63
  setEditedScript: (toolCallId: string, content: string) => void;
64
  getEditedScript: (toolCallId: string) => string | undefined;
65
  clearEditedScripts: () => void;
66
- /** Append a streaming delta to an existing message. */
67
- appendToMessage: (sessionId: string, messageId: string, delta: string) => void;
68
- /** Remove all messages for a session (also clears from localStorage). */
69
- deleteSessionMessages: (sessionId: string) => void;
70
- /** Remove the last turn (last user msg + all following assistant/tool msgs). */
71
- removeLastTurn: (sessionId: string) => void;
72
- setLlmHealthError: (error: LLMHealthError | null) => void;
73
  }
74
 
75
- export const useAgentStore = create<AgentStore>()(
76
- persist(
77
- (set, get) => ({
78
- messagesBySession: {},
79
  isProcessing: false,
80
  isConnected: false,
81
  user: null,
82
  error: null,
83
  llmHealthError: null,
84
- traceLogs: [],
85
  panelContent: null,
86
  panelTabs: [],
87
  activePanelTab: null,
88
- plan: [],
89
- currentTurnMessageId: null,
90
- editedScripts: {},
91
 
92
- addMessage: (sessionId: string, message: Message) => {
93
- set((state) => {
94
- const currentMessages = state.messagesBySession[sessionId] || [];
95
- return {
96
- messagesBySession: {
97
- ...state.messagesBySession,
98
- [sessionId]: [...currentMessages, message],
99
- },
100
- };
101
- });
102
- },
103
-
104
- updateMessage: (sessionId: string, messageId: string, updates: Partial<Message>) => {
105
- set((state) => {
106
- const currentMessages = state.messagesBySession[sessionId] || [];
107
- const updatedMessages = currentMessages.map((msg) =>
108
- msg.id === messageId ? { ...msg, ...updates } : msg
109
- );
110
- return {
111
- messagesBySession: {
112
- ...state.messagesBySession,
113
- [sessionId]: updatedMessages,
114
- },
115
- };
116
- });
117
- },
118
-
119
- clearMessages: (sessionId: string) => {
120
- set((state) => ({
121
- messagesBySession: {
122
- ...state.messagesBySession,
123
- [sessionId]: [],
124
- },
125
- }));
126
- },
127
-
128
- setProcessing: (isProcessing: boolean) => {
129
- set({ isProcessing });
130
- },
131
-
132
- setConnected: (isConnected: boolean) => {
133
- set({ isConnected });
134
- },
135
-
136
- setUser: (user: User | null) => {
137
- set({ user });
138
- },
139
-
140
- setError: (error: string | null) => {
141
- set({ error });
142
- },
143
 
144
- getMessages: (sessionId: string) => {
145
- return get().messagesBySession[sessionId] || [];
146
- },
147
 
148
- addTraceLog: (log: TraceLog) => {
149
- set((state) => ({
150
- traceLogs: [...state.traceLogs, log],
151
- }));
152
- },
153
 
154
- updateTraceLog: (toolCallId: string, toolName: string, updates: Partial<TraceLog>) => {
155
- set((state) => {
156
- const traceLogs = [...state.traceLogs];
157
- // Prefer matching by tool_call_id (reliable), fall back to tool name (legacy)
158
- let matched = false;
159
- if (toolCallId) {
160
- for (let i = traceLogs.length - 1; i >= 0; i--) {
161
- if (traceLogs[i].toolCallId === toolCallId) {
162
- traceLogs[i] = { ...traceLogs[i], ...updates };
163
- matched = true;
164
- break;
165
- }
166
- }
167
- }
168
- if (!matched) {
169
- // Fallback: match by tool name (last uncompleted call)
170
- for (let i = traceLogs.length - 1; i >= 0; i--) {
171
- if (traceLogs[i].tool === toolName && traceLogs[i].type === 'call' && !traceLogs[i].completed) {
172
- traceLogs[i] = { ...traceLogs[i], ...updates };
173
- break;
174
- }
175
- }
176
- }
177
- return { traceLogs };
178
- });
179
- },
180
 
181
- clearTraceLogs: () => {
182
- set({ traceLogs: [] });
183
- },
184
 
185
- setPanelContent: (content) => {
186
- set({ panelContent: content });
187
- },
188
 
189
  setPanelTab: (tab: PanelTab) => {
190
  set((state) => {
191
- const existingIndex = state.panelTabs.findIndex(t => t.id === tab.id);
192
  let newTabs: PanelTab[];
193
- if (existingIndex >= 0) {
194
- // Update existing tab
195
  newTabs = [...state.panelTabs];
196
- newTabs[existingIndex] = tab;
197
  } else {
198
- // Add new tab
199
  newTabs = [...state.panelTabs, tab];
200
  }
201
  return {
202
  panelTabs: newTabs,
203
- activePanelTab: state.activePanelTab || tab.id, // Auto-select first tab
204
  };
205
  });
206
  },
207
 
208
- updatePanelTabContent: (tabId: string, content: string) => {
209
- set((state) => {
210
- const newTabs = state.panelTabs.map(tab =>
211
  tab.id === tabId ? { ...tab, content } : tab
212
- );
213
- return { panelTabs: newTabs };
214
- });
215
  },
216
 
217
- setActivePanelTab: (tabId: string) => {
218
- set({ activePanelTab: tabId });
219
- },
220
 
221
- clearPanelTabs: () => {
222
- set({ panelTabs: [], activePanelTab: null });
223
- },
224
 
225
- removePanelTab: (tabId: string) => {
226
  set((state) => {
227
  const newTabs = state.panelTabs.filter(t => t.id !== tabId);
228
- // If we removed the active tab, switch to another tab or null
229
  let newActiveTab = state.activePanelTab;
230
  if (state.activePanelTab === tabId) {
231
  newActiveTab = newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null;
232
  }
233
- return {
234
- panelTabs: newTabs,
235
- activePanelTab: newActiveTab,
236
- };
237
  });
238
  },
239
 
240
- setPlan: (plan: PlanItem[]) => {
241
- set({ plan });
242
- },
243
-
244
- setCurrentTurnMessageId: (id: string | null) => {
245
- set({ currentTurnMessageId: id });
246
- },
247
 
248
- updateCurrentTurnTrace: (sessionId: string) => {
249
  const state = get();
250
- if (!state.currentTurnMessageId) return;
251
-
252
- const currentMessages = state.messagesBySession[sessionId] || [];
253
- const latestTools = state.traceLogs.length > 0 ? [...state.traceLogs] : undefined;
254
- if (!latestTools) return;
255
-
256
- // Build a lookup of the latest state for each tool by id
257
- const toolById = new Map(latestTools.map(t => [t.id, t]));
258
-
259
- const updatedMessages = currentMessages.map((msg) => {
260
- if (msg.id !== state.currentTurnMessageId) return msg;
261
-
262
- const segments = msg.segments ? [...msg.segments] : [];
263
-
264
- // First pass: update existing tools in their original segments
265
- const placedToolIds = new Set<string>();
266
- for (let i = 0; i < segments.length; i++) {
267
- if (segments[i].type === 'tools' && segments[i].tools) {
268
- segments[i] = {
269
- ...segments[i],
270
- tools: segments[i].tools!.map(t => {
271
- placedToolIds.add(t.id);
272
- return toolById.get(t.id) || t;
273
- }),
274
- };
275
- }
276
- }
277
-
278
- // Collect only genuinely new tools (not yet in any segment)
279
- const newTools = latestTools.filter(t => !placedToolIds.has(t.id));
280
-
281
- if (newTools.length > 0) {
282
- const lastToolsIdx = segments.map((s) => s.type).lastIndexOf('tools');
283
-
284
- if (lastToolsIdx >= 0 && lastToolsIdx === segments.length - 1) {
285
- // Last segment is tools — append new tools to it
286
- segments[lastToolsIdx] = {
287
- ...segments[lastToolsIdx],
288
- tools: [...(segments[lastToolsIdx].tools || []), ...newTools],
289
- };
290
- } else {
291
- // Text came after previous tools — create a new segment with only new tools
292
- segments.push({ type: 'tools', tools: newTools });
293
- }
294
- }
295
-
296
- return { ...msg, segments };
297
- });
298
-
299
  set({
300
- messagesBySession: {
301
- ...state.messagesBySession,
302
- [sessionId]: updatedMessages,
303
- },
304
  });
305
  },
306
 
307
- showToolOutput: (log: TraceLog) => {
308
- // Show tool output in the right panel - only ONE tool output tab at a time
309
- const state = get();
310
 
311
- // Determine language based on content
312
- let language = 'text';
313
- const content = log.output || '';
314
-
315
- // Check if content looks like JSON
316
- if (content.trim().startsWith('{') || content.trim().startsWith('[') || content.includes('```json')) {
317
- language = 'json';
318
- }
319
- // Check if content has markdown tables or formatting
320
- else if (content.includes('|') && content.includes('---') || content.includes('```')) {
321
- language = 'markdown';
322
- }
323
-
324
- // Remove any existing tool output tab (only keep one)
325
- const otherTabs = state.panelTabs.filter(t => t.id !== 'tool_output');
326
 
327
- // Create/replace the single tool output tab
328
- const newTab = {
329
- id: 'tool_output',
330
- title: log.tool,
331
- content: content || 'No output available',
332
- language,
333
- };
334
 
335
- set({
336
- panelTabs: [...otherTabs, newTab],
337
- activePanelTab: 'tool_output',
338
- });
339
- },
340
-
341
- setEditedScript: (toolCallId: string, content: string) => {
342
  set((state) => ({
343
  editedScripts: { ...state.editedScripts, [toolCallId]: content },
344
  }));
345
  },
346
 
347
- getEditedScript: (toolCallId: string) => {
348
- return get().editedScripts[toolCallId];
349
- },
350
-
351
- clearEditedScripts: () => {
352
- set({ editedScripts: {} });
353
- },
354
-
355
- appendToMessage: (sessionId: string, messageId: string, delta: string) => {
356
- set((state) => {
357
- const messages = state.messagesBySession[sessionId] || [];
358
- return {
359
- messagesBySession: {
360
- ...state.messagesBySession,
361
- [sessionId]: messages.map((msg) => {
362
- if (msg.id !== messageId) return msg;
363
- const newContent = msg.content + delta;
364
- const segments = msg.segments ? [...msg.segments] : [];
365
- const lastSeg = segments[segments.length - 1];
366
-
367
- if (lastSeg && lastSeg.type === 'text') {
368
- // Append to the existing text segment
369
- segments[segments.length - 1] = {
370
- ...lastSeg,
371
- content: (lastSeg.content || '') + delta,
372
- };
373
- } else {
374
- // Last segment is 'tools' (or empty) — start a NEW text segment
375
- // so that tools and text remain visually separated.
376
- segments.push({ type: 'text', content: delta });
377
- }
378
-
379
- return { ...msg, content: newContent, segments };
380
- }),
381
- },
382
- };
383
- });
384
- },
385
-
386
- deleteSessionMessages: (sessionId: string) => {
387
- set((state) => {
388
- const { [sessionId]: _, ...rest } = state.messagesBySession;
389
- return { messagesBySession: rest };
390
- });
391
- },
392
-
393
- removeLastTurn: (sessionId: string) => {
394
- set((state) => {
395
- const msgs = state.messagesBySession[sessionId];
396
- if (!msgs || msgs.length === 0) return state;
397
-
398
- // Find the index of the last user message
399
- let lastUserIdx = -1;
400
- for (let i = msgs.length - 1; i >= 0; i--) {
401
- if (msgs[i].role === 'user') {
402
- lastUserIdx = i;
403
- break;
404
- }
405
- }
406
- if (lastUserIdx === -1) return state; // no user message to remove
407
 
408
- // Remove everything from that user message onward
409
- return {
410
- messagesBySession: {
411
- ...state.messagesBySession,
412
- [sessionId]: msgs.slice(0, lastUserIdx),
413
- },
414
- };
415
- });
416
- },
417
-
418
- setLlmHealthError: (error: LLMHealthError | null) => {
419
- set({ llmHealthError: error });
420
- },
421
- }),
422
- {
423
- name: 'hf-agent-messages',
424
- // Only persist messages — all transient UI state stays in-memory
425
- partialize: (state) => ({
426
- messagesBySession: state.messagesBySession,
427
- }),
428
- }
429
- )
430
- );
 
1
+ /**
2
+ * Agent store — manages UI state that is NOT handled by the Vercel AI SDK.
3
+ *
4
+ * Message state (messages, streaming, tool calls) is now managed by useChat().
5
+ * This store only handles:
6
+ * - Connection / processing flags
7
+ * - Panel state (right panel tabs)
8
+ * - Plan state
9
+ * - User info / error banners
10
+ * - Edited scripts (for hf_jobs code editing)
11
+ */
12
  import { create } from 'zustand';
13
+ import type { User } from '@/types/agent';
 
14
 
15
  export interface PlanItem {
16
  id: string;
 
33
  }
34
 
35
  interface AgentStore {
36
+ // Global UI flags
 
37
  isProcessing: boolean;
38
  isConnected: boolean;
39
  user: User | null;
40
  error: string | null;
41
  llmHealthError: LLMHealthError | null;
42
+
43
+ // Right panel
44
  panelContent: { title: string; content: string; language?: string; parameters?: Record<string, unknown> } | null;
45
  panelTabs: PanelTab[];
46
  activePanelTab: string | null;
47
+
48
+ // Plan
49
  plan: PlanItem[];
50
+
51
+ // Edited scripts (tool_call_id -> edited content)
52
+ editedScripts: Record<string, string>;
53
 
54
  // Actions
 
 
 
55
  setProcessing: (isProcessing: boolean) => void;
56
  setConnected: (isConnected: boolean) => void;
57
  setUser: (user: User | null) => void;
58
  setError: (error: string | null) => void;
59
+ setLlmHealthError: (error: LLMHealthError | null) => void;
60
+
 
 
61
  setPanelContent: (content: { title: string; content: string; language?: string; parameters?: Record<string, unknown> } | null) => void;
62
  setPanelTab: (tab: PanelTab) => void;
63
  updatePanelTabContent: (tabId: string, content: string) => void;
64
  setActivePanelTab: (tabId: string) => void;
65
  clearPanelTabs: () => void;
66
  removePanelTab: (tabId: string) => void;
67
+ showToolOutput: (output: { tool: string; output?: string; args?: Record<string, unknown> }) => void;
68
+
69
  setPlan: (plan: PlanItem[]) => void;
70
+
 
 
71
  setEditedScript: (toolCallId: string, content: string) => void;
72
  getEditedScript: (toolCallId: string) => string | undefined;
73
  clearEditedScripts: () => void;
 
 
 
 
 
 
 
74
  }
75
 
76
+ export const useAgentStore = create<AgentStore>()((set, get) => ({
 
 
 
77
  isProcessing: false,
78
  isConnected: false,
79
  user: null,
80
  error: null,
81
  llmHealthError: null,
82
+
83
  panelContent: null,
84
  panelTabs: [],
85
  activePanelTab: null,
 
 
 
86
 
87
+ plan: [],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
+ editedScripts: {},
 
 
90
 
91
+ // ── Global flags ──────────────────────────────────────────────────
 
 
 
 
92
 
93
+ setProcessing: (isProcessing) => set({ isProcessing }),
94
+ setConnected: (isConnected) => set({ isConnected }),
95
+ setUser: (user) => set({ user }),
96
+ setError: (error) => set({ error }),
97
+ setLlmHealthError: (error) => set({ llmHealthError: error }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
+ // ── Panel ─────────────────────────────────────────────────────────
 
 
100
 
101
+ setPanelContent: (content) => set({ panelContent: content }),
 
 
102
 
103
  setPanelTab: (tab: PanelTab) => {
104
  set((state) => {
105
+ const idx = state.panelTabs.findIndex(t => t.id === tab.id);
106
  let newTabs: PanelTab[];
107
+ if (idx >= 0) {
 
108
  newTabs = [...state.panelTabs];
109
+ newTabs[idx] = tab;
110
  } else {
 
111
  newTabs = [...state.panelTabs, tab];
112
  }
113
  return {
114
  panelTabs: newTabs,
115
+ activePanelTab: state.activePanelTab || tab.id,
116
  };
117
  });
118
  },
119
 
120
+ updatePanelTabContent: (tabId, content) => {
121
+ set((state) => ({
122
+ panelTabs: state.panelTabs.map(tab =>
123
  tab.id === tabId ? { ...tab, content } : tab
124
+ ),
125
+ }));
 
126
  },
127
 
128
+ setActivePanelTab: (tabId) => set({ activePanelTab: tabId }),
 
 
129
 
130
+ clearPanelTabs: () => set({ panelTabs: [], activePanelTab: null }),
 
 
131
 
132
+ removePanelTab: (tabId) => {
133
  set((state) => {
134
  const newTabs = state.panelTabs.filter(t => t.id !== tabId);
 
135
  let newActiveTab = state.activePanelTab;
136
  if (state.activePanelTab === tabId) {
137
  newActiveTab = newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null;
138
  }
139
+ return { panelTabs: newTabs, activePanelTab: newActiveTab };
 
 
 
140
  });
141
  },
142
 
143
+ showToolOutput: (info) => {
144
+ const content = info.output || (info.args ? JSON.stringify(info.args, null, 2) : 'No output available');
145
+ let language = 'text';
146
+ if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
147
+ else if (content.includes('```')) language = 'markdown';
 
 
148
 
 
149
  const state = get();
150
+ const otherTabs = state.panelTabs.filter(t => t.id !== 'tool_output');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  set({
152
+ panelTabs: [...otherTabs, { id: 'tool_output', title: info.tool, content, language }],
153
+ activePanelTab: 'tool_output',
 
 
154
  });
155
  },
156
 
157
+ // ── Plan ──────────────────────────────────────────────────────────
 
 
158
 
159
+ setPlan: (plan) => set({ plan }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
+ // ── Edited scripts ────────────────────────────────────────────────
 
 
 
 
 
 
162
 
163
+ setEditedScript: (toolCallId, content) => {
 
 
 
 
 
 
164
  set((state) => ({
165
  editedScripts: { ...state.editedScripts, [toolCallId]: content },
166
  }));
167
  },
168
 
169
+ getEditedScript: (toolCallId) => get().editedScripts[toolCallId],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
+ clearEditedScripts: () => set({ editedScripts: {} }),
172
+ }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/store/sessionStore.ts CHANGED
@@ -1,7 +1,7 @@
1
  import { create } from 'zustand';
2
  import { persist } from 'zustand/middleware';
3
  import type { SessionMeta } from '@/types/agent';
4
- import { useAgentStore } from './agentStore';
5
 
6
  interface SessionStore {
7
  sessions: SessionMeta[];
@@ -35,9 +35,7 @@ export const useSessionStore = create<SessionStore>()(
35
  },
36
 
37
  deleteSession: (id: string) => {
38
- // Clean up persisted messages for this session
39
- useAgentStore.getState().deleteSessionMessages(id);
40
-
41
  set((state) => {
42
  const newSessions = state.sessions.filter((s) => s.id !== id);
43
  const newActiveId =
 
1
  import { create } from 'zustand';
2
  import { persist } from 'zustand/middleware';
3
  import type { SessionMeta } from '@/types/agent';
4
+ import { deleteMessages } from '@/lib/chat-message-store';
5
 
6
  interface SessionStore {
7
  sessions: SessionMeta[];
 
35
  },
36
 
37
  deleteSession: (id: string) => {
38
+ deleteMessages(id);
 
 
39
  set((state) => {
40
  const newSessions = state.sessions.filter((s) => s.id !== id);
41
  const newActiveId =
frontend/src/types/agent.ts CHANGED
@@ -1,7 +1,15 @@
1
  /**
2
- * Agent-related types
 
 
 
3
  */
4
 
 
 
 
 
 
5
  export interface SessionMeta {
6
  id: string;
7
  title: string;
@@ -9,87 +17,12 @@ export interface SessionMeta {
9
  isActive: boolean;
10
  }
11
 
12
- export interface MessageSegment {
13
- type: 'text' | 'tools';
14
- content?: string;
15
- tools?: TraceLog[];
16
- }
17
-
18
- export interface Message {
19
- id: string;
20
- role: 'user' | 'assistant' | 'tool';
21
- content: string;
22
- timestamp: string;
23
- segments?: MessageSegment[];
24
- approval?: {
25
- status: 'pending' | 'approved' | 'rejected';
26
- batch: ApprovalBatch;
27
- decisions?: ToolApproval[];
28
- };
29
- toolOutput?: string;
30
- }
31
-
32
- export interface ToolCall {
33
- id: string;
34
- tool: string;
35
- arguments: Record<string, unknown>;
36
- status: 'pending' | 'running' | 'completed' | 'failed';
37
- output?: string;
38
- }
39
-
40
  export interface ToolApproval {
41
  tool_call_id: string;
42
  approved: boolean;
43
  feedback?: string | null;
44
  }
45
 
46
- export interface ApprovalBatch {
47
- tools: Array<{
48
- tool: string;
49
- arguments: Record<string, unknown>;
50
- tool_call_id: string;
51
- }>;
52
- count: number;
53
- }
54
-
55
- /**
56
- * Single state field for each tool call lifecycle.
57
- * Follows the Vercel AI SDK pattern: clear, non-overlapping states.
58
- */
59
- export type ToolState =
60
- | 'calling' // tool_call received, execution starting
61
- | 'pending_approval' // waiting for user to approve/reject
62
- | 'approved' // user approved, waiting for execution to start
63
- | 'running' // execution in progress
64
- | 'completed' // execution finished successfully
65
- | 'failed' // execution finished with error
66
- | 'rejected' // user rejected the tool call
67
- | 'timed_out'; // no response after timeout
68
-
69
- // Keep backward compat alias
70
- export type ApprovalStatus = 'none' | 'pending' | 'approved' | 'rejected';
71
-
72
- export interface TraceLog {
73
- id: string;
74
- toolCallId?: string;
75
- type: 'call' | 'output';
76
- text: string;
77
- tool: string;
78
- timestamp: string;
79
- /** Single source of truth for tool lifecycle state */
80
- state: ToolState;
81
- args?: Record<string, unknown>;
82
- output?: string;
83
- success?: boolean;
84
- // Legacy fields — kept for backward compat with persisted data
85
- completed?: boolean;
86
- approvalStatus?: ApprovalStatus;
87
- /** Parsed job info (URL, status, logs) for hf_jobs */
88
- jobUrl?: string;
89
- jobStatus?: string;
90
- jobLogs?: string;
91
- }
92
-
93
  export interface User {
94
  authenticated: boolean;
95
  username?: string;
 
1
  /**
2
+ * Agent-related types.
3
+ *
4
+ * Message and tool-call types are now provided by the Vercel AI SDK
5
+ * (UIMessage, UIMessagePart, etc.). Only non-SDK types remain here.
6
  */
7
 
8
+ /** Custom metadata attached to every UIMessage via the `metadata` field. */
9
+ export interface MessageMeta {
10
+ createdAt?: string;
11
+ }
12
+
13
  export interface SessionMeta {
14
  id: string;
15
  title: string;
 
17
  isActive: boolean;
18
  }
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  export interface ToolApproval {
21
  tool_call_id: string;
22
  approved: boolean;
23
  feedback?: string | null;
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  export interface User {
27
  authenticated: boolean;
28
  username?: string;