tfrere HF Staff Cursor commited on
Commit
e77f678
·
1 Parent(s): cb3b6ca

Comprehensive frontend/backend overhaul: unified tool system, responsive UI, code quality

Browse files

- Unified tool call rendering: merged ApprovalFlow into ToolCallGroup (single inline card with all states: running, pending approval, approved, completed, failed)
- Parallel tool execution via asyncio.gather
- Tool calls always visible in chat (create assistant message on first tool_call event)
- Streaming markdown: throttled ReactMarkdown rendering (Claude approach, no split buffer)
- Robust auto-scroll: MutationObserver + scroll intent tracking
- Responsive layout: mobile sidebar as temporary drawer, code panel as bottom sheet
- LLM-generated session titles via /api/title endpoint
- Primary color unified to #FF9D00 across MUI theme + CSS vars
- Removed dead code: ApprovalFlow.tsx, pendingApprovals store, commented prints
- Fixed approval_required handler: creates TraceLogs for approval tools
- Security: hardcoded token removed, HttpOnly cookies, CSRF state, session limits
- localStorage persistence for messages + sessions via Zustand persist
- Dark/light mode, outlined icons, ChatGPT-style sidebar, welcome screen
- Input debounce: prevent double-sends via isProcessing guard
- Strict TypeScript: any → Record<string, unknown>, dev-only logger

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

Files changed (40) hide show
  1. agent/context_manager/manager.py +61 -4
  2. agent/core/agent_loop.py +197 -74
  3. agent/core/session.py +34 -27
  4. agent/core/session_uploader.py +2 -4
  5. agent/core/tools.py +8 -4
  6. agent/prompts/system_prompt.yaml +2 -2
  7. agent/tools/jobs_tool.py +11 -7
  8. backend/dependencies.py +144 -0
  9. backend/models.py +11 -0
  10. backend/routes/agent.py +192 -26
  11. backend/routes/auth.py +71 -50
  12. backend/session_manager.py +109 -14
  13. backend/websocket.py +0 -10
  14. frontend/src/App.tsx +5 -0
  15. frontend/src/components/ApprovalModal/ApprovalModal.tsx +0 -208
  16. frontend/src/components/Chat/ApprovalFlow.tsx +0 -515
  17. frontend/src/components/Chat/AssistantMessage.tsx +108 -0
  18. frontend/src/components/Chat/ChatInput.tsx +18 -9
  19. frontend/src/components/Chat/MarkdownContent.tsx +181 -0
  20. frontend/src/components/Chat/MessageBubble.tsx +39 -203
  21. frontend/src/components/Chat/MessageList.tsx +171 -74
  22. frontend/src/components/Chat/ThinkingIndicator.tsx +63 -0
  23. frontend/src/components/Chat/ToolCallGroup.tsx +410 -0
  24. frontend/src/components/Chat/UserMessage.tsx +117 -0
  25. frontend/src/components/CodePanel/CodePanel.tsx +209 -189
  26. frontend/src/components/Layout/AppLayout.tsx +309 -146
  27. frontend/src/components/SessionSidebar/SessionSidebar.tsx +277 -179
  28. frontend/src/components/WelcomeScreen/WelcomeScreen.tsx +177 -0
  29. frontend/src/hooks/useAgentWebSocket.ts +197 -93
  30. frontend/src/hooks/useAuth.ts +53 -0
  31. frontend/src/main.tsx +13 -3
  32. frontend/src/store/agentStore.ts +153 -34
  33. frontend/src/store/layoutStore.ts +28 -10
  34. frontend/src/store/sessionStore.ts +9 -5
  35. frontend/src/theme.ts +202 -137
  36. frontend/src/types/agent.ts +9 -0
  37. frontend/src/types/events.ts +2 -0
  38. frontend/src/utils/api.ts +58 -0
  39. frontend/src/utils/logger.ts +24 -0
  40. frontend/vite.config.ts +1 -0
agent/context_manager/manager.py CHANGED
@@ -2,6 +2,7 @@
2
  Context management for conversation history
3
  """
4
 
 
5
  import os
6
  import zoneinfo
7
  from datetime import datetime
@@ -9,10 +10,67 @@ from pathlib import Path
9
  from typing import Any
10
 
11
  import yaml
12
- from huggingface_hub import HfApi
13
  from jinja2 import Template
14
  from litellm import Message, acompletion
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  class ContextManager:
18
  """Manages conversation context and message history for the agent"""
@@ -54,9 +112,8 @@ class ContextManager:
54
  current_time = now.strftime("%H:%M:%S.%f")[:-3]
55
  current_timezone = f"{now.strftime('%Z')} (UTC{now.strftime('%z')[:3]}:{now.strftime('%z')[3:]})"
56
 
57
- # Get HF user info with explicit token from env
58
- hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
59
- hf_user_info = HfApi(token=hf_token).whoami().get("name", "unknown")
60
 
61
  template = Template(template_str)
62
  return template.render(
 
2
  Context management for conversation history
3
  """
4
 
5
+ import logging
6
  import os
7
  import zoneinfo
8
  from datetime import datetime
 
10
  from typing import Any
11
 
12
  import yaml
 
13
  from jinja2 import Template
14
  from litellm import Message, acompletion
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Module-level cache for HF username — avoids repeating the slow whoami() call
19
+ _hf_username_cache: str | None = None
20
+
21
+ _HF_WHOAMI_URL = "https://huggingface.co/api/whoami-v2"
22
+ _HF_WHOAMI_TIMEOUT = 5 # seconds
23
+
24
+
25
+ def _get_hf_username() -> str:
26
+ """Return the HF username, cached after the first call.
27
+
28
+ Uses subprocess + curl to avoid Python HTTP client IPv6 issues that
29
+ cause 40+ second hangs (httpx/urllib try IPv6 first which times out
30
+ at OS level before falling back to IPv4 — the "Happy Eyeballs" problem).
31
+ """
32
+ import json
33
+ import subprocess
34
+ import time as _t
35
+
36
+ global _hf_username_cache
37
+ if _hf_username_cache is not None:
38
+ return _hf_username_cache
39
+
40
+ hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
41
+ if not hf_token:
42
+ logger.warning("No HF_TOKEN set, using 'unknown' as username")
43
+ _hf_username_cache = "unknown"
44
+ return _hf_username_cache
45
+
46
+ t0 = _t.monotonic()
47
+ try:
48
+ result = subprocess.run(
49
+ [
50
+ "curl", "-s", "-4", # force IPv4
51
+ "-m", str(_HF_WHOAMI_TIMEOUT), # max time
52
+ "-H", f"Authorization: Bearer {hf_token}",
53
+ _HF_WHOAMI_URL,
54
+ ],
55
+ capture_output=True,
56
+ text=True,
57
+ timeout=_HF_WHOAMI_TIMEOUT + 2,
58
+ )
59
+ t1 = _t.monotonic()
60
+ if result.returncode == 0 and result.stdout:
61
+ data = json.loads(result.stdout)
62
+ _hf_username_cache = data.get("name", "unknown")
63
+ logger.info(f"HF username resolved to '{_hf_username_cache}' in {t1 - t0:.2f}s")
64
+ else:
65
+ logger.warning(f"curl whoami failed (rc={result.returncode}) in {t1 - t0:.2f}s")
66
+ _hf_username_cache = "unknown"
67
+ except Exception as e:
68
+ t1 = _t.monotonic()
69
+ logger.warning(f"HF whoami failed in {t1 - t0:.2f}s: {e}")
70
+ _hf_username_cache = "unknown"
71
+
72
+ return _hf_username_cache
73
+
74
 
75
  class ContextManager:
76
  """Manages conversation context and message history for the agent"""
 
112
  current_time = now.strftime("%H:%M:%S.%f")[:-3]
113
  current_timezone = f"{now.strftime('%Z')} (UTC{now.strftime('%z')[:3]}:{now.strftime('%z')[3:]})"
114
 
115
+ # Get HF user info (cached after the first call)
116
+ hf_user_info = _get_hf_username()
 
117
 
118
  template = Template(template_str)
119
  return template.render(
agent/core/agent_loop.py CHANGED
@@ -4,6 +4,7 @@ Main agent implementation with integrated tool system and MCP support
4
 
5
  import asyncio
6
  import json
 
7
 
8
  from litellm import ChatCompletionMessageToolCall, Message, ModelResponse, acompletion
9
  from lmnr import observe
@@ -13,6 +14,8 @@ from agent.core.session import Event, OpType, Session
13
  from agent.core.tools import ToolRouter
14
  from agent.tools.jobs_tool import CPU_FLAVORS
15
 
 
 
16
  ToolCall = ChatCompletionMessageToolCall
17
 
18
 
@@ -129,35 +132,100 @@ class Handlers:
129
  tools = session.tool_router.get_tool_specs_for_llm()
130
 
131
  try:
132
- response: ModelResponse = await acompletion(
 
133
  model=session.config.model_name,
134
  messages=messages,
135
  tools=tools,
136
  tool_choice="auto",
 
 
137
  )
138
 
139
- # Extract text response, token usage, and tool calls
140
- message = response.choices[0].message
141
- content = message.content
142
- token_count = response.usage.total_tokens
143
- tool_calls: list[ToolCall] = message.get("tool_calls", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  # If no tool calls, add assistant message and we're done
146
  if not tool_calls:
147
  if content:
148
  assistant_msg = Message(role="assistant", content=content)
149
- session.context_manager.add_message(assistant_msg, token_count)
150
- await session.send_event(
151
- Event(
152
- event_type="assistant_message",
153
- data={"content": content},
154
- )
155
  )
156
  final_response = content
157
  break
158
 
159
  # Add assistant message with tool calls to history
160
- # LiteLLM will format this correctly for the provider
161
  assistant_msg = Message(
162
  role="assistant",
163
  content=content,
@@ -165,66 +233,98 @@ class Handlers:
165
  )
166
  session.context_manager.add_message(assistant_msg, token_count)
167
 
168
- if content:
169
- await session.send_event(
170
- Event(event_type="assistant_message", data={"content": content})
171
- )
172
-
173
  # Separate tools into those requiring approval and those that don't
174
  approval_required_tools = []
175
  non_approval_tools = []
176
 
177
  for tc in tool_calls:
178
  tool_name = tc.function.name
179
- tool_args = json.loads(tc.function.arguments)
 
 
 
 
180
 
181
  if _needs_approval(tool_name, tool_args, session.config):
182
  approval_required_tools.append(tc)
183
  else:
184
  non_approval_tools.append(tc)
185
 
186
- # Execute non-approval tools first
187
- for tc in non_approval_tools:
188
- tool_name = tc.function.name
189
- tool_args = json.loads(tc.function.arguments)
190
-
191
- # Validate tool arguments before calling
192
- args_valid, error_msg = _validate_tool_args(tool_args)
193
- if not args_valid:
194
- # Return error to agent instead of calling tool
195
- output = error_msg
196
- success = False
197
- else:
198
- await session.send_event(
199
- Event(
200
- event_type="tool_call",
201
- data={"tool": tool_name, "arguments": tool_args},
202
- )
203
  )
204
 
205
- output, success = await session.tool_router.call_tool(
206
- tool_name, tool_args, session=session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  )
 
208
 
209
- # Add tool result to history
210
- tool_msg = Message(
211
- role="tool",
212
- content=output,
213
- tool_call_id=tc.id,
214
- name=tool_name,
215
  )
216
- session.context_manager.add_message(tool_msg)
217
 
218
- await session.send_event(
219
- Event(
220
- event_type="tool_output",
221
- data={
222
- "tool": tool_name,
223
- "output": output,
224
- "success": success,
225
- },
 
 
 
 
 
 
 
 
 
 
 
 
226
  )
227
- )
228
 
229
  # If there are tools requiring approval, ask for batch approval
230
  if approval_required_tools:
@@ -232,7 +332,10 @@ class Handlers:
232
  tools_data = []
233
  for tc in approval_required_tools:
234
  tool_name = tc.function.name
235
- tool_args = json.loads(tc.function.arguments)
 
 
 
236
  tools_data.append(
237
  {
238
  "tool": tool_name,
@@ -319,11 +422,27 @@ class Handlers:
319
 
320
  @staticmethod
321
  async def undo(session: Session) -> None:
322
- """Handle undo (like undo in codex.rs:1314)"""
323
- # Remove last user turn and all following items
324
- # Simplified: just remove last 2 items
325
- for _ in range(min(2, len(session.context_manager.items))):
326
- session.context_manager.items.pop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
 
328
  await session.send_event(Event(event_type="undo_complete"))
329
 
@@ -372,7 +491,11 @@ class Handlers:
372
  await session.send_event(
373
  Event(
374
  event_type="tool_call",
375
- data={"tool": tool_name, "arguments": tool_args},
 
 
 
 
376
  )
377
  )
378
 
@@ -396,7 +519,7 @@ class Handlers:
396
  for result in results:
397
  if isinstance(result, Exception):
398
  # Handle execution error
399
- print(f"Tool execution error: {result}")
400
  continue
401
 
402
  tc, tool_name, output, success = result
@@ -415,6 +538,7 @@ class Handlers:
415
  event_type="tool_output",
416
  data={
417
  "tool": tool_name,
 
418
  "output": output,
419
  "success": success,
420
  },
@@ -441,6 +565,7 @@ class Handlers:
441
  event_type="tool_output",
442
  data={
443
  "tool": tool_name,
 
444
  "output": rejection_msg,
445
  "success": False,
446
  },
@@ -458,11 +583,9 @@ class Handlers:
458
  """Handle shutdown (like shutdown in codex.rs:1329)"""
459
  # Save session trajectory if enabled (fire-and-forget, returns immediately)
460
  if session.config.save_sessions:
461
- print("💾 Saving session...")
462
  repo_id = session.config.session_dataset_repo
463
  _ = session.save_and_upload_detached(repo_id)
464
- # if local_path:
465
- # print("✅ Session saved locally, upload in progress")
466
 
467
  session.is_running = False
468
  await session.send_event(Event(event_type="shutdown"))
@@ -477,7 +600,7 @@ async def process_submission(session: Session, submission) -> bool:
477
  bool: True to continue, False to shutdown
478
  """
479
  op = submission.operation
480
- # print(f"📨 Received: {op.op_type.value}")
481
 
482
  if op.op_type == OpType.USER_INPUT:
483
  text = op.data.get("text", "") if op.data else ""
@@ -504,7 +627,7 @@ async def process_submission(session: Session, submission) -> bool:
504
  if op.op_type == OpType.SHUTDOWN:
505
  return not await Handlers.shutdown(session)
506
 
507
- print(f"⚠️ Unknown operation: {op.op_type}")
508
  return True
509
 
510
 
@@ -522,7 +645,7 @@ async def submission_loop(
522
 
523
  # Create session with tool router
524
  session = Session(event_queue, config=config, tool_router=tool_router)
525
- print("Agent loop started")
526
 
527
  # Retry any failed uploads from previous sessions (fire-and-forget)
528
  if config and config.save_sessions:
@@ -546,25 +669,25 @@ async def submission_loop(
546
  if not should_continue:
547
  break
548
  except asyncio.CancelledError:
549
- print("\n⚠️ Agent loop cancelled")
550
  break
551
  except Exception as e:
552
- print(f"Error in agent loop: {e}")
553
  await session.send_event(
554
  Event(event_type="error", data={"error": str(e)})
555
  )
556
 
557
- print("🛑 Agent loop exited")
558
 
559
  finally:
560
  # Emergency save if session saving is enabled and shutdown wasn't called properly
561
  if session.config.save_sessions and session.is_running:
562
- print("\n💾 Emergency save: preserving session before exit...")
563
  try:
564
  local_path = session.save_and_upload_detached(
565
  session.config.session_dataset_repo
566
  )
567
  if local_path:
568
- print("Emergency save successful, upload in progress")
569
  except Exception as e:
570
- print(f"Emergency save failed: {e}")
 
4
 
5
  import asyncio
6
  import json
7
+ import logging
8
 
9
  from litellm import ChatCompletionMessageToolCall, Message, ModelResponse, acompletion
10
  from lmnr import observe
 
14
  from agent.core.tools import ToolRouter
15
  from agent.tools.jobs_tool import CPU_FLAVORS
16
 
17
+ logger = logging.getLogger(__name__)
18
+
19
  ToolCall = ChatCompletionMessageToolCall
20
 
21
 
 
132
  tools = session.tool_router.get_tool_specs_for_llm()
133
 
134
  try:
135
+ # ── Stream the LLM response ──────────────────────────
136
+ response = await acompletion(
137
  model=session.config.model_name,
138
  messages=messages,
139
  tools=tools,
140
  tool_choice="auto",
141
+ stream=True,
142
+ stream_options={"include_usage": True},
143
  )
144
 
145
+ full_content = ""
146
+ tool_calls_acc: dict[int, dict] = {}
147
+ token_count = 0
148
+
149
+ async for chunk in response:
150
+ choice = chunk.choices[0] if chunk.choices else None
151
+ if not choice:
152
+ # Last chunk may carry only usage info
153
+ if hasattr(chunk, "usage") and chunk.usage:
154
+ token_count = chunk.usage.total_tokens
155
+ continue
156
+
157
+ delta = choice.delta
158
+
159
+ # Stream text deltas to the frontend
160
+ if delta.content:
161
+ full_content += delta.content
162
+ await session.send_event(
163
+ Event(
164
+ event_type="assistant_chunk",
165
+ data={"content": delta.content},
166
+ )
167
+ )
168
+
169
+ # Accumulate tool-call deltas (name + args arrive in pieces)
170
+ if delta.tool_calls:
171
+ for tc_delta in delta.tool_calls:
172
+ idx = tc_delta.index
173
+ if idx not in tool_calls_acc:
174
+ tool_calls_acc[idx] = {
175
+ "id": "",
176
+ "type": "function",
177
+ "function": {"name": "", "arguments": ""},
178
+ }
179
+ if tc_delta.id:
180
+ tool_calls_acc[idx]["id"] = tc_delta.id
181
+ if tc_delta.function:
182
+ if tc_delta.function.name:
183
+ tool_calls_acc[idx]["function"][
184
+ "name"
185
+ ] += tc_delta.function.name
186
+ if tc_delta.function.arguments:
187
+ tool_calls_acc[idx]["function"][
188
+ "arguments"
189
+ ] += tc_delta.function.arguments
190
+
191
+ # Capture usage from the final chunk
192
+ if hasattr(chunk, "usage") and chunk.usage:
193
+ token_count = chunk.usage.total_tokens
194
+
195
+ # ── Stream finished — reconstruct full message ───────
196
+ content = full_content or None
197
+
198
+ # Build tool_calls list from accumulated deltas
199
+ tool_calls: list[ToolCall] = []
200
+ for idx in sorted(tool_calls_acc.keys()):
201
+ tc_data = tool_calls_acc[idx]
202
+ tool_calls.append(
203
+ ToolCall(
204
+ id=tc_data["id"],
205
+ type="function",
206
+ function={
207
+ "name": tc_data["function"]["name"],
208
+ "arguments": tc_data["function"]["arguments"],
209
+ },
210
+ )
211
+ )
212
+
213
+ # Signal end of streaming to the frontend
214
+ await session.send_event(
215
+ Event(event_type="assistant_stream_end", data={})
216
+ )
217
 
218
  # If no tool calls, add assistant message and we're done
219
  if not tool_calls:
220
  if content:
221
  assistant_msg = Message(role="assistant", content=content)
222
+ session.context_manager.add_message(
223
+ assistant_msg, token_count
 
 
 
 
224
  )
225
  final_response = content
226
  break
227
 
228
  # Add assistant message with tool calls to history
 
229
  assistant_msg = Message(
230
  role="assistant",
231
  content=content,
 
233
  )
234
  session.context_manager.add_message(assistant_msg, token_count)
235
 
 
 
 
 
 
236
  # Separate tools into those requiring approval and those that don't
237
  approval_required_tools = []
238
  non_approval_tools = []
239
 
240
  for tc in tool_calls:
241
  tool_name = tc.function.name
242
+ try:
243
+ tool_args = json.loads(tc.function.arguments)
244
+ except (json.JSONDecodeError, TypeError) as e:
245
+ logger.warning(f"Malformed tool arguments for {tool_name}: {e}")
246
+ tool_args = {}
247
 
248
  if _needs_approval(tool_name, tool_args, session.config):
249
  approval_required_tools.append(tc)
250
  else:
251
  non_approval_tools.append(tc)
252
 
253
+ # Execute non-approval tools (in parallel when possible)
254
+ if non_approval_tools:
255
+ # 1. Parse args and validate upfront
256
+ parsed_tools: list[
257
+ tuple[ChatCompletionMessageToolCall, str, dict, bool, str]
258
+ ] = []
259
+ for tc in non_approval_tools:
260
+ tool_name = tc.function.name
261
+ try:
262
+ tool_args = json.loads(tc.function.arguments)
263
+ except (json.JSONDecodeError, TypeError):
264
+ tool_args = {}
265
+
266
+ args_valid, error_msg = _validate_tool_args(tool_args)
267
+ parsed_tools.append(
268
+ (tc, tool_name, tool_args, args_valid, error_msg)
 
269
  )
270
 
271
+ # 2. Send all tool_call events upfront (so frontend shows them all)
272
+ for tc, tool_name, tool_args, args_valid, _ in parsed_tools:
273
+ if args_valid:
274
+ await session.send_event(
275
+ Event(
276
+ event_type="tool_call",
277
+ data={
278
+ "tool": tool_name,
279
+ "arguments": tool_args,
280
+ "tool_call_id": tc.id,
281
+ },
282
+ )
283
+ )
284
+
285
+ # 3. Execute all valid tools in parallel
286
+ async def _exec_tool(
287
+ tc: ChatCompletionMessageToolCall,
288
+ name: str,
289
+ args: dict,
290
+ valid: bool,
291
+ err: str,
292
+ ) -> tuple[ChatCompletionMessageToolCall, str, dict, str, bool]:
293
+ if not valid:
294
+ return (tc, name, args, err, False)
295
+ out, ok = await session.tool_router.call_tool(
296
+ name, args, session=session
297
  )
298
+ return (tc, name, args, out, ok)
299
 
300
+ results = await asyncio.gather(
301
+ *[
302
+ _exec_tool(tc, name, args, valid, err)
303
+ for tc, name, args, valid, err in parsed_tools
304
+ ]
 
305
  )
 
306
 
307
+ # 4. Record results and send outputs (order preserved)
308
+ for tc, tool_name, tool_args, output, success in results:
309
+ tool_msg = Message(
310
+ role="tool",
311
+ content=output,
312
+ tool_call_id=tc.id,
313
+ name=tool_name,
314
+ )
315
+ session.context_manager.add_message(tool_msg)
316
+
317
+ await session.send_event(
318
+ Event(
319
+ event_type="tool_output",
320
+ data={
321
+ "tool": tool_name,
322
+ "tool_call_id": tc.id,
323
+ "output": output,
324
+ "success": success,
325
+ },
326
+ )
327
  )
 
328
 
329
  # If there are tools requiring approval, ask for batch approval
330
  if approval_required_tools:
 
332
  tools_data = []
333
  for tc in approval_required_tools:
334
  tool_name = tc.function.name
335
+ try:
336
+ tool_args = json.loads(tc.function.arguments)
337
+ except (json.JSONDecodeError, TypeError):
338
+ tool_args = {}
339
  tools_data.append(
340
  {
341
  "tool": tool_name,
 
422
 
423
  @staticmethod
424
  async def undo(session: Session) -> None:
425
+ """Remove the last complete turn (user msg + all assistant/tool msgs that follow).
426
+
427
+ Anthropic requires every tool_use to have a matching tool_result,
428
+ so we can't just pop 2 items — we must pop everything back to
429
+ (and including) the last user message to keep the history valid.
430
+ """
431
+ items = session.context_manager.items
432
+ if not items:
433
+ await session.send_event(Event(event_type="undo_complete"))
434
+ return
435
+
436
+ # Pop from the end until we've removed the last user message
437
+ removed_user = False
438
+ while items:
439
+ msg = items.pop()
440
+ if getattr(msg, "role", None) == "user":
441
+ removed_user = True
442
+ break
443
+
444
+ if not removed_user:
445
+ logger.warning("Undo: no user message found to remove")
446
 
447
  await session.send_event(Event(event_type="undo_complete"))
448
 
 
491
  await session.send_event(
492
  Event(
493
  event_type="tool_call",
494
+ data={
495
+ "tool": tool_name,
496
+ "arguments": tool_args,
497
+ "tool_call_id": tc.id,
498
+ },
499
  )
500
  )
501
 
 
519
  for result in results:
520
  if isinstance(result, Exception):
521
  # Handle execution error
522
+ logger.error(f"Tool execution error: {result}")
523
  continue
524
 
525
  tc, tool_name, output, success = result
 
538
  event_type="tool_output",
539
  data={
540
  "tool": tool_name,
541
+ "tool_call_id": tc.id,
542
  "output": output,
543
  "success": success,
544
  },
 
565
  event_type="tool_output",
566
  data={
567
  "tool": tool_name,
568
+ "tool_call_id": tc.id,
569
  "output": rejection_msg,
570
  "success": False,
571
  },
 
583
  """Handle shutdown (like shutdown in codex.rs:1329)"""
584
  # Save session trajectory if enabled (fire-and-forget, returns immediately)
585
  if session.config.save_sessions:
586
+ logger.info("Saving session...")
587
  repo_id = session.config.session_dataset_repo
588
  _ = session.save_and_upload_detached(repo_id)
 
 
589
 
590
  session.is_running = False
591
  await session.send_event(Event(event_type="shutdown"))
 
600
  bool: True to continue, False to shutdown
601
  """
602
  op = submission.operation
603
+ logger.debug("Received operation: %s", op.op_type.value)
604
 
605
  if op.op_type == OpType.USER_INPUT:
606
  text = op.data.get("text", "") if op.data else ""
 
627
  if op.op_type == OpType.SHUTDOWN:
628
  return not await Handlers.shutdown(session)
629
 
630
+ logger.warning(f"Unknown operation: {op.op_type}")
631
  return True
632
 
633
 
 
645
 
646
  # Create session with tool router
647
  session = Session(event_queue, config=config, tool_router=tool_router)
648
+ logger.info("Agent loop started")
649
 
650
  # Retry any failed uploads from previous sessions (fire-and-forget)
651
  if config and config.save_sessions:
 
669
  if not should_continue:
670
  break
671
  except asyncio.CancelledError:
672
+ logger.warning("Agent loop cancelled")
673
  break
674
  except Exception as e:
675
+ logger.error(f"Error in agent loop: {e}")
676
  await session.send_event(
677
  Event(event_type="error", data={"error": str(e)})
678
  )
679
 
680
+ logger.info("Agent loop exited")
681
 
682
  finally:
683
  # Emergency save if session saving is enabled and shutdown wasn't called properly
684
  if session.config.save_sessions and session.is_running:
685
+ logger.info("Emergency save: preserving session before exit...")
686
  try:
687
  local_path = session.save_and_upload_detached(
688
  session.config.session_dataset_repo
689
  )
690
  if local_path:
691
+ logger.info("Emergency save successful, upload in progress")
692
  except Exception as e:
693
+ logger.error(f"Emergency save failed: {e}")
agent/core/session.py CHANGED
@@ -1,5 +1,6 @@
1
  import asyncio
2
  import json
 
3
  import subprocess
4
  import sys
5
  import uuid
@@ -9,11 +10,37 @@ from enum import Enum
9
  from pathlib import Path
10
  from typing import Any, Optional
11
 
12
- from litellm import get_max_tokens
13
-
14
  from agent.config import Config
15
  from agent.context_manager.manager import ContextManager
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  class OpType(Enum):
19
  USER_INPUT = "user_input"
@@ -46,7 +73,7 @@ class Session:
46
  self.tool_router = tool_router
47
  tool_specs = tool_router.get_tool_specs_for_llm() if tool_router else []
48
  self.context_manager = context_manager or ContextManager(
49
- max_context=get_max_tokens(config.model_name),
50
  compact_size=0.1,
51
  untouched_messages=5,
52
  tool_specs=tool_specs,
@@ -99,7 +126,7 @@ class Session:
99
 
100
  turns_since_last_save = self.turn_count - self.last_auto_save_turn
101
  if turns_since_last_save >= interval:
102
- print(f"\n💾 Auto-saving session (turn {self.turn_count})...")
103
  # Fire-and-forget save - returns immediately
104
  self.save_and_upload_detached(self.config.session_dataset_repo)
105
  self.last_auto_save_turn = self.turn_count
@@ -151,29 +178,9 @@ class Session:
151
 
152
  return str(filepath)
153
  except Exception as e:
154
- print(f"Failed to save session locally: {e}")
155
  return None
156
 
157
- def update_local_save_status(
158
- self, filepath: str, upload_status: str, dataset_url: Optional[str] = None
159
- ) -> bool:
160
- """Update the upload status of an existing local save file"""
161
- try:
162
- with open(filepath, "r") as f:
163
- data = json.load(f)
164
-
165
- data["upload_status"] = upload_status
166
- data["upload_url"] = dataset_url
167
- data["last_save_time"] = datetime.now().isoformat()
168
-
169
- with open(filepath, "w") as f:
170
- json.dump(data, f, indent=2)
171
-
172
- return True
173
- except Exception as e:
174
- print(f"Failed to update local save status: {e}")
175
- return False
176
-
177
  def save_and_upload_detached(self, repo_id: str) -> Optional[str]:
178
  """
179
  Save session locally and spawn detached subprocess for upload (fire-and-forget)
@@ -202,7 +209,7 @@ class Session:
202
  start_new_session=True, # Detach from parent
203
  )
204
  except Exception as e:
205
- print(f"⚠️ Failed to spawn upload subprocess: {e}")
206
 
207
  return local_path
208
 
@@ -232,4 +239,4 @@ class Session:
232
  start_new_session=True, # Detach from parent
233
  )
234
  except Exception as e:
235
- print(f"⚠️ Failed to spawn retry subprocess: {e}")
 
1
  import asyncio
2
  import json
3
+ import logging
4
  import subprocess
5
  import sys
6
  import uuid
 
10
  from pathlib import Path
11
  from typing import Any, Optional
12
 
 
 
13
  from agent.config import Config
14
  from agent.context_manager.manager import ContextManager
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Local max-token lookup — avoids litellm.get_max_tokens() which can hang
19
+ # on network calls for certain providers (known litellm issue).
20
+ _MAX_TOKENS_MAP: dict[str, int] = {
21
+ "anthropic/claude-opus-4-5-20251101": 200_000,
22
+ "anthropic/claude-sonnet-4-5-20250929": 200_000,
23
+ "anthropic/claude-sonnet-4-20250514": 200_000,
24
+ "anthropic/claude-haiku-3-5-20241022": 200_000,
25
+ "anthropic/claude-3-5-sonnet-20241022": 200_000,
26
+ "anthropic/claude-3-opus-20240229": 200_000,
27
+ }
28
+ _DEFAULT_MAX_TOKENS = 200_000
29
+
30
+
31
+ def _get_max_tokens_safe(model_name: str) -> int:
32
+ """Return the max context window for a model without network calls."""
33
+ tokens = _MAX_TOKENS_MAP.get(model_name)
34
+ if tokens:
35
+ return tokens
36
+ # Fallback: try litellm but with a short timeout via threading
37
+ try:
38
+ from litellm import get_max_tokens
39
+ return get_max_tokens(model_name)
40
+ except Exception as e:
41
+ logger.warning(f"get_max_tokens failed for {model_name}, using default: {e}")
42
+ return _DEFAULT_MAX_TOKENS
43
+
44
 
45
  class OpType(Enum):
46
  USER_INPUT = "user_input"
 
73
  self.tool_router = tool_router
74
  tool_specs = tool_router.get_tool_specs_for_llm() if tool_router else []
75
  self.context_manager = context_manager or ContextManager(
76
+ max_context=_get_max_tokens_safe(config.model_name),
77
  compact_size=0.1,
78
  untouched_messages=5,
79
  tool_specs=tool_specs,
 
126
 
127
  turns_since_last_save = self.turn_count - self.last_auto_save_turn
128
  if turns_since_last_save >= interval:
129
+ logger.info(f"Auto-saving session (turn {self.turn_count})...")
130
  # Fire-and-forget save - returns immediately
131
  self.save_and_upload_detached(self.config.session_dataset_repo)
132
  self.last_auto_save_turn = self.turn_count
 
178
 
179
  return str(filepath)
180
  except Exception as e:
181
+ logger.error(f"Failed to save session locally: {e}")
182
  return None
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  def save_and_upload_detached(self, repo_id: str) -> Optional[str]:
185
  """
186
  Save session locally and spawn detached subprocess for upload (fire-and-forget)
 
209
  start_new_session=True, # Detach from parent
210
  )
211
  except Exception as e:
212
+ logger.warning(f"Failed to spawn upload subprocess: {e}")
213
 
214
  return local_path
215
 
 
239
  start_new_session=True, # Detach from parent
240
  )
241
  except Exception as e:
242
+ logger.warning(f"Failed to spawn retry subprocess: {e}")
agent/core/session_uploader.py CHANGED
@@ -15,10 +15,8 @@ from dotenv import load_dotenv
15
 
16
  load_dotenv()
17
 
18
- # Fallback token for session uploads (write-only access to akseljoonas/hf-agent-sessions)
19
- _SESSION_TOKEN = "".join([
20
- "hf_", "Nzya", "Eeb", "ESz", "DtA", "BoW", "Czj", "SEC", "ZZv", "kVL", "Ac", "Vf", "Sz"
21
- ])
22
 
23
 
24
  def upload_session_as_file(
 
15
 
16
  load_dotenv()
17
 
18
+ # Token for session uploads — loaded from env var (never hardcode tokens in source)
19
+ _SESSION_TOKEN = os.environ.get("HF_SESSION_UPLOAD_TOKEN", "")
 
 
20
 
21
 
22
  def upload_session_as_file(
agent/core/tools.py CHANGED
@@ -3,10 +3,13 @@ Tool system for the agent
3
  Provides ToolSpec and ToolRouter for managing both built-in and MCP tools
4
  """
5
 
 
6
  import warnings
7
  from dataclasses import dataclass
8
  from typing import Any, Awaitable, Callable, Optional
9
 
 
 
10
  from fastmcp import Client
11
  from fastmcp.exceptions import ToolError
12
  from lmnr import observe
@@ -131,6 +134,7 @@ class ToolRouter:
131
  for tool in create_builtin_tools():
132
  self.register_tool(tool)
133
 
 
134
  if mcp_servers:
135
  mcp_servers_payload = {}
136
  for name, server in mcp_servers.items():
@@ -158,7 +162,7 @@ class ToolRouter:
158
  handler=None,
159
  )
160
  )
161
- print(
162
  f"Loaded {len(registered_names)} MCP tools: {', '.join(registered_names)} ({skipped_count} disabled)"
163
  )
164
 
@@ -179,7 +183,7 @@ class ToolRouter:
179
  handler=search_openapi_handler,
180
  )
181
  )
182
- print(f"Loaded OpenAPI search tool: {openapi_spec['name']}")
183
 
184
  def get_tool_specs_for_llm(self) -> list[dict[str, Any]]:
185
  """Get tool specifications in OpenAI format"""
@@ -208,7 +212,7 @@ class ToolRouter:
208
  await self.register_openapi_tool()
209
 
210
  total_tools = len(self.tools)
211
- print(f"\nAgent ready with {total_tools} tools total\n")
212
 
213
  return self
214
 
@@ -328,6 +332,6 @@ def create_builtin_tools() -> list[ToolSpec]:
328
  ]
329
 
330
  tool_names = ", ".join([t.name for t in tools])
331
- print(f"Loaded {len(tools)} built-in tools: {tool_names}")
332
 
333
  return tools
 
3
  Provides ToolSpec and ToolRouter for managing both built-in and MCP tools
4
  """
5
 
6
+ import logging
7
  import warnings
8
  from dataclasses import dataclass
9
  from typing import Any, Awaitable, Callable, Optional
10
 
11
+ logger = logging.getLogger(__name__)
12
+
13
  from fastmcp import Client
14
  from fastmcp.exceptions import ToolError
15
  from lmnr import observe
 
134
  for tool in create_builtin_tools():
135
  self.register_tool(tool)
136
 
137
+ self.mcp_client: Client | None = None
138
  if mcp_servers:
139
  mcp_servers_payload = {}
140
  for name, server in mcp_servers.items():
 
162
  handler=None,
163
  )
164
  )
165
+ logger.info(
166
  f"Loaded {len(registered_names)} MCP tools: {', '.join(registered_names)} ({skipped_count} disabled)"
167
  )
168
 
 
183
  handler=search_openapi_handler,
184
  )
185
  )
186
+ logger.info(f"Loaded OpenAPI search tool: {openapi_spec['name']}")
187
 
188
  def get_tool_specs_for_llm(self) -> list[dict[str, Any]]:
189
  """Get tool specifications in OpenAI format"""
 
212
  await self.register_openapi_tool()
213
 
214
  total_tools = len(self.tools)
215
+ logger.info(f"Agent ready with {total_tools} tools total")
216
 
217
  return self
218
 
 
332
  ]
333
 
334
  tool_names = ", ".join([t.name for t in tools])
335
+ logger.info(f"Loaded {len(tools)} built-in tools: {tool_names}")
336
 
337
  return tools
agent/prompts/system_prompt.yaml CHANGED
@@ -1,5 +1,5 @@
1
  system_prompt: |
2
- You are Hugging Face Agent, a skilled AI assistant for machine learning engineering. Hugging Face is a company that provides two main services : libraries to write deep learning tasks, and ressources (models, datasets, compute) to execute them. You will aid users to do theses tasks, interacting with the Hugging Face stack via {{ num_tools }}.
3
 
4
  # General behavior
5
 
@@ -9,7 +9,7 @@ system_prompt: |
9
 
10
  **CRITICAL : Research first, Then Implement**
11
 
12
- For ANY implementation task (training, fine-tuning, inference, data processing, etc.), you should proceed in thoses three mandatory steps:
13
 
14
  1. **FIRST**: Search HF documentation to find the correct approach.
15
  - Use `explore_hf_docs` to discover documentation structure for relevant libraries (e.g., "trl", "transformers", "diffusers").
 
1
  system_prompt: |
2
+ You are Hugging Face Agent, a skilled AI assistant for machine learning engineering. Hugging Face is a company that provides two main services : libraries to write deep learning tasks, and resources (models, datasets, compute) to execute them. You will aid users to do these tasks, interacting with the Hugging Face stack via {{ num_tools }}.
3
 
4
  # General behavior
5
 
 
9
 
10
  **CRITICAL : Research first, Then Implement**
11
 
12
+ For ANY implementation task (training, fine-tuning, inference, data processing, etc.), you should proceed in these three mandatory steps:
13
 
14
  1. **FIRST**: Search HF documentation to find the correct approach.
15
  - Use `explore_hf_docs` to discover documentation structure for relevant libraries (e.g., "trl", "transformers", "diffusers").
agent/tools/jobs_tool.py CHANGED
@@ -11,12 +11,16 @@ import os
11
  import re
12
  from typing import Any, Dict, Literal, Optional, Callable, Awaitable
13
 
 
 
14
  import httpx
15
  from huggingface_hub import HfApi
16
  from huggingface_hub.utils import HfHubHTTPError
17
 
18
  from agent.core.session import Event
19
  from agent.tools.types import ToolResult
 
 
20
  from agent.tools.utilities import (
21
  format_job_details,
22
  format_jobs_table,
@@ -401,7 +405,7 @@ class HfJobsTool:
401
 
402
  # Process log line
403
  log_line = item
404
- print("\t" + log_line)
405
  if self.log_callback:
406
  await self.log_callback(log_line)
407
  all_logs.append(log_line)
@@ -429,19 +433,19 @@ class HfJobsTool:
429
 
430
  if current_status in terminal_states:
431
  # Job finished, no need to retry
432
- print(f"\tJob reached terminal state: {current_status}")
433
  break
434
 
435
  # Job still running, retry connection
436
- print(
437
- f"\tConnection interrupted ({str(e)[:50]}...), reconnecting in {retry_delay}s..."
438
  )
439
  await asyncio.sleep(retry_delay)
440
  continue
441
 
442
  except (ConnectionError, TimeoutError, OSError):
443
  # Can't even check job status, wait and retry
444
- print(f"\tConnection error, retrying in {retry_delay}s...")
445
  await asyncio.sleep(retry_delay)
446
  continue
447
 
@@ -505,8 +509,8 @@ class HfJobsTool:
505
  )
506
 
507
  # Wait for completion and stream logs
508
- print(f"{job_type} job started: {job.url}")
509
- print("Streaming logs...\n---\n")
510
 
511
  final_status, all_logs = await self._wait_for_job_completion(
512
  job_id=job.id,
 
11
  import re
12
  from typing import Any, Dict, Literal, Optional, Callable, Awaitable
13
 
14
+ import logging
15
+
16
  import httpx
17
  from huggingface_hub import HfApi
18
  from huggingface_hub.utils import HfHubHTTPError
19
 
20
  from agent.core.session import Event
21
  from agent.tools.types import ToolResult
22
+
23
+ logger = logging.getLogger(__name__)
24
  from agent.tools.utilities import (
25
  format_job_details,
26
  format_jobs_table,
 
405
 
406
  # Process log line
407
  log_line = item
408
+ logger.debug(log_line)
409
  if self.log_callback:
410
  await self.log_callback(log_line)
411
  all_logs.append(log_line)
 
433
 
434
  if current_status in terminal_states:
435
  # Job finished, no need to retry
436
+ logger.info(f"Job reached terminal state: {current_status}")
437
  break
438
 
439
  # Job still running, retry connection
440
+ logger.warning(
441
+ f"Connection interrupted ({str(e)[:50]}...), reconnecting in {retry_delay}s..."
442
  )
443
  await asyncio.sleep(retry_delay)
444
  continue
445
 
446
  except (ConnectionError, TimeoutError, OSError):
447
  # Can't even check job status, wait and retry
448
+ logger.warning(f"Connection error, retrying in {retry_delay}s...")
449
  await asyncio.sleep(retry_delay)
450
  continue
451
 
 
509
  )
510
 
511
  # Wait for completion and stream logs
512
+ logger.info(f"{job_type} job started: {job.url}")
513
+ logger.info("Streaming logs...")
514
 
515
  final_status, all_logs = await self._wait_for_job_completion(
516
  job_id=job.id,
backend/dependencies.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication dependencies for FastAPI routes.
2
+
3
+ Provides auth validation for both REST and WebSocket endpoints.
4
+ - In dev mode (OAUTH_CLIENT_ID not set): auth is bypassed, returns a default "dev" user.
5
+ - In production: validates Bearer tokens or cookies against HF OAuth.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import time
11
+ from typing import Any
12
+
13
+ import httpx
14
+ from fastapi import HTTPException, Request, WebSocket, status
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
19
+ AUTH_ENABLED = bool(os.environ.get("OAUTH_CLIENT_ID", ""))
20
+
21
+ # Simple in-memory token cache: token -> (user_info, expiry_time)
22
+ _token_cache: dict[str, tuple[dict[str, Any], float]] = {}
23
+ TOKEN_CACHE_TTL = 300 # 5 minutes
24
+
25
+ DEV_USER: dict[str, Any] = {
26
+ "user_id": "dev",
27
+ "username": "dev",
28
+ "authenticated": True,
29
+ }
30
+
31
+
32
+ async def _validate_token(token: str) -> dict[str, Any] | None:
33
+ """Validate a token against HF OAuth userinfo endpoint.
34
+
35
+ Results are cached for TOKEN_CACHE_TTL seconds to avoid excessive API calls.
36
+ """
37
+ now = time.time()
38
+
39
+ # Check cache
40
+ if token in _token_cache:
41
+ user_info, expiry = _token_cache[token]
42
+ if now < expiry:
43
+ return user_info
44
+ del _token_cache[token]
45
+
46
+ # Validate against HF
47
+ async with httpx.AsyncClient(timeout=10.0) as client:
48
+ try:
49
+ response = await client.get(
50
+ f"{OPENID_PROVIDER_URL}/oauth/userinfo",
51
+ headers={"Authorization": f"Bearer {token}"},
52
+ )
53
+ if response.status_code != 200:
54
+ logger.debug("Token validation failed: status %d", response.status_code)
55
+ return None
56
+ user_info = response.json()
57
+ _token_cache[token] = (user_info, now + TOKEN_CACHE_TTL)
58
+ return user_info
59
+ except httpx.HTTPError as e:
60
+ logger.warning("Token validation error: %s", e)
61
+ return None
62
+
63
+
64
+ def _user_from_info(user_info: dict[str, Any]) -> dict[str, Any]:
65
+ """Build a normalized user dict from HF userinfo response."""
66
+ return {
67
+ "user_id": user_info.get("sub", user_info.get("preferred_username", "unknown")),
68
+ "username": user_info.get("preferred_username", "unknown"),
69
+ "name": user_info.get("name"),
70
+ "picture": user_info.get("picture"),
71
+ "authenticated": True,
72
+ }
73
+
74
+
75
+ async def _extract_user_from_token(token: str) -> dict[str, Any] | None:
76
+ """Validate a token and return a user dict, or None."""
77
+ user_info = await _validate_token(token)
78
+ if user_info:
79
+ return _user_from_info(user_info)
80
+ return None
81
+
82
+
83
+ async def get_current_user(request: Request) -> dict[str, Any]:
84
+ """FastAPI dependency: extract and validate the current user.
85
+
86
+ Checks (in order):
87
+ 1. Authorization: Bearer <token> header
88
+ 2. hf_access_token cookie
89
+
90
+ In dev mode (AUTH_ENABLED=False), returns a default dev user.
91
+ """
92
+ if not AUTH_ENABLED:
93
+ return DEV_USER
94
+
95
+ # Try Authorization header
96
+ auth_header = request.headers.get("Authorization", "")
97
+ if auth_header.startswith("Bearer "):
98
+ token = auth_header[7:]
99
+ user = await _extract_user_from_token(token)
100
+ if user:
101
+ return user
102
+
103
+ # Try cookie
104
+ token = request.cookies.get("hf_access_token")
105
+ if token:
106
+ user = await _extract_user_from_token(token)
107
+ if user:
108
+ return user
109
+
110
+ raise HTTPException(
111
+ status_code=status.HTTP_401_UNAUTHORIZED,
112
+ detail="Not authenticated. Please log in via /auth/login.",
113
+ headers={"WWW-Authenticate": "Bearer"},
114
+ )
115
+
116
+
117
+ async def get_ws_user(websocket: WebSocket) -> dict[str, Any] | None:
118
+ """Extract and validate user from WebSocket connection.
119
+
120
+ WebSocket doesn't support custom headers from browser, so we check:
121
+ 1. ?token= query parameter
122
+ 2. hf_access_token cookie (sent automatically for same-origin)
123
+
124
+ Returns user dict or None if not authenticated.
125
+ In dev mode, returns the default dev user.
126
+ """
127
+ if not AUTH_ENABLED:
128
+ return DEV_USER
129
+
130
+ # Try query param
131
+ token = websocket.query_params.get("token")
132
+ if token:
133
+ user = await _extract_user_from_token(token)
134
+ if user:
135
+ return user
136
+
137
+ # Try cookie (works for same-origin WebSocket)
138
+ token = websocket.cookies.get("hf_access_token")
139
+ if token:
140
+ user = await _extract_user_from_token(token)
141
+ if user:
142
+ return user
143
+
144
+ return None
backend/models.py CHANGED
@@ -67,6 +67,7 @@ class SessionInfo(BaseModel):
67
  created_at: str
68
  is_active: bool
69
  message_count: int
 
70
 
71
 
72
  class HealthResponse(BaseModel):
@@ -74,3 +75,13 @@ class HealthResponse(BaseModel):
74
 
75
  status: str = "ok"
76
  active_sessions: int = 0
 
 
 
 
 
 
 
 
 
 
 
67
  created_at: str
68
  is_active: bool
69
  message_count: int
70
+ user_id: str = "dev"
71
 
72
 
73
  class HealthResponse(BaseModel):
 
75
 
76
  status: str = "ok"
77
  active_sessions: int = 0
78
+ max_sessions: int = 0
79
+
80
+
81
+ class LLMHealthResponse(BaseModel):
82
+ """LLM provider health check response."""
83
+
84
+ status: str # "ok" | "error"
85
+ model: str
86
+ error: str | None = None
87
+ error_type: str | None = None # "auth" | "credits" | "rate_limit" | "network" | "unknown"
backend/routes/agent.py CHANGED
@@ -1,17 +1,26 @@
1
- """Agent API routes - WebSocket and REST endpoints."""
 
 
 
 
2
 
3
  import logging
 
 
 
4
 
5
- from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
 
6
 
7
  from models import (
8
  ApprovalRequest,
9
  HealthResponse,
 
10
  SessionInfo,
11
  SessionResponse,
12
  SubmitRequest,
13
  )
14
- from session_manager import session_manager
15
  from websocket import manager as ws_manager
16
 
17
  logger = logging.getLogger(__name__)
@@ -19,40 +28,139 @@ logger = logging.getLogger(__name__)
19
  router = APIRouter(prefix="/api", tags=["agent"])
20
 
21
 
 
 
 
 
 
 
 
 
 
22
  @router.get("/health", response_model=HealthResponse)
23
  async def health_check() -> HealthResponse:
24
  """Health check endpoint."""
25
  return HealthResponse(
26
- status="ok", active_sessions=session_manager.active_session_count
 
 
27
  )
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  @router.post("/session", response_model=SessionResponse)
31
- async def create_session() -> SessionResponse:
32
- """Create a new agent session."""
33
- session_id = await session_manager.create_session()
 
 
 
 
 
 
34
  return SessionResponse(session_id=session_id, ready=True)
35
 
36
 
37
  @router.get("/session/{session_id}", response_model=SessionInfo)
38
- async def get_session(session_id: str) -> SessionInfo:
39
- """Get session information."""
 
 
 
40
  info = session_manager.get_session_info(session_id)
41
- if not info:
42
- raise HTTPException(status_code=404, detail="Session not found")
43
  return SessionInfo(**info)
44
 
45
 
46
  @router.get("/sessions", response_model=list[SessionInfo])
47
- async def list_sessions() -> list[SessionInfo]:
48
- """List all sessions."""
49
- sessions = session_manager.list_sessions()
50
  return [SessionInfo(**s) for s in sessions]
51
 
52
 
53
  @router.delete("/session/{session_id}")
54
- async def delete_session(session_id: str) -> dict:
55
- """Delete a session."""
 
 
 
56
  success = await session_manager.delete_session(session_id)
57
  if not success:
58
  raise HTTPException(status_code=404, detail="Session not found")
@@ -60,8 +168,11 @@ async def delete_session(session_id: str) -> dict:
60
 
61
 
62
  @router.post("/submit")
63
- async def submit_input(request: SubmitRequest) -> dict:
64
- """Submit user input to a session."""
 
 
 
65
  success = await session_manager.submit_user_input(request.session_id, request.text)
66
  if not success:
67
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -69,8 +180,11 @@ async def submit_input(request: SubmitRequest) -> dict:
69
 
70
 
71
  @router.post("/approve")
72
- async def submit_approval(request: ApprovalRequest) -> dict:
73
- """Submit tool approvals to a session."""
 
 
 
74
  approvals = [
75
  {
76
  "tool_call_id": a.tool_call_id,
@@ -86,8 +200,11 @@ async def submit_approval(request: ApprovalRequest) -> dict:
86
 
87
 
88
  @router.post("/interrupt/{session_id}")
89
- async def interrupt_session(session_id: str) -> dict:
 
 
90
  """Interrupt the current operation in a session."""
 
91
  success = await session_manager.interrupt(session_id)
92
  if not success:
93
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -95,8 +212,11 @@ async def interrupt_session(session_id: str) -> dict:
95
 
96
 
97
  @router.post("/undo/{session_id}")
98
- async def undo_session(session_id: str) -> dict:
 
 
99
  """Undo the last turn in a session."""
 
100
  success = await session_manager.undo(session_id)
101
  if not success:
102
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -104,8 +224,11 @@ async def undo_session(session_id: str) -> dict:
104
 
105
 
106
  @router.post("/compact/{session_id}")
107
- async def compact_session(session_id: str) -> dict:
 
 
108
  """Compact the context in a session."""
 
109
  success = await session_manager.compact(session_id)
110
  if not success:
111
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -113,8 +236,11 @@ async def compact_session(session_id: str) -> dict:
113
 
114
 
115
  @router.post("/shutdown/{session_id}")
116
- async def shutdown_session(session_id: str) -> dict:
 
 
117
  """Shutdown a session."""
 
118
  success = await session_manager.shutdown_session(session_id)
119
  if not success:
120
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -123,17 +249,57 @@ async def shutdown_session(session_id: str) -> dict:
123
 
124
  @router.websocket("/ws/{session_id}")
125
  async def websocket_endpoint(websocket: WebSocket, session_id: str) -> None:
126
- """WebSocket endpoint for real-time events."""
 
 
 
 
 
 
 
 
 
 
127
  logger.info(f"WebSocket connection request for session {session_id}")
 
 
 
 
 
 
 
 
 
128
  # Verify session exists
129
  info = session_manager.get_session_info(session_id)
130
  if not info:
131
- logger.warning(f"WebSocket connection rejected: Session {session_id} not found")
 
132
  await websocket.close(code=4004, reason="Session not found")
133
  return
134
 
 
 
 
 
 
 
 
 
 
135
  await ws_manager.connect(websocket, session_id)
136
 
 
 
 
 
 
 
 
 
 
 
 
137
  try:
138
  while True:
139
  # Keep connection alive, handle ping/pong
 
1
+ """Agent API routes - WebSocket and REST endpoints.
2
+
3
+ All routes (except /health) require authentication via the get_current_user
4
+ dependency. In dev mode (no OAUTH_CLIENT_ID), auth is bypassed automatically.
5
+ """
6
 
7
  import logging
8
+ from typing import Any
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
11
 
12
+ from dependencies import get_current_user, get_ws_user
13
+ from litellm import acompletion
14
 
15
  from models import (
16
  ApprovalRequest,
17
  HealthResponse,
18
+ LLMHealthResponse,
19
  SessionInfo,
20
  SessionResponse,
21
  SubmitRequest,
22
  )
23
+ from session_manager import MAX_SESSIONS, SessionCapacityError, session_manager
24
  from websocket import manager as ws_manager
25
 
26
  logger = logging.getLogger(__name__)
 
28
  router = APIRouter(prefix="/api", tags=["agent"])
29
 
30
 
31
+ def _check_session_access(session_id: str, user: dict[str, Any]) -> None:
32
+ """Verify the user has access to the given session. Raises 403 or 404."""
33
+ info = session_manager.get_session_info(session_id)
34
+ if not info:
35
+ raise HTTPException(status_code=404, detail="Session not found")
36
+ if not session_manager.verify_session_access(session_id, user["user_id"]):
37
+ raise HTTPException(status_code=403, detail="Access denied to this session")
38
+
39
+
40
  @router.get("/health", response_model=HealthResponse)
41
  async def health_check() -> HealthResponse:
42
  """Health check endpoint."""
43
  return HealthResponse(
44
+ status="ok",
45
+ active_sessions=session_manager.active_session_count,
46
+ max_sessions=MAX_SESSIONS,
47
  )
48
 
49
 
50
+ @router.get("/health/llm", response_model=LLMHealthResponse)
51
+ async def llm_health_check() -> LLMHealthResponse:
52
+ """Check if the LLM provider is reachable and the API key is valid.
53
+
54
+ Makes a minimal 1-token completion call. Catches common errors:
55
+ - 401 → invalid API key
56
+ - 402/insufficient_quota → out of credits
57
+ - 429 → rate limited
58
+ - timeout / network → provider unreachable
59
+ """
60
+ model = session_manager.config.model_name
61
+ try:
62
+ await acompletion(
63
+ model=model,
64
+ messages=[{"role": "user", "content": "hi"}],
65
+ max_tokens=1,
66
+ timeout=10,
67
+ )
68
+ return LLMHealthResponse(status="ok", model=model)
69
+ except Exception as e:
70
+ err_str = str(e).lower()
71
+ error_type = "unknown"
72
+
73
+ if "401" in err_str or "auth" in err_str or "invalid" in err_str or "api key" in err_str:
74
+ error_type = "auth"
75
+ elif "402" in err_str or "credit" in err_str or "quota" in err_str or "insufficient" in err_str or "billing" in err_str:
76
+ error_type = "credits"
77
+ elif "429" in err_str or "rate" in err_str:
78
+ error_type = "rate_limit"
79
+ elif "timeout" in err_str or "connect" in err_str or "network" in err_str:
80
+ error_type = "network"
81
+
82
+ logger.warning(f"LLM health check failed ({error_type}): {e}")
83
+ return LLMHealthResponse(
84
+ status="error",
85
+ model=model,
86
+ error=str(e)[:500],
87
+ error_type=error_type,
88
+ )
89
+
90
+
91
+ @router.post("/title")
92
+ async def generate_title(
93
+ request: SubmitRequest, user: dict = Depends(get_current_user)
94
+ ) -> dict:
95
+ """Generate a short title for a chat session based on the first user message."""
96
+ model = session_manager.config.model_name
97
+ try:
98
+ response = await acompletion(
99
+ model=model,
100
+ messages=[
101
+ {
102
+ "role": "system",
103
+ "content": (
104
+ "Generate a very short title (max 6 words) for a chat conversation "
105
+ "that starts with the following user message. "
106
+ "Reply with ONLY the title, no quotes, no punctuation at the end."
107
+ ),
108
+ },
109
+ {"role": "user", "content": request.text[:500]},
110
+ ],
111
+ max_tokens=20,
112
+ temperature=0.3,
113
+ timeout=8,
114
+ )
115
+ title = response.choices[0].message.content.strip().strip('"').strip("'")
116
+ # Safety: cap at 50 chars
117
+ if len(title) > 50:
118
+ title = title[:50].rstrip() + "…"
119
+ return {"title": title}
120
+ except Exception as e:
121
+ logger.warning(f"Title generation failed: {e}")
122
+ # Fallback: truncate the message
123
+ fallback = request.text.strip()
124
+ title = fallback[:40].rstrip() + "…" if len(fallback) > 40 else fallback
125
+ return {"title": title}
126
+
127
+
128
  @router.post("/session", response_model=SessionResponse)
129
+ async def create_session(user: dict = Depends(get_current_user)) -> SessionResponse:
130
+ """Create a new agent session bound to the authenticated user.
131
+
132
+ Returns 503 if the server or user has reached the session limit.
133
+ """
134
+ try:
135
+ session_id = await session_manager.create_session(user_id=user["user_id"])
136
+ except SessionCapacityError as e:
137
+ raise HTTPException(status_code=503, detail=str(e))
138
  return SessionResponse(session_id=session_id, ready=True)
139
 
140
 
141
  @router.get("/session/{session_id}", response_model=SessionInfo)
142
+ async def get_session(
143
+ session_id: str, user: dict = Depends(get_current_user)
144
+ ) -> SessionInfo:
145
+ """Get session information. Only accessible by the session owner."""
146
+ _check_session_access(session_id, user)
147
  info = session_manager.get_session_info(session_id)
 
 
148
  return SessionInfo(**info)
149
 
150
 
151
  @router.get("/sessions", response_model=list[SessionInfo])
152
+ async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
153
+ """List sessions belonging to the authenticated user."""
154
+ sessions = session_manager.list_sessions(user_id=user["user_id"])
155
  return [SessionInfo(**s) for s in sessions]
156
 
157
 
158
  @router.delete("/session/{session_id}")
159
+ async def delete_session(
160
+ session_id: str, user: dict = Depends(get_current_user)
161
+ ) -> dict:
162
+ """Delete a session. Only accessible by the session owner."""
163
+ _check_session_access(session_id, user)
164
  success = await session_manager.delete_session(session_id)
165
  if not success:
166
  raise HTTPException(status_code=404, detail="Session not found")
 
168
 
169
 
170
  @router.post("/submit")
171
+ async def submit_input(
172
+ request: SubmitRequest, user: dict = Depends(get_current_user)
173
+ ) -> dict:
174
+ """Submit user input to a session. Only accessible by the session owner."""
175
+ _check_session_access(request.session_id, user)
176
  success = await session_manager.submit_user_input(request.session_id, request.text)
177
  if not success:
178
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
180
 
181
 
182
  @router.post("/approve")
183
+ async def submit_approval(
184
+ request: ApprovalRequest, user: dict = Depends(get_current_user)
185
+ ) -> dict:
186
+ """Submit tool approvals to a session. Only accessible by the session owner."""
187
+ _check_session_access(request.session_id, user)
188
  approvals = [
189
  {
190
  "tool_call_id": a.tool_call_id,
 
200
 
201
 
202
  @router.post("/interrupt/{session_id}")
203
+ async def interrupt_session(
204
+ session_id: str, user: dict = Depends(get_current_user)
205
+ ) -> dict:
206
  """Interrupt the current operation in a session."""
207
+ _check_session_access(session_id, user)
208
  success = await session_manager.interrupt(session_id)
209
  if not success:
210
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
212
 
213
 
214
  @router.post("/undo/{session_id}")
215
+ async def undo_session(
216
+ session_id: str, user: dict = Depends(get_current_user)
217
+ ) -> dict:
218
  """Undo the last turn in a session."""
219
+ _check_session_access(session_id, user)
220
  success = await session_manager.undo(session_id)
221
  if not success:
222
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
224
 
225
 
226
  @router.post("/compact/{session_id}")
227
+ async def compact_session(
228
+ session_id: str, user: dict = Depends(get_current_user)
229
+ ) -> dict:
230
  """Compact the context in a session."""
231
+ _check_session_access(session_id, user)
232
  success = await session_manager.compact(session_id)
233
  if not success:
234
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
236
 
237
 
238
  @router.post("/shutdown/{session_id}")
239
+ async def shutdown_session(
240
+ session_id: str, user: dict = Depends(get_current_user)
241
+ ) -> dict:
242
  """Shutdown a session."""
243
+ _check_session_access(session_id, user)
244
  success = await session_manager.shutdown_session(session_id)
245
  if not success:
246
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
249
 
250
  @router.websocket("/ws/{session_id}")
251
  async def websocket_endpoint(websocket: WebSocket, session_id: str) -> None:
252
+ """WebSocket endpoint for real-time events.
253
+
254
+ Authentication is done via:
255
+ - ?token= query parameter (for browsers that can't send WS headers)
256
+ - Cookie (automatic for same-origin connections)
257
+ - Dev mode bypass (when OAUTH_CLIENT_ID is not set)
258
+
259
+ NOTE: We must accept() before close() so the browser receives our custom
260
+ close codes (4001, 4003, 4004). If we close() before accept(), Starlette
261
+ sends HTTP 403 and the browser only sees code 1006 (abnormal closure).
262
+ """
263
  logger.info(f"WebSocket connection request for session {session_id}")
264
+
265
+ # Authenticate the WebSocket connection
266
+ user = await get_ws_user(websocket)
267
+ if not user:
268
+ logger.warning(f"WebSocket rejected: authentication failed for session {session_id}")
269
+ await websocket.accept()
270
+ await websocket.close(code=4001, reason="Authentication required")
271
+ return
272
+
273
  # Verify session exists
274
  info = session_manager.get_session_info(session_id)
275
  if not info:
276
+ logger.warning(f"WebSocket rejected: session {session_id} not found")
277
+ await websocket.accept()
278
  await websocket.close(code=4004, reason="Session not found")
279
  return
280
 
281
+ # Verify user owns the session
282
+ if not session_manager.verify_session_access(session_id, user["user_id"]):
283
+ logger.warning(
284
+ f"WebSocket rejected: user {user['user_id']} denied access to session {session_id}"
285
+ )
286
+ await websocket.accept()
287
+ await websocket.close(code=4003, reason="Access denied")
288
+ return
289
+
290
  await ws_manager.connect(websocket, session_id)
291
 
292
+ # Send "ready" immediately on WebSocket connection so the frontend
293
+ # knows the session is alive. The original ready event from _run_session
294
+ # fires before the WS is connected and is always lost.
295
+ try:
296
+ await websocket.send_json({
297
+ "event_type": "ready",
298
+ "data": {"message": "Agent initialized"},
299
+ })
300
+ except Exception as e:
301
+ logger.error(f"Failed to send ready event for session {session_id}: {e}")
302
+
303
  try:
304
  while True:
305
  # Keep connection alive, handle ping/pong
backend/routes/auth.py CHANGED
@@ -1,13 +1,20 @@
1
- """Authentication routes for HF OAuth."""
 
 
 
 
2
 
3
  import os
4
  import secrets
 
5
  from urllib.parse import urlencode
6
 
7
  import httpx
8
- from fastapi import APIRouter, HTTPException, Request
9
  from fastapi.responses import RedirectResponse
10
 
 
 
11
  router = APIRouter(prefix="/auth", tags=["auth"])
12
 
13
  # OAuth configuration from environment
@@ -15,10 +22,19 @@ OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID", "")
15
  OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
16
  OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
17
 
18
- # In-memory session store (replace with proper session management in production)
 
19
  oauth_states: dict[str, dict] = {}
20
 
21
 
 
 
 
 
 
 
 
 
22
  def get_redirect_uri(request: Request) -> str:
23
  """Get the OAuth callback redirect URI."""
24
  # In HF Spaces, use the SPACE_HOST if available
@@ -38,9 +54,15 @@ async def oauth_login(request: Request) -> RedirectResponse:
38
  detail="OAuth not configured. Set OAUTH_CLIENT_ID environment variable.",
39
  )
40
 
 
 
 
41
  # Generate state for CSRF protection
42
  state = secrets.token_urlsafe(32)
43
- oauth_states[state] = {"redirect_uri": get_redirect_uri(request)}
 
 
 
44
 
45
  # Build authorization URL
46
  params = {
@@ -91,58 +113,57 @@ async def oauth_callback(
91
 
92
  # Get user info
93
  access_token = token_data.get("access_token")
94
- if access_token:
95
- async with httpx.AsyncClient() as client:
96
- try:
97
- userinfo_response = await client.get(
98
- f"{OPENID_PROVIDER_URL}/oauth/userinfo",
99
- headers={"Authorization": f"Bearer {access_token}"},
100
- )
101
- userinfo_response.raise_for_status()
102
- user_info = userinfo_response.json()
103
- except httpx.HTTPError:
104
- user_info = {}
105
- else:
106
- user_info = {}
107
-
108
- # For now, redirect to home with token in query params
109
- # In production, use secure cookies or session storage
110
- redirect_params = {
111
- "access_token": access_token,
112
- "username": user_info.get("preferred_username", ""),
113
- }
114
 
115
- return RedirectResponse(url=f"/?{urlencode(redirect_params)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
 
118
  @router.get("/logout")
119
  async def logout() -> RedirectResponse:
120
- """Log out the user."""
121
- return RedirectResponse(url="/")
 
 
122
 
123
 
124
- @router.get("/me")
125
- async def get_current_user(request: Request) -> dict:
126
- """Get current user info from Authorization header."""
127
- auth_header = request.headers.get("Authorization", "")
128
- if not auth_header.startswith("Bearer "):
129
- return {"authenticated": False}
130
 
131
- token = auth_header.split(" ")[1]
132
 
133
- async with httpx.AsyncClient() as client:
134
- try:
135
- response = await client.get(
136
- f"{OPENID_PROVIDER_URL}/oauth/userinfo",
137
- headers={"Authorization": f"Bearer {token}"},
138
- )
139
- response.raise_for_status()
140
- user_info = response.json()
141
- return {
142
- "authenticated": True,
143
- "username": user_info.get("preferred_username"),
144
- "name": user_info.get("name"),
145
- "picture": user_info.get("picture"),
146
- }
147
- except httpx.HTTPError:
148
- return {"authenticated": False}
 
1
+ """Authentication routes for HF OAuth.
2
+
3
+ Handles the OAuth 2.0 authorization code flow with HF as provider.
4
+ After successful auth, sets an HttpOnly cookie with the access token.
5
+ """
6
 
7
  import os
8
  import secrets
9
+ import time
10
  from urllib.parse import urlencode
11
 
12
  import httpx
13
+ from fastapi import APIRouter, Depends, HTTPException, Request
14
  from fastapi.responses import RedirectResponse
15
 
16
+ from dependencies import AUTH_ENABLED, get_current_user
17
+
18
  router = APIRouter(prefix="/auth", tags=["auth"])
19
 
20
  # OAuth configuration from environment
 
22
  OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
23
  OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
24
 
25
+ # In-memory OAuth state store with expiry (5 min TTL)
26
+ _OAUTH_STATE_TTL = 300
27
  oauth_states: dict[str, dict] = {}
28
 
29
 
30
+ def _cleanup_expired_states() -> None:
31
+ """Remove expired OAuth states to prevent memory growth."""
32
+ now = time.time()
33
+ expired = [k for k, v in oauth_states.items() if now > v.get("expires_at", 0)]
34
+ for k in expired:
35
+ del oauth_states[k]
36
+
37
+
38
  def get_redirect_uri(request: Request) -> str:
39
  """Get the OAuth callback redirect URI."""
40
  # In HF Spaces, use the SPACE_HOST if available
 
54
  detail="OAuth not configured. Set OAUTH_CLIENT_ID environment variable.",
55
  )
56
 
57
+ # Clean up expired states to prevent memory growth
58
+ _cleanup_expired_states()
59
+
60
  # Generate state for CSRF protection
61
  state = secrets.token_urlsafe(32)
62
+ oauth_states[state] = {
63
+ "redirect_uri": get_redirect_uri(request),
64
+ "expires_at": time.time() + _OAUTH_STATE_TTL,
65
+ }
66
 
67
  # Build authorization URL
68
  params = {
 
113
 
114
  # Get user info
115
  access_token = token_data.get("access_token")
116
+ if not access_token:
117
+ raise HTTPException(
118
+ status_code=500,
119
+ detail="Token exchange succeeded but no access_token was returned.",
120
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
+ # Fetch user info (optional — failure is not fatal)
123
+ async with httpx.AsyncClient() as client:
124
+ try:
125
+ userinfo_response = await client.get(
126
+ f"{OPENID_PROVIDER_URL}/oauth/userinfo",
127
+ headers={"Authorization": f"Bearer {access_token}"},
128
+ )
129
+ userinfo_response.raise_for_status()
130
+ except httpx.HTTPError:
131
+ pass # user_info not required for auth flow
132
+
133
+ # Set access token as HttpOnly cookie (not in URL — avoids leaks via
134
+ # Referrer headers, browser history, and server logs)
135
+ is_production = bool(os.environ.get("SPACE_HOST"))
136
+ response = RedirectResponse(url="/", status_code=302)
137
+ response.set_cookie(
138
+ key="hf_access_token",
139
+ value=access_token,
140
+ httponly=True,
141
+ secure=is_production, # Secure flag only in production (HTTPS)
142
+ samesite="lax",
143
+ max_age=3600 * 24, # 24 hours
144
+ path="/",
145
+ )
146
+ return response
147
 
148
 
149
  @router.get("/logout")
150
  async def logout() -> RedirectResponse:
151
+ """Log out the user by clearing the auth cookie."""
152
+ response = RedirectResponse(url="/")
153
+ response.delete_cookie(key="hf_access_token", path="/")
154
+ return response
155
 
156
 
157
+ @router.get("/status")
158
+ async def auth_status() -> dict:
159
+ """Check if OAuth is enabled on this instance."""
160
+ return {"auth_enabled": AUTH_ENABLED}
 
 
161
 
 
162
 
163
+ @router.get("/me")
164
+ async def get_me(user: dict = Depends(get_current_user)) -> dict:
165
+ """Get current user info. Returns the authenticated user or dev user.
166
+
167
+ Uses the shared auth dependency which handles cookie + Bearer token.
168
+ """
169
+ return user
 
 
 
 
 
 
 
 
 
backend/session_manager.py CHANGED
@@ -48,11 +48,27 @@ class AgentSession:
48
  session: Session
49
  tool_router: ToolRouter
50
  submission_queue: asyncio.Queue
 
51
  task: asyncio.Task | None = None
52
  created_at: datetime = field(default_factory=datetime.utcnow)
53
  is_active: bool = True
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  class SessionManager:
57
  """Manages multiple concurrent agent sessions."""
58
 
@@ -61,19 +77,66 @@ class SessionManager:
61
  self.sessions: dict[str, AgentSession] = {}
62
  self._lock = asyncio.Lock()
63
 
64
- async def create_session(self) -> str:
65
- """Create a new agent session and return its ID."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  session_id = str(uuid.uuid4())
67
 
68
  # Create queues for this session
69
  submission_queue: asyncio.Queue = asyncio.Queue()
70
  event_queue: asyncio.Queue = asyncio.Queue()
71
 
72
- # Create tool router
73
- tool_router = ToolRouter(self.config.mcpServers)
 
 
 
 
 
 
 
 
 
 
74
 
75
- # Create the agent session
76
- session = Session(event_queue, config=self.config, tool_router=tool_router)
77
 
78
  # Create wrapper
79
  agent_session = AgentSession(
@@ -81,6 +144,7 @@ class SessionManager:
81
  session=session,
82
  tool_router=tool_router,
83
  submission_queue=submission_queue,
 
84
  )
85
 
86
  async with self._lock:
@@ -92,7 +156,7 @@ class SessionManager:
92
  )
93
  agent_session.task = task
94
 
95
- logger.info(f"Created session {session_id}")
96
  return session_id
97
 
98
  async def _run_session(
@@ -245,6 +309,27 @@ class SessionManager:
245
 
246
  return True
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  def get_session_info(self, session_id: str) -> dict[str, Any] | None:
249
  """Get information about a session."""
250
  agent_session = self.sessions.get(session_id)
@@ -256,15 +341,25 @@ class SessionManager:
256
  "created_at": agent_session.created_at.isoformat(),
257
  "is_active": agent_session.is_active,
258
  "message_count": len(agent_session.session.context_manager.items),
 
259
  }
260
 
261
- def list_sessions(self) -> list[dict[str, Any]]:
262
- """List all sessions."""
263
- return [
264
- self.get_session_info(sid)
265
- for sid in self.sessions
266
- if self.get_session_info(sid)
267
- ]
 
 
 
 
 
 
 
 
 
268
 
269
  @property
270
  def active_session_count(self) -> int:
 
48
  session: Session
49
  tool_router: ToolRouter
50
  submission_queue: asyncio.Queue
51
+ user_id: str = "dev" # Owner of this session
52
  task: asyncio.Task | None = None
53
  created_at: datetime = field(default_factory=datetime.utcnow)
54
  is_active: bool = True
55
 
56
 
57
+ class SessionCapacityError(Exception):
58
+ """Raised when no more sessions can be created."""
59
+
60
+ def __init__(self, message: str, error_type: str = "global") -> None:
61
+ super().__init__(message)
62
+ self.error_type = error_type # "global" or "per_user"
63
+
64
+
65
+ # ── Capacity limits ─────────────────────────────────────────────────
66
+ # Estimated for HF Spaces cpu-basic (2 vCPU, 16 GB RAM).
67
+ # Each session uses ~10-20 MB (context, tools, queues, task).
68
+ MAX_SESSIONS: int = 50
69
+ MAX_SESSIONS_PER_USER: int = 3
70
+
71
+
72
  class SessionManager:
73
  """Manages multiple concurrent agent sessions."""
74
 
 
77
  self.sessions: dict[str, AgentSession] = {}
78
  self._lock = asyncio.Lock()
79
 
80
+ def _count_user_sessions(self, user_id: str) -> int:
81
+ """Count active sessions owned by a specific user."""
82
+ return sum(
83
+ 1
84
+ for s in self.sessions.values()
85
+ if s.user_id == user_id and s.is_active
86
+ )
87
+
88
+ async def create_session(self, user_id: str = "dev") -> str:
89
+ """Create a new agent session and return its ID.
90
+
91
+ Session() and ToolRouter() constructors contain blocking I/O
92
+ (e.g. HfApi().whoami(), litellm.get_max_tokens()) so they are
93
+ executed in a thread pool to avoid freezing the async event loop.
94
+
95
+ Args:
96
+ user_id: The ID of the user who owns this session.
97
+
98
+ Raises:
99
+ SessionCapacityError: If the server or user has reached the
100
+ maximum number of concurrent sessions.
101
+ """
102
+ # ── Capacity checks ──────────────────────────────────────────
103
+ async with self._lock:
104
+ active_count = self.active_session_count
105
+ if active_count >= MAX_SESSIONS:
106
+ raise SessionCapacityError(
107
+ f"Server is at capacity ({active_count}/{MAX_SESSIONS} sessions). "
108
+ "Please try again later.",
109
+ error_type="global",
110
+ )
111
+ if user_id != "dev":
112
+ user_count = self._count_user_sessions(user_id)
113
+ if user_count >= MAX_SESSIONS_PER_USER:
114
+ raise SessionCapacityError(
115
+ f"You have reached the maximum of {MAX_SESSIONS_PER_USER} "
116
+ "concurrent sessions. Please close an existing session first.",
117
+ error_type="per_user",
118
+ )
119
+
120
  session_id = str(uuid.uuid4())
121
 
122
  # Create queues for this session
123
  submission_queue: asyncio.Queue = asyncio.Queue()
124
  event_queue: asyncio.Queue = asyncio.Queue()
125
 
126
+ # Run blocking constructors in a thread to keep the event loop responsive.
127
+ # Without this, Session.__init__ → ContextManager → litellm.get_max_tokens()
128
+ # blocks all HTTP/WebSocket handling.
129
+ import time as _time
130
+
131
+ def _create_session_sync():
132
+ t0 = _time.monotonic()
133
+ tool_router = ToolRouter(self.config.mcpServers)
134
+ session = Session(event_queue, config=self.config, tool_router=tool_router)
135
+ t1 = _time.monotonic()
136
+ logger.info(f"Session initialized in {t1 - t0:.2f}s")
137
+ return tool_router, session
138
 
139
+ tool_router, session = await asyncio.to_thread(_create_session_sync)
 
140
 
141
  # Create wrapper
142
  agent_session = AgentSession(
 
144
  session=session,
145
  tool_router=tool_router,
146
  submission_queue=submission_queue,
147
+ user_id=user_id,
148
  )
149
 
150
  async with self._lock:
 
156
  )
157
  agent_session.task = task
158
 
159
+ logger.info(f"Created session {session_id} for user {user_id}")
160
  return session_id
161
 
162
  async def _run_session(
 
309
 
310
  return True
311
 
312
+ def get_session_owner(self, session_id: str) -> str | None:
313
+ """Get the user_id that owns a session, or None if session doesn't exist."""
314
+ agent_session = self.sessions.get(session_id)
315
+ if not agent_session:
316
+ return None
317
+ return agent_session.user_id
318
+
319
+ def verify_session_access(self, session_id: str, user_id: str) -> bool:
320
+ """Check if a user has access to a session.
321
+
322
+ Returns True if:
323
+ - The session exists AND the user owns it
324
+ - The user_id is "dev" (dev mode bypass)
325
+ """
326
+ owner = self.get_session_owner(session_id)
327
+ if owner is None:
328
+ return False
329
+ if user_id == "dev" or owner == "dev":
330
+ return True
331
+ return owner == user_id
332
+
333
  def get_session_info(self, session_id: str) -> dict[str, Any] | None:
334
  """Get information about a session."""
335
  agent_session = self.sessions.get(session_id)
 
341
  "created_at": agent_session.created_at.isoformat(),
342
  "is_active": agent_session.is_active,
343
  "message_count": len(agent_session.session.context_manager.items),
344
+ "user_id": agent_session.user_id,
345
  }
346
 
347
+ def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
348
+ """List sessions, optionally filtered by user.
349
+
350
+ Args:
351
+ user_id: If provided, only return sessions owned by this user.
352
+ If "dev", return all sessions (dev mode).
353
+ """
354
+ results = []
355
+ for sid in self.sessions:
356
+ info = self.get_session_info(sid)
357
+ if not info:
358
+ continue
359
+ if user_id and user_id != "dev" and info.get("user_id") != user_id:
360
+ continue
361
+ results.append(info)
362
+ return results
363
 
364
  @property
365
  def active_session_count(self) -> int:
backend/websocket.py CHANGED
@@ -1,6 +1,5 @@
1
  """WebSocket connection manager for real-time communication."""
2
 
3
- import asyncio
4
  import logging
5
  from typing import Any
6
 
@@ -15,23 +14,18 @@ class ConnectionManager:
15
  def __init__(self) -> None:
16
  # session_id -> WebSocket
17
  self.active_connections: dict[str, WebSocket] = {}
18
- # session_id -> asyncio.Queue for outgoing messages
19
- self.message_queues: dict[str, asyncio.Queue] = {}
20
 
21
  async def connect(self, websocket: WebSocket, session_id: str) -> None:
22
  """Accept a WebSocket connection and register it."""
23
  logger.info(f"Attempting to accept WebSocket for session {session_id}")
24
  await websocket.accept()
25
  self.active_connections[session_id] = websocket
26
- self.message_queues[session_id] = asyncio.Queue()
27
  logger.info(f"WebSocket connected and registered for session {session_id}")
28
 
29
  def disconnect(self, session_id: str) -> None:
30
  """Remove a WebSocket connection."""
31
  if session_id in self.active_connections:
32
  del self.active_connections[session_id]
33
- if session_id in self.message_queues:
34
- del self.message_queues[session_id]
35
  logger.info(f"WebSocket disconnected for session {session_id}")
36
 
37
  async def send_event(
@@ -63,10 +57,6 @@ class ConnectionManager:
63
  """Check if a session has an active WebSocket connection."""
64
  return session_id in self.active_connections
65
 
66
- def get_queue(self, session_id: str) -> asyncio.Queue | None:
67
- """Get the message queue for a session."""
68
- return self.message_queues.get(session_id)
69
-
70
 
71
  # Global connection manager instance
72
  manager = ConnectionManager()
 
1
  """WebSocket connection manager for real-time communication."""
2
 
 
3
  import logging
4
  from typing import Any
5
 
 
14
  def __init__(self) -> None:
15
  # session_id -> WebSocket
16
  self.active_connections: dict[str, WebSocket] = {}
 
 
17
 
18
  async def connect(self, websocket: WebSocket, session_id: str) -> None:
19
  """Accept a WebSocket connection and register it."""
20
  logger.info(f"Attempting to accept WebSocket for session {session_id}")
21
  await websocket.accept()
22
  self.active_connections[session_id] = websocket
 
23
  logger.info(f"WebSocket connected and registered for session {session_id}")
24
 
25
  def disconnect(self, session_id: str) -> None:
26
  """Remove a WebSocket connection."""
27
  if session_id in self.active_connections:
28
  del self.active_connections[session_id]
 
 
29
  logger.info(f"WebSocket disconnected for session {session_id}")
30
 
31
  async def send_event(
 
57
  """Check if a session has an active WebSocket connection."""
58
  return session_id in self.active_connections
59
 
 
 
 
 
60
 
61
  # Global connection manager instance
62
  manager = ConnectionManager()
frontend/src/App.tsx CHANGED
@@ -1,7 +1,12 @@
1
  import { Box } from '@mui/material';
2
  import AppLayout from '@/components/Layout/AppLayout';
 
3
 
4
  function App() {
 
 
 
 
5
  return (
6
  <Box sx={{ height: '100vh', display: 'flex' }}>
7
  <AppLayout />
 
1
  import { Box } from '@mui/material';
2
  import AppLayout from '@/components/Layout/AppLayout';
3
+ import { useAuth } from '@/hooks/useAuth';
4
 
5
  function App() {
6
+ // Non-blocking auth check — fires in background, updates store when done.
7
+ // If auth fails later, apiFetch redirects to /auth/login.
8
+ useAuth();
9
+
10
  return (
11
  <Box sx={{ height: '100vh', display: 'flex' }}>
12
  <AppLayout />
frontend/src/components/ApprovalModal/ApprovalModal.tsx DELETED
@@ -1,208 +0,0 @@
1
- import { useState, useCallback } from 'react';
2
- import {
3
- Dialog,
4
- DialogTitle,
5
- DialogContent,
6
- DialogActions,
7
- Button,
8
- Box,
9
- Typography,
10
- Checkbox,
11
- FormControlLabel,
12
- Accordion,
13
- AccordionSummary,
14
- AccordionDetails,
15
- TextField,
16
- Chip,
17
- } from '@mui/material';
18
- import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
19
- import WarningIcon from '@mui/icons-material/Warning';
20
- import { useAgentStore } from '@/store/agentStore';
21
-
22
- interface ApprovalModalProps {
23
- sessionId: string | null;
24
- }
25
-
26
- interface ApprovalState {
27
- [toolCallId: string]: {
28
- approved: boolean;
29
- feedback: string;
30
- };
31
- }
32
-
33
- export default function ApprovalModal({ sessionId }: ApprovalModalProps) {
34
- const { pendingApprovals, setPendingApprovals } = useAgentStore();
35
- const [approvalState, setApprovalState] = useState<ApprovalState>({});
36
-
37
- const isOpen = pendingApprovals !== null && pendingApprovals.tools.length > 0;
38
-
39
- const handleApprovalChange = useCallback(
40
- (toolCallId: string, approved: boolean) => {
41
- setApprovalState((prev) => ({
42
- ...prev,
43
- [toolCallId]: {
44
- ...prev[toolCallId],
45
- approved,
46
- feedback: prev[toolCallId]?.feedback || '',
47
- },
48
- }));
49
- },
50
- []
51
- );
52
-
53
- const handleFeedbackChange = useCallback(
54
- (toolCallId: string, feedback: string) => {
55
- setApprovalState((prev) => ({
56
- ...prev,
57
- [toolCallId]: {
58
- ...prev[toolCallId],
59
- feedback,
60
- },
61
- }));
62
- },
63
- []
64
- );
65
-
66
- const handleSubmit = useCallback(async () => {
67
- if (!sessionId || !pendingApprovals) return;
68
-
69
- const approvals = pendingApprovals.tools.map((tool) => ({
70
- tool_call_id: tool.tool_call_id,
71
- approved: approvalState[tool.tool_call_id]?.approved ?? false,
72
- feedback: approvalState[tool.tool_call_id]?.feedback || null,
73
- }));
74
-
75
- try {
76
- await fetch('/api/approve', {
77
- method: 'POST',
78
- headers: { 'Content-Type': 'application/json' },
79
- body: JSON.stringify({
80
- session_id: sessionId,
81
- approvals,
82
- }),
83
- });
84
- setPendingApprovals(null);
85
- setApprovalState({});
86
- } catch (e) {
87
- console.error('Approval submission failed:', e);
88
- }
89
- }, [sessionId, pendingApprovals, approvalState, setPendingApprovals]);
90
-
91
- const handleApproveAll = useCallback(() => {
92
- if (!pendingApprovals) return;
93
- const newState: ApprovalState = {};
94
- pendingApprovals.tools.forEach((tool) => {
95
- newState[tool.tool_call_id] = { approved: true, feedback: '' };
96
- });
97
- setApprovalState(newState);
98
- }, [pendingApprovals]);
99
-
100
- const handleRejectAll = useCallback(() => {
101
- if (!pendingApprovals) return;
102
- const newState: ApprovalState = {};
103
- pendingApprovals.tools.forEach((tool) => {
104
- newState[tool.tool_call_id] = { approved: false, feedback: '' };
105
- });
106
- setApprovalState(newState);
107
- }, [pendingApprovals]);
108
-
109
- if (!isOpen || !pendingApprovals) return null;
110
-
111
- const approvedCount = Object.values(approvalState).filter((s) => s.approved).length;
112
-
113
- return (
114
- <Dialog
115
- open={isOpen}
116
- maxWidth="md"
117
- fullWidth
118
- PaperProps={{
119
- sx: { bgcolor: 'background.paper' },
120
- }}
121
- >
122
- <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
123
- <WarningIcon color="warning" />
124
- Approval Required
125
- <Chip
126
- label={`${pendingApprovals.count} tool${pendingApprovals.count > 1 ? 's' : ''}`}
127
- size="small"
128
- sx={{ ml: 1 }}
129
- />
130
- </DialogTitle>
131
- <DialogContent dividers>
132
- <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
133
- The following tool calls require your approval before execution:
134
- </Typography>
135
- {pendingApprovals.tools.map((tool, index) => (
136
- <Accordion key={tool.tool_call_id} defaultExpanded={index === 0}>
137
- <AccordionSummary expandIcon={<ExpandMoreIcon />}>
138
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
139
- <FormControlLabel
140
- control={
141
- <Checkbox
142
- checked={approvalState[tool.tool_call_id]?.approved ?? false}
143
- onChange={(e) => {
144
- e.stopPropagation();
145
- handleApprovalChange(tool.tool_call_id, e.target.checked);
146
- }}
147
- onClick={(e) => e.stopPropagation()}
148
- />
149
- }
150
- label=""
151
- sx={{ m: 0 }}
152
- />
153
- <Chip label={tool.tool} size="small" color="primary" variant="outlined" />
154
- <Typography variant="body2" color="text.secondary" sx={{ ml: 'auto' }}>
155
- {approvalState[tool.tool_call_id]?.approved ? 'Approved' : 'Pending'}
156
- </Typography>
157
- </Box>
158
- </AccordionSummary>
159
- <AccordionDetails>
160
- <Typography variant="subtitle2" gutterBottom>
161
- Arguments:
162
- </Typography>
163
- <Box
164
- component="pre"
165
- sx={{
166
- bgcolor: 'background.default',
167
- p: 1.5,
168
- borderRadius: 1,
169
- overflow: 'auto',
170
- fontSize: '0.8rem',
171
- maxHeight: 200,
172
- }}
173
- >
174
- {JSON.stringify(tool.arguments, null, 2)}
175
- </Box>
176
- {!approvalState[tool.tool_call_id]?.approved && (
177
- <TextField
178
- fullWidth
179
- size="small"
180
- label="Feedback (optional)"
181
- placeholder="Explain why you're rejecting this..."
182
- value={approvalState[tool.tool_call_id]?.feedback || ''}
183
- onChange={(e) => handleFeedbackChange(tool.tool_call_id, e.target.value)}
184
- sx={{ mt: 2 }}
185
- />
186
- )}
187
- </AccordionDetails>
188
- </Accordion>
189
- ))}
190
- </DialogContent>
191
- <DialogActions sx={{ px: 3, py: 2 }}>
192
- <Button onClick={handleRejectAll} color="error" variant="outlined">
193
- Reject All
194
- </Button>
195
- <Button onClick={handleApproveAll} color="success" variant="outlined">
196
- Approve All
197
- </Button>
198
- <Box sx={{ flex: 1 }} />
199
- <Typography variant="body2" color="text.secondary" sx={{ mr: 2 }}>
200
- {approvedCount} of {pendingApprovals.count} approved
201
- </Typography>
202
- <Button onClick={handleSubmit} variant="contained" color="primary">
203
- Submit
204
- </Button>
205
- </DialogActions>
206
- </Dialog>
207
- );
208
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/Chat/ApprovalFlow.tsx DELETED
@@ -1,515 +0,0 @@
1
- import { useState, useCallback, useEffect } from 'react';
2
- import { Box, Typography, Button, TextField, IconButton, Link } from '@mui/material';
3
- import SendIcon from '@mui/icons-material/Send';
4
- import OpenInNewIcon from '@mui/icons-material/OpenInNew';
5
- import CheckCircleIcon from '@mui/icons-material/CheckCircle';
6
- import CancelIcon from '@mui/icons-material/Cancel';
7
- import LaunchIcon from '@mui/icons-material/Launch';
8
- import { useAgentStore } from '@/store/agentStore';
9
- import { useLayoutStore } from '@/store/layoutStore';
10
- import { useSessionStore } from '@/store/sessionStore';
11
- import type { Message, ToolApproval } from '@/types/agent';
12
-
13
- interface ApprovalFlowProps {
14
- message: Message;
15
- }
16
-
17
- export default function ApprovalFlow({ message }: ApprovalFlowProps) {
18
- const { setPanelContent, setPanelTab, setActivePanelTab, clearPanelTabs, updateMessage } = useAgentStore();
19
- const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
20
- const { activeSessionId } = useSessionStore();
21
- const [currentIndex, setCurrentIndex] = useState(0);
22
- const [feedback, setFeedback] = useState('');
23
- const [decisions, setDecisions] = useState<ToolApproval[]>([]);
24
-
25
- const approvalData = message.approval;
26
-
27
- if (!approvalData) return null;
28
-
29
- const { batch, status } = approvalData;
30
-
31
- // Parse toolOutput to extract job info (URL, status, logs, errors)
32
- let logsContent = '';
33
- let showLogsButton = false;
34
- let jobUrl = '';
35
- let jobStatus = '';
36
- let jobFailed = false;
37
- let errorMessage = '';
38
-
39
- if (message.toolOutput) {
40
- const output = message.toolOutput;
41
-
42
- // Extract job URL: **View at:** https://...
43
- const urlMatch = output.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
44
- if (urlMatch) {
45
- jobUrl = urlMatch[1];
46
- }
47
-
48
- // Extract job status: **Final Status:** ...
49
- const statusMatch = output.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
50
- if (statusMatch) {
51
- jobStatus = statusMatch[1].trim();
52
- jobFailed = jobStatus.toLowerCase().includes('error') || jobStatus.toLowerCase().includes('failed');
53
- }
54
-
55
- // Extract logs
56
- if (output.includes('**Logs:**')) {
57
- const parts = output.split('**Logs:**');
58
- if (parts.length > 1) {
59
- const logsPart = parts[1].trim();
60
- const codeBlockMatch = logsPart.match(/```([\s\S]*?)```/);
61
- if (codeBlockMatch) {
62
- logsContent = codeBlockMatch[1].trim();
63
- showLogsButton = true;
64
- }
65
- }
66
- }
67
-
68
- // Detect errors - if output exists but doesn't have the expected job completion format
69
- // This catches early failures (validation errors, API errors, etc.)
70
- const isExpectedFormat = output.includes('**Job ID:**') || output.includes('**View at:**');
71
- const looksLikeError = output.toLowerCase().includes('error') ||
72
- output.toLowerCase().includes('failed') ||
73
- output.toLowerCase().includes('exception') ||
74
- output.includes('Traceback');
75
-
76
- if (!isExpectedFormat || (looksLikeError && !logsContent)) {
77
- // This is likely an error message - show it
78
- errorMessage = output;
79
- jobFailed = true;
80
- }
81
- }
82
-
83
- // Sync right panel with current tool
84
- useEffect(() => {
85
- if (!batch || currentIndex >= batch.tools.length) return;
86
-
87
- // Only auto-open panel if pending
88
- if (status !== 'pending') return;
89
-
90
- const tool = batch.tools[currentIndex];
91
- const args = tool.arguments as any;
92
-
93
- if (tool.tool === 'hf_jobs' && (args.operation === 'run' || args.operation === 'scheduled run') && args.script) {
94
- setPanelContent({
95
- title: 'Compute Job Script',
96
- content: args.script,
97
- language: 'python',
98
- parameters: args
99
- });
100
- // Don't auto-open if already resolved
101
- } else if (tool.tool === 'hf_repo_files' && args.operation === 'upload' && args.content) {
102
- setPanelContent({
103
- title: `File Upload: ${args.path || 'unnamed'}`,
104
- content: args.content,
105
- parameters: args
106
- });
107
- }
108
- }, [currentIndex, batch, status, setPanelContent]);
109
-
110
- const handleResolve = useCallback(async (approved: boolean) => {
111
- if (!batch || !activeSessionId) return;
112
-
113
- const currentTool = batch.tools[currentIndex];
114
- const newDecisions = [
115
- ...decisions,
116
- {
117
- tool_call_id: currentTool.tool_call_id,
118
- approved,
119
- feedback: approved ? null : feedback || 'Rejected by user',
120
- },
121
- ];
122
-
123
- if (currentIndex < batch.tools.length - 1) {
124
- setDecisions(newDecisions);
125
- setCurrentIndex(currentIndex + 1);
126
- setFeedback('');
127
- } else {
128
- // All tools in batch resolved
129
- try {
130
- await fetch('/api/approve', {
131
- method: 'POST',
132
- headers: { 'Content-Type': 'application/json' },
133
- body: JSON.stringify({
134
- session_id: activeSessionId,
135
- approvals: newDecisions,
136
- }),
137
- });
138
-
139
- // Update message status
140
- updateMessage(activeSessionId, message.id, {
141
- approval: {
142
- ...approvalData!,
143
- status: approved ? 'approved' : 'rejected',
144
- decisions: newDecisions
145
- }
146
- });
147
-
148
- } catch (e) {
149
- console.error('Approval submission failed:', e);
150
- }
151
- }
152
- }, [activeSessionId, message.id, batch, currentIndex, feedback, decisions, approvalData, updateMessage]);
153
-
154
- if (!batch || currentIndex >= batch.tools.length) return null;
155
-
156
- const currentTool = batch.tools[currentIndex];
157
-
158
- // Check if script contains push_to_hub or upload_file
159
- const args = currentTool.arguments as any;
160
- const containsPushToHub = currentTool.tool === 'hf_jobs' && args.script && (args.script.includes('push_to_hub') || args.script.includes('upload_file'));
161
-
162
- const getToolDescription = (toolName: string, args: any) => {
163
- if (toolName === 'hf_jobs') {
164
- return (
165
- <Box sx={{ flex: 1 }}>
166
- <Typography variant="body2" sx={{ color: 'var(--muted-text)' }}>
167
- The agent wants to execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>hf_jobs</Box> on{' '}
168
- <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>{args.hardware_flavor || 'default'}</Box> with a timeout of{' '}
169
- <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>{args.timeout || '30m'}</Box>
170
- </Typography>
171
- </Box>
172
- );
173
- }
174
- return (
175
- <Typography variant="body2" sx={{ color: 'var(--muted-text)', flex: 1 }}>
176
- The agent wants to execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{toolName}</Box>
177
- </Typography>
178
- );
179
- };
180
-
181
- const showCode = () => {
182
- const args = currentTool.arguments as any;
183
- if (currentTool.tool === 'hf_jobs' && args.script) {
184
- // Clear existing tabs and set up script tab (and logs if available)
185
- clearPanelTabs();
186
- setPanelTab({
187
- id: 'script',
188
- title: 'Script',
189
- content: args.script,
190
- language: 'python',
191
- parameters: args
192
- });
193
- // If logs are available (job completed), also add logs tab
194
- if (logsContent) {
195
- setPanelTab({
196
- id: 'logs',
197
- title: 'Logs',
198
- content: logsContent,
199
- language: 'text'
200
- });
201
- }
202
- setActivePanelTab('script');
203
- setRightPanelOpen(true);
204
- setLeftSidebarOpen(false);
205
- } else {
206
- setPanelContent({
207
- title: `Tool: ${currentTool.tool}`,
208
- content: JSON.stringify(args, null, 2),
209
- language: 'json',
210
- parameters: args
211
- });
212
- setRightPanelOpen(true);
213
- setLeftSidebarOpen(false);
214
- }
215
- };
216
-
217
- const handleViewLogs = (e: React.MouseEvent) => {
218
- e.stopPropagation();
219
- const args = currentTool.arguments as any;
220
- // Set up both tabs so user can switch between script and logs
221
- clearPanelTabs();
222
- if (currentTool.tool === 'hf_jobs' && args.script) {
223
- setPanelTab({
224
- id: 'script',
225
- title: 'Script',
226
- content: args.script,
227
- language: 'python',
228
- parameters: args
229
- });
230
- }
231
- setPanelTab({
232
- id: 'logs',
233
- title: 'Logs',
234
- content: logsContent,
235
- language: 'text'
236
- });
237
- setActivePanelTab('logs');
238
- setRightPanelOpen(true);
239
- setLeftSidebarOpen(false);
240
- };
241
-
242
- return (
243
- <Box
244
- className="action-card"
245
- sx={{
246
- width: '100%',
247
- padding: '18px',
248
- borderRadius: 'var(--radius-md)',
249
- background: 'linear-gradient(180deg, rgba(255,255,255,0.015), transparent)',
250
- border: '1px solid rgba(255,255,255,0.03)',
251
- display: 'flex',
252
- flexDirection: 'column',
253
- gap: '12px',
254
- opacity: status !== 'pending' && !showLogsButton ? 0.8 : 1
255
- }}
256
- >
257
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
258
- <Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'var(--text)' }}>
259
- {status === 'pending' ? 'Approval Required' : status === 'approved' ? 'Approved' : 'Rejected'}
260
- </Typography>
261
- <Typography variant="caption" sx={{ color: 'var(--muted-text)' }}>
262
- ({currentIndex + 1}/{batch.count})
263
- </Typography>
264
- {status === 'approved' && <CheckCircleIcon sx={{ fontSize: 18, color: 'var(--accent-green)' }} />}
265
- {status === 'rejected' && <CancelIcon sx={{ fontSize: 18, color: 'var(--accent-red)' }} />}
266
- </Box>
267
-
268
- <Box
269
- onClick={showCode}
270
- sx={{
271
- display: 'flex',
272
- alignItems: 'center',
273
- gap: 1,
274
- cursor: 'pointer',
275
- p: 1.5,
276
- borderRadius: '8px',
277
- bgcolor: 'rgba(0,0,0,0.2)',
278
- border: '1px solid rgba(255,255,255,0.05)',
279
- transition: 'all 0.2s',
280
- '&:hover': {
281
- bgcolor: 'rgba(255,255,255,0.03)',
282
- borderColor: 'var(--accent-primary)',
283
- }
284
- }}
285
- >
286
- {getToolDescription(currentTool.tool, currentTool.arguments)}
287
- <OpenInNewIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.7 }} />
288
- </Box>
289
-
290
- {/* Script/Logs buttons for hf_jobs - always show when we have a script */}
291
- {currentTool.tool === 'hf_jobs' && args.script && (
292
- <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
293
- <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
294
- <Button
295
- variant="outlined"
296
- size="small"
297
- onClick={showCode}
298
- sx={{
299
- textTransform: 'none',
300
- borderColor: 'rgba(255,255,255,0.1)',
301
- color: 'var(--muted-text)',
302
- fontSize: '0.75rem',
303
- py: 0.5,
304
- '&:hover': {
305
- borderColor: 'var(--accent-primary)',
306
- color: 'var(--accent-primary)',
307
- bgcolor: 'rgba(255,255,255,0.03)'
308
- }
309
- }}
310
- >
311
- View Script
312
- </Button>
313
- <Button
314
- variant="outlined"
315
- size="small"
316
- onClick={handleViewLogs}
317
- disabled={!logsContent && status === 'pending'}
318
- sx={{
319
- textTransform: 'none',
320
- borderColor: 'rgba(255,255,255,0.1)',
321
- color: logsContent ? 'var(--accent-primary)' : 'var(--muted-text)',
322
- fontSize: '0.75rem',
323
- py: 0.5,
324
- '&:hover': {
325
- borderColor: 'var(--accent-primary)',
326
- bgcolor: 'rgba(255,255,255,0.03)'
327
- },
328
- '&.Mui-disabled': {
329
- color: 'rgba(255,255,255,0.3)',
330
- borderColor: 'rgba(255,255,255,0.05)',
331
- }
332
- }}
333
- >
334
- {logsContent ? 'View Logs' : 'Logs (waiting for job...)'}
335
- </Button>
336
- </Box>
337
-
338
- {/* Job URL - only show when we have a specific URL */}
339
- {jobUrl && (
340
- <Link
341
- href={jobUrl}
342
- target="_blank"
343
- rel="noopener noreferrer"
344
- sx={{
345
- display: 'flex',
346
- alignItems: 'center',
347
- gap: 0.5,
348
- color: 'var(--accent-primary)',
349
- fontSize: '0.75rem',
350
- textDecoration: 'none',
351
- opacity: 0.9,
352
- '&:hover': {
353
- opacity: 1,
354
- textDecoration: 'underline',
355
- }
356
- }}
357
- >
358
- <LaunchIcon sx={{ fontSize: 14 }} />
359
- View Job on Hugging Face
360
- </Link>
361
- )}
362
-
363
- {/* Show job status if available */}
364
- {jobStatus && (
365
- <Typography
366
- variant="caption"
367
- sx={{
368
- color: jobFailed ? 'var(--accent-red)' : 'var(--accent-green)',
369
- fontSize: '0.75rem',
370
- fontWeight: 500,
371
- }}
372
- >
373
- Status: {jobStatus}
374
- </Typography>
375
- )}
376
- </Box>
377
- )}
378
-
379
- {containsPushToHub && (
380
- <Typography variant="caption" sx={{ color: 'var(--accent-green)', fontSize: '0.75rem', opacity: 0.8, px: 0.5 }}>
381
- We've detected the result will be pushed to hub.
382
- </Typography>
383
- )}
384
-
385
- {/* Show error message if job failed */}
386
- {errorMessage && status !== 'pending' && (
387
- <Box
388
- sx={{
389
- p: 1.5,
390
- borderRadius: '8px',
391
- bgcolor: 'rgba(224, 90, 79, 0.1)',
392
- border: '1px solid rgba(224, 90, 79, 0.3)',
393
- }}
394
- >
395
- <Typography
396
- variant="caption"
397
- sx={{
398
- color: 'var(--accent-red)',
399
- fontWeight: 600,
400
- display: 'block',
401
- mb: 0.5,
402
- }}
403
- >
404
- Error
405
- </Typography>
406
- <Typography
407
- component="pre"
408
- sx={{
409
- color: 'var(--text)',
410
- fontSize: '0.75rem',
411
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
412
- whiteSpace: 'pre-wrap',
413
- wordBreak: 'break-word',
414
- m: 0,
415
- maxHeight: '150px',
416
- overflow: 'auto',
417
- }}
418
- >
419
- {errorMessage.length > 500 ? errorMessage.substring(0, 500) + '...' : errorMessage}
420
- </Typography>
421
- </Box>
422
- )}
423
-
424
-
425
- {status === 'pending' && (
426
- <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
427
- <Box sx={{ display: 'flex', gap: 1 }}>
428
- <TextField
429
- fullWidth
430
- size="small"
431
- placeholder="Feedback (optional)"
432
- value={feedback}
433
- onChange={(e) => setFeedback(e.target.value)}
434
- variant="outlined"
435
- sx={{
436
- '& .MuiOutlinedInput-root': {
437
- bgcolor: 'rgba(0,0,0,0.2)',
438
- fontFamily: 'inherit',
439
- fontSize: '0.9rem'
440
- }
441
- }}
442
- />
443
- <IconButton
444
- onClick={() => handleResolve(false)}
445
- disabled={!feedback}
446
- title="Reject with feedback"
447
- sx={{
448
- color: 'var(--accent-red)',
449
- border: '1px solid rgba(255,255,255,0.05)',
450
- borderRadius: '8px',
451
- width: 40,
452
- height: 40,
453
- '&:hover': {
454
- bgcolor: 'rgba(224, 90, 79, 0.1)',
455
- borderColor: 'var(--accent-red)',
456
- },
457
- '&.Mui-disabled': {
458
- color: 'rgba(255,255,255,0.1)',
459
- borderColor: 'rgba(255,255,255,0.02)'
460
- }
461
- }}
462
- >
463
- <SendIcon fontSize="small" />
464
- </IconButton>
465
- </Box>
466
-
467
- <Box className="action-buttons" sx={{ display: 'flex', gap: '10px' }}>
468
- <Button
469
- className="btn-reject"
470
- onClick={() => handleResolve(false)}
471
- sx={{
472
- flex: 1,
473
- background: 'transparent',
474
- border: '1px solid rgba(255,255,255,0.05)',
475
- color: 'var(--accent-red)',
476
- padding: '10px 14px',
477
- borderRadius: '10px',
478
- '&:hover': {
479
- bgcolor: 'rgba(224, 90, 79, 0.05)',
480
- borderColor: 'var(--accent-red)',
481
- }
482
- }}
483
- >
484
- Reject
485
- </Button>
486
- <Button
487
- className="btn-approve"
488
- onClick={() => handleResolve(true)}
489
- sx={{
490
- flex: 1,
491
- background: 'transparent',
492
- border: '1px solid rgba(255,255,255,0.05)',
493
- color: 'var(--accent-green)',
494
- padding: '10px 14px',
495
- borderRadius: '10px',
496
- '&:hover': {
497
- bgcolor: 'rgba(47, 204, 113, 0.05)',
498
- borderColor: 'var(--accent-green)',
499
- }
500
- }}
501
- >
502
- Approve
503
- </Button>
504
- </Box>
505
- </Box>
506
- )}
507
-
508
- {status === 'rejected' && decisions.some(d => d.feedback) && (
509
- <Typography variant="body2" sx={{ color: 'var(--accent-red)', mt: 1 }}>
510
- Feedback: {decisions.find(d => d.feedback)?.feedback}
511
- </Typography>
512
- )}
513
- </Box>
514
- );
515
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/Chat/AssistantMessage.tsx ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box, Stack, Avatar, Typography } from '@mui/material';
2
+ import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
3
+ import MarkdownContent from './MarkdownContent';
4
+ import ToolCallGroup from './ToolCallGroup';
5
+ import type { Message } from '@/types/agent';
6
+
7
+ interface AssistantMessageProps {
8
+ message: Message;
9
+ /** True when this message is actively receiving streaming chunks. */
10
+ isStreaming?: boolean;
11
+ }
12
+
13
+ export default function AssistantMessage({ message, isStreaming = false }: AssistantMessageProps) {
14
+ const renderSegments = () => {
15
+ if (message.segments && message.segments.length > 0) {
16
+ // Find the index of the last text segment (that's the one being streamed)
17
+ let lastTextIdx = -1;
18
+ for (let i = message.segments.length - 1; i >= 0; i--) {
19
+ if (message.segments[i].type === 'text') {
20
+ lastTextIdx = i;
21
+ break;
22
+ }
23
+ }
24
+
25
+ return message.segments.map((segment, idx) => {
26
+ if (segment.type === 'text' && segment.content) {
27
+ return (
28
+ <MarkdownContent
29
+ key={idx}
30
+ content={segment.content}
31
+ isStreaming={isStreaming && idx === lastTextIdx}
32
+ />
33
+ );
34
+ }
35
+ if (segment.type === 'tools' && segment.tools && segment.tools.length > 0) {
36
+ return <ToolCallGroup key={idx} tools={segment.tools} />;
37
+ }
38
+ return null;
39
+ });
40
+ }
41
+
42
+ // Fallback: render raw content
43
+ if (message.content) {
44
+ return <MarkdownContent content={message.content} isStreaming={isStreaming} />;
45
+ }
46
+
47
+ return null;
48
+ };
49
+
50
+ return (
51
+ <Stack direction="row" spacing={1.5} alignItems="flex-start">
52
+ <Avatar
53
+ sx={{
54
+ width: 28,
55
+ height: 28,
56
+ bgcolor: 'primary.main',
57
+ flexShrink: 0,
58
+ mt: 0.5,
59
+ }}
60
+ >
61
+ <SmartToyOutlinedIcon sx={{ fontSize: 16, color: '#fff' }} />
62
+ </Avatar>
63
+
64
+ <Box sx={{ flex: 1, minWidth: 0 }}>
65
+ {/* Role label + timestamp */}
66
+ <Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
67
+ <Typography
68
+ variant="caption"
69
+ sx={{
70
+ fontWeight: 700,
71
+ fontSize: '0.72rem',
72
+ color: 'var(--muted-text)',
73
+ textTransform: 'uppercase',
74
+ letterSpacing: '0.04em',
75
+ }}
76
+ >
77
+ Assistant
78
+ </Typography>
79
+ <Typography
80
+ variant="caption"
81
+ sx={{
82
+ fontSize: '0.66rem',
83
+ color: 'var(--muted-text)',
84
+ opacity: 0.6,
85
+ }}
86
+ >
87
+ {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
88
+ </Typography>
89
+ </Stack>
90
+
91
+ {/* Message bubble */}
92
+ <Box
93
+ sx={{
94
+ maxWidth: { xs: '95%', md: '85%' },
95
+ bgcolor: 'var(--surface)',
96
+ borderRadius: 1.5,
97
+ borderTopLeftRadius: 4,
98
+ px: { xs: 1.5, md: 2.5 },
99
+ py: 1.5,
100
+ border: '1px solid var(--border)',
101
+ }}
102
+ >
103
+ {renderSegments()}
104
+ </Box>
105
+ </Box>
106
+ </Stack>
107
+ );
108
+ }
frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, KeyboardEvent } from 'react';
2
  import { Box, TextField, IconButton, CircularProgress, Typography } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
4
 
@@ -9,6 +9,14 @@ interface ChatInputProps {
9
 
10
  export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
11
  const [input, setInput] = useState('');
 
 
 
 
 
 
 
 
12
 
13
  const handleSend = useCallback(() => {
14
  if (input.trim() && !disabled) {
@@ -30,23 +38,23 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
30
  return (
31
  <Box
32
  sx={{
33
- pb: 4,
34
- pt: 2,
35
  position: 'relative',
36
  zIndex: 10,
37
  }}
38
  >
39
- <Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: 2 }}>
40
  <Box
41
  className="composer"
42
  sx={{
43
  display: 'flex',
44
  gap: '10px',
45
  alignItems: 'flex-start',
46
- bgcolor: 'rgba(255,255,255,0.01)',
47
  borderRadius: 'var(--radius-md)',
48
  p: '12px',
49
- border: '1px solid rgba(255,255,255,0.03)',
50
  transition: 'box-shadow 0.2s ease, border-color 0.2s ease',
51
  '&:focus-within': {
52
  borderColor: 'var(--accent-yellow)',
@@ -64,6 +72,7 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
64
  placeholder="Ask anything..."
65
  disabled={disabled}
66
  variant="standard"
 
67
  InputProps={{
68
  disableUnderline: true,
69
  sx: {
@@ -72,7 +81,7 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
72
  fontFamily: 'inherit',
73
  padding: 0,
74
  lineHeight: 1.5,
75
- minHeight: '56px',
76
  alignItems: 'flex-start',
77
  }
78
  }}
@@ -99,7 +108,7 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
99
  transition: 'all 0.2s',
100
  '&:hover': {
101
  color: 'var(--accent-yellow)',
102
- bgcolor: 'rgba(255,255,255,0.05)',
103
  },
104
  '&.Mui-disabled': {
105
  opacity: 0.3,
@@ -115,7 +124,7 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
115
  <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
116
  powered by
117
  </Typography>
118
- <img src="/claude-logo.png" alt="Claude" style={{ height: '12px', objectFit: 'contain' }} />
119
  <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
120
  claude-opus-4-5-20251101
121
  </Typography>
 
1
+ import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
2
  import { Box, TextField, IconButton, CircularProgress, Typography } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
4
 
 
9
 
10
  export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
11
  const [input, setInput] = useState('');
12
+ const inputRef = useRef<HTMLTextAreaElement>(null);
13
+
14
+ // Auto-focus the textarea when the session becomes ready (disabled → false)
15
+ useEffect(() => {
16
+ if (!disabled && inputRef.current) {
17
+ inputRef.current.focus();
18
+ }
19
+ }, [disabled]);
20
 
21
  const handleSend = useCallback(() => {
22
  if (input.trim() && !disabled) {
 
38
  return (
39
  <Box
40
  sx={{
41
+ pb: { xs: 2, md: 4 },
42
+ pt: { xs: 1, md: 2 },
43
  position: 'relative',
44
  zIndex: 10,
45
  }}
46
  >
47
+ <Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: { xs: 0, sm: 1, md: 2 } }}>
48
  <Box
49
  className="composer"
50
  sx={{
51
  display: 'flex',
52
  gap: '10px',
53
  alignItems: 'flex-start',
54
+ bgcolor: 'var(--composer-bg)',
55
  borderRadius: 'var(--radius-md)',
56
  p: '12px',
57
+ border: '1px solid var(--border)',
58
  transition: 'box-shadow 0.2s ease, border-color 0.2s ease',
59
  '&:focus-within': {
60
  borderColor: 'var(--accent-yellow)',
 
72
  placeholder="Ask anything..."
73
  disabled={disabled}
74
  variant="standard"
75
+ inputRef={inputRef}
76
  InputProps={{
77
  disableUnderline: true,
78
  sx: {
 
81
  fontFamily: 'inherit',
82
  padding: 0,
83
  lineHeight: 1.5,
84
+ minHeight: { xs: '44px', md: '56px' },
85
  alignItems: 'flex-start',
86
  }
87
  }}
 
108
  transition: 'all 0.2s',
109
  '&:hover': {
110
  color: 'var(--accent-yellow)',
111
+ bgcolor: 'var(--hover-bg)',
112
  },
113
  '&.Mui-disabled': {
114
  opacity: 0.3,
 
124
  <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
125
  powered by
126
  </Typography>
127
+ <Box component="img" src="/claude-logo.png" alt="Claude" sx={{ height: '12px', objectFit: 'contain' }} />
128
  <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
129
  claude-opus-4-5-20251101
130
  </Typography>
frontend/src/components/Chat/MarkdownContent.tsx ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useRef, useState, useEffect } from 'react';
2
+ import { Box } from '@mui/material';
3
+ import ReactMarkdown from 'react-markdown';
4
+ import remarkGfm from 'remark-gfm';
5
+ import type { SxProps, Theme } from '@mui/material/styles';
6
+
7
+ interface MarkdownContentProps {
8
+ content: string;
9
+ sx?: SxProps<Theme>;
10
+ /** When true, shows a blinking cursor and throttles renders. */
11
+ isStreaming?: boolean;
12
+ }
13
+
14
+ /** Shared markdown styles — adapts to light/dark via CSS variables. */
15
+ const markdownSx: SxProps<Theme> = {
16
+ fontSize: '0.925rem',
17
+ lineHeight: 1.7,
18
+ color: 'var(--text)',
19
+ wordBreak: 'break-word',
20
+
21
+ '& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } },
22
+
23
+ '& h1, & h2, & h3, & h4': { mt: 2.5, mb: 1, fontWeight: 600, lineHeight: 1.3 },
24
+ '& h1': { fontSize: '1.35rem' },
25
+ '& h2': { fontSize: '1.15rem' },
26
+ '& h3': { fontSize: '1.05rem' },
27
+
28
+ '& pre': {
29
+ bgcolor: 'var(--code-bg)',
30
+ p: 2,
31
+ borderRadius: 2,
32
+ overflow: 'auto',
33
+ fontSize: '0.82rem',
34
+ lineHeight: 1.6,
35
+ border: '1px solid var(--tool-border)',
36
+ my: 2,
37
+ },
38
+ '& code': {
39
+ bgcolor: 'var(--hover-bg)',
40
+ px: 0.75,
41
+ py: 0.25,
42
+ borderRadius: 0.5,
43
+ fontSize: '0.84rem',
44
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
45
+ },
46
+ '& pre code': { bgcolor: 'transparent', p: 0 },
47
+
48
+ '& a': {
49
+ color: 'var(--accent-yellow)',
50
+ textDecoration: 'none',
51
+ fontWeight: 500,
52
+ '&:hover': { textDecoration: 'underline' },
53
+ },
54
+
55
+ '& ul, & ol': { pl: 3, my: 1 },
56
+ '& li': { mb: 0.5 },
57
+ '& li::marker': { color: 'var(--muted-text)' },
58
+
59
+ '& blockquote': {
60
+ borderLeft: '3px solid var(--accent-yellow)',
61
+ pl: 2,
62
+ ml: 0,
63
+ my: 1.5,
64
+ color: 'var(--muted-text)',
65
+ fontStyle: 'italic',
66
+ },
67
+
68
+ '& table': {
69
+ borderCollapse: 'collapse',
70
+ width: '100%',
71
+ my: 2,
72
+ fontSize: '0.85rem',
73
+ },
74
+ '& th': {
75
+ borderBottom: '2px solid var(--border-hover)',
76
+ textAlign: 'left',
77
+ p: 1,
78
+ fontWeight: 600,
79
+ },
80
+ '& td': {
81
+ borderBottom: '1px solid var(--tool-border)',
82
+ p: 1,
83
+ },
84
+
85
+ '& hr': {
86
+ border: 'none',
87
+ borderTop: '1px solid var(--border)',
88
+ my: 2,
89
+ },
90
+
91
+ '& img': {
92
+ maxWidth: '100%',
93
+ borderRadius: 2,
94
+ },
95
+ };
96
+
97
+ /** Blinking cursor shown at the end of streaming text. */
98
+ const StreamingCursor = () => (
99
+ <Box
100
+ component="span"
101
+ sx={{
102
+ display: 'inline-block',
103
+ width: '2px',
104
+ height: '1.1em',
105
+ bgcolor: 'var(--text)',
106
+ ml: '2px',
107
+ verticalAlign: 'text-bottom',
108
+ animation: 'cursorBlink 1s step-end infinite',
109
+ '@keyframes cursorBlink': {
110
+ '0%, 100%': { opacity: 1 },
111
+ '50%': { opacity: 0 },
112
+ },
113
+ }}
114
+ />
115
+ );
116
+
117
+ /**
118
+ * Throttled content for streaming: render the full markdown through
119
+ * ReactMarkdown but only re-parse every ~80ms to avoid layout thrashing.
120
+ * This is the Claude approach — always render as markdown, never split
121
+ * into raw text. The parser handles incomplete tables gracefully.
122
+ */
123
+ function useThrottledValue(value: string, isStreaming: boolean, intervalMs = 80): string {
124
+ const [throttled, setThrottled] = useState(value);
125
+ const lastUpdate = useRef(0);
126
+ const pending = useRef<ReturnType<typeof setTimeout> | null>(null);
127
+ const latestValue = useRef(value);
128
+ latestValue.current = value;
129
+
130
+ useEffect(() => {
131
+ if (!isStreaming) {
132
+ // Not streaming — always use latest value immediately
133
+ setThrottled(value);
134
+ return;
135
+ }
136
+
137
+ const now = Date.now();
138
+ const elapsed = now - lastUpdate.current;
139
+
140
+ if (elapsed >= intervalMs) {
141
+ // Enough time passed — update immediately
142
+ setThrottled(value);
143
+ lastUpdate.current = now;
144
+ } else {
145
+ // Schedule an update for the remaining time
146
+ if (pending.current) clearTimeout(pending.current);
147
+ pending.current = setTimeout(() => {
148
+ setThrottled(latestValue.current);
149
+ lastUpdate.current = Date.now();
150
+ pending.current = null;
151
+ }, intervalMs - elapsed);
152
+ }
153
+
154
+ return () => {
155
+ if (pending.current) clearTimeout(pending.current);
156
+ };
157
+ }, [value, isStreaming, intervalMs]);
158
+
159
+ // When streaming ends, flush immediately
160
+ useEffect(() => {
161
+ if (!isStreaming) {
162
+ setThrottled(latestValue.current);
163
+ }
164
+ }, [isStreaming]);
165
+
166
+ return throttled;
167
+ }
168
+
169
+ export default function MarkdownContent({ content, sx, isStreaming = false }: MarkdownContentProps) {
170
+ // Throttle re-parses during streaming to ~12fps (every 80ms)
171
+ const displayContent = useThrottledValue(content, isStreaming);
172
+
173
+ const remarkPlugins = useMemo(() => [remarkGfm], []);
174
+
175
+ return (
176
+ <Box sx={[markdownSx, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}>
177
+ <ReactMarkdown remarkPlugins={remarkPlugins}>{displayContent}</ReactMarkdown>
178
+ {isStreaming && <StreamingCursor />}
179
+ </Box>
180
+ );
181
+ }
frontend/src/components/Chat/MessageBubble.tsx CHANGED
@@ -1,215 +1,51 @@
1
- import { Box, Paper, Typography } from '@mui/material';
2
- import ReactMarkdown from 'react-markdown';
3
- import remarkGfm from 'remark-gfm';
4
- import ApprovalFlow from './ApprovalFlow';
5
- import type { Message, TraceLog } from '@/types/agent';
6
- import { useAgentStore } from '@/store/agentStore';
7
- import { useLayoutStore } from '@/store/layoutStore';
8
 
9
  interface MessageBubbleProps {
10
  message: Message;
 
 
 
 
 
 
 
 
11
  }
12
 
13
- // Render a tools segment with clickable tool calls
14
- function ToolsSegment({ tools }: { tools: TraceLog[] }) {
15
- const { showToolOutput } = useAgentStore();
16
- const { setRightPanelOpen } = useLayoutStore();
17
-
18
- const handleToolClick = (log: TraceLog) => {
19
- if (log.completed && log.output) {
20
- showToolOutput(log);
21
- setRightPanelOpen(true);
22
- }
23
- };
24
-
25
- return (
26
- <Box
27
- sx={{
28
- bgcolor: 'rgba(0,0,0,0.3)',
29
- borderRadius: 1,
30
- p: 1.5,
31
- border: 1,
32
- borderColor: 'rgba(255,255,255,0.05)',
33
- my: 1.5,
34
- }}
35
- >
36
- <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
37
- {tools.map((log) => {
38
- const isClickable = log.completed && log.output;
39
- return (
40
- <Typography
41
- key={log.id}
42
- variant="caption"
43
- component="div"
44
- onClick={() => handleToolClick(log)}
45
- sx={{
46
- color: 'var(--muted-text)',
47
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
48
- fontSize: '0.75rem',
49
- display: 'flex',
50
- alignItems: 'center',
51
- gap: 0.5,
52
- cursor: isClickable ? 'pointer' : 'default',
53
- borderRadius: 0.5,
54
- px: 0.5,
55
- mx: -0.5,
56
- transition: 'background-color 0.15s ease',
57
- '&:hover': isClickable ? {
58
- bgcolor: 'rgba(255,255,255,0.05)',
59
- } : {},
60
- }}
61
- >
62
- <span style={{
63
- color: log.completed
64
- ? (log.success === false ? '#F87171' : '#FDB022')
65
- : 'inherit',
66
- fontSize: '0.85rem',
67
- }}>
68
- {log.completed ? (log.success === false ? '✗' : '✓') : '•'}
69
- </span>
70
- <span style={{
71
- fontWeight: 600,
72
- color: isClickable ? 'rgba(255, 255, 255, 0.9)' : 'inherit',
73
- textDecoration: isClickable ? 'underline' : 'none',
74
- textDecorationColor: 'rgba(255,255,255,0.3)',
75
- textUnderlineOffset: '2px',
76
- }}>
77
- {log.tool}
78
- </span>
79
- {!log.completed && <span style={{ opacity: 0.6 }}>...</span>}
80
- {isClickable && (
81
- <span style={{
82
- opacity: 0.4,
83
- fontSize: '0.65rem',
84
- marginLeft: 'auto',
85
- }}>
86
- click to view
87
- </span>
88
- )}
89
- </Typography>
90
- );
91
- })}
92
- </Box>
93
- </Box>
94
- );
95
- }
96
-
97
- // Markdown styles
98
- const markdownStyles = {
99
- '& p': { m: 0, mb: 1, '&:last-child': { mb: 0 } },
100
- '& pre': {
101
- bgcolor: 'rgba(0,0,0,0.5)',
102
- p: 1.5,
103
- borderRadius: 1,
104
- overflow: 'auto',
105
- fontSize: '0.85rem',
106
- border: '1px solid rgba(255,255,255,0.05)',
107
- },
108
- '& code': {
109
- bgcolor: 'rgba(255,255,255,0.05)',
110
- px: 0.5,
111
- py: 0.25,
112
- borderRadius: 0.5,
113
- fontSize: '0.85rem',
114
- fontFamily: '"JetBrains Mono", monospace',
115
- },
116
- '& pre code': { bgcolor: 'transparent', p: 0 },
117
- '& a': {
118
- color: 'var(--accent-yellow)',
119
- textDecoration: 'none',
120
- '&:hover': { textDecoration: 'underline' },
121
- },
122
- '& ul, & ol': { pl: 2, my: 1 },
123
- '& table': {
124
- borderCollapse: 'collapse',
125
- width: '100%',
126
- my: 2,
127
- fontSize: '0.875rem',
128
- },
129
- '& th': {
130
- borderBottom: '1px solid rgba(255,255,255,0.1)',
131
- textAlign: 'left',
132
- p: 1,
133
- bgcolor: 'rgba(255,255,255,0.02)',
134
- },
135
- '& td': {
136
- borderBottom: '1px solid rgba(255,255,255,0.05)',
137
- p: 1,
138
- },
139
- };
140
-
141
- export default function MessageBubble({ message }: MessageBubbleProps) {
142
- const isUser = message.role === 'user';
143
- const isAssistant = message.role === 'assistant';
144
-
145
- if (message.approval) {
146
- return (
147
- <Box sx={{ width: '100%', maxWidth: '880px', mx: 'auto', my: 2 }}>
148
- <ApprovalFlow message={message} />
149
- </Box>
150
- );
151
  }
152
 
153
- // Render segments chronologically if available, otherwise fall back to content
154
- const renderContent = () => {
155
- if (message.segments && message.segments.length > 0) {
156
- return message.segments.map((segment, idx) => {
157
- if (segment.type === 'text' && segment.content) {
158
- return (
159
- <Box key={idx} sx={markdownStyles}>
160
- <ReactMarkdown remarkPlugins={[remarkGfm]}>{segment.content}</ReactMarkdown>
161
- </Box>
162
- );
163
- }
164
- if (segment.type === 'tools' && segment.tools && segment.tools.length > 0) {
165
- return <ToolsSegment key={idx} tools={segment.tools} />;
166
- }
167
- return null;
168
- });
169
- }
170
- // Fallback: just render content
171
  return (
172
- <Box sx={markdownStyles}>
173
- <ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
174
- </Box>
 
 
 
175
  );
176
- };
177
 
178
- return (
179
- <Box
180
- sx={{
181
- display: 'flex',
182
- justifyContent: isUser ? 'flex-end' : 'flex-start',
183
- width: '100%',
184
- maxWidth: '880px',
185
- mx: 'auto',
186
- }}
187
- >
188
- <Paper
189
- elevation={0}
190
- className={`message ${isUser ? 'user' : isAssistant ? 'assistant' : ''}`}
191
- sx={{
192
- p: '14px 18px',
193
- margin: '10px 0',
194
- maxWidth: '100%',
195
- borderRadius: 'var(--radius-lg)',
196
- borderTopLeftRadius: isAssistant ? '6px' : undefined,
197
- lineHeight: 1.45,
198
- boxShadow: 'var(--shadow-1)',
199
- border: '1px solid rgba(255,255,255,0.03)',
200
- background: 'linear-gradient(180deg, rgba(255,255,255,0.015), transparent)',
201
- }}
202
- >
203
- {renderContent()}
204
 
205
- <Typography
206
- className="meta"
207
- variant="caption"
208
- sx={{ display: 'block', textAlign: 'right', mt: 1, fontSize: '11px', opacity: 0.5 }}
209
- >
210
- {new Date(message.timestamp).toLocaleTimeString()}
211
- </Typography>
212
- </Paper>
213
- </Box>
214
- );
215
  }
 
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
37
+ message={message}
38
+ isLastTurn={isLastTurn}
39
+ onUndoTurn={onUndoTurn}
40
+ isProcessing={isProcessing}
41
+ />
42
  );
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
  }
frontend/src/components/Chat/MessageList.tsx CHANGED
@@ -1,7 +1,13 @@
1
- import { useEffect, useRef } from 'react';
2
- import { Box, Typography } from '@mui/material';
3
- import { useSessionStore } from '@/store/sessionStore';
4
  import MessageBubble from './MessageBubble';
 
 
 
 
 
 
5
  import type { Message } from '@/types/agent';
6
 
7
  interface MessageListProps {
@@ -9,92 +15,183 @@ interface MessageListProps {
9
  isProcessing: boolean;
10
  }
11
 
12
- const TechnicalIndicator = () => (
13
- <Box
14
- component="span"
15
- sx={{
16
- color: 'primary.main',
17
- fontFamily: 'monospace',
18
- fontWeight: 'bold',
19
- fontSize: '1.2rem',
20
- lineHeight: 0,
21
- display: 'inline-block',
22
- verticalAlign: 'middle',
23
- width: '1em',
24
- letterSpacing: '-3px',
25
- transform: 'scale(0.6) translateY(-2px)',
26
- '&::after': {
27
- content: '""',
28
- animation: 'dots 2s steps(4, end) infinite',
29
- },
30
- '@keyframes dots': {
31
- '0%': { content: '""' },
32
- '25%': { content: '"."' },
33
- '50%': { content: '".."' },
34
- '75%, 100%': { content: '"..."' },
35
- },
36
- }}
37
- />
38
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  export default function MessageList({ messages, isProcessing }: MessageListProps) {
41
- const bottomRef = useRef<HTMLDivElement>(null);
 
42
  const { activeSessionId } = useSessionStore();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- // Auto-scroll to bottom when new messages arrive
 
 
45
  useEffect(() => {
46
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
47
- }, [messages, isProcessing]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  return (
50
  <Box
 
51
  sx={{
52
  flex: 1,
53
  overflow: 'auto',
54
- p: 2,
55
- display: 'flex',
56
- flexDirection: 'column',
57
  }}
58
  >
59
- <Box sx={{ maxWidth: 'md', mx: 'auto', width: '100%', display: 'flex', flexDirection: 'column', gap: 2 }}>
60
- {messages.length === 0 && !isProcessing ? (
61
- <Box
62
- sx={{
63
- flex: 1,
64
- display: 'flex',
65
- alignItems: 'center',
66
- justifyContent: 'center',
67
- py: 8,
68
- }}
69
- >
70
- <Typography color="text.secondary" sx={{ fontFamily: 'monospace' }}>
71
- Awaiting input…
72
- </Typography>
73
- </Box>
74
- ) : (
75
- messages.map((message) => (
76
- <MessageBubble key={message.id} message={message} />
 
 
 
77
  ))
78
  )}
79
-
80
- {isProcessing && (
81
- <Box sx={{ width: '100%', mb: 2 }}>
82
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, px: 0.5 }}>
83
- <Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
84
- Thinking
85
- </Typography>
86
- <TechnicalIndicator />
87
- </Box>
88
- </Box>
89
- )}
90
 
91
- {activeSessionId && (
92
- // ApprovalFlow is now handled within messages
93
- null
94
- )}
95
-
96
- <div ref={bottomRef} />
97
- </Box>
98
  </Box>
99
  );
100
- }
 
1
+ import { useEffect, useRef, useMemo, useCallback } from 'react';
2
+ import { Box, Stack, Typography, Avatar } from '@mui/material';
3
+ import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
4
  import MessageBubble from './MessageBubble';
5
+ import ThinkingIndicator from './ThinkingIndicator';
6
+ import MarkdownContent from './MarkdownContent';
7
+ import { useAgentStore } from '@/store/agentStore';
8
+ import { useSessionStore } from '@/store/sessionStore';
9
+ import { apiFetch } from '@/utils/api';
10
+ import { logger } from '@/utils/logger';
11
  import type { Message } from '@/types/agent';
12
 
13
  interface MessageListProps {
 
15
  isProcessing: boolean;
16
  }
17
 
18
+ const WELCOME_MD = `I'm ready to help you with machine learning tasks using the Hugging Face ecosystem.
19
+
20
+ **Training & Fine-tuning** — SFT, DPO, GRPO, PPO with TRL · LoRA/PEFT · Submit and monitor jobs on cloud GPUs
21
+
22
+ **Data** — Find and explore datasets · Process, filter, transform · Push to the Hub
23
+
24
+ **Models** — Search and discover models · Get details and configs · Deploy for inference
25
+
26
+ **Research** — Find papers and documentation · Explore code examples · Check APIs and best practices
27
+
28
+ **Infrastructure** — Run jobs on CPU/GPU instances · Manage repos, branches, PRs · Monitor Spaces and endpoints
29
+
30
+ What would you like to do?`;
31
+
32
+ /** Static welcome message rendered when the conversation is empty. */
33
+ function WelcomeMessage() {
34
+ return (
35
+ <Stack direction="row" spacing={1.5} alignItems="flex-start">
36
+ <Avatar
37
+ sx={{
38
+ width: 28,
39
+ height: 28,
40
+ bgcolor: 'primary.main',
41
+ flexShrink: 0,
42
+ mt: 0.5,
43
+ }}
44
+ >
45
+ <SmartToyOutlinedIcon sx={{ fontSize: 16, color: '#fff' }} />
46
+ </Avatar>
47
+
48
+ <Box sx={{ flex: 1, minWidth: 0 }}>
49
+ <Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
50
+ <Typography
51
+ variant="caption"
52
+ sx={{
53
+ fontWeight: 700,
54
+ fontSize: '0.72rem',
55
+ color: 'var(--muted-text)',
56
+ textTransform: 'uppercase',
57
+ letterSpacing: '0.04em',
58
+ }}
59
+ >
60
+ Assistant
61
+ </Typography>
62
+ </Stack>
63
+ <Box
64
+ sx={{
65
+ maxWidth: { xs: '95%', md: '85%' },
66
+ bgcolor: 'var(--surface)',
67
+ borderRadius: 1.5,
68
+ borderTopLeftRadius: 4,
69
+ px: { xs: 1.5, md: 2.5 },
70
+ py: 1.5,
71
+ border: '1px solid var(--border)',
72
+ }}
73
+ >
74
+ <MarkdownContent content={WELCOME_MD} />
75
+ </Box>
76
+ </Box>
77
+ </Stack>
78
+ );
79
+ }
80
 
81
  export default function MessageList({ messages, isProcessing }: MessageListProps) {
82
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
83
+ const stickToBottom = useRef(true);
84
  const { activeSessionId } = useSessionStore();
85
+ const { removeLastTurn, currentTurnMessageId } = useAgentStore();
86
+
87
+ // ── Scroll-to-bottom helper ─────────────────────────────────────
88
+ const scrollToBottom = useCallback(() => {
89
+ const el = scrollContainerRef.current;
90
+ if (el) el.scrollTop = el.scrollHeight;
91
+ }, []);
92
+
93
+ // ── Track user scroll intent ────────────────────────────────────
94
+ // When user scrolls up (>80px from bottom), disable auto-scroll.
95
+ // When they scroll back to bottom, re-enable it.
96
+ useEffect(() => {
97
+ const el = scrollContainerRef.current;
98
+ if (!el) return;
99
+
100
+ const onScroll = () => {
101
+ const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
102
+ stickToBottom.current = distFromBottom < 80;
103
+ };
104
+
105
+ el.addEventListener('scroll', onScroll, { passive: true });
106
+ return () => el.removeEventListener('scroll', onScroll);
107
+ }, []);
108
+
109
+ // ── Auto-scroll on new messages / state changes ─────────────────
110
+ useEffect(() => {
111
+ if (stickToBottom.current) scrollToBottom();
112
+ }, [messages, isProcessing, scrollToBottom]);
113
 
114
+ // ── Auto-scroll on DOM mutations (streaming content growth) ─────
115
+ // This catches token-by-token updates that don't change the messages
116
+ // array reference (appendToMessage mutates in place).
117
  useEffect(() => {
118
+ const el = scrollContainerRef.current;
119
+ if (!el) return;
120
+
121
+ const observer = new MutationObserver(() => {
122
+ if (stickToBottom.current) {
123
+ el.scrollTop = el.scrollHeight;
124
+ }
125
+ });
126
+
127
+ observer.observe(el, {
128
+ childList: true,
129
+ subtree: true,
130
+ characterData: true,
131
+ });
132
+
133
+ return () => observer.disconnect();
134
+ }, []);
135
+
136
+ // Find the index of the last user message (start of the last turn)
137
+ const lastUserMsgId = useMemo(() => {
138
+ for (let i = messages.length - 1; i >= 0; i--) {
139
+ if (messages[i].role === 'user') return messages[i].id;
140
+ }
141
+ return null;
142
+ }, [messages]);
143
+
144
+ const handleUndoLastTurn = useCallback(async () => {
145
+ if (!activeSessionId) return;
146
+ try {
147
+ await apiFetch(`/api/undo/${activeSessionId}`, { method: 'POST' });
148
+ // Optimistic removal — backend will also confirm via undo_complete WS event
149
+ removeLastTurn(activeSessionId);
150
+ } catch (e) {
151
+ logger.error('Undo failed:', e);
152
+ }
153
+ }, [activeSessionId, removeLastTurn]);
154
 
155
  return (
156
  <Box
157
+ ref={scrollContainerRef}
158
  sx={{
159
  flex: 1,
160
  overflow: 'auto',
161
+ px: { xs: 0.5, sm: 1, md: 2 },
162
+ py: { xs: 2, md: 3 },
 
163
  }}
164
  >
165
+ <Stack
166
+ spacing={3}
167
+ sx={{
168
+ maxWidth: 880,
169
+ mx: 'auto',
170
+ width: '100%',
171
+ }}
172
+ >
173
+ {/* Always show the welcome message at the top */}
174
+ <WelcomeMessage />
175
+
176
+ {messages.length > 0 && (
177
+ messages.map((msg) => (
178
+ <MessageBubble
179
+ key={msg.id}
180
+ message={msg}
181
+ isLastTurn={msg.id === lastUserMsgId}
182
+ onUndoTurn={handleUndoLastTurn}
183
+ isProcessing={isProcessing}
184
+ isStreaming={isProcessing && msg.id === currentTurnMessageId}
185
+ />
186
  ))
187
  )}
 
 
 
 
 
 
 
 
 
 
 
188
 
189
+ {/* Show thinking dots only when processing but no streaming message yet */}
190
+ {isProcessing && !currentTurnMessageId && <ThinkingIndicator />}
191
+
192
+ {/* Sentinel — keeps scroll anchor at the bottom */}
193
+ <div />
194
+ </Stack>
 
195
  </Box>
196
  );
197
+ }
frontend/src/components/Chat/ThinkingIndicator.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box, Stack, Avatar, Typography } from '@mui/material';
2
+ import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
3
+
4
+ /** Pulsing dots shown while the agent is processing. */
5
+ export default function ThinkingIndicator() {
6
+ return (
7
+ <Stack direction="row" spacing={1.5} alignItems="flex-start">
8
+ <Avatar
9
+ sx={{
10
+ width: 28,
11
+ height: 28,
12
+ bgcolor: 'primary.main',
13
+ flexShrink: 0,
14
+ mt: 0.5,
15
+ }}
16
+ >
17
+ <SmartToyOutlinedIcon sx={{ fontSize: 16, color: '#fff' }} />
18
+ </Avatar>
19
+
20
+ <Box sx={{ pt: 0.75 }}>
21
+ <Typography
22
+ variant="caption"
23
+ sx={{
24
+ fontWeight: 700,
25
+ fontSize: '0.72rem',
26
+ color: 'var(--muted-text)',
27
+ textTransform: 'uppercase',
28
+ letterSpacing: '0.04em',
29
+ display: 'flex',
30
+ alignItems: 'center',
31
+ gap: 0.75,
32
+ }}
33
+ >
34
+ Thinking
35
+ <Box
36
+ component="span"
37
+ sx={{
38
+ display: 'inline-flex',
39
+ gap: '3px',
40
+ '& span': {
41
+ width: 4,
42
+ height: 4,
43
+ borderRadius: '50%',
44
+ bgcolor: 'primary.main',
45
+ animation: 'dotPulse 1.4s ease-in-out infinite',
46
+ },
47
+ '& span:nth-of-type(2)': { animationDelay: '0.2s' },
48
+ '& span:nth-of-type(3)': { animationDelay: '0.4s' },
49
+ '@keyframes dotPulse': {
50
+ '0%, 80%, 100%': { opacity: 0.25, transform: 'scale(0.8)' },
51
+ '40%': { opacity: 1, transform: 'scale(1)' },
52
+ },
53
+ }}
54
+ >
55
+ <span />
56
+ <span />
57
+ <span />
58
+ </Box>
59
+ </Typography>
60
+ </Box>
61
+ </Stack>
62
+ );
63
+ }
frontend/src/components/Chat/ToolCallGroup.tsx ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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';
5
+ import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
6
+ 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, ApprovalStatus } from '@/types/agent';
16
+
17
+ interface ToolCallGroupProps {
18
+ tools: TraceLog[];
19
+ }
20
+
21
+ // ── Status icon based on tool state ─────────────────────────────────
22
+ function StatusIcon({ log }: { log: TraceLog }) {
23
+ // Awaiting approval
24
+ if (log.approvalStatus === 'pending') {
25
+ return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
26
+ }
27
+ // Rejected
28
+ if (log.approvalStatus === 'rejected') {
29
+ return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
30
+ }
31
+ // Running (not completed yet)
32
+ if (!log.completed) {
33
+ return (
34
+ <MoreHorizIcon
35
+ sx={{
36
+ fontSize: 16,
37
+ color: 'var(--muted-text)',
38
+ animation: 'pulse 1.5s ease-in-out infinite',
39
+ '@keyframes pulse': {
40
+ '0%, 100%': { opacity: 0.4 },
41
+ '50%': { opacity: 1 },
42
+ },
43
+ }}
44
+ />
45
+ );
46
+ }
47
+ // Failed
48
+ if (log.success === false) {
49
+ return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
50
+ }
51
+ // Completed successfully
52
+ return <CheckCircleOutlineIcon sx={{ fontSize: 16, color: 'success.main' }} />;
53
+ }
54
+
55
+ // ── Status chip label ───────────────────────────────────────────────
56
+ function statusLabel(log: TraceLog): string | null {
57
+ if (log.approvalStatus === 'pending') return 'awaiting approval';
58
+ if (log.approvalStatus === 'rejected') return 'rejected';
59
+ if (!log.completed) return 'running';
60
+ return null;
61
+ }
62
+
63
+ function statusColor(log: TraceLog): string {
64
+ if (log.approvalStatus === 'pending') return 'var(--accent-yellow)';
65
+ if (log.approvalStatus === 'rejected') return 'var(--accent-red)';
66
+ return 'var(--accent-yellow)';
67
+ }
68
+
69
+ // ── Inline approval UI ──────────────────────────────────────────────
70
+ function InlineApproval({
71
+ log,
72
+ onResolve,
73
+ }: {
74
+ log: TraceLog;
75
+ onResolve: (toolCallId: string, approved: boolean, feedback?: string) => void;
76
+ }) {
77
+ const [feedback, setFeedback] = useState('');
78
+
79
+ return (
80
+ <Box sx={{ px: 1.5, py: 1.5, borderTop: '1px solid var(--tool-border)' }}>
81
+ {/* Tool description */}
82
+ {log.tool === 'hf_jobs' && log.args && (
83
+ <Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.75rem', mb: 1.5 }}>
84
+ Execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{log.tool}</Box> on{' '}
85
+ <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
86
+ {String(log.args.hardware_flavor || 'default')}
87
+ </Box>
88
+ {log.args.timeout && (
89
+ <> with timeout <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
90
+ {String(log.args.timeout)}
91
+ </Box></>
92
+ )}
93
+ </Typography>
94
+ )}
95
+
96
+ {/* Feedback + buttons */}
97
+ <Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
98
+ <TextField
99
+ fullWidth
100
+ size="small"
101
+ placeholder="Feedback (optional)"
102
+ value={feedback}
103
+ onChange={(e) => setFeedback(e.target.value)}
104
+ variant="outlined"
105
+ sx={{
106
+ '& .MuiOutlinedInput-root': {
107
+ bgcolor: 'rgba(0,0,0,0.15)',
108
+ fontFamily: 'inherit',
109
+ fontSize: '0.8rem',
110
+ },
111
+ }}
112
+ />
113
+ <IconButton
114
+ onClick={() => onResolve(log.toolCallId || '', false, feedback || 'Rejected by user')}
115
+ disabled={!feedback}
116
+ size="small"
117
+ sx={{
118
+ color: 'var(--accent-red)',
119
+ border: '1px solid rgba(255,255,255,0.05)',
120
+ borderRadius: '6px',
121
+ '&:hover': { bgcolor: 'rgba(224,90,79,0.1)', borderColor: 'var(--accent-red)' },
122
+ '&.Mui-disabled': { color: 'rgba(255,255,255,0.1)' },
123
+ }}
124
+ >
125
+ <SendIcon sx={{ fontSize: 14 }} />
126
+ </IconButton>
127
+ </Box>
128
+
129
+ <Box sx={{ display: 'flex', gap: 1 }}>
130
+ <Button
131
+ size="small"
132
+ onClick={() => onResolve(log.toolCallId || '', false, feedback || 'Rejected by user')}
133
+ sx={{
134
+ flex: 1,
135
+ textTransform: 'none',
136
+ border: '1px solid rgba(255,255,255,0.05)',
137
+ color: 'var(--accent-red)',
138
+ fontSize: '0.75rem',
139
+ py: 0.75,
140
+ borderRadius: '8px',
141
+ '&:hover': { bgcolor: 'rgba(224,90,79,0.05)', borderColor: 'var(--accent-red)' },
142
+ }}
143
+ >
144
+ Reject
145
+ </Button>
146
+ <Button
147
+ size="small"
148
+ onClick={() => onResolve(log.toolCallId || '', true)}
149
+ sx={{
150
+ flex: 1,
151
+ textTransform: 'none',
152
+ border: '1px solid rgba(255,255,255,0.05)',
153
+ color: 'var(--accent-green)',
154
+ fontSize: '0.75rem',
155
+ py: 0.75,
156
+ borderRadius: '8px',
157
+ '&:hover': { bgcolor: 'rgba(47,204,113,0.05)', borderColor: 'var(--accent-green)' },
158
+ }}
159
+ >
160
+ Approve
161
+ </Button>
162
+ </Box>
163
+ </Box>
164
+ );
165
+ }
166
+
167
+ // ── Main component ──────────────────────────────────────────────────
168
+ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
169
+ const { showToolOutput, setPanelTab, setActivePanelTab, clearPanelTabs } = useAgentStore();
170
+ const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
171
+ const { activeSessionId } = useSessionStore();
172
+
173
+ const handleClick = useCallback(
174
+ (log: TraceLog) => {
175
+ // For hf_jobs with scripts, use tab system
176
+ if (log.tool === 'hf_jobs' && log.args?.script) {
177
+ clearPanelTabs();
178
+ setPanelTab({
179
+ id: 'script',
180
+ title: 'Script',
181
+ content: String(log.args.script),
182
+ language: 'python',
183
+ });
184
+ if (log.jobLogs) {
185
+ setPanelTab({
186
+ id: 'logs',
187
+ title: 'Logs',
188
+ content: log.jobLogs,
189
+ language: 'text',
190
+ });
191
+ }
192
+ setActivePanelTab('script');
193
+ setRightPanelOpen(true);
194
+ setLeftSidebarOpen(false);
195
+ return;
196
+ }
197
+
198
+ // Show output if completed, or args if still running
199
+ if (log.completed && log.output) {
200
+ showToolOutput(log);
201
+ } else if (log.args) {
202
+ const content = JSON.stringify(log.args, null, 2);
203
+ showToolOutput({ ...log, output: content });
204
+ } else {
205
+ return;
206
+ }
207
+ setRightPanelOpen(true);
208
+ },
209
+ [showToolOutput, setRightPanelOpen, setLeftSidebarOpen, clearPanelTabs, setPanelTab, setActivePanelTab],
210
+ );
211
+
212
+ const handleApprovalResolve = useCallback(
213
+ async (toolCallId: string, approved: boolean, feedback?: string) => {
214
+ if (!activeSessionId) return;
215
+ try {
216
+ await apiFetch('/api/approve', {
217
+ method: 'POST',
218
+ body: JSON.stringify({
219
+ session_id: activeSessionId,
220
+ approvals: [{
221
+ tool_call_id: toolCallId,
222
+ approved,
223
+ feedback: approved ? null : feedback || 'Rejected by user',
224
+ }],
225
+ }),
226
+ });
227
+ // The WebSocket will send back tool_output events which will update the trace
228
+ } catch (e) {
229
+ logger.error('Approval failed:', e);
230
+ }
231
+ },
232
+ [activeSessionId],
233
+ );
234
+
235
+ return (
236
+ <Box
237
+ sx={{
238
+ borderRadius: 2,
239
+ border: '1px solid var(--tool-border)',
240
+ bgcolor: 'var(--tool-bg)',
241
+ overflow: 'hidden',
242
+ my: 1,
243
+ }}
244
+ >
245
+ <Stack divider={<Box sx={{ borderBottom: '1px solid var(--tool-border)' }} />}>
246
+ {tools.map((log) => {
247
+ const clickable = (log.completed && !!log.output) || !!log.args;
248
+ const label = statusLabel(log);
249
+ const isPendingApproval = log.approvalStatus === 'pending';
250
+
251
+ return (
252
+ <Box key={log.id}>
253
+ {/* Main tool row */}
254
+ <Stack
255
+ direction="row"
256
+ alignItems="center"
257
+ spacing={1}
258
+ onClick={() => !isPendingApproval && handleClick(log)}
259
+ sx={{
260
+ px: 1.5,
261
+ py: 1,
262
+ cursor: isPendingApproval ? 'default' : clickable ? 'pointer' : 'default',
263
+ transition: 'background-color 0.15s',
264
+ '&:hover': clickable && !isPendingApproval ? { bgcolor: 'var(--hover-bg)' } : {},
265
+ }}
266
+ >
267
+ <StatusIcon log={log} />
268
+
269
+ <Typography
270
+ variant="body2"
271
+ sx={{
272
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
273
+ fontWeight: 600,
274
+ fontSize: '0.78rem',
275
+ color: 'var(--text)',
276
+ flex: 1,
277
+ minWidth: 0,
278
+ overflow: 'hidden',
279
+ textOverflow: 'ellipsis',
280
+ whiteSpace: 'nowrap',
281
+ }}
282
+ >
283
+ {log.tool}
284
+ </Typography>
285
+
286
+ {/* Quick action links for completed jobs */}
287
+ {log.completed && log.tool === 'hf_jobs' && log.args?.script && (
288
+ <Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
289
+ <Typography
290
+ component="span"
291
+ onClick={() => handleClick(log)}
292
+ sx={{
293
+ fontSize: '0.68rem',
294
+ color: 'var(--muted-text)',
295
+ cursor: 'pointer',
296
+ px: 0.75,
297
+ py: 0.25,
298
+ borderRadius: 0.5,
299
+ '&:hover': { color: 'var(--accent-yellow)', bgcolor: 'var(--hover-bg)' },
300
+ }}
301
+ >
302
+ Script
303
+ </Typography>
304
+ {log.jobLogs && (
305
+ <Typography
306
+ component="span"
307
+ onClick={() => {
308
+ clearPanelTabs();
309
+ if (log.args?.script) {
310
+ setPanelTab({ id: 'script', title: 'Script', content: String(log.args.script), language: 'python' });
311
+ }
312
+ setPanelTab({ id: 'logs', title: 'Logs', content: log.jobLogs!, language: 'text' });
313
+ setActivePanelTab('logs');
314
+ setRightPanelOpen(true);
315
+ setLeftSidebarOpen(false);
316
+ }}
317
+ sx={{
318
+ fontSize: '0.68rem',
319
+ color: 'var(--accent-yellow)',
320
+ cursor: 'pointer',
321
+ px: 0.75,
322
+ py: 0.25,
323
+ borderRadius: 0.5,
324
+ '&:hover': { bgcolor: 'var(--hover-bg)' },
325
+ }}
326
+ >
327
+ Logs
328
+ </Typography>
329
+ )}
330
+ </Box>
331
+ )}
332
+
333
+ {label && (
334
+ <Chip
335
+ label={label}
336
+ size="small"
337
+ sx={{
338
+ height: 20,
339
+ fontSize: '0.65rem',
340
+ fontWeight: 600,
341
+ bgcolor: 'var(--accent-yellow-weak)',
342
+ color: statusColor(log),
343
+ letterSpacing: '0.03em',
344
+ }}
345
+ />
346
+ )}
347
+
348
+ {clickable && !isPendingApproval && (
349
+ <OpenInNewIcon sx={{ fontSize: 14, color: 'var(--muted-text)', opacity: 0.6 }} />
350
+ )}
351
+ </Stack>
352
+
353
+ {/* Job status + link row */}
354
+ {(log.jobUrl || log.jobStatus) && (
355
+ <Box
356
+ sx={{
357
+ display: 'flex',
358
+ alignItems: 'center',
359
+ gap: 1.5,
360
+ px: 1.5,
361
+ py: 0.75,
362
+ borderTop: '1px solid var(--tool-border)',
363
+ }}
364
+ >
365
+ {log.jobStatus && (
366
+ <Typography
367
+ variant="caption"
368
+ sx={{
369
+ color: log.success === false ? 'var(--accent-red)' : 'var(--accent-green)',
370
+ fontSize: '0.7rem',
371
+ fontWeight: 600,
372
+ }}
373
+ >
374
+ {log.jobStatus}
375
+ </Typography>
376
+ )}
377
+ {log.jobUrl && (
378
+ <Link
379
+ href={log.jobUrl}
380
+ target="_blank"
381
+ rel="noopener noreferrer"
382
+ onClick={(e) => e.stopPropagation()}
383
+ sx={{
384
+ display: 'inline-flex',
385
+ alignItems: 'center',
386
+ gap: 0.5,
387
+ color: 'var(--accent-yellow)',
388
+ fontSize: '0.68rem',
389
+ textDecoration: 'none',
390
+ '&:hover': { textDecoration: 'underline' },
391
+ }}
392
+ >
393
+ <LaunchIcon sx={{ fontSize: 12 }} />
394
+ View on HF
395
+ </Link>
396
+ )}
397
+ </Box>
398
+ )}
399
+
400
+ {/* Inline approval UI (only when pending) */}
401
+ {isPendingApproval && (
402
+ <InlineApproval log={log} onResolve={handleApprovalResolve} />
403
+ )}
404
+ </Box>
405
+ );
406
+ })}
407
+ </Stack>
408
+ </Box>
409
+ );
410
+ }
frontend/src/components/Chat/UserMessage.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box, Stack, Typography, Avatar, IconButton, Tooltip } from '@mui/material';
2
+ import PersonOutlineIcon from '@mui/icons-material/PersonOutline';
3
+ import CloseIcon from '@mui/icons-material/Close';
4
+ import type { Message } from '@/types/agent';
5
+
6
+ interface UserMessageProps {
7
+ message: Message;
8
+ /** True if this message starts the last turn. */
9
+ isLastTurn?: boolean;
10
+ /** Callback to remove the last turn. */
11
+ onUndoTurn?: () => void;
12
+ /** Whether the agent is currently processing (disables undo). */
13
+ isProcessing?: boolean;
14
+ }
15
+
16
+ export default function UserMessage({
17
+ message,
18
+ isLastTurn = false,
19
+ onUndoTurn,
20
+ isProcessing = false,
21
+ }: UserMessageProps) {
22
+ const showUndo = isLastTurn && !isProcessing && !!onUndoTurn;
23
+
24
+ return (
25
+ <Stack
26
+ direction="row"
27
+ spacing={1.5}
28
+ justifyContent="flex-end"
29
+ alignItems="flex-start"
30
+ sx={{
31
+ // Show the undo button when hovering the entire row
32
+ '& .undo-btn': {
33
+ opacity: 0,
34
+ transition: 'opacity 0.15s ease',
35
+ },
36
+ '&:hover .undo-btn': {
37
+ opacity: 1,
38
+ },
39
+ }}
40
+ >
41
+ {/* Undo button — visible on hover, left of the bubble */}
42
+ {showUndo && (
43
+ <Box className="undo-btn" sx={{ display: 'flex', alignItems: 'center', mt: 0.75 }}>
44
+ <Tooltip title="Remove this turn" placement="left">
45
+ <IconButton
46
+ onClick={onUndoTurn}
47
+ size="small"
48
+ sx={{
49
+ width: 24,
50
+ height: 24,
51
+ color: 'var(--muted-text)',
52
+ '&:hover': {
53
+ color: 'var(--accent-red)',
54
+ bgcolor: 'rgba(244,67,54,0.08)',
55
+ },
56
+ }}
57
+ >
58
+ <CloseIcon sx={{ fontSize: 14 }} />
59
+ </IconButton>
60
+ </Tooltip>
61
+ </Box>
62
+ )}
63
+
64
+ <Box
65
+ sx={{
66
+ maxWidth: { xs: '88%', md: '72%' },
67
+ bgcolor: 'var(--surface)',
68
+ borderRadius: 1.5,
69
+ borderTopRightRadius: 4,
70
+ px: { xs: 1.5, md: 2.5 },
71
+ py: 1.5,
72
+ border: '1px solid var(--border)',
73
+ }}
74
+ >
75
+ <Typography
76
+ variant="body1"
77
+ sx={{
78
+ fontSize: '0.925rem',
79
+ lineHeight: 1.65,
80
+ color: 'var(--text)',
81
+ whiteSpace: 'pre-wrap',
82
+ wordBreak: 'break-word',
83
+ }}
84
+ >
85
+ {message.content}
86
+ </Typography>
87
+
88
+ <Typography
89
+ variant="caption"
90
+ sx={{
91
+ display: 'block',
92
+ textAlign: 'right',
93
+ mt: 1,
94
+ fontSize: '0.68rem',
95
+ color: 'var(--muted-text)',
96
+ opacity: 0.7,
97
+ }}
98
+ >
99
+ {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
100
+ </Typography>
101
+ </Box>
102
+
103
+ <Avatar
104
+ sx={{
105
+ width: 28,
106
+ height: 28,
107
+ bgcolor: 'var(--hover-bg)',
108
+ border: '1px solid var(--border)',
109
+ flexShrink: 0,
110
+ mt: 0.5,
111
+ }}
112
+ >
113
+ <PersonOutlineIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />
114
+ </Avatar>
115
+ </Stack>
116
+ );
117
+ }
frontend/src/components/CodePanel/CodePanel.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useRef, useEffect, useMemo } from 'react';
2
- import { Box, Typography, IconButton } from '@mui/material';
3
  import CloseIcon from '@mui/icons-material/Close';
4
  import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
5
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
@@ -8,25 +8,104 @@ import CodeIcon from '@mui/icons-material/Code';
8
  import TerminalIcon from '@mui/icons-material/Terminal';
9
  import ArticleIcon from '@mui/icons-material/Article';
10
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
11
- import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
12
  import ReactMarkdown from 'react-markdown';
13
  import remarkGfm from 'remark-gfm';
14
  import { useAgentStore } from '@/store/agentStore';
15
  import { useLayoutStore } from '@/store/layoutStore';
16
  import { processLogs } from '@/utils/logProcessor';
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  export default function CodePanel() {
19
- const { panelContent, panelTabs, activePanelTab, setActivePanelTab, removePanelTab, plan } = useAgentStore();
20
- const { setRightPanelOpen } = useLayoutStore();
 
21
  const scrollRef = useRef<HTMLDivElement>(null);
22
 
23
- // Get the active tab content, or fall back to panelContent for backwards compatibility
24
- const activeTab = panelTabs.find(t => t.id === activePanelTab);
25
  const currentContent = activeTab || panelContent;
 
 
 
 
26
 
27
  const displayContent = useMemo(() => {
28
  if (!currentContent?.content) return '';
29
- // Apply log processing only for text/logs, not for code/json
30
  if (!currentContent.language || currentContent.language === 'text') {
31
  return processLogs(currentContent.content);
32
  }
@@ -34,36 +113,80 @@ export default function CodePanel() {
34
  }, [currentContent?.content, currentContent?.language]);
35
 
36
  useEffect(() => {
37
- // Auto-scroll only for logs tab
38
  if (scrollRef.current && activePanelTab === 'logs') {
39
  scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
40
  }
41
  }, [displayContent, activePanelTab]);
42
 
43
- const hasTabs = panelTabs.length > 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  return (
46
  <Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
47
- {/* Header - Fixed 60px to align */}
48
- <Box sx={{
49
- height: '60px',
50
- display: 'flex',
51
- alignItems: 'center',
52
- justifyContent: 'space-between',
53
- px: 2,
54
- borderBottom: '1px solid rgba(255,255,255,0.03)'
55
- }}>
 
 
 
56
  {hasTabs ? (
57
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
58
  {panelTabs.map((tab) => {
59
  const isActive = activePanelTab === tab.id;
60
- // Choose icon based on tab type
61
- let icon = <TerminalIcon sx={{ fontSize: 14 }} />;
62
- if (tab.id === 'script' || tab.language === 'python') {
63
- icon = <CodeIcon sx={{ fontSize: 14 }} />;
64
- } else if (tab.id === 'tool_output' || tab.language === 'markdown' || tab.language === 'json') {
65
- icon = <ArticleIcon sx={{ fontSize: 14 }} />;
66
- }
67
  return (
68
  <Box
69
  key={tab.id}
@@ -81,16 +204,14 @@ export default function CodePanel() {
81
  textTransform: 'uppercase',
82
  letterSpacing: '0.05em',
83
  color: isActive ? 'var(--text)' : 'var(--muted-text)',
84
- bgcolor: isActive ? 'rgba(255,255,255,0.08)' : 'transparent',
85
  border: '1px solid',
86
- borderColor: isActive ? 'rgba(255,255,255,0.1)' : 'transparent',
87
  transition: 'all 0.15s ease',
88
- '&:hover': {
89
- bgcolor: 'rgba(255,255,255,0.05)',
90
- },
91
  }}
92
  >
93
- {icon}
94
  <span>{tab.title}</span>
95
  <Box
96
  component="span"
@@ -108,10 +229,7 @@ export default function CodePanel() {
108
  borderRadius: '50%',
109
  fontSize: '0.65rem',
110
  opacity: 0.5,
111
- '&:hover': {
112
- opacity: 1,
113
- bgcolor: 'rgba(255,255,255,0.1)',
114
- },
115
  }}
116
  >
117
 
@@ -121,16 +239,20 @@ export default function CodePanel() {
121
  })}
122
  </Box>
123
  ) : (
124
- <Typography variant="caption" sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
 
 
 
125
  {currentContent?.title || 'Code Panel'}
126
  </Typography>
127
  )}
 
128
  <IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
129
  <CloseIcon fontSize="small" />
130
  </IconButton>
131
  </Box>
132
 
133
- {/* Main Content Area */}
134
  <Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
135
  {!currentContent ? (
136
  <Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
@@ -144,174 +266,72 @@ export default function CodePanel() {
144
  ref={scrollRef}
145
  className="code-panel"
146
  sx={{
147
- background: '#0A0B0C',
148
  borderRadius: 'var(--radius-md)',
149
- padding: '18px',
150
- border: '1px solid rgba(255,255,255,0.03)',
151
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", monospace',
152
  fontSize: '13px',
153
  lineHeight: 1.55,
154
  height: '100%',
155
  overflow: 'auto',
156
  }}
157
  >
158
- {currentContent.content ? (
159
- currentContent.language === 'python' ? (
160
- <SyntaxHighlighter
161
- language="python"
162
- style={vscDarkPlus}
163
- customStyle={{
164
- margin: 0,
165
- padding: 0,
166
- background: 'transparent',
167
- fontSize: '13px',
168
- fontFamily: 'inherit',
169
- }}
170
- wrapLines={true}
171
- wrapLongLines={true}
172
- >
173
- {displayContent}
174
- </SyntaxHighlighter>
175
- ) : currentContent.language === 'json' ? (
176
- <SyntaxHighlighter
177
- language="json"
178
- style={vscDarkPlus}
179
- customStyle={{
180
- margin: 0,
181
- padding: 0,
182
- background: 'transparent',
183
- fontSize: '13px',
184
- fontFamily: 'inherit',
185
- }}
186
- wrapLines={true}
187
- wrapLongLines={true}
188
- >
189
- {displayContent}
190
- </SyntaxHighlighter>
191
- ) : currentContent.language === 'markdown' ? (
192
- <Box sx={{
193
- color: 'var(--text)',
194
- fontSize: '13px',
195
- lineHeight: 1.6,
196
- '& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } },
197
- '& pre': {
198
- bgcolor: 'rgba(0,0,0,0.4)',
199
- p: 1.5,
200
- borderRadius: 1,
201
- overflow: 'auto',
202
- fontSize: '12px',
203
- border: '1px solid rgba(255,255,255,0.05)',
204
- },
205
- '& code': {
206
- bgcolor: 'rgba(255,255,255,0.05)',
207
- px: 0.5,
208
- py: 0.25,
209
- borderRadius: 0.5,
210
- fontSize: '12px',
211
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
212
- },
213
- '& pre code': { bgcolor: 'transparent', p: 0 },
214
- '& a': {
215
- color: 'var(--accent-yellow)',
216
- textDecoration: 'none',
217
- '&:hover': { textDecoration: 'underline' },
218
- },
219
- '& ul, & ol': { pl: 2.5, my: 1 },
220
- '& li': { mb: 0.5 },
221
- '& table': {
222
- borderCollapse: 'collapse',
223
- width: '100%',
224
- my: 2,
225
- fontSize: '12px',
226
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
227
- },
228
- '& th': {
229
- borderBottom: '2px solid rgba(255,255,255,0.15)',
230
- textAlign: 'left',
231
- p: 1,
232
- fontWeight: 600,
233
- },
234
- '& td': {
235
- borderBottom: '1px solid rgba(255,255,255,0.05)',
236
- p: 1,
237
- },
238
- '& h1, & h2, & h3, & h4': {
239
- mt: 2,
240
- mb: 1,
241
- fontWeight: 600,
242
- },
243
- '& h1': { fontSize: '1.25rem' },
244
- '& h2': { fontSize: '1.1rem' },
245
- '& h3': { fontSize: '1rem' },
246
- '& blockquote': {
247
- borderLeft: '3px solid rgba(255,255,255,0.2)',
248
- pl: 2,
249
- ml: 0,
250
- color: 'var(--muted-text)',
251
- },
252
- }}>
253
- <ReactMarkdown remarkPlugins={[remarkGfm]}>{displayContent}</ReactMarkdown>
254
- </Box>
255
- ) : (
256
- <Box component="pre" sx={{
257
- m: 0,
258
- fontFamily: 'inherit',
259
- color: 'var(--text)',
260
- whiteSpace: 'pre-wrap',
261
- wordBreak: 'break-all'
262
- }}>
263
- <code>{displayContent}</code>
264
- </Box>
265
- )
266
- ) : (
267
- <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
268
- <Typography variant="caption">
269
- NO CONTENT TO DISPLAY
270
- </Typography>
271
- </Box>
272
- )}
273
  </Box>
274
  </Box>
275
  )}
276
  </Box>
277
 
278
- {/* Plan Display at Bottom */}
279
  {plan && plan.length > 0 && (
280
- <Box sx={{
281
- borderTop: '1px solid rgba(255,255,255,0.03)',
282
- bgcolor: 'rgba(0,0,0,0.2)',
 
283
  maxHeight: '30%',
284
  display: 'flex',
285
- flexDirection: 'column'
286
- }}>
287
- <Box sx={{ p: 1.5, borderBottom: '1px solid rgba(255,255,255,0.03)', display: 'flex', alignItems: 'center', gap: 1 }}>
288
- <Typography variant="caption" sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
289
- CURRENT PLAN
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  </Typography>
291
- </Box>
292
- <Box sx={{ p: 2, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 1 }}>
293
- {plan.map((item) => (
294
- <Box key={item.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
295
- <Box sx={{ mt: 0.2 }}>
296
- {item.status === 'completed' && <CheckCircleIcon sx={{ fontSize: 16, color: 'var(--accent-green)' }} />}
297
- {item.status === 'in_progress' && <PlayCircleOutlineIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />}
298
- {item.status === 'pending' && <RadioButtonUncheckedIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.5 }} />}
299
- </Box>
300
- <Typography
301
- variant="body2"
302
- sx={{
303
- fontSize: '13px',
304
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
305
- color: item.status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
306
- textDecoration: item.status === 'completed' ? 'line-through' : 'none',
307
- opacity: item.status === 'pending' ? 0.7 : 1
308
- }}
309
- >
310
- {item.content}
311
- </Typography>
312
- </Box>
313
- ))}
314
- </Box>
315
  </Box>
316
  )}
317
  </Box>
 
1
  import { useRef, useEffect, useMemo } from 'react';
2
+ import { Box, Stack, Typography, IconButton } from '@mui/material';
3
  import CloseIcon from '@mui/icons-material/Close';
4
  import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
5
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
 
8
  import TerminalIcon from '@mui/icons-material/Terminal';
9
  import ArticleIcon from '@mui/icons-material/Article';
10
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
11
+ import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
12
  import ReactMarkdown from 'react-markdown';
13
  import remarkGfm from 'remark-gfm';
14
  import { useAgentStore } from '@/store/agentStore';
15
  import { useLayoutStore } from '@/store/layoutStore';
16
  import { processLogs } from '@/utils/logProcessor';
17
 
18
+ // ── Helpers ──────────────────────────────────────────────────────
19
+
20
+ function tabIcon(id: string, language?: string) {
21
+ if (id === 'script' || language === 'python') return <CodeIcon sx={{ fontSize: 14 }} />;
22
+ if (id === 'tool_output' || language === 'markdown' || language === 'json')
23
+ return <ArticleIcon sx={{ fontSize: 14 }} />;
24
+ return <TerminalIcon sx={{ fontSize: 14 }} />;
25
+ }
26
+
27
+ function PlanStatusIcon({ status }: { status: string }) {
28
+ if (status === 'completed') return <CheckCircleIcon sx={{ fontSize: 16, color: 'var(--accent-green)' }} />;
29
+ if (status === 'in_progress') return <PlayCircleOutlineIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
30
+ return <RadioButtonUncheckedIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.5 }} />;
31
+ }
32
+
33
+ // ── Markdown styles (adapts via CSS vars) ────────────────────────
34
+ const markdownSx = {
35
+ color: 'var(--text)',
36
+ fontSize: '13px',
37
+ lineHeight: 1.6,
38
+ '& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } },
39
+ '& pre': {
40
+ bgcolor: 'var(--code-bg)',
41
+ p: 1.5,
42
+ borderRadius: 1,
43
+ overflow: 'auto',
44
+ fontSize: '12px',
45
+ border: '1px solid var(--tool-border)',
46
+ },
47
+ '& code': {
48
+ bgcolor: 'var(--hover-bg)',
49
+ px: 0.5,
50
+ py: 0.25,
51
+ borderRadius: 0.5,
52
+ fontSize: '12px',
53
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
54
+ },
55
+ '& pre code': { bgcolor: 'transparent', p: 0 },
56
+ '& a': {
57
+ color: 'var(--accent-yellow)',
58
+ textDecoration: 'none',
59
+ '&:hover': { textDecoration: 'underline' },
60
+ },
61
+ '& ul, & ol': { pl: 2.5, my: 1 },
62
+ '& li': { mb: 0.5 },
63
+ '& table': {
64
+ borderCollapse: 'collapse',
65
+ width: '100%',
66
+ my: 2,
67
+ fontSize: '12px',
68
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
69
+ },
70
+ '& th': {
71
+ borderBottom: '2px solid var(--border-hover)',
72
+ textAlign: 'left',
73
+ p: 1,
74
+ fontWeight: 600,
75
+ },
76
+ '& td': {
77
+ borderBottom: '1px solid var(--tool-border)',
78
+ p: 1,
79
+ },
80
+ '& h1, & h2, & h3, & h4': { mt: 2, mb: 1, fontWeight: 600 },
81
+ '& h1': { fontSize: '1.25rem' },
82
+ '& h2': { fontSize: '1.1rem' },
83
+ '& h3': { fontSize: '1rem' },
84
+ '& blockquote': {
85
+ borderLeft: '3px solid var(--accent-yellow)',
86
+ pl: 2,
87
+ ml: 0,
88
+ color: 'var(--muted-text)',
89
+ },
90
+ } as const;
91
+
92
+ // ── Component ────────────────────────────────────────────────────
93
+
94
  export default function CodePanel() {
95
+ const { panelContent, panelTabs, activePanelTab, setActivePanelTab, removePanelTab, plan } =
96
+ useAgentStore();
97
+ const { setRightPanelOpen, themeMode } = useLayoutStore();
98
  const scrollRef = useRef<HTMLDivElement>(null);
99
 
100
+ const activeTab = panelTabs.find((t) => t.id === activePanelTab);
 
101
  const currentContent = activeTab || panelContent;
102
+ const hasTabs = panelTabs.length > 0;
103
+
104
+ const isDark = themeMode === 'dark';
105
+ const syntaxTheme = isDark ? vscDarkPlus : vs;
106
 
107
  const displayContent = useMemo(() => {
108
  if (!currentContent?.content) return '';
 
109
  if (!currentContent.language || currentContent.language === 'text') {
110
  return processLogs(currentContent.content);
111
  }
 
113
  }, [currentContent?.content, currentContent?.language]);
114
 
115
  useEffect(() => {
 
116
  if (scrollRef.current && activePanelTab === 'logs') {
117
  scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
118
  }
119
  }, [displayContent, activePanelTab]);
120
 
121
+ // ── Syntax-highlighted code block (DRY) ────────────────────────
122
+ const renderSyntaxBlock = (language: string) => (
123
+ <SyntaxHighlighter
124
+ language={language}
125
+ style={syntaxTheme}
126
+ customStyle={{
127
+ margin: 0,
128
+ padding: 0,
129
+ background: 'transparent',
130
+ fontSize: '13px',
131
+ fontFamily: 'inherit',
132
+ }}
133
+ wrapLines
134
+ wrapLongLines
135
+ >
136
+ {displayContent}
137
+ </SyntaxHighlighter>
138
+ );
139
+
140
+ // ── Content renderer ───────────────────────────────────────────
141
+ const renderContent = () => {
142
+ if (!currentContent?.content) {
143
+ return (
144
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
145
+ <Typography variant="caption">NO CONTENT TO DISPLAY</Typography>
146
+ </Box>
147
+ );
148
+ }
149
+
150
+ if (currentContent.language === 'python') return renderSyntaxBlock('python');
151
+ if (currentContent.language === 'json') return renderSyntaxBlock('json');
152
+
153
+ if (currentContent.language === 'markdown') {
154
+ return (
155
+ <Box sx={markdownSx}>
156
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{displayContent}</ReactMarkdown>
157
+ </Box>
158
+ );
159
+ }
160
+
161
+ // Plain text / logs
162
+ return (
163
+ <Box
164
+ component="pre"
165
+ sx={{ m: 0, fontFamily: 'inherit', color: 'var(--text)', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
166
+ >
167
+ <code>{displayContent}</code>
168
+ </Box>
169
+ );
170
+ };
171
 
172
  return (
173
  <Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
174
+ {/* ── Header (60 px, aligned with top bar) ────────────────── */}
175
+ <Box
176
+ sx={{
177
+ height: 60,
178
+ display: 'flex',
179
+ alignItems: 'center',
180
+ justifyContent: 'space-between',
181
+ px: 2,
182
+ borderBottom: '1px solid var(--border)',
183
+ flexShrink: 0,
184
+ }}
185
+ >
186
  {hasTabs ? (
187
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
188
  {panelTabs.map((tab) => {
189
  const isActive = activePanelTab === tab.id;
 
 
 
 
 
 
 
190
  return (
191
  <Box
192
  key={tab.id}
 
204
  textTransform: 'uppercase',
205
  letterSpacing: '0.05em',
206
  color: isActive ? 'var(--text)' : 'var(--muted-text)',
207
+ bgcolor: isActive ? 'var(--tab-active-bg)' : 'transparent',
208
  border: '1px solid',
209
+ borderColor: isActive ? 'var(--tab-active-border)' : 'transparent',
210
  transition: 'all 0.15s ease',
211
+ '&:hover': { bgcolor: 'var(--tab-hover-bg)' },
 
 
212
  }}
213
  >
214
+ {tabIcon(tab.id, tab.language)}
215
  <span>{tab.title}</span>
216
  <Box
217
  component="span"
 
229
  borderRadius: '50%',
230
  fontSize: '0.65rem',
231
  opacity: 0.5,
232
+ '&:hover': { opacity: 1, bgcolor: 'var(--tab-close-hover)' },
 
 
 
233
  }}
234
  >
235
 
 
239
  })}
240
  </Box>
241
  ) : (
242
+ <Typography
243
+ variant="caption"
244
+ sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
245
+ >
246
  {currentContent?.title || 'Code Panel'}
247
  </Typography>
248
  )}
249
+
250
  <IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
251
  <CloseIcon fontSize="small" />
252
  </IconButton>
253
  </Box>
254
 
255
+ {/* ── Main content area ─────────────────────────────────── */}
256
  <Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
257
  {!currentContent ? (
258
  <Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
 
266
  ref={scrollRef}
267
  className="code-panel"
268
  sx={{
269
+ bgcolor: 'var(--code-panel-bg)',
270
  borderRadius: 'var(--radius-md)',
271
+ p: '18px',
272
+ border: '1px solid var(--border)',
273
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
274
  fontSize: '13px',
275
  lineHeight: 1.55,
276
  height: '100%',
277
  overflow: 'auto',
278
  }}
279
  >
280
+ {renderContent()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  </Box>
282
  </Box>
283
  )}
284
  </Box>
285
 
286
+ {/* ── Plan display (bottom) ─────────────────────────────── */}
287
  {plan && plan.length > 0 && (
288
+ <Box
289
+ sx={{
290
+ borderTop: '1px solid var(--border)',
291
+ bgcolor: 'var(--plan-bg)',
292
  maxHeight: '30%',
293
  display: 'flex',
294
+ flexDirection: 'column',
295
+ }}
296
+ >
297
+ <Box
298
+ sx={{
299
+ p: 1.5,
300
+ borderBottom: '1px solid var(--border)',
301
+ display: 'flex',
302
+ alignItems: 'center',
303
+ gap: 1,
304
+ }}
305
+ >
306
+ <Typography
307
+ variant="caption"
308
+ sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
309
+ >
310
+ CURRENT PLAN
311
+ </Typography>
312
+ </Box>
313
+
314
+ <Stack spacing={1} sx={{ p: 2, overflow: 'auto' }}>
315
+ {plan.map((item) => (
316
+ <Stack key={item.id} direction="row" alignItems="flex-start" spacing={1.5}>
317
+ <Box sx={{ mt: 0.2 }}>
318
+ <PlanStatusIcon status={item.status} />
319
+ </Box>
320
+ <Typography
321
+ variant="body2"
322
+ sx={{
323
+ fontSize: '13px',
324
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
325
+ color: item.status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
326
+ textDecoration: item.status === 'completed' ? 'line-through' : 'none',
327
+ opacity: item.status === 'pending' ? 0.7 : 1,
328
+ }}
329
+ >
330
+ {item.content}
331
  </Typography>
332
+ </Stack>
333
+ ))}
334
+ </Stack>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  </Box>
336
  )}
337
  </Box>
frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -4,10 +4,17 @@ import {
4
  Drawer,
5
  Typography,
6
  IconButton,
 
 
 
 
7
  } from '@mui/material';
8
  import MenuIcon from '@mui/icons-material/Menu';
9
  import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
10
  import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
 
 
 
11
 
12
  import { useSessionStore } from '@/store/sessionStore';
13
  import { useAgentStore } from '@/store/agentStore';
@@ -17,49 +24,56 @@ import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
17
  import CodePanel from '@/components/CodePanel/CodePanel';
18
  import ChatInput from '@/components/Chat/ChatInput';
19
  import MessageList from '@/components/Chat/MessageList';
 
 
20
  import type { Message } from '@/types/agent';
21
 
22
  const DRAWER_WIDTH = 260;
23
 
24
  export default function AppLayout() {
25
- const { activeSessionId } = useSessionStore();
26
- const { isConnected, isProcessing, getMessages, addMessage } = useAgentStore();
27
  const {
28
  isLeftSidebarOpen,
29
  isRightPanelOpen,
30
  rightPanelWidth,
 
31
  setRightPanelWidth,
 
32
  toggleLeftSidebar,
33
- toggleRightPanel
34
  } = useLayoutStore();
35
 
36
- const isResizing = useRef(false);
37
-
38
- const startResizing = useCallback((e: React.MouseEvent) => {
39
- e.preventDefault();
40
- isResizing.current = true;
41
- document.addEventListener('mousemove', handleMouseMove);
42
- document.addEventListener('mouseup', stopResizing);
43
- document.body.style.cursor = 'col-resize';
44
- }, []);
45
 
46
- const stopResizing = useCallback(() => {
47
- isResizing.current = false;
48
- document.removeEventListener('mousemove', handleMouseMove);
49
- document.removeEventListener('mouseup', stopResizing);
50
- document.body.style.cursor = 'default';
51
- }, []);
52
 
53
  const handleMouseMove = useCallback((e: MouseEvent) => {
54
  if (!isResizing.current) return;
55
  const newWidth = window.innerWidth - e.clientX;
56
- const maxWidth = window.innerWidth * 0.8;
57
  const minWidth = 300;
58
  if (newWidth > minWidth && newWidth < maxWidth) {
59
  setRightPanelWidth(newWidth);
60
  }
61
  }, [setRightPanelWidth]);
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  useEffect(() => {
64
  return () => {
65
  document.removeEventListener('mousemove', handleMouseMove);
@@ -67,18 +81,49 @@ export default function AppLayout() {
67
  };
68
  }, [handleMouseMove, stopResizing]);
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  const messages = activeSessionId ? getMessages(activeSessionId) : [];
 
71
 
72
  useAgentWebSocket({
73
  sessionId: activeSessionId,
74
- onReady: () => console.log('Agent ready'),
75
- onError: (error) => console.error('Agent error:', error),
 
 
 
 
76
  });
77
 
78
  const handleSendMessage = useCallback(
79
  async (text: string) => {
80
- if (!activeSessionId || !text.trim()) return;
81
 
 
 
 
82
  const userMsg: Message = {
83
  id: `user_${Date.now()}`,
84
  role: 'user',
@@ -87,55 +132,127 @@ export default function AppLayout() {
87
  };
88
  addMessage(activeSessionId, userMsg);
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  try {
91
- await fetch('/api/submit', {
92
  method: 'POST',
93
- headers: { 'Content-Type': 'application/json' },
94
  body: JSON.stringify({
95
  session_id: activeSessionId,
96
  text: text.trim(),
97
  }),
98
  });
99
  } catch (e) {
100
- console.error('Send failed:', e);
101
  }
102
  },
103
- [activeSessionId, addMessage]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  );
105
 
 
106
  return (
107
  <Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
108
- {/* Left Sidebar Drawer */}
109
- <Box
110
- component="nav"
111
- sx={{
112
- width: { md: isLeftSidebarOpen ? DRAWER_WIDTH : 0 },
113
- flexShrink: { md: 0 },
114
- transition: isResizing.current ? 'none' : 'width 0.2s',
115
- overflow: 'hidden',
116
- }}
117
- >
118
- <Drawer
119
- variant="persistent"
120
  sx={{
121
- display: { xs: 'none', md: 'block' },
122
- '& .MuiDrawer-paper': {
123
- boxSizing: 'border-box',
124
- width: DRAWER_WIDTH,
125
- borderRight: '1px solid',
126
- borderColor: 'divider',
127
- top: 0,
128
- height: '100%',
129
- bgcolor: 'var(--panel)', // Ensure correct background matches sidebar
130
- },
131
  }}
132
- open={isLeftSidebarOpen}
133
  >
134
- <SessionSidebar />
135
- </Drawer>
136
- </Box>
137
 
138
- {/* Main Content Area */}
139
  <Box
140
  sx={{
141
  flexGrow: 1,
@@ -143,142 +260,188 @@ export default function AppLayout() {
143
  display: 'flex',
144
  flexDirection: 'column',
145
  transition: isResizing.current ? 'none' : 'width 0.2s',
146
- position: 'relative',
147
  overflow: 'hidden',
 
148
  }}
149
  >
150
- {/* Top Header Bar (Fixed) */}
151
  <Box sx={{
152
- height: '60px',
153
- px: 1,
154
  display: 'flex',
155
  alignItems: 'center',
156
  borderBottom: 1,
157
  borderColor: 'divider',
158
  bgcolor: 'background.default',
159
  zIndex: 1200,
 
160
  }}>
161
  <IconButton onClick={toggleLeftSidebar} size="small">
162
- {isLeftSidebarOpen ? <ChevronLeftIcon /> : <MenuIcon />}
163
  </IconButton>
164
 
165
- <Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
166
- <img
167
- src="/hf-logo-white.png"
168
- alt="Hugging Face"
169
- style={{ height: '40px', objectFit: 'contain' }}
 
170
  />
 
 
 
 
 
 
 
 
 
 
 
171
  </Box>
172
 
173
- <IconButton
174
- onClick={toggleRightPanel}
175
- size="small"
176
- sx={{ visibility: isRightPanelOpen ? 'hidden' : 'visible' }}
 
 
 
177
  >
178
- <MenuIcon />
179
  </IconButton>
180
  </Box>
181
 
 
 
 
 
182
  <Box
183
- component="main"
184
- className="chat-pane"
185
  sx={{
186
  flexGrow: 1,
187
  display: 'flex',
188
- flexDirection: 'column',
189
  overflow: 'hidden',
190
- background: 'linear-gradient(180deg, var(--bg), var(--panel))',
191
- padding: '24px',
192
  }}
193
  >
194
- {activeSessionId ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  <>
196
- <MessageList messages={messages} isProcessing={isProcessing} />
197
- <ChatInput
198
- onSend={handleSendMessage}
199
- disabled={isProcessing || !isConnected}
200
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  </>
202
- ) : (
203
- <Box
204
- sx={{
205
- flex: 1,
206
- display: 'flex',
207
- alignItems: 'center',
208
- justifyContent: 'center',
209
- flexDirection: 'column',
210
- gap: 2,
211
- }}
212
- >
213
- <Typography variant="h5" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
214
- NO SESSION SELECTED
215
- </Typography>
216
- <Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
217
- Initialize a session via the sidebar
218
- </Typography>
219
- </Box>
220
  )}
221
  </Box>
222
  </Box>
223
 
224
- {/* Resize Handle */}
225
- {isRightPanelOpen && (
226
- <Box
227
- onMouseDown={startResizing}
228
- sx={{
229
- width: '4px',
230
- cursor: 'col-resize',
231
- bgcolor: 'divider',
232
- display: 'flex',
233
- alignItems: 'center',
234
- justifyContent: 'center',
235
- transition: 'background-color 0.2s',
236
- zIndex: 1300,
237
- overflow: 'hidden',
238
- '&:hover': {
239
- bgcolor: 'primary.main',
240
- },
241
- }}
242
- >
243
- <DragIndicatorIcon
244
- sx={{
245
- fontSize: '0.8rem',
246
- color: 'text.secondary',
247
- pointerEvents: 'none',
248
- }}
249
- />
250
- </Box>
251
- )}
252
-
253
- {/* Right Panel Drawer */}
254
- <Box
255
- component="nav"
256
- sx={{
257
- width: { md: isRightPanelOpen ? rightPanelWidth : 0 },
258
- flexShrink: { md: 0 },
259
- transition: isResizing.current ? 'none' : 'width 0.2s',
260
- overflow: 'hidden',
261
- }}
262
- >
263
  <Drawer
264
- anchor="right"
265
- variant="persistent"
 
266
  sx={{
267
- display: { xs: 'none', md: 'block' },
268
  '& .MuiDrawer-paper': {
269
- boxSizing: 'border-box',
270
- width: rightPanelWidth,
271
- borderLeft: 'none',
272
- top: 0,
273
- height: '100%',
274
  bgcolor: 'var(--panel)',
275
  },
276
  }}
277
- open={isRightPanelOpen}
278
  >
279
  <CodePanel />
280
  </Drawer>
281
- </Box>
282
  </Box>
283
  );
284
  }
 
4
  Drawer,
5
  Typography,
6
  IconButton,
7
+ Alert,
8
+ AlertTitle,
9
+ useMediaQuery,
10
+ useTheme,
11
  } from '@mui/material';
12
  import MenuIcon from '@mui/icons-material/Menu';
13
  import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
14
  import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
15
+ import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
16
+ import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
17
+ import { logger } from '@/utils/logger';
18
 
19
  import { useSessionStore } from '@/store/sessionStore';
20
  import { useAgentStore } from '@/store/agentStore';
 
24
  import CodePanel from '@/components/CodePanel/CodePanel';
25
  import ChatInput from '@/components/Chat/ChatInput';
26
  import MessageList from '@/components/Chat/MessageList';
27
+ import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
28
+ import { apiFetch } from '@/utils/api';
29
  import type { Message } from '@/types/agent';
30
 
31
  const DRAWER_WIDTH = 260;
32
 
33
  export default function AppLayout() {
34
+ const { sessions, activeSessionId, deleteSession, updateSessionTitle } = useSessionStore();
35
+ const { isConnected, isProcessing, getMessages, addMessage, setProcessing, llmHealthError, setLlmHealthError } = useAgentStore();
36
  const {
37
  isLeftSidebarOpen,
38
  isRightPanelOpen,
39
  rightPanelWidth,
40
+ themeMode,
41
  setRightPanelWidth,
42
+ setLeftSidebarOpen,
43
  toggleLeftSidebar,
44
+ toggleTheme,
45
  } = useLayoutStore();
46
 
47
+ const theme = useTheme();
48
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
 
 
 
 
 
 
 
49
 
50
+ const isResizing = useRef(false);
 
 
 
 
 
51
 
52
  const handleMouseMove = useCallback((e: MouseEvent) => {
53
  if (!isResizing.current) return;
54
  const newWidth = window.innerWidth - e.clientX;
55
+ const maxWidth = window.innerWidth * 0.6;
56
  const minWidth = 300;
57
  if (newWidth > minWidth && newWidth < maxWidth) {
58
  setRightPanelWidth(newWidth);
59
  }
60
  }, [setRightPanelWidth]);
61
 
62
+ const stopResizing = useCallback(() => {
63
+ isResizing.current = false;
64
+ document.removeEventListener('mousemove', handleMouseMove);
65
+ document.removeEventListener('mouseup', stopResizing);
66
+ document.body.style.cursor = 'default';
67
+ }, [handleMouseMove]);
68
+
69
+ const startResizing = useCallback((e: React.MouseEvent) => {
70
+ e.preventDefault();
71
+ isResizing.current = true;
72
+ document.addEventListener('mousemove', handleMouseMove);
73
+ document.addEventListener('mouseup', stopResizing);
74
+ document.body.style.cursor = 'col-resize';
75
+ }, [handleMouseMove, stopResizing]);
76
+
77
  useEffect(() => {
78
  return () => {
79
  document.removeEventListener('mousemove', handleMouseMove);
 
81
  };
82
  }, [handleMouseMove, stopResizing]);
83
 
84
+ // ── LLM health check on mount ───────────────────────────────────
85
+ useEffect(() => {
86
+ let cancelled = false;
87
+ (async () => {
88
+ try {
89
+ const res = await apiFetch('/api/health/llm');
90
+ const data = await res.json();
91
+ if (!cancelled && data.status === 'error') {
92
+ setLlmHealthError({
93
+ error: data.error || 'Unknown LLM error',
94
+ errorType: data.error_type || 'unknown',
95
+ model: data.model,
96
+ });
97
+ } else if (!cancelled) {
98
+ setLlmHealthError(null);
99
+ }
100
+ } catch {
101
+ // Backend unreachable — not an LLM issue, ignore
102
+ }
103
+ })();
104
+ return () => { cancelled = true; };
105
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
106
+
107
  const messages = activeSessionId ? getMessages(activeSessionId) : [];
108
+ const hasAnySessions = sessions.length > 0;
109
 
110
  useAgentWebSocket({
111
  sessionId: activeSessionId,
112
+ onReady: () => logger.log('Agent ready'),
113
+ onError: (error) => logger.error('Agent error:', error),
114
+ onSessionDead: (deadSessionId) => {
115
+ logger.log('Removing dead session:', deadSessionId);
116
+ deleteSession(deadSessionId);
117
+ },
118
  });
119
 
120
  const handleSendMessage = useCallback(
121
  async (text: string) => {
122
+ if (!activeSessionId || !text.trim() || isProcessing) return;
123
 
124
+ // Lock input immediately to prevent double-sends
125
+ setProcessing(true);
126
+
127
  const userMsg: Message = {
128
  id: `user_${Date.now()}`,
129
  role: 'user',
 
132
  };
133
  addMessage(activeSessionId, userMsg);
134
 
135
+ // Auto-title the session from the first user message (async, non-blocking)
136
+ const currentMessages = getMessages(activeSessionId);
137
+ const isFirstMessage = currentMessages.filter((m) => m.role === 'user').length <= 1;
138
+ if (isFirstMessage) {
139
+ const sessionId = activeSessionId;
140
+ apiFetch('/api/title', {
141
+ method: 'POST',
142
+ body: JSON.stringify({ session_id: sessionId, text: text.trim() }),
143
+ })
144
+ .then((res) => res.json())
145
+ .then((data) => {
146
+ if (data.title) updateSessionTitle(sessionId, data.title);
147
+ })
148
+ .catch(() => {
149
+ const raw = text.trim();
150
+ updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '…' : raw);
151
+ });
152
+ }
153
+
154
  try {
155
+ await apiFetch('/api/submit', {
156
  method: 'POST',
 
157
  body: JSON.stringify({
158
  session_id: activeSessionId,
159
  text: text.trim(),
160
  }),
161
  });
162
  } catch (e) {
163
+ logger.error('Send failed:', e);
164
  }
165
  },
166
+ [activeSessionId, addMessage, getMessages, updateSessionTitle, isProcessing, setProcessing]
167
+ );
168
+
169
+ // Close sidebar on mobile after selecting a session
170
+ const handleSidebarClose = useCallback(() => {
171
+ if (isMobile) setLeftSidebarOpen(false);
172
+ }, [isMobile, setLeftSidebarOpen]);
173
+
174
+ // ── LLM error banner (shared) ─────────────────────────────────────
175
+ const llmBanner = llmHealthError && (
176
+ <Alert
177
+ severity="error"
178
+ variant="filled"
179
+ onClose={() => setLlmHealthError(null)}
180
+ sx={{ borderRadius: 0, flexShrink: 0, '& .MuiAlert-message': { flex: 1 } }}
181
+ >
182
+ <AlertTitle sx={{ fontWeight: 700, fontSize: '0.85rem' }}>
183
+ {llmHealthError.errorType === 'credits'
184
+ ? 'API Credits Exhausted'
185
+ : llmHealthError.errorType === 'auth'
186
+ ? 'Invalid API Key'
187
+ : llmHealthError.errorType === 'rate_limit'
188
+ ? 'Rate Limited'
189
+ : llmHealthError.errorType === 'network'
190
+ ? 'LLM Provider Unreachable'
191
+ : 'LLM Error'}
192
+ </AlertTitle>
193
+ <Typography variant="body2" sx={{ fontSize: '0.8rem', opacity: 0.9 }}>
194
+ Model: <strong>{llmHealthError.model}</strong> — {llmHealthError.error.slice(0, 200)}
195
+ </Typography>
196
+ </Alert>
197
+ );
198
+
199
+ // ── Welcome screen: no sessions at all ────────────────────────────
200
+ if (!hasAnySessions) {
201
+ return (
202
+ <Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
203
+ {llmBanner}
204
+ <WelcomeScreen />
205
+ </Box>
206
+ );
207
+ }
208
+
209
+ // ── Sidebar drawer ────────────────────────────────────────────────
210
+ const sidebarDrawer = (
211
+ <Drawer
212
+ variant={isMobile ? 'temporary' : 'persistent'}
213
+ anchor="left"
214
+ open={isLeftSidebarOpen}
215
+ onClose={() => setLeftSidebarOpen(false)}
216
+ ModalProps={{ keepMounted: true }} // Better mobile perf
217
+ sx={{
218
+ '& .MuiDrawer-paper': {
219
+ boxSizing: 'border-box',
220
+ width: DRAWER_WIDTH,
221
+ borderRight: '1px solid',
222
+ borderColor: 'divider',
223
+ top: 0,
224
+ height: '100%',
225
+ bgcolor: 'var(--panel)',
226
+ },
227
+ }}
228
+ >
229
+ <SessionSidebar onClose={handleSidebarClose} />
230
+ </Drawer>
231
  );
232
 
233
+ // ── Main chat interface ───────────────────────────────────────────
234
  return (
235
  <Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
236
+ {/* ── Left Sidebar ─────────────────────────────────────────── */}
237
+ {isMobile ? (
238
+ // Mobile: temporary overlay drawer (no reserved width)
239
+ sidebarDrawer
240
+ ) : (
241
+ // Desktop: persistent drawer with reserved width
242
+ <Box
243
+ component="nav"
 
 
 
 
244
  sx={{
245
+ width: isLeftSidebarOpen ? DRAWER_WIDTH : 0,
246
+ flexShrink: 0,
247
+ transition: isResizing.current ? 'none' : 'width 0.2s',
248
+ overflow: 'hidden',
 
 
 
 
 
 
249
  }}
 
250
  >
251
+ {sidebarDrawer}
252
+ </Box>
253
+ )}
254
 
255
+ {/* ── Main Content (header + chat + code panel) ────────────── */}
256
  <Box
257
  sx={{
258
  flexGrow: 1,
 
260
  display: 'flex',
261
  flexDirection: 'column',
262
  transition: isResizing.current ? 'none' : 'width 0.2s',
 
263
  overflow: 'hidden',
264
+ minWidth: 0,
265
  }}
266
  >
267
+ {/* ── Top Header Bar ─────────────────────────────────────── */}
268
  <Box sx={{
269
+ height: { xs: 52, md: 60 },
270
+ px: { xs: 1, md: 2 },
271
  display: 'flex',
272
  alignItems: 'center',
273
  borderBottom: 1,
274
  borderColor: 'divider',
275
  bgcolor: 'background.default',
276
  zIndex: 1200,
277
+ flexShrink: 0,
278
  }}>
279
  <IconButton onClick={toggleLeftSidebar} size="small">
280
+ {isLeftSidebarOpen && !isMobile ? <ChevronLeftIcon /> : <MenuIcon />}
281
  </IconButton>
282
 
283
+ <Box sx={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 0.75 }}>
284
+ <Box
285
+ component="img"
286
+ src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
287
+ alt="HF"
288
+ sx={{ width: { xs: 20, md: 22 }, height: { xs: 20, md: 22 } }}
289
  />
290
+ <Typography
291
+ variant="subtitle1"
292
+ sx={{
293
+ fontWeight: 700,
294
+ color: 'var(--text)',
295
+ letterSpacing: '-0.01em',
296
+ fontSize: { xs: '0.88rem', md: '0.95rem' },
297
+ }}
298
+ >
299
+ HF Agent
300
+ </Typography>
301
  </Box>
302
 
303
+ <IconButton
304
+ onClick={toggleTheme}
305
+ size="small"
306
+ sx={{
307
+ color: 'text.secondary',
308
+ '&:hover': { color: 'primary.main' },
309
+ }}
310
  >
311
+ {themeMode === 'dark' ? <LightModeOutlinedIcon fontSize="small" /> : <DarkModeOutlinedIcon fontSize="small" />}
312
  </IconButton>
313
  </Box>
314
 
315
+ {/* ── LLM Health Error Banner ────────────────────────────── */}
316
+ {llmBanner}
317
+
318
+ {/* ── Chat + Code Panel ──────────────────────────────────── */}
319
  <Box
 
 
320
  sx={{
321
  flexGrow: 1,
322
  display: 'flex',
 
323
  overflow: 'hidden',
 
 
324
  }}
325
  >
326
+ {/* Chat area */}
327
+ <Box
328
+ component="main"
329
+ className="chat-pane"
330
+ sx={{
331
+ flexGrow: 1,
332
+ display: 'flex',
333
+ flexDirection: 'column',
334
+ overflow: 'hidden',
335
+ background: 'var(--body-gradient)',
336
+ p: { xs: 1.5, sm: 2, md: 3 },
337
+ minWidth: 0,
338
+ }}
339
+ >
340
+ {activeSessionId ? (
341
+ <>
342
+ <MessageList messages={messages} isProcessing={isProcessing} />
343
+ {!isConnected && messages.length > 0 && (
344
+ <Box sx={{
345
+ display: 'flex',
346
+ alignItems: 'center',
347
+ justifyContent: 'center',
348
+ gap: 1,
349
+ py: 1,
350
+ px: { xs: 1, md: 2 },
351
+ mb: 1,
352
+ borderRadius: 'var(--radius-md)',
353
+ bgcolor: 'rgba(255, 171, 0, 0.08)',
354
+ border: '1px solid rgba(255, 171, 0, 0.2)',
355
+ }}>
356
+ <Typography variant="body2" sx={{ color: 'var(--accent-yellow)', fontFamily: 'monospace', fontSize: { xs: '0.7rem', md: '0.8rem' } }}>
357
+ Session expired — create a new session to continue.
358
+ </Typography>
359
+ </Box>
360
+ )}
361
+ <ChatInput
362
+ onSend={handleSendMessage}
363
+ disabled={isProcessing || !isConnected}
364
+ />
365
+ </>
366
+ ) : (
367
+ <Box
368
+ sx={{
369
+ flex: 1,
370
+ display: 'flex',
371
+ alignItems: 'center',
372
+ justifyContent: 'center',
373
+ flexDirection: 'column',
374
+ gap: 2,
375
+ px: 2,
376
+ }}
377
+ >
378
+ <Typography variant="h5" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '1rem', md: '1.5rem' } }}>
379
+ NO SESSION SELECTED
380
+ </Typography>
381
+ <Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '0.75rem', md: '0.875rem' } }}>
382
+ Initialize a session via the sidebar
383
+ </Typography>
384
+ </Box>
385
+ )}
386
+ </Box>
387
+
388
+ {/* Code panel — inline on desktop, overlay drawer on mobile */}
389
+ {isRightPanelOpen && !isMobile && (
390
  <>
391
+ <Box
392
+ onMouseDown={startResizing}
393
+ sx={{
394
+ width: '4px',
395
+ cursor: 'col-resize',
396
+ bgcolor: 'divider',
397
+ display: 'flex',
398
+ alignItems: 'center',
399
+ justifyContent: 'center',
400
+ transition: 'background-color 0.2s',
401
+ flexShrink: 0,
402
+ '&:hover': { bgcolor: 'primary.main' },
403
+ }}
404
+ >
405
+ <DragIndicatorIcon
406
+ sx={{ fontSize: '0.8rem', color: 'text.secondary', pointerEvents: 'none' }}
407
+ />
408
+ </Box>
409
+ <Box
410
+ sx={{
411
+ width: rightPanelWidth,
412
+ flexShrink: 0,
413
+ height: '100%',
414
+ overflow: 'hidden',
415
+ borderLeft: '1px solid',
416
+ borderColor: 'divider',
417
+ bgcolor: 'var(--panel)',
418
+ }}
419
+ >
420
+ <CodePanel />
421
+ </Box>
422
  </>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  )}
424
  </Box>
425
  </Box>
426
 
427
+ {/* Code panel — drawer overlay on mobile */}
428
+ {isMobile && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  <Drawer
430
+ anchor="bottom"
431
+ open={isRightPanelOpen}
432
+ onClose={() => useLayoutStore.getState().setRightPanelOpen(false)}
433
  sx={{
 
434
  '& .MuiDrawer-paper': {
435
+ height: '75vh',
436
+ borderTopLeftRadius: 16,
437
+ borderTopRightRadius: 16,
 
 
438
  bgcolor: 'var(--panel)',
439
  },
440
  }}
 
441
  >
442
  <CodePanel />
443
  </Drawer>
444
+ )}
445
  </Box>
446
  );
447
  }
frontend/src/components/SessionSidebar/SessionSidebar.tsx CHANGED
@@ -1,246 +1,344 @@
1
- import { useCallback } from 'react';
2
  import {
 
3
  Box,
4
- List,
5
- ListItem,
6
  IconButton,
7
  Typography,
8
- Button,
9
- Tooltip,
10
  } from '@mui/material';
11
- import DeleteIcon from '@mui/icons-material/Delete';
12
- import UndoIcon from '@mui/icons-material/Undo';
 
13
  import { useSessionStore } from '@/store/sessionStore';
14
  import { useAgentStore } from '@/store/agentStore';
 
15
 
16
  interface SessionSidebarProps {
17
  onClose?: () => void;
18
  }
19
 
20
- const StatusDiode = ({ connected }: { connected: boolean }) => (
 
21
  <Box
22
  sx={{
23
- width: 10,
24
- height: 10,
25
  borderRadius: '50%',
26
- bgcolor: connected ? 'var(--accent-green)' : 'var(--accent-red)', // Use green/red for connection status
27
- boxShadow: connected ? '0 0 6px rgba(47, 204, 113, 0.4)' : 'none',
28
- transition: 'all 0.3s ease',
29
  }}
30
  />
31
  );
32
 
33
- const RunningIndicator = () => (
34
- <Box
35
- className="running-indicator"
36
- sx={{
37
- width: 10,
38
- height: 10,
39
- borderRadius: '50%',
40
- bgcolor: 'var(--accent-yellow)',
41
- boxShadow: '0 0 6px rgba(199,165,0,0.18)',
42
- }}
43
- />
44
- );
45
-
46
  export default function SessionSidebar({ onClose }: SessionSidebarProps) {
47
  const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
48
  useSessionStore();
49
- const { clearMessages, isConnected, isProcessing, setPlan, setPanelContent } = useAgentStore();
 
 
 
 
 
50
 
51
  const handleNewSession = useCallback(async () => {
 
 
 
52
  try {
53
- const response = await fetch('/api/session', { method: 'POST' });
 
 
 
 
 
54
  const data = await response.json();
55
  createSession(data.session_id);
56
- // Clear plan and code panel for new session
57
  setPlan([]);
58
  setPanelContent(null);
59
  onClose?.();
60
- } catch (e) {
61
- console.error('Failed to create session:', e);
 
 
62
  }
63
- }, [createSession, setPlan, setPanelContent, onClose]);
64
 
65
- const handleDeleteSession = useCallback(
66
  async (sessionId: string, e: React.MouseEvent) => {
67
  e.stopPropagation();
68
  try {
69
- await fetch(`/api/session/${sessionId}`, { method: 'DELETE' });
 
 
 
70
  deleteSession(sessionId);
71
- clearMessages(sessionId);
72
- } catch (e) {
73
- console.error('Failed to delete session:', e);
74
  }
75
  },
76
- [deleteSession, clearMessages]
77
  );
78
 
79
- const handleSelectSession = useCallback(
80
  (sessionId: string) => {
81
  switchSession(sessionId);
82
- // Clear plan and code panel when switching sessions
83
  setPlan([]);
84
  setPanelContent(null);
85
  onClose?.();
86
  },
87
- [switchSession, setPlan, setPanelContent, onClose]
88
  );
89
 
90
- const handleUndo = useCallback(async () => {
91
- if (!activeSessionId) return;
92
- try {
93
- await fetch(`/api/undo/${activeSessionId}`, { method: 'POST' });
94
- } catch (e) {
95
- console.error('Undo failed:', e);
96
- }
97
- }, [activeSessionId]);
98
 
99
- const formatTime = (dateString: string) => {
100
- return new Date(dateString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
101
- };
102
 
103
  return (
104
- <Box className="sidebar" sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
105
- {/* Header - Aligned with AppLayout 60px */}
106
- <Box sx={{
107
- height: '60px',
108
- display: 'flex',
109
- alignItems: 'center',
110
- px: 2,
111
- borderBottom: '1px solid rgba(255,255,255,0.03)'
112
- }}>
113
- <Box className="brand-logo" sx={{ display: 'flex' }}>
114
- <img
115
- src="/hf-log-only-white.png"
116
- alt="HF Agent"
117
- style={{ height: '24px', objectFit: 'contain' }}
118
- />
119
- </Box>
 
 
 
 
 
 
120
  </Box>
121
 
122
- {/* Content */}
123
- <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', p: 2, overflow: 'hidden' }}>
124
- {/* System Info / Status */}
125
- <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
126
- <StatusDiode connected={isConnected} />
127
- <Typography variant="caption" sx={{ color: 'var(--muted-text)', fontFamily: 'inherit' }}>
128
- {isConnected ? 'System Online' : 'Disconnected'}
129
- </Typography>
130
- </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
- <Button
133
- fullWidth
134
- className="create-session"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  onClick={handleNewSession}
 
136
  sx={{
137
  display: 'inline-flex',
138
  alignItems: 'center',
139
- justifyContent: 'flex-start',
140
- gap: '10px',
141
- padding: '10px 14px',
142
- borderRadius: 'var(--radius-md)',
143
- border: '1px solid rgba(255,255,255,0.06)',
144
- bgcolor: 'transparent',
145
- color: 'var(--text)',
146
- fontWeight: 600,
147
- textTransform: 'none',
148
- mb: 3,
 
 
 
149
  '&:hover': {
150
- bgcolor: 'rgba(255,255,255,0.02)',
151
- border: '1px solid rgba(255,255,255,0.1)',
 
 
 
152
  },
153
- '&::before': {
154
- content: '""',
155
- width: '4px',
156
- height: '20px',
157
- background: 'linear-gradient(180deg, var(--accent-yellow), rgba(199,165,0,0.9))',
158
- borderRadius: '4px',
159
- }
160
  }}
161
  >
162
- New Session
163
- </Button>
164
-
165
- {/* Session List */}
166
- <Box sx={{ flex: 1, overflow: 'auto', mx: -1, px: 1 }}>
167
- <List disablePadding sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
168
- {[...sessions].reverse().map((session, index) => {
169
- const sessionNumber = sessions.length - index;
170
- const isSelected = session.id === activeSessionId;
171
- return (
172
- <ListItem
173
- key={session.id}
174
- disablePadding
175
- className="session-item"
176
- onClick={() => handleSelectSession(session.id)}
177
- sx={{
178
- display: 'flex',
179
- alignItems: 'center',
180
- gap: '12px',
181
- padding: '10px',
182
- borderRadius: 'var(--radius-md)',
183
- bgcolor: isSelected ? 'rgba(255,255,255,0.05)' : 'transparent',
184
- cursor: 'pointer',
185
- transition: 'background 0.18s ease, transform 0.08s ease',
186
- '&:hover': {
187
- bgcolor: 'rgba(255,255,255,0.02)',
188
- transform: 'translateY(-1px)',
189
- },
190
- '& .delete-btn': {
191
- opacity: 0,
192
- transition: 'opacity 0.2s',
193
- },
194
- '&:hover .delete-btn': {
195
- opacity: 1,
196
- }
197
- }}
198
- >
199
- <Box sx={{ flex: 1, overflow: 'hidden' }}>
200
- <Typography variant="body2" sx={{ fontWeight: 500, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
201
- Session {String(sessionNumber).padStart(2, '0')}
202
- </Typography>
203
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
204
- {session.isActive && <RunningIndicator />}
205
- <Typography className="time" variant="caption" sx={{ fontSize: '12px', color: 'var(--muted-text)' }}>
206
- {formatTime(session.createdAt)}
207
- </Typography>
208
- </Box>
209
- </Box>
210
-
211
- <IconButton
212
- className="delete-btn"
213
- size="small"
214
- onClick={(e) => handleDeleteSession(session.id, e)}
215
- sx={{ color: 'var(--muted-text)', '&:hover': { color: 'var(--accent-red)' } }}
216
- >
217
- <DeleteIcon fontSize="small" />
218
- </IconButton>
219
- </ListItem>
220
- );
221
- })}
222
- </List>
223
  </Box>
224
- </Box>
225
 
226
- {/* Footer */}
227
- <Box sx={{ p: 2, borderTop: '1px solid rgba(255,255,255,0.03)' }}>
228
- <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
229
- <Typography variant="caption" className="small-note" sx={{ fontSize: '12px', color: 'var(--muted-text)' }}>
230
- {sessions.length} active
231
- </Typography>
232
- <Tooltip title="Undo last turn">
233
- <span>
234
- <IconButton
235
- onClick={handleUndo}
236
- disabled={!activeSessionId || isProcessing}
237
- size="small"
238
- sx={{ color: 'var(--muted-text)', '&:hover': { color: 'var(--text)' } }}
239
- >
240
- <UndoIcon fontSize="small" />
241
- </IconButton>
242
- </span>
243
- </Tooltip>
244
  </Box>
245
  </Box>
246
  </Box>
 
1
+ import { useCallback, useState } from 'react';
2
  import {
3
+ Alert,
4
  Box,
 
 
5
  IconButton,
6
  Typography,
7
+ CircularProgress,
8
+ Divider,
9
  } from '@mui/material';
10
+ import AddIcon from '@mui/icons-material/Add';
11
+ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
12
+ import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
13
  import { useSessionStore } from '@/store/sessionStore';
14
  import { useAgentStore } from '@/store/agentStore';
15
+ import { apiFetch } from '@/utils/api';
16
 
17
  interface SessionSidebarProps {
18
  onClose?: () => void;
19
  }
20
 
21
+ /** Small coloured dot for connection status */
22
+ const StatusDot = ({ connected }: { connected: boolean }) => (
23
  <Box
24
  sx={{
25
+ width: 6,
26
+ height: 6,
27
  borderRadius: '50%',
28
+ bgcolor: connected ? 'var(--accent-green)' : 'var(--accent-red)',
29
+ boxShadow: connected ? '0 0 4px rgba(76,175,80,0.4)' : 'none',
30
+ flexShrink: 0,
31
  }}
32
  />
33
  );
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  export default function SessionSidebar({ onClose }: SessionSidebarProps) {
36
  const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
37
  useSessionStore();
38
+ const { isConnected, setPlan, setPanelContent } =
39
+ useAgentStore();
40
+ const [isCreatingSession, setIsCreatingSession] = useState(false);
41
+ const [capacityError, setCapacityError] = useState<string | null>(null);
42
+
43
+ // ── Handlers ──────────────────────────────────────────────────────
44
 
45
  const handleNewSession = useCallback(async () => {
46
+ if (isCreatingSession) return;
47
+ setIsCreatingSession(true);
48
+ setCapacityError(null);
49
  try {
50
+ const response = await apiFetch('/api/session', { method: 'POST' });
51
+ if (response.status === 503) {
52
+ const data = await response.json();
53
+ setCapacityError(data.detail || 'Server is at capacity.');
54
+ return;
55
+ }
56
  const data = await response.json();
57
  createSession(data.session_id);
 
58
  setPlan([]);
59
  setPanelContent(null);
60
  onClose?.();
61
+ } catch {
62
+ setCapacityError('Failed to create session.');
63
+ } finally {
64
+ setIsCreatingSession(false);
65
  }
66
+ }, [isCreatingSession, createSession, setPlan, setPanelContent, onClose]);
67
 
68
+ const handleDelete = useCallback(
69
  async (sessionId: string, e: React.MouseEvent) => {
70
  e.stopPropagation();
71
  try {
72
+ await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
73
+ deleteSession(sessionId);
74
+ } catch {
75
+ // Delete locally even if backend fails (session may already be gone)
76
  deleteSession(sessionId);
 
 
 
77
  }
78
  },
79
+ [deleteSession],
80
  );
81
 
82
+ const handleSelect = useCallback(
83
  (sessionId: string) => {
84
  switchSession(sessionId);
 
85
  setPlan([]);
86
  setPanelContent(null);
87
  onClose?.();
88
  },
89
+ [switchSession, setPlan, setPanelContent, onClose],
90
  );
91
 
92
+ const formatTime = (d: string) =>
93
+ new Date(d).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
 
 
 
 
 
 
94
 
95
+ // ── Render ────────────────────────────────────────────────────────
 
 
96
 
97
  return (
98
+ <Box
99
+ sx={{
100
+ height: '100%',
101
+ display: 'flex',
102
+ flexDirection: 'column',
103
+ bgcolor: 'var(--panel)',
104
+ }}
105
+ >
106
+ {/* ── Header ─────────────────────────────────────────────────── */}
107
+ <Box sx={{ px: 1.75, pt: 2, pb: 0 }}>
108
+ <Typography
109
+ variant="caption"
110
+ sx={{
111
+ color: 'var(--muted-text)',
112
+ fontSize: '0.65rem',
113
+ fontWeight: 600,
114
+ textTransform: 'uppercase',
115
+ letterSpacing: '0.08em',
116
+ }}
117
+ >
118
+ Recent chats
119
+ </Typography>
120
  </Box>
121
 
122
+ {/* ── Capacity error ─────────────────────────────────────────── */}
123
+ {capacityError && (
124
+ <Alert
125
+ severity="warning"
126
+ variant="outlined"
127
+ onClose={() => setCapacityError(null)}
128
+ sx={{
129
+ m: 1,
130
+ fontSize: '0.7rem',
131
+ py: 0.25,
132
+ '& .MuiAlert-message': { py: 0 },
133
+ borderColor: '#FF9D00',
134
+ color: 'var(--text)',
135
+ }}
136
+ >
137
+ {capacityError}
138
+ </Alert>
139
+ )}
140
+
141
+ {/* ── Session list ───────────────────────────────────────────── */}
142
+ <Box
143
+ sx={{
144
+ flex: 1,
145
+ overflow: 'auto',
146
+ py: 1,
147
+ // Thinner scrollbar
148
+ '&::-webkit-scrollbar': { width: 4 },
149
+ '&::-webkit-scrollbar-thumb': {
150
+ bgcolor: 'var(--scrollbar-thumb)',
151
+ borderRadius: 2,
152
+ },
153
+ }}
154
+ >
155
+ {sessions.length === 0 ? (
156
+ <Box
157
+ sx={{
158
+ display: 'flex',
159
+ flexDirection: 'column',
160
+ alignItems: 'center',
161
+ justifyContent: 'center',
162
+ py: 8,
163
+ px: 3,
164
+ gap: 1.5,
165
+ }}
166
+ >
167
+ <ChatBubbleOutlineIcon
168
+ sx={{ fontSize: 28, color: 'var(--muted-text)', opacity: 0.25 }}
169
+ />
170
+ <Typography
171
+ variant="caption"
172
+ sx={{
173
+ color: 'var(--muted-text)',
174
+ opacity: 0.5,
175
+ textAlign: 'center',
176
+ lineHeight: 1.5,
177
+ fontSize: '0.72rem',
178
+ }}
179
+ >
180
+ No sessions yet
181
+ </Typography>
182
+ </Box>
183
+ ) : (
184
+ [...sessions].reverse().map((session, index) => {
185
+ const num = sessions.length - index;
186
+ const isSelected = session.id === activeSessionId;
187
 
188
+ return (
189
+ <Box
190
+ key={session.id}
191
+ onClick={() => handleSelect(session.id)}
192
+ sx={{
193
+ display: 'flex',
194
+ alignItems: 'center',
195
+ gap: 1,
196
+ px: 1.5,
197
+ py: 0.875,
198
+ mx: 0.75,
199
+ borderRadius: '10px',
200
+ cursor: 'pointer',
201
+ transition: 'background-color 0.12s ease',
202
+ bgcolor: isSelected
203
+ ? 'var(--hover-bg)'
204
+ : 'transparent',
205
+ '&:hover': {
206
+ bgcolor: 'var(--hover-bg)',
207
+ },
208
+ '& .delete-btn': {
209
+ opacity: 0,
210
+ transition: 'opacity 0.12s',
211
+ },
212
+ '&:hover .delete-btn': {
213
+ opacity: 1,
214
+ },
215
+ }}
216
+ >
217
+ <ChatBubbleOutlineIcon
218
+ sx={{
219
+ fontSize: 15,
220
+ color: isSelected ? 'var(--text)' : 'var(--muted-text)',
221
+ opacity: isSelected ? 0.8 : 0.4,
222
+ flexShrink: 0,
223
+ }}
224
+ />
225
+
226
+ <Box sx={{ flex: 1, minWidth: 0 }}>
227
+ <Typography
228
+ variant="body2"
229
+ sx={{
230
+ fontWeight: isSelected ? 600 : 400,
231
+ color: 'var(--text)',
232
+ fontSize: '0.84rem',
233
+ lineHeight: 1.4,
234
+ whiteSpace: 'nowrap',
235
+ overflow: 'hidden',
236
+ textOverflow: 'ellipsis',
237
+ }}
238
+ >
239
+ {session.title.startsWith('Chat ') ? `Session ${String(num).padStart(2, '0')}` : session.title}
240
+ </Typography>
241
+ <Typography
242
+ variant="caption"
243
+ sx={{
244
+ color: 'var(--muted-text)',
245
+ fontSize: '0.65rem',
246
+ lineHeight: 1.2,
247
+ }}
248
+ >
249
+ {formatTime(session.createdAt)}
250
+ </Typography>
251
+ </Box>
252
+
253
+ <IconButton
254
+ className="delete-btn"
255
+ size="small"
256
+ onClick={(e) => handleDelete(session.id, e)}
257
+ sx={{
258
+ color: 'var(--muted-text)',
259
+ width: 26,
260
+ height: 26,
261
+ flexShrink: 0,
262
+ '&:hover': { color: 'var(--accent-red)', bgcolor: 'rgba(244,67,54,0.08)' },
263
+ }}
264
+ >
265
+ <DeleteOutlineIcon sx={{ fontSize: 15 }} />
266
+ </IconButton>
267
+ </Box>
268
+ );
269
+ })
270
+ )}
271
+ </Box>
272
+
273
+ {/* ── Footer: New Session + status ──────────────────────────── */}
274
+ <Divider sx={{ opacity: 0.5 }} />
275
+ <Box
276
+ sx={{
277
+ px: 1.5,
278
+ py: 1.5,
279
+ display: 'flex',
280
+ flexDirection: 'column',
281
+ gap: 1,
282
+ flexShrink: 0,
283
+ }}
284
+ >
285
+ <Box
286
+ component="button"
287
  onClick={handleNewSession}
288
+ disabled={isCreatingSession}
289
  sx={{
290
  display: 'inline-flex',
291
  alignItems: 'center',
292
+ justifyContent: 'center',
293
+ gap: 0.75,
294
+ width: '100%',
295
+ px: 1.5,
296
+ py: 1.25,
297
+ border: 'none',
298
+ borderRadius: '10px',
299
+ bgcolor: '#FF9D00',
300
+ color: '#000',
301
+ fontSize: '0.85rem',
302
+ fontWeight: 700,
303
+ cursor: 'pointer',
304
+ transition: 'all 0.12s ease',
305
  '&:hover': {
306
+ bgcolor: '#FFB340',
307
+ },
308
+ '&:disabled': {
309
+ opacity: 0.5,
310
+ cursor: 'not-allowed',
311
  },
 
 
 
 
 
 
 
312
  }}
313
  >
314
+ {isCreatingSession ? (
315
+ <>
316
+ <CircularProgress size={12} sx={{ color: '#000' }} />
317
+ Creating...
318
+ </>
319
+ ) : (
320
+ <>
321
+ <AddIcon sx={{ fontSize: 16 }} />
322
+ New Session
323
+ </>
324
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  </Box>
 
326
 
327
+ <Box
328
+ sx={{
329
+ display: 'flex',
330
+ alignItems: 'center',
331
+ justifyContent: 'center',
332
+ gap: 0.5,
333
+ }}
334
+ >
335
+ <StatusDot connected={isConnected} />
336
+ <Typography
337
+ variant="caption"
338
+ sx={{ color: 'var(--muted-text)', fontSize: '0.62rem', letterSpacing: '0.02em' }}
339
+ >
340
+ {sessions.length} session{sessions.length !== 1 ? 's' : ''} &middot; Backend {isConnected ? 'online' : 'offline'}
341
+ </Typography>
 
 
 
342
  </Box>
343
  </Box>
344
  </Box>
frontend/src/components/WelcomeScreen/WelcomeScreen.tsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from 'react';
2
+ import {
3
+ Box,
4
+ Typography,
5
+ Button,
6
+ CircularProgress,
7
+ Alert,
8
+ } from '@mui/material';
9
+ import { useSessionStore } from '@/store/sessionStore';
10
+ import { useAgentStore } from '@/store/agentStore';
11
+ import { apiFetch } from '@/utils/api';
12
+
13
+ /** HF brand orange */
14
+ const HF_ORANGE = '#FF9D00';
15
+
16
+ export default function WelcomeScreen() {
17
+ const { createSession } = useSessionStore();
18
+ const { setPlan, setPanelContent } = useAgentStore();
19
+ const [isCreating, setIsCreating] = useState(false);
20
+ const [error, setError] = useState<string | null>(null);
21
+
22
+ const handleStart = useCallback(async () => {
23
+ if (isCreating) return;
24
+ setIsCreating(true);
25
+ setError(null);
26
+
27
+ try {
28
+ const response = await apiFetch('/api/session', { method: 'POST' });
29
+ if (response.status === 503) {
30
+ const data = await response.json();
31
+ setError(data.detail || 'Server is at capacity. Please try again later.');
32
+ return;
33
+ }
34
+ if (!response.ok) {
35
+ setError('Failed to create session. Please try again.');
36
+ return;
37
+ }
38
+ const data = await response.json();
39
+ createSession(data.session_id);
40
+ setPlan([]);
41
+ setPanelContent(null);
42
+ } catch {
43
+ setError('Could not reach the server. Please try again.');
44
+ } finally {
45
+ setIsCreating(false);
46
+ }
47
+ }, [isCreating, createSession, setPlan, setPanelContent]);
48
+
49
+ return (
50
+ <Box
51
+ sx={{
52
+ width: '100%',
53
+ height: '100%',
54
+ display: 'flex',
55
+ flexDirection: 'column',
56
+ alignItems: 'center',
57
+ justifyContent: 'center',
58
+ background: 'var(--body-gradient)',
59
+ py: 8,
60
+ }}
61
+ >
62
+ {/* HF Logo — large, centered */}
63
+ <Box
64
+ component="img"
65
+ src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
66
+ alt="Hugging Face"
67
+ sx={{
68
+ width: 96,
69
+ height: 96,
70
+ mb: 3,
71
+ display: 'block',
72
+ }}
73
+ />
74
+
75
+ {/* Title */}
76
+ <Typography
77
+ variant="h2"
78
+ sx={{
79
+ fontWeight: 800,
80
+ color: 'var(--text)',
81
+ mb: 1.5,
82
+ letterSpacing: '-0.02em',
83
+ fontSize: { xs: '2rem', md: '2.8rem' },
84
+ }}
85
+ >
86
+ HF Agent
87
+ </Typography>
88
+
89
+ {/* Description */}
90
+ <Typography
91
+ variant="body1"
92
+ sx={{
93
+ color: 'var(--muted-text)',
94
+ maxWidth: 520,
95
+ mb: 5,
96
+ lineHeight: 1.8,
97
+ fontSize: '0.95rem',
98
+ textAlign: 'center',
99
+ px: 2,
100
+ '& strong': {
101
+ color: 'var(--text)',
102
+ fontWeight: 600,
103
+ },
104
+ }}
105
+ >
106
+ A general-purpose AI agent for <strong>machine learning engineering</strong>.
107
+ It browses <strong>Hugging Face documentation</strong>, manages{' '}
108
+ <strong>repositories</strong>, launches <strong>training jobs</strong>,
109
+ and explores <strong>datasets</strong> — all through natural conversation.
110
+ </Typography>
111
+
112
+ {/* Start Button */}
113
+ <Button
114
+ variant="contained"
115
+ size="large"
116
+ onClick={handleStart}
117
+ disabled={isCreating}
118
+ startIcon={
119
+ isCreating ? <CircularProgress size={20} color="inherit" /> : null
120
+ }
121
+ sx={{
122
+ px: 5,
123
+ py: 1.5,
124
+ fontSize: '1rem',
125
+ fontWeight: 700,
126
+ textTransform: 'none',
127
+ borderRadius: '12px',
128
+ bgcolor: HF_ORANGE,
129
+ color: '#000',
130
+ boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
131
+ transition: 'all 0.2s ease',
132
+ '&:hover': {
133
+ bgcolor: '#FFB340',
134
+ boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
135
+ },
136
+ '&.Mui-disabled': {
137
+ bgcolor: 'rgba(255, 157, 0, 0.35)',
138
+ color: 'rgba(0,0,0,0.45)',
139
+ },
140
+ }}
141
+ >
142
+ {isCreating ? 'Initializing...' : 'Start Session'}
143
+ </Button>
144
+
145
+ {/* Error */}
146
+ {error && (
147
+ <Alert
148
+ severity="warning"
149
+ variant="outlined"
150
+ onClose={() => setError(null)}
151
+ sx={{
152
+ mt: 3,
153
+ maxWidth: 400,
154
+ fontSize: '0.8rem',
155
+ borderColor: HF_ORANGE,
156
+ color: 'var(--text)',
157
+ }}
158
+ >
159
+ {error}
160
+ </Alert>
161
+ )}
162
+
163
+ {/* Footnote */}
164
+ <Typography
165
+ variant="caption"
166
+ sx={{
167
+ mt: 5,
168
+ color: 'var(--muted-text)',
169
+ opacity: 0.5,
170
+ fontSize: '0.7rem',
171
+ }}
172
+ >
173
+ Conversations are stored locally in your browser.
174
+ </Typography>
175
+ </Box>
176
+ );
177
+ }
frontend/src/hooks/useAgentWebSocket.ts CHANGED
@@ -1,34 +1,40 @@
1
  import { useCallback, useEffect, useRef } from 'react';
2
- import { useAgentStore } from '@/store/agentStore';
3
  import { useSessionStore } from '@/store/sessionStore';
4
  import { useLayoutStore } from '@/store/layoutStore';
 
 
5
  import type { AgentEvent } from '@/types/events';
6
  import type { Message, TraceLog } from '@/types/agent';
7
 
8
  const WS_RECONNECT_DELAY = 1000;
9
  const WS_MAX_RECONNECT_DELAY = 30000;
 
10
 
11
  interface UseAgentWebSocketOptions {
12
  sessionId: string | null;
13
  onReady?: () => void;
14
  onError?: (error: string) => void;
 
15
  }
16
 
17
  export function useAgentWebSocket({
18
  sessionId,
19
  onReady,
20
  onError,
 
21
  }: UseAgentWebSocketOptions) {
22
  const wsRef = useRef<WebSocket | null>(null);
23
  const reconnectTimeoutRef = useRef<number | null>(null);
24
  const reconnectDelayRef = useRef(WS_RECONNECT_DELAY);
 
25
 
26
  const {
27
  addMessage,
28
  updateMessage,
 
29
  setProcessing,
30
  setConnected,
31
- setPendingApprovals,
32
  setError,
33
  addTraceLog,
34
  updateTraceLog,
@@ -40,6 +46,7 @@ export function useAgentWebSocket({
40
  setPlan,
41
  setCurrentTurnMessageId,
42
  updateCurrentTurnTrace,
 
43
  } = useAgentStore();
44
 
45
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
@@ -66,6 +73,48 @@ export function useAgentWebSocket({
66
  setCurrentTurnMessageId(null); // Start a new turn
67
  break;
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  case 'assistant_message': {
70
  const content = (event.data?.content as string) || '';
71
  const currentTrace = useAgentStore.getState().traceLogs;
@@ -126,23 +175,41 @@ export function useAgentWebSocket({
126
 
127
  case 'tool_call': {
128
  const toolName = (event.data?.tool as string) || 'unknown';
129
- const args = (event.data?.arguments as Record<string, any>) || {};
 
130
 
131
  // Don't display plan_tool in trace logs (it shows up elsewhere in the UI)
132
  if (toolName !== 'plan_tool') {
133
  const log: TraceLog = {
134
- id: `tool_${Date.now()}`,
 
135
  type: 'call',
136
  text: `Agent is executing ${toolName}...`,
137
  tool: toolName,
138
  timestamp: new Date().toISOString(),
139
  completed: false,
140
- // Store args for auto-exec message creation later
141
- args: toolName === 'hf_jobs' ? args : undefined,
142
  };
143
  addTraceLog(log);
144
- // Update the current turn message's trace in real-time
145
- updateCurrentTurnTrace(sessionId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  }
147
 
148
  // Auto-expand Right Panel for specific tools
@@ -171,68 +238,57 @@ export function useAgentWebSocket({
171
  setLeftSidebarOpen(false);
172
  }
173
 
174
- console.log('Tool call:', toolName, args);
175
  break;
176
  }
177
 
178
  case 'tool_output': {
179
  const toolName = (event.data?.tool as string) || 'unknown';
 
180
  const output = (event.data?.output as string) || '';
181
  const success = event.data?.success as boolean;
182
 
183
- // Mark the corresponding trace log as completed and store the output
184
- updateTraceLog(toolName, { completed: true, output, success });
185
- // Update the current turn message's trace in real-time
 
 
 
 
 
 
 
 
 
186
  updateCurrentTurnTrace(sessionId);
187
 
188
- // Special handling for hf_jobs - update or create job message with output
189
- if (toolName === 'hf_jobs') {
190
- const messages = useAgentStore.getState().getMessages(sessionId);
191
- const traceLogs = useAgentStore.getState().traceLogs;
192
-
193
- // Find existing approval message for this job
194
- let jobMsg = [...messages].reverse().find(m => m.approval);
195
 
196
- if (!jobMsg) {
197
- // No approval message exists - this was an auto-executed job
198
- // Create a job execution message so user can see results
199
- const jobTrace = [...traceLogs].reverse().find(t => t.tool === 'hf_jobs');
200
- const args = jobTrace?.args || {};
201
 
202
- const autoExecMessage: Message = {
203
- id: `msg_auto_${Date.now()}`,
204
- role: 'assistant',
205
- content: '',
206
- timestamp: new Date().toISOString(),
207
- approval: {
208
- status: 'approved', // Auto-approved (no user action needed)
209
- batch: {
210
- tools: [{
211
- tool: toolName,
212
- arguments: args,
213
- tool_call_id: `auto_${Date.now()}`
214
- }],
215
- count: 1
216
- }
217
- },
218
- toolOutput: output
219
- };
220
- addMessage(sessionId, autoExecMessage);
221
- console.log('Created auto-exec message with tool output:', toolName);
222
- } else {
223
- // Update existing approval message
224
- const currentOutput = jobMsg.toolOutput || '';
225
- const newOutput = currentOutput ? currentOutput + '\n\n' + output : output;
226
 
227
- useAgentStore.getState().updateMessage(sessionId, jobMsg.id, {
228
- toolOutput: newOutput
229
- });
230
- console.log('Updated job message with tool output:', toolName);
 
 
 
231
  }
 
 
 
232
  }
233
 
234
  // Don't create message bubbles for tool outputs - they only show in trace logs
235
- console.log('Tool output:', toolName, success);
236
  break;
237
  }
238
 
@@ -267,7 +323,7 @@ export function useAgentWebSocket({
267
  }
268
 
269
  case 'plan_update': {
270
- const plan = (event.data?.plan as any[]) || [];
271
  setPlan(plan);
272
  if (!useLayoutStore.getState().isRightPanelOpen) {
273
  setRightPanelOpen(true);
@@ -281,25 +337,59 @@ export function useAgentWebSocket({
281
  arguments: Record<string, unknown>;
282
  tool_call_id: string;
283
  }>;
284
- const count = (event.data?.count as number) || 0;
285
-
286
- // Create a persistent message for the approval request
287
- const message: Message = {
288
- id: `msg_approval_${Date.now()}`,
289
- role: 'assistant',
290
- content: '', // Content is handled by the approval UI
291
- timestamp: new Date().toISOString(),
292
- approval: {
293
- status: 'pending',
294
- batch: { tools, count }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  }
296
- };
297
- addMessage(sessionId, message);
298
 
299
- // Show the first tool's content in the panel so users see what they're approving
300
  if (tools && tools.length > 0) {
301
  const firstTool = tools[0];
302
- const args = firstTool.arguments as Record<string, any>;
303
 
304
  clearPanelTabs();
305
 
@@ -324,7 +414,6 @@ export function useAgentWebSocket({
324
  });
325
  setActivePanelTab('content');
326
  } else {
327
- // For other tools, show args as JSON
328
  setPanelTab({
329
  id: 'args',
330
  title: firstTool.tool,
@@ -339,11 +428,6 @@ export function useAgentWebSocket({
339
  setLeftSidebarOpen(false);
340
  }
341
 
342
- // Clear currentTurnMessageId so subsequent assistant_message events create a new message below the approval
343
- setCurrentTurnMessageId(null);
344
-
345
- // We don't set pendingApprovals in the global store anymore as the message handles the UI
346
- setPendingApprovals(null);
347
  setProcessing(false);
348
  break;
349
  }
@@ -356,7 +440,7 @@ export function useAgentWebSocket({
356
  case 'compacted': {
357
  const oldTokens = event.data?.old_tokens as number;
358
  const newTokens = event.data?.new_tokens as number;
359
- console.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
360
  break;
361
  }
362
 
@@ -378,16 +462,19 @@ export function useAgentWebSocket({
378
  break;
379
 
380
  case 'undo_complete':
381
- // Could remove last messages from store
 
 
 
382
  break;
383
 
384
  default:
385
- console.log('Unknown event:', event);
386
  }
387
  },
388
  // Zustand setters are stable, so we don't need them in deps
389
  // eslint-disable-next-line react-hooks/exhaustive-deps
390
- [sessionId, onReady, onError]
391
  );
392
 
393
  const connect = useCallback(() => {
@@ -399,21 +486,17 @@ export function useAgentWebSocket({
399
  return;
400
  }
401
 
402
- // Connect directly to backend (Vite doesn't proxy WebSockets)
403
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
404
- // In development, connect directly to backend port 7860
405
- // In production, use the same host
406
- const isDev = import.meta.env.DEV;
407
- const host = isDev ? '127.0.0.1:7860' : window.location.host;
408
- const wsUrl = `${protocol}//${host}/api/ws/${sessionId}`;
409
 
410
- console.log('Connecting to WebSocket:', wsUrl);
411
  const ws = new WebSocket(wsUrl);
412
 
413
  ws.onopen = () => {
414
- console.log('WebSocket connected');
415
  setConnected(true);
416
  reconnectDelayRef.current = WS_RECONNECT_DELAY;
 
417
  };
418
 
419
  ws.onmessage = (event) => {
@@ -421,20 +504,31 @@ export function useAgentWebSocket({
421
  const data = JSON.parse(event.data) as AgentEvent;
422
  handleEvent(data);
423
  } catch (e) {
424
- console.error('Failed to parse WebSocket message:', e);
425
  }
426
  };
427
 
428
  ws.onerror = (error) => {
429
- console.error('WebSocket error:', error);
430
  };
431
 
432
  ws.onclose = (event) => {
433
- console.log('WebSocket closed', event.code, event.reason);
434
  setConnected(false);
435
 
436
- // Only reconnect if it wasn't a normal closure and session still exists
437
- if (event.code !== 1000 && sessionId) {
 
 
 
 
 
 
 
 
 
 
 
438
  // Attempt to reconnect with exponential backoff
439
  if (reconnectTimeoutRef.current) {
440
  clearTimeout(reconnectTimeoutRef.current);
@@ -446,6 +540,12 @@ export function useAgentWebSocket({
446
  );
447
  connect();
448
  }, reconnectDelayRef.current);
 
 
 
 
 
 
449
  }
450
  };
451
 
@@ -477,6 +577,10 @@ export function useAgentWebSocket({
477
  return;
478
  }
479
 
 
 
 
 
480
  // Small delay to ensure session is fully created on backend
481
  const timeoutId = setTimeout(() => {
482
  connect();
 
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,
 
46
  setPlan,
47
  setCurrentTurnMessageId,
48
  updateCurrentTurnTrace,
49
+ removeLastTurn,
50
  } = useAgentStore();
51
 
52
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
 
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;
 
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
  completed: false,
191
+ args,
 
192
  };
193
  addTraceLog(log);
194
+
195
+ // If no assistant message exists for this turn, create one now
196
+ // so the ToolCallGroup renders immediately in the chat flow.
197
+ const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
198
+ if (!currentTurnMsgId) {
199
+ const messageId = `msg_${Date.now()}`;
200
+ const currentTrace = useAgentStore.getState().traceLogs;
201
+ addMessage(sessionId, {
202
+ id: messageId,
203
+ role: 'assistant',
204
+ content: '',
205
+ timestamp: new Date().toISOString(),
206
+ segments: [{ type: 'tools', tools: [...currentTrace] }],
207
+ });
208
+ setCurrentTurnMessageId(messageId);
209
+ clearTraceLogs();
210
+ } else {
211
+ updateCurrentTurnTrace(sessionId);
212
+ }
213
  }
214
 
215
  // Auto-expand Right Panel for specific tools
 
238
  setLeftSidebarOpen(false);
239
  }
240
 
241
+ logger.log('Tool call:', toolName, args);
242
  break;
243
  }
244
 
245
  case 'tool_output': {
246
  const toolName = (event.data?.tool as string) || 'unknown';
247
+ const toolCallId = (event.data?.tool_call_id as string) || '';
248
  const output = (event.data?.output as string) || '';
249
  const success = event.data?.success as boolean;
250
 
251
+ // Mark the corresponding trace log as completed and store the output.
252
+ // If it had a pending approval, mark it as approved (tool_output means it ran).
253
+ const prevLog = useAgentStore.getState().traceLogs.find(
254
+ (l) => l.toolCallId === toolCallId
255
+ );
256
+ const wasApproval = prevLog?.approvalStatus === 'pending';
257
+ updateTraceLog(toolCallId, toolName, {
258
+ completed: true,
259
+ output,
260
+ success,
261
+ ...(wasApproval ? { approvalStatus: 'approved' as const } : {}),
262
+ });
263
  updateCurrentTurnTrace(sessionId);
264
 
265
+ // For hf_jobs: parse job output and enrich the TraceLog with job info
266
+ if (toolName === 'hf_jobs' && output) {
267
+ const updates: Partial<TraceLog> = { approvalStatus: 'approved' as const };
 
 
 
 
268
 
269
+ // Parse job URL
270
+ const urlMatch = output.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
271
+ if (urlMatch) updates.jobUrl = urlMatch[1];
 
 
272
 
273
+ // Parse job status
274
+ const statusMatch = output.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
275
+ if (statusMatch) updates.jobStatus = statusMatch[1].trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
+ // Parse logs
278
+ if (output.includes('**Logs:**')) {
279
+ const parts = output.split('**Logs:**');
280
+ if (parts.length > 1) {
281
+ const codeBlockMatch = parts[1].trim().match(/```([\s\S]*?)```/);
282
+ if (codeBlockMatch) updates.jobLogs = codeBlockMatch[1].trim();
283
+ }
284
  }
285
+
286
+ updateTraceLog(toolCallId, toolName, updates);
287
+ updateCurrentTurnTrace(sessionId);
288
  }
289
 
290
  // Don't create message bubbles for tool outputs - they only show in trace logs
291
+ logger.log('Tool output:', toolName, success);
292
  break;
293
  }
294
 
 
323
  }
324
 
325
  case 'plan_update': {
326
+ const plan = (event.data?.plan as PlanItem[]) || [];
327
  setPlan(plan);
328
  if (!useLayoutStore.getState().isRightPanelOpen) {
329
  setRightPanelOpen(true);
 
337
  arguments: Record<string, unknown>;
338
  tool_call_id: string;
339
  }>;
340
+
341
+ // Create or update trace logs for approval tools.
342
+ // The backend only sends tool_call events for non-approval tools,
343
+ // so we must create TraceLogs here for approval-requiring tools.
344
+ if (tools) {
345
+ for (const t of tools) {
346
+ // Check if a TraceLog already exists (shouldn't, but be safe)
347
+ const existing = useAgentStore.getState().traceLogs.find(
348
+ (log) => log.toolCallId === t.tool_call_id
349
+ );
350
+ if (!existing) {
351
+ addTraceLog({
352
+ id: `tool_${Date.now()}_${t.tool_call_id}`,
353
+ toolCallId: t.tool_call_id,
354
+ type: 'call',
355
+ text: `Approval required for ${t.tool}`,
356
+ tool: t.tool,
357
+ timestamp: new Date().toISOString(),
358
+ completed: false,
359
+ args: t.arguments as Record<string, unknown>,
360
+ approvalStatus: 'pending',
361
+ });
362
+ } else {
363
+ updateTraceLog(t.tool_call_id, t.tool, {
364
+ approvalStatus: 'pending',
365
+ args: t.arguments as Record<string, unknown>,
366
+ });
367
+ }
368
+ }
369
+
370
+ // Ensure there's a message to render the approval UI in
371
+ const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
372
+ if (!currentTurnMsgId) {
373
+ const messageId = `msg_${Date.now()}`;
374
+ const currentTrace = useAgentStore.getState().traceLogs;
375
+ addMessage(sessionId, {
376
+ id: messageId,
377
+ role: 'assistant',
378
+ content: '',
379
+ timestamp: new Date().toISOString(),
380
+ segments: [{ type: 'tools', tools: [...currentTrace] }],
381
+ });
382
+ setCurrentTurnMessageId(messageId);
383
+ clearTraceLogs();
384
+ } else {
385
+ updateCurrentTurnTrace(sessionId);
386
  }
387
+ }
 
388
 
389
+ // Show the first tool's content in the panel
390
  if (tools && tools.length > 0) {
391
  const firstTool = tools[0];
392
+ const args = firstTool.arguments as Record<string, string | undefined>;
393
 
394
  clearPanelTabs();
395
 
 
414
  });
415
  setActivePanelTab('content');
416
  } else {
 
417
  setPanelTab({
418
  id: 'args',
419
  title: firstTool.tool,
 
428
  setLeftSidebarOpen(false);
429
  }
430
 
 
 
 
 
 
431
  setProcessing(false);
432
  break;
433
  }
 
440
  case 'compacted': {
441
  const oldTokens = event.data?.old_tokens as number;
442
  const newTokens = event.data?.new_tokens as number;
443
+ logger.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
444
  break;
445
  }
446
 
 
462
  break;
463
 
464
  case 'undo_complete':
465
+ if (sessionId) {
466
+ removeLastTurn(sessionId);
467
+ }
468
+ setProcessing(false);
469
  break;
470
 
471
  default:
472
+ logger.log('Unknown event:', event);
473
  }
474
  },
475
  // Zustand setters are stable, so we don't need them in deps
476
  // eslint-disable-next-line react-hooks/exhaustive-deps
477
+ [sessionId, onReady, onError, onSessionDead]
478
  );
479
 
480
  const connect = useCallback(() => {
 
486
  return;
487
  }
488
 
489
+ // Build WebSocket URL (centralized in utils/api.ts)
490
+ const wsUrl = getWebSocketUrl(sessionId);
 
 
 
 
 
491
 
492
+ logger.log('Connecting to WebSocket:', wsUrl);
493
  const ws = new WebSocket(wsUrl);
494
 
495
  ws.onopen = () => {
496
+ logger.log('WebSocket connected');
497
  setConnected(true);
498
  reconnectDelayRef.current = WS_RECONNECT_DELAY;
499
+ retriesRef.current = 0; // Reset retry counter on successful connect
500
  };
501
 
502
  ws.onmessage = (event) => {
 
504
  const data = JSON.parse(event.data) as AgentEvent;
505
  handleEvent(data);
506
  } catch (e) {
507
+ logger.error('Failed to parse WebSocket message:', e);
508
  }
509
  };
510
 
511
  ws.onerror = (error) => {
512
+ logger.error('WebSocket error:', error);
513
  };
514
 
515
  ws.onclose = (event) => {
516
+ logger.log('WebSocket closed', event.code, event.reason);
517
  setConnected(false);
518
 
519
+ // Don't reconnect if:
520
+ // - Normal closure (1000)
521
+ // - Session not found (4004) — session was deleted or backend restarted
522
+ // - Auth failed (4001) or access denied (4003) — won't succeed on retry
523
+ // - No session ID
524
+ const noRetryCodes = [1000, 4001, 4003, 4004];
525
+ if (!noRetryCodes.includes(event.code) && sessionId) {
526
+ retriesRef.current += 1;
527
+ if (retriesRef.current > WS_MAX_RETRIES) {
528
+ logger.warn(`WebSocket: max retries (${WS_MAX_RETRIES}) reached, giving up.`);
529
+ onSessionDead?.(sessionId);
530
+ return;
531
+ }
532
  // Attempt to reconnect with exponential backoff
533
  if (reconnectTimeoutRef.current) {
534
  clearTimeout(reconnectTimeoutRef.current);
 
540
  );
541
  connect();
542
  }, reconnectDelayRef.current);
543
+ } else if (event.code === 4004 && sessionId) {
544
+ // Session not found — remove it from the store (lazy cleanup)
545
+ logger.warn(`Session ${sessionId} no longer exists on backend, removing.`);
546
+ onSessionDead?.(sessionId);
547
+ } else if (noRetryCodes.includes(event.code) && event.code !== 1000) {
548
+ logger.warn(`WebSocket permanently closed: ${event.code} ${event.reason}`);
549
  }
550
  };
551
 
 
577
  return;
578
  }
579
 
580
+ // Reset retry state for new session
581
+ retriesRef.current = 0;
582
+ reconnectDelayRef.current = WS_RECONNECT_DELAY;
583
+
584
  // Small delay to ensure session is fully created on backend
585
  const timeoutId = setTimeout(() => {
586
  connect();
frontend/src/hooks/useAuth.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Authentication hook — non-blocking.
3
+ *
4
+ * The app renders immediately. This hook fires a background check to /auth/me
5
+ * and updates the agent store with user info when it resolves.
6
+ * If an API call later returns 401, apiFetch handles the redirect to /auth/login.
7
+ *
8
+ * This avoids blocking the entire UI on an auth check that depends on backend
9
+ * availability (which can be slow during session/MCP initialization).
10
+ */
11
+
12
+ import { useEffect } from 'react';
13
+ import { useAgentStore } from '@/store/agentStore';
14
+
15
+ export function useAuth() {
16
+ const setUser = useAgentStore((s) => s.setUser);
17
+
18
+ useEffect(() => {
19
+ async function checkAuth() {
20
+ try {
21
+ const response = await fetch('/auth/me', { credentials: 'include' });
22
+ if (response.ok) {
23
+ const data = await response.json();
24
+ if (data.authenticated) {
25
+ setUser({
26
+ authenticated: true,
27
+ username: data.username,
28
+ name: data.name,
29
+ picture: data.picture,
30
+ });
31
+ return;
32
+ }
33
+ }
34
+
35
+ // Not authenticated — check if auth is required
36
+ const statusRes = await fetch('/auth/status', { credentials: 'include' });
37
+ const statusData = await statusRes.json();
38
+ if (statusData.auth_enabled) {
39
+ window.location.href = '/auth/login';
40
+ return;
41
+ }
42
+
43
+ // Dev mode — set dev user
44
+ setUser({ authenticated: true, username: 'dev' });
45
+ } catch {
46
+ // Backend not ready — set dev user so the app is usable
47
+ setUser({ authenticated: true, username: 'dev' });
48
+ }
49
+ }
50
+
51
+ checkAuth();
52
+ }, [setUser]);
53
+ }
frontend/src/main.tsx CHANGED
@@ -3,13 +3,23 @@ import { createRoot } from 'react-dom/client';
3
  import { ThemeProvider } from '@mui/material/styles';
4
  import CssBaseline from '@mui/material/CssBaseline';
5
  import App from './App';
6
- import theme from './theme';
 
7
 
8
- createRoot(document.getElementById('root')!).render(
9
- <StrictMode>
 
 
 
10
  <ThemeProvider theme={theme}>
11
  <CssBaseline />
12
  <App />
13
  </ThemeProvider>
 
 
 
 
 
 
14
  </StrictMode>
15
  );
 
3
  import { ThemeProvider } from '@mui/material/styles';
4
  import CssBaseline from '@mui/material/CssBaseline';
5
  import App from './App';
6
+ import { darkTheme, lightTheme } from './theme';
7
+ import { useLayoutStore } from './store/layoutStore';
8
 
9
+ function Root() {
10
+ const themeMode = useLayoutStore((s) => s.themeMode);
11
+ const theme = themeMode === 'light' ? lightTheme : darkTheme;
12
+
13
+ return (
14
  <ThemeProvider theme={theme}>
15
  <CssBaseline />
16
  <App />
17
  </ThemeProvider>
18
+ );
19
+ }
20
+
21
+ createRoot(document.getElementById('root')!).render(
22
+ <StrictMode>
23
+ <Root />
24
  </StrictMode>
25
  );
frontend/src/store/agentStore.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { create } from 'zustand';
2
- import type { Message, ApprovalBatch, User, TraceLog } from '@/types/agent';
 
3
 
4
  export interface PlanItem {
5
  id: string;
@@ -12,7 +13,13 @@ interface PanelTab {
12
  title: string;
13
  content: string;
14
  language?: string;
15
- parameters?: any;
 
 
 
 
 
 
16
  }
17
 
18
  interface AgentStore {
@@ -20,11 +27,11 @@ interface AgentStore {
20
  messagesBySession: Record<string, Message[]>;
21
  isProcessing: boolean;
22
  isConnected: boolean;
23
- pendingApprovals: ApprovalBatch | null;
24
  user: User | null;
25
  error: string | null;
 
26
  traceLogs: TraceLog[];
27
- panelContent: { title: string; content: string; language?: string; parameters?: any } | null;
28
  panelTabs: PanelTab[];
29
  activePanelTab: string | null;
30
  plan: PlanItem[];
@@ -36,14 +43,13 @@ interface AgentStore {
36
  clearMessages: (sessionId: string) => void;
37
  setProcessing: (isProcessing: boolean) => void;
38
  setConnected: (isConnected: boolean) => void;
39
- setPendingApprovals: (approvals: ApprovalBatch | null) => void;
40
  setUser: (user: User | null) => void;
41
  setError: (error: string | null) => void;
42
  getMessages: (sessionId: string) => Message[];
43
  addTraceLog: (log: TraceLog) => void;
44
- updateTraceLog: (toolName: string, updates: Partial<TraceLog>) => void;
45
  clearTraceLogs: () => void;
46
- setPanelContent: (content: { title: string; content: string; language?: string; parameters?: any } | null) => void;
47
  setPanelTab: (tab: PanelTab) => void;
48
  setActivePanelTab: (tabId: string) => void;
49
  clearPanelTabs: () => void;
@@ -52,15 +58,24 @@ interface AgentStore {
52
  setCurrentTurnMessageId: (id: string | null) => void;
53
  updateCurrentTurnTrace: (sessionId: string) => void;
54
  showToolOutput: (log: TraceLog) => void;
 
 
 
 
 
 
 
55
  }
56
 
57
- export const useAgentStore = create<AgentStore>((set, get) => ({
 
 
58
  messagesBySession: {},
59
  isProcessing: false,
60
  isConnected: false,
61
- pendingApprovals: null,
62
  user: null,
63
  error: null,
 
64
  traceLogs: [],
65
  panelContent: null,
66
  panelTabs: [],
@@ -112,10 +127,6 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
112
  set({ isConnected });
113
  },
114
 
115
- setPendingApprovals: (approvals: ApprovalBatch | null) => {
116
- set({ pendingApprovals: approvals });
117
- },
118
-
119
  setUser: (user: User | null) => {
120
  set({ user });
121
  },
@@ -134,14 +145,27 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
134
  }));
135
  },
136
 
137
- updateTraceLog: (toolName: string, updates: Partial<TraceLog>) => {
138
  set((state) => {
139
- // Find the last trace log with this tool name and update it
140
  const traceLogs = [...state.traceLogs];
141
- for (let i = traceLogs.length - 1; i >= 0; i--) {
142
- if (traceLogs[i].tool === toolName && traceLogs[i].type === 'call') {
143
- traceLogs[i] = { ...traceLogs[i], ...updates };
144
- break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  }
146
  }
147
  return { traceLogs };
@@ -208,20 +232,39 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
208
 
209
  updateCurrentTurnTrace: (sessionId: string) => {
210
  const state = get();
211
- if (state.currentTurnMessageId) {
212
- const currentMessages = state.messagesBySession[sessionId] || [];
213
- const updatedMessages = currentMessages.map((msg) =>
214
- msg.id === state.currentTurnMessageId
215
- ? { ...msg, trace: state.traceLogs.length > 0 ? [...state.traceLogs] : undefined }
216
- : msg
217
- );
218
- set({
219
- messagesBySession: {
220
- ...state.messagesBySession,
221
- [sessionId]: updatedMessages,
222
- },
223
- });
224
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  },
226
 
227
  showToolOutput: (log: TraceLog) => {
@@ -257,4 +300,80 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
257
  activePanelTab: 'tool_output',
258
  });
259
  },
260
- }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
 
13
  title: string;
14
  content: string;
15
  language?: string;
16
+ parameters?: Record<string, unknown>;
17
+ }
18
+
19
+ export interface LLMHealthError {
20
+ error: string;
21
+ errorType: 'auth' | 'credits' | 'rate_limit' | 'network' | 'unknown';
22
+ model: string;
23
  }
24
 
25
  interface AgentStore {
 
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[];
 
43
  clearMessages: (sessionId: string) => void;
44
  setProcessing: (isProcessing: boolean) => void;
45
  setConnected: (isConnected: boolean) => void;
 
46
  setUser: (user: User | null) => void;
47
  setError: (error: string | null) => void;
48
  getMessages: (sessionId: string) => Message[];
49
  addTraceLog: (log: TraceLog) => void;
50
+ updateTraceLog: (toolCallId: string, toolName: string, updates: Partial<TraceLog>) => void;
51
  clearTraceLogs: () => void;
52
+ setPanelContent: (content: { title: string; content: string; language?: string; parameters?: Record<string, unknown> } | null) => void;
53
  setPanelTab: (tab: PanelTab) => void;
54
  setActivePanelTab: (tabId: string) => void;
55
  clearPanelTabs: () => void;
 
58
  setCurrentTurnMessageId: (id: string | null) => void;
59
  updateCurrentTurnTrace: (sessionId: string) => void;
60
  showToolOutput: (log: TraceLog) => void;
61
+ /** Append a streaming delta to an existing message. */
62
+ appendToMessage: (sessionId: string, messageId: string, delta: string) => void;
63
+ /** Remove all messages for a session (also clears from localStorage). */
64
+ deleteSessionMessages: (sessionId: string) => void;
65
+ /** Remove the last turn (last user msg + all following assistant/tool msgs). */
66
+ removeLastTurn: (sessionId: string) => void;
67
+ setLlmHealthError: (error: LLMHealthError | null) => void;
68
  }
69
 
70
+ export const useAgentStore = create<AgentStore>()(
71
+ persist(
72
+ (set, get) => ({
73
  messagesBySession: {},
74
  isProcessing: false,
75
  isConnected: false,
 
76
  user: null,
77
  error: null,
78
+ llmHealthError: null,
79
  traceLogs: [],
80
  panelContent: null,
81
  panelTabs: [],
 
127
  set({ isConnected });
128
  },
129
 
 
 
 
 
130
  setUser: (user: User | null) => {
131
  set({ user });
132
  },
 
145
  }));
146
  },
147
 
148
+ updateTraceLog: (toolCallId: string, toolName: string, updates: Partial<TraceLog>) => {
149
  set((state) => {
 
150
  const traceLogs = [...state.traceLogs];
151
+ // Prefer matching by tool_call_id (reliable), fall back to tool name (legacy)
152
+ let matched = false;
153
+ if (toolCallId) {
154
+ for (let i = traceLogs.length - 1; i >= 0; i--) {
155
+ if (traceLogs[i].toolCallId === toolCallId) {
156
+ traceLogs[i] = { ...traceLogs[i], ...updates };
157
+ matched = true;
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ if (!matched) {
163
+ // Fallback: match by tool name (last uncompleted call)
164
+ for (let i = traceLogs.length - 1; i >= 0; i--) {
165
+ if (traceLogs[i].tool === toolName && traceLogs[i].type === 'call' && !traceLogs[i].completed) {
166
+ traceLogs[i] = { ...traceLogs[i], ...updates };
167
+ break;
168
+ }
169
  }
170
  }
171
  return { traceLogs };
 
232
 
233
  updateCurrentTurnTrace: (sessionId: string) => {
234
  const state = get();
235
+ if (!state.currentTurnMessageId) return;
236
+
237
+ const currentMessages = state.messagesBySession[sessionId] || [];
238
+ const latestTools = state.traceLogs.length > 0 ? [...state.traceLogs] : undefined;
239
+ if (!latestTools) return;
240
+
241
+ const updatedMessages = currentMessages.map((msg) => {
242
+ if (msg.id !== state.currentTurnMessageId) return msg;
243
+
244
+ const segments = msg.segments ? [...msg.segments] : [];
245
+ const lastToolsIdx = segments.map((s) => s.type).lastIndexOf('tools');
246
+
247
+ if (lastToolsIdx >= 0 && lastToolsIdx === segments.length - 1) {
248
+ // Last segment IS a tools segment — update it in place
249
+ segments[lastToolsIdx] = { type: 'tools', tools: latestTools };
250
+ } else if (lastToolsIdx >= 0) {
251
+ // A tools segment exists but is NOT last (text came after it).
252
+ // Append a NEW tools segment at the end.
253
+ segments.push({ type: 'tools', tools: latestTools });
254
+ } else {
255
+ // No tools segment at all — create one at the end.
256
+ segments.push({ type: 'tools', tools: latestTools });
257
+ }
258
+
259
+ return { ...msg, segments };
260
+ });
261
+
262
+ set({
263
+ messagesBySession: {
264
+ ...state.messagesBySession,
265
+ [sessionId]: updatedMessages,
266
+ },
267
+ });
268
  },
269
 
270
  showToolOutput: (log: TraceLog) => {
 
300
  activePanelTab: 'tool_output',
301
  });
302
  },
303
+
304
+ appendToMessage: (sessionId: string, messageId: string, delta: string) => {
305
+ set((state) => {
306
+ const messages = state.messagesBySession[sessionId] || [];
307
+ return {
308
+ messagesBySession: {
309
+ ...state.messagesBySession,
310
+ [sessionId]: messages.map((msg) => {
311
+ if (msg.id !== messageId) return msg;
312
+ const newContent = msg.content + delta;
313
+ const segments = msg.segments ? [...msg.segments] : [];
314
+ const lastSeg = segments[segments.length - 1];
315
+
316
+ if (lastSeg && lastSeg.type === 'text') {
317
+ // Append to the existing text segment
318
+ segments[segments.length - 1] = {
319
+ ...lastSeg,
320
+ content: (lastSeg.content || '') + delta,
321
+ };
322
+ } else {
323
+ // Last segment is 'tools' (or empty) — start a NEW text segment
324
+ // so that tools and text remain visually separated.
325
+ segments.push({ type: 'text', content: delta });
326
+ }
327
+
328
+ return { ...msg, content: newContent, segments };
329
+ }),
330
+ },
331
+ };
332
+ });
333
+ },
334
+
335
+ deleteSessionMessages: (sessionId: string) => {
336
+ set((state) => {
337
+ const { [sessionId]: _, ...rest } = state.messagesBySession;
338
+ return { messagesBySession: rest };
339
+ });
340
+ },
341
+
342
+ removeLastTurn: (sessionId: string) => {
343
+ set((state) => {
344
+ const msgs = state.messagesBySession[sessionId];
345
+ if (!msgs || msgs.length === 0) return state;
346
+
347
+ // Find the index of the last user message
348
+ let lastUserIdx = -1;
349
+ for (let i = msgs.length - 1; i >= 0; i--) {
350
+ if (msgs[i].role === 'user') {
351
+ lastUserIdx = i;
352
+ break;
353
+ }
354
+ }
355
+ if (lastUserIdx === -1) return state; // no user message to remove
356
+
357
+ // Remove everything from that user message onward
358
+ return {
359
+ messagesBySession: {
360
+ ...state.messagesBySession,
361
+ [sessionId]: msgs.slice(0, lastUserIdx),
362
+ },
363
+ };
364
+ });
365
+ },
366
+
367
+ setLlmHealthError: (error: LLMHealthError | null) => {
368
+ set({ llmHealthError: error });
369
+ },
370
+ }),
371
+ {
372
+ name: 'hf-agent-messages',
373
+ // Only persist messages — all transient UI state stays in-memory
374
+ partialize: (state) => ({
375
+ messagesBySession: state.messagesBySession,
376
+ }),
377
+ }
378
+ )
379
+ );
frontend/src/store/layoutStore.ts CHANGED
@@ -1,23 +1,41 @@
1
  import { create } from 'zustand';
 
 
 
2
 
3
  interface LayoutStore {
4
  isLeftSidebarOpen: boolean;
5
  isRightPanelOpen: boolean;
6
  rightPanelWidth: number;
 
7
  setLeftSidebarOpen: (open: boolean) => void;
8
  setRightPanelOpen: (open: boolean) => void;
9
  setRightPanelWidth: (width: number) => void;
10
  toggleLeftSidebar: () => void;
11
  toggleRightPanel: () => void;
 
12
  }
13
 
14
- export const useLayoutStore = create<LayoutStore>((set) => ({
15
- isLeftSidebarOpen: true,
16
- isRightPanelOpen: false,
17
- rightPanelWidth: 450,
18
- setLeftSidebarOpen: (open) => set({ isLeftSidebarOpen: open }),
19
- setRightPanelOpen: (open) => set({ isRightPanelOpen: open }),
20
- setRightPanelWidth: (width) => set({ rightPanelWidth: width }),
21
- toggleLeftSidebar: () => set((state) => ({ isLeftSidebarOpen: !state.isLeftSidebarOpen })),
22
- toggleRightPanel: () => set((state) => ({ isRightPanelOpen: !state.isRightPanelOpen })),
23
- }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { create } from 'zustand';
2
+ import { persist } from 'zustand/middleware';
3
+
4
+ export type ThemeMode = 'dark' | 'light';
5
 
6
  interface LayoutStore {
7
  isLeftSidebarOpen: boolean;
8
  isRightPanelOpen: boolean;
9
  rightPanelWidth: number;
10
+ themeMode: ThemeMode;
11
  setLeftSidebarOpen: (open: boolean) => void;
12
  setRightPanelOpen: (open: boolean) => void;
13
  setRightPanelWidth: (width: number) => void;
14
  toggleLeftSidebar: () => void;
15
  toggleRightPanel: () => void;
16
+ toggleTheme: () => void;
17
  }
18
 
19
+ export const useLayoutStore = create<LayoutStore>()(
20
+ persist(
21
+ (set) => ({
22
+ isLeftSidebarOpen: true,
23
+ isRightPanelOpen: false,
24
+ rightPanelWidth: 450,
25
+ themeMode: 'dark' as ThemeMode,
26
+ setLeftSidebarOpen: (open) => set({ isLeftSidebarOpen: open }),
27
+ setRightPanelOpen: (open) => set({ isRightPanelOpen: open }),
28
+ setRightPanelWidth: (width) => set({ rightPanelWidth: width }),
29
+ toggleLeftSidebar: () => set((state) => ({ isLeftSidebarOpen: !state.isLeftSidebarOpen })),
30
+ toggleRightPanel: () => set((state) => ({ isRightPanelOpen: !state.isRightPanelOpen })),
31
+ toggleTheme: () =>
32
+ set((state) => ({
33
+ themeMode: state.themeMode === 'dark' ? 'light' : 'dark',
34
+ })),
35
+ }),
36
+ {
37
+ name: 'hf-agent-layout',
38
+ partialize: (state) => ({ themeMode: state.themeMode }),
39
+ }
40
+ )
41
+ );
frontend/src/store/sessionStore.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { create } from 'zustand';
2
  import { persist } from 'zustand/middleware';
3
  import type { SessionMeta } from '@/types/agent';
 
4
 
5
  interface SessionStore {
6
  sessions: SessionMeta[];
@@ -10,8 +11,8 @@ interface SessionStore {
10
  createSession: (id: string) => void;
11
  deleteSession: (id: string) => void;
12
  switchSession: (id: string) => void;
13
- updateSessionTitle: (id: string, title: string) => void;
14
  setSessionActive: (id: string, isActive: boolean) => void;
 
15
  }
16
 
17
  export const useSessionStore = create<SessionStore>()(
@@ -34,6 +35,9 @@ export const useSessionStore = create<SessionStore>()(
34
  },
35
 
36
  deleteSession: (id: string) => {
 
 
 
37
  set((state) => {
38
  const newSessions = state.sessions.filter((s) => s.id !== id);
39
  const newActiveId =
@@ -51,18 +55,18 @@ export const useSessionStore = create<SessionStore>()(
51
  set({ activeSessionId: id });
52
  },
53
 
54
- updateSessionTitle: (id: string, title: string) => {
55
  set((state) => ({
56
  sessions: state.sessions.map((s) =>
57
- s.id === id ? { ...s, title } : s
58
  ),
59
  }));
60
  },
61
 
62
- setSessionActive: (id: string, isActive: boolean) => {
63
  set((state) => ({
64
  sessions: state.sessions.map((s) =>
65
- s.id === id ? { ...s, isActive } : s
66
  ),
67
  }));
68
  },
 
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[];
 
11
  createSession: (id: string) => void;
12
  deleteSession: (id: string) => void;
13
  switchSession: (id: string) => void;
 
14
  setSessionActive: (id: string, isActive: boolean) => void;
15
+ updateSessionTitle: (id: string, title: string) => void;
16
  }
17
 
18
  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 =
 
55
  set({ activeSessionId: id });
56
  },
57
 
58
+ setSessionActive: (id: string, isActive: boolean) => {
59
  set((state) => ({
60
  sessions: state.sessions.map((s) =>
61
+ s.id === id ? { ...s, isActive } : s
62
  ),
63
  }));
64
  },
65
 
66
+ updateSessionTitle: (id: string, title: string) => {
67
  set((state) => ({
68
  sessions: state.sessions.map((s) =>
69
+ s.id === id ? { ...s, title } : s
70
  ),
71
  }));
72
  },
frontend/src/theme.ts CHANGED
@@ -1,158 +1,223 @@
1
- import { createTheme } from '@mui/material/styles';
2
 
3
- const theme = createTheme({
4
- palette: {
5
- mode: 'dark',
6
- primary: {
7
- main: '#C7A500', // --accent-yellow
8
- },
9
- secondary: {
10
- main: '#FF9D00',
11
- },
12
- background: {
13
- default: '#0B0D10', // --bg
14
- paper: '#0F1316', // --panel
15
- },
16
- text: {
17
- primary: '#E6EEF8', // --text
18
- secondary: '#98A0AA', // --muted-text
19
- },
20
- divider: 'rgba(255,255,255,0.03)',
21
- success: {
22
- main: '#2FCC71', // --accent-green
23
- },
24
- error: {
25
- main: '#E05A4F', // --accent-red
26
- },
27
- warning: {
28
- main: '#C7A500',
29
- },
30
- info: {
31
- main: '#58A6FF',
32
  },
33
  },
34
- typography: {
35
- fontFamily: 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',
36
- fontSize: 15,
37
- h1: { fontWeight: 600, color: '#E6EEF8' },
38
- h2: { fontWeight: 600, color: '#E6EEF8' },
39
- h3: { fontWeight: 600, color: '#E6EEF8' },
40
- h4: { fontWeight: 600, color: '#E6EEF8' },
41
- h5: { fontWeight: 600, color: '#E6EEF8' },
42
- h6: { fontWeight: 600, color: '#E6EEF8' },
43
- body1: { fontSize: '15px', color: '#E6EEF8' },
44
- body2: { fontSize: '0.875rem', color: '#98A0AA' },
45
- button: {
46
- fontFamily: 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',
47
- textTransform: 'none',
48
- fontWeight: 600,
49
  },
50
  },
51
- components: {
52
- MuiCssBaseline: {
53
- styleOverrides: {
54
- ':root': {
55
- '--bg': '#0B0D10',
56
- '--panel': '#0F1316',
57
- '--surface': '#121416',
58
- '--text': '#E6EEF8',
59
- '--muted-text': '#98A0AA',
60
- '--accent-yellow': '#C7A500',
61
- '--accent-yellow-weak': 'rgba(199,165,0,0.08)',
62
- '--accent-green': '#2FCC71',
63
- '--accent-red': '#E05A4F',
64
- '--shadow-1': '0 6px 18px rgba(2,6,12,0.55)',
65
- '--radius-lg': '20px',
66
- '--radius-md': '12px',
67
- '--focus': '0 0 0 3px rgba(199,165,0,0.12)',
68
- },
69
- body: {
70
- background: 'linear-gradient(180deg, var(--bg), #090B0D)',
71
- color: 'var(--text)',
72
- scrollbarWidth: 'thin',
73
- '&::-webkit-scrollbar': {
74
- width: '8px',
75
- height: '8px',
76
- },
77
- '&::-webkit-scrollbar-thumb': {
78
- backgroundColor: '#30363D',
79
- borderRadius: '2px',
80
- },
81
- '&::-webkit-scrollbar-track': {
82
- backgroundColor: 'transparent',
83
- },
84
- },
85
- 'code, pre': {
86
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", monospace',
87
- },
88
- '.brand-logo': {
89
- position: 'relative',
90
- padding: '6px',
91
- borderRadius: '8px',
92
- '&::after': {
93
- content: '""',
94
- position: 'absolute',
95
- inset: '-6px',
96
- borderRadius: '10px',
97
- background: 'var(--accent-yellow-weak)',
98
- zIndex: -1,
99
- pointerEvents: 'none',
100
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  },
 
102
  },
103
- },
104
- MuiButton: {
105
- styleOverrides: {
106
- root: {
107
- borderRadius: '10px',
108
- fontWeight: 600,
109
- transition: 'transform 0.06s ease, background 0.12s ease, box-shadow 0.12s ease',
110
- '&:hover': {
111
- transform: 'translateY(-1px)',
112
- },
113
- },
114
  },
115
- },
116
- MuiPaper: {
117
- styleOverrides: {
118
- root: {
119
- backgroundImage: 'none',
120
- backgroundColor: 'transparent', // Default to transparent for gradients
 
 
 
 
 
 
121
  },
122
  },
123
  },
124
- MuiDrawer: {
125
- styleOverrides: {
126
- paper: {
127
- backgroundColor: 'var(--panel)',
128
- borderRight: '1px solid rgba(255,255,255,0.03)',
129
- },
 
 
 
130
  },
131
  },
132
- MuiTextField: {
133
- styleOverrides: {
134
- root: {
135
- '& .MuiOutlinedInput-root': {
136
- borderRadius: 'var(--radius-md)',
137
- '& fieldset': {
138
- borderColor: 'rgba(255,255,255,0.03)',
139
- },
140
- '&:hover fieldset': {
141
- borderColor: 'rgba(255,255,255,0.1)',
142
- },
143
- '&.Mui-focused fieldset': {
144
- borderColor: 'var(--accent-yellow)',
145
- borderWidth: '1px',
146
- boxShadow: 'var(--focus)',
147
- },
148
  },
149
  },
150
  },
151
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  },
153
- shape: {
154
- borderRadius: 12,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  },
 
156
  });
157
 
158
- export default theme;
 
 
1
+ import { createTheme, type ThemeOptions } from '@mui/material/styles';
2
 
3
+ // ── Shared tokens ────────────────────────────────────────────────
4
+ const sharedTypography: ThemeOptions['typography'] = {
5
+ fontFamily: 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',
6
+ fontSize: 15,
7
+ button: {
8
+ fontFamily: 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',
9
+ textTransform: 'none' as const,
10
+ fontWeight: 600,
11
+ },
12
+ };
13
+
14
+ const sharedComponents: ThemeOptions['components'] = {
15
+ MuiButton: {
16
+ styleOverrides: {
17
+ root: {
18
+ borderRadius: '10px',
19
+ fontWeight: 600,
20
+ transition: 'transform 0.06s ease, background 0.12s ease, box-shadow 0.12s ease',
21
+ '&:hover': { transform: 'translateY(-1px)' },
22
+ },
 
 
 
 
 
 
 
 
 
23
  },
24
  },
25
+ MuiPaper: {
26
+ styleOverrides: {
27
+ root: { backgroundImage: 'none', backgroundColor: 'transparent' },
 
 
 
 
 
 
 
 
 
 
 
 
28
  },
29
  },
30
+ };
31
+
32
+ const sharedShape: ThemeOptions['shape'] = { borderRadius: 12 };
33
+
34
+ // ── Dark palette ─────────────────────────────────────────────────
35
+ const darkVars = {
36
+ '--bg': '#0B0D10',
37
+ '--panel': '#0F1316',
38
+ '--surface': '#121416',
39
+ '--text': '#E6EEF8',
40
+ '--muted-text': '#98A0AA',
41
+ '--accent-yellow': '#FF9D00',
42
+ '--accent-yellow-weak': 'rgba(255,157,0,0.08)',
43
+ '--accent-green': '#2FCC71',
44
+ '--accent-red': '#E05A4F',
45
+ '--shadow-1': '0 6px 18px rgba(2,6,12,0.55)',
46
+ '--radius-lg': '20px',
47
+ '--radius-md': '12px',
48
+ '--focus': '0 0 0 3px rgba(255,157,0,0.12)',
49
+ '--border': 'rgba(255,255,255,0.03)',
50
+ '--border-hover': 'rgba(255,255,255,0.1)',
51
+ '--code-bg': 'rgba(0,0,0,0.5)',
52
+ '--tool-bg': 'rgba(0,0,0,0.3)',
53
+ '--tool-border': 'rgba(255,255,255,0.05)',
54
+ '--hover-bg': 'rgba(255,255,255,0.05)',
55
+ '--composer-bg': 'rgba(255,255,255,0.01)',
56
+ '--msg-gradient': 'linear-gradient(180deg, rgba(255,255,255,0.015), transparent)',
57
+ '--body-gradient': 'linear-gradient(180deg, #0B0D10, #090B0D)',
58
+ '--scrollbar-thumb': '#30363D',
59
+ '--success-icon': '#FDB022',
60
+ '--error-icon': '#F87171',
61
+ '--clickable-text': 'rgba(255, 255, 255, 0.9)',
62
+ '--clickable-underline': 'rgba(255,255,255,0.3)',
63
+ '--code-panel-bg': '#0A0B0C',
64
+ '--tab-active-bg': 'rgba(255,255,255,0.08)',
65
+ '--tab-active-border': 'rgba(255,255,255,0.1)',
66
+ '--tab-hover-bg': 'rgba(255,255,255,0.05)',
67
+ '--tab-close-hover': 'rgba(255,255,255,0.1)',
68
+ '--plan-bg': 'rgba(0,0,0,0.2)',
69
+ } as const;
70
+
71
+ // ── Light palette ────────────────────────────────────────────────
72
+ const lightVars = {
73
+ '--bg': '#FFFFFF',
74
+ '--panel': '#F7F8FA',
75
+ '--surface': '#F0F1F3',
76
+ '--text': '#1A1A2E',
77
+ '--muted-text': '#6B7280',
78
+ '--accent-yellow': '#FF9D00',
79
+ '--accent-yellow-weak': 'rgba(255,157,0,0.08)',
80
+ '--accent-green': '#16A34A',
81
+ '--accent-red': '#DC2626',
82
+ '--shadow-1': '0 4px 12px rgba(0,0,0,0.08)',
83
+ '--radius-lg': '20px',
84
+ '--radius-md': '12px',
85
+ '--focus': '0 0 0 3px rgba(255,157,0,0.15)',
86
+ '--border': 'rgba(0,0,0,0.08)',
87
+ '--border-hover': 'rgba(0,0,0,0.15)',
88
+ '--code-bg': 'rgba(0,0,0,0.04)',
89
+ '--tool-bg': 'rgba(0,0,0,0.03)',
90
+ '--tool-border': 'rgba(0,0,0,0.08)',
91
+ '--hover-bg': 'rgba(0,0,0,0.04)',
92
+ '--composer-bg': 'rgba(0,0,0,0.02)',
93
+ '--msg-gradient': 'linear-gradient(180deg, rgba(0,0,0,0.01), transparent)',
94
+ '--body-gradient': 'linear-gradient(180deg, #FFFFFF, #F7F8FA)',
95
+ '--scrollbar-thumb': '#C4C8CC',
96
+ '--success-icon': '#FF9D00',
97
+ '--error-icon': '#DC2626',
98
+ '--clickable-text': 'rgba(0, 0, 0, 0.85)',
99
+ '--clickable-underline': 'rgba(0,0,0,0.25)',
100
+ '--code-panel-bg': '#F5F6F8',
101
+ '--tab-active-bg': 'rgba(0,0,0,0.06)',
102
+ '--tab-active-border': 'rgba(0,0,0,0.1)',
103
+ '--tab-hover-bg': 'rgba(0,0,0,0.04)',
104
+ '--tab-close-hover': 'rgba(0,0,0,0.08)',
105
+ '--plan-bg': 'rgba(0,0,0,0.03)',
106
+ } as const;
107
+
108
+ // ── Shared CSS baseline (scrollbar, code, brand-logo) ────────────
109
+ function makeCssBaseline(vars: Record<string, string>) {
110
+ return {
111
+ styleOverrides: {
112
+ ':root': vars,
113
+ body: {
114
+ background: 'var(--body-gradient)',
115
+ color: 'var(--text)',
116
+ scrollbarWidth: 'thin' as const,
117
+ '&::-webkit-scrollbar': { width: '8px', height: '8px' },
118
+ '&::-webkit-scrollbar-thumb': {
119
+ backgroundColor: 'var(--scrollbar-thumb)',
120
+ borderRadius: '2px',
121
  },
122
+ '&::-webkit-scrollbar-track': { backgroundColor: 'transparent' },
123
  },
124
+ 'code, pre': {
125
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", monospace',
 
 
 
 
 
 
 
 
 
126
  },
127
+ '.brand-logo': {
128
+ position: 'relative' as const,
129
+ padding: '6px',
130
+ borderRadius: '8px',
131
+ '&::after': {
132
+ content: '""',
133
+ position: 'absolute' as const,
134
+ inset: '-6px',
135
+ borderRadius: '10px',
136
+ background: 'var(--accent-yellow-weak)',
137
+ zIndex: -1,
138
+ pointerEvents: 'none' as const,
139
  },
140
  },
141
  },
142
+ };
143
+ }
144
+
145
+ function makeDrawer() {
146
+ return {
147
+ styleOverrides: {
148
+ paper: {
149
+ backgroundColor: 'var(--panel)',
150
+ borderRight: '1px solid var(--border)',
151
  },
152
  },
153
+ };
154
+ }
155
+
156
+ function makeTextField() {
157
+ return {
158
+ styleOverrides: {
159
+ root: {
160
+ '& .MuiOutlinedInput-root': {
161
+ borderRadius: 'var(--radius-md)',
162
+ '& fieldset': { borderColor: 'var(--border)' },
163
+ '&:hover fieldset': { borderColor: 'var(--border-hover)' },
164
+ '&.Mui-focused fieldset': {
165
+ borderColor: 'var(--accent-yellow)',
166
+ borderWidth: '1px',
167
+ boxShadow: 'var(--focus)',
 
168
  },
169
  },
170
  },
171
  },
172
+ };
173
+ }
174
+
175
+ // ── Theme builders ───────────────────────────────────────────────
176
+ export const darkTheme = createTheme({
177
+ palette: {
178
+ mode: 'dark',
179
+ primary: { main: '#FF9D00' },
180
+ secondary: { main: '#C7A500' },
181
+ background: { default: '#0B0D10', paper: '#0F1316' },
182
+ text: { primary: '#E6EEF8', secondary: '#98A0AA' },
183
+ divider: 'rgba(255,255,255,0.03)',
184
+ success: { main: '#2FCC71' },
185
+ error: { main: '#E05A4F' },
186
+ warning: { main: '#FF9D00' },
187
+ info: { main: '#58A6FF' },
188
+ },
189
+ typography: sharedTypography,
190
+ components: {
191
+ ...sharedComponents,
192
+ MuiCssBaseline: makeCssBaseline(darkVars),
193
+ MuiDrawer: makeDrawer(),
194
+ MuiTextField: makeTextField(),
195
  },
196
+ shape: sharedShape,
197
+ });
198
+
199
+ export const lightTheme = createTheme({
200
+ palette: {
201
+ mode: 'light',
202
+ primary: { main: '#FF9D00' },
203
+ secondary: { main: '#B8960A' },
204
+ background: { default: '#FFFFFF', paper: '#F7F8FA' },
205
+ text: { primary: '#1A1A2E', secondary: '#6B7280' },
206
+ divider: 'rgba(0,0,0,0.08)',
207
+ success: { main: '#16A34A' },
208
+ error: { main: '#DC2626' },
209
+ warning: { main: '#FF9D00' },
210
+ info: { main: '#2563EB' },
211
+ },
212
+ typography: sharedTypography,
213
+ components: {
214
+ ...sharedComponents,
215
+ MuiCssBaseline: makeCssBaseline(lightVars),
216
+ MuiDrawer: makeDrawer(),
217
+ MuiTextField: makeTextField(),
218
  },
219
+ shape: sharedShape,
220
  });
221
 
222
+ // Keep default export for backwards compat
223
+ export default darkTheme;
frontend/src/types/agent.ts CHANGED
@@ -52,8 +52,11 @@ export interface ApprovalBatch {
52
  count: number;
53
  }
54
 
 
 
55
  export interface TraceLog {
56
  id: string;
 
57
  type: 'call' | 'output';
58
  text: string;
59
  tool: string;
@@ -62,6 +65,12 @@ export interface TraceLog {
62
  args?: Record<string, unknown>; // Store args for auto-exec jobs
63
  output?: string; // Store tool output for display
64
  success?: boolean; // Whether the tool call succeeded
 
 
 
 
 
 
65
  }
66
 
67
  export interface User {
 
52
  count: number;
53
  }
54
 
55
+ export type ApprovalStatus = 'none' | 'pending' | 'approved' | 'rejected';
56
+
57
  export interface TraceLog {
58
  id: string;
59
+ toolCallId?: string; // Backend tool_call_id for reliable matching
60
  type: 'call' | 'output';
61
  text: string;
62
  tool: string;
 
65
  args?: Record<string, unknown>; // Store args for auto-exec jobs
66
  output?: string; // Store tool output for display
67
  success?: boolean; // Whether the tool call succeeded
68
+ /** Approval state for tools that need user confirmation */
69
+ approvalStatus?: ApprovalStatus;
70
+ /** Parsed job info (URL, status, logs) for hf_jobs */
71
+ jobUrl?: string;
72
+ jobStatus?: string;
73
+ jobLogs?: string;
74
  }
75
 
76
  export interface User {
frontend/src/types/events.ts CHANGED
@@ -6,6 +6,8 @@ export type EventType =
6
  | 'ready'
7
  | 'processing'
8
  | 'assistant_message'
 
 
9
  | 'tool_call'
10
  | 'tool_output'
11
  | 'tool_log'
 
6
  | 'ready'
7
  | 'processing'
8
  | 'assistant_message'
9
+ | 'assistant_chunk'
10
+ | 'assistant_stream_end'
11
  | 'tool_call'
12
  | 'tool_output'
13
  | 'tool_log'
frontend/src/utils/api.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Centralized API utilities with automatic auth header injection.
3
+ *
4
+ * In production (OAuth enabled):
5
+ * - REST calls include the HttpOnly cookie automatically (same-origin)
6
+ * - WebSocket passes token via query parameter
7
+ *
8
+ * In development (no OAuth):
9
+ * - Auth is bypassed on the backend, no token needed
10
+ */
11
+
12
+ /** Get the base URL for API calls (handles dev proxy vs production) */
13
+ function getApiBase(): string {
14
+ // In development, Vite proxies /api and /auth to the backend
15
+ // In production, same origin
16
+ return '';
17
+ }
18
+
19
+ /** Wrapper around fetch that includes credentials (cookies) and common headers. */
20
+ export async function apiFetch(
21
+ path: string,
22
+ options: RequestInit = {}
23
+ ): Promise<Response> {
24
+ const url = `${getApiBase()}${path}`;
25
+
26
+ const headers: Record<string, string> = {
27
+ 'Content-Type': 'application/json',
28
+ ...(options.headers as Record<string, string>),
29
+ };
30
+
31
+ const response = await fetch(url, {
32
+ ...options,
33
+ headers,
34
+ credentials: 'include', // Send cookies (hf_access_token) with every request
35
+ });
36
+
37
+ // Handle 401 - redirect to login if auth is required
38
+ if (response.status === 401) {
39
+ const authStatus = await fetch(`${getApiBase()}/auth/status`, {
40
+ credentials: 'include',
41
+ });
42
+ const data = await authStatus.json();
43
+ if (data.auth_enabled) {
44
+ window.location.href = '/auth/login';
45
+ throw new Error('Authentication required — redirecting to login.');
46
+ }
47
+ }
48
+
49
+ return response;
50
+ }
51
+
52
+ /** Build the WebSocket URL for a session, including auth token if available. */
53
+ export function getWebSocketUrl(sessionId: string): string {
54
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
55
+ // Always use same origin — Vite proxy (ws: true) handles dev,
56
+ // same origin works directly in production. No cross-origin issues.
57
+ return `${protocol}//${window.location.host}/api/ws/${sessionId}`;
58
+ }
frontend/src/utils/logger.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Lightweight logger that silences verbose output in production.
3
+ *
4
+ * - `log` / `debug` are only emitted when `import.meta.env.DEV` is true.
5
+ * - `warn` and `error` always go through so real issues surface in prod.
6
+ */
7
+
8
+ const isDev = import.meta.env.DEV;
9
+
10
+ /* eslint-disable no-console */
11
+ export const logger = {
12
+ /** Debug-level log — DEV only. */
13
+ log: (...args: unknown[]) => {
14
+ if (isDev) console.log(...args);
15
+ },
16
+ /** Debug-level log — DEV only. */
17
+ debug: (...args: unknown[]) => {
18
+ if (isDev) console.debug(...args);
19
+ },
20
+ /** Warning — always emitted. */
21
+ warn: console.warn.bind(console),
22
+ /** Error — always emitted. */
23
+ error: console.error.bind(console),
24
+ };
frontend/vite.config.ts CHANGED
@@ -15,6 +15,7 @@ export default defineConfig({
15
  '/api': {
16
  target: 'http://localhost:7860',
17
  changeOrigin: true,
 
18
  },
19
  '/auth': {
20
  target: 'http://localhost:7860',
 
15
  '/api': {
16
  target: 'http://localhost:7860',
17
  changeOrigin: true,
18
+ ws: true, // Proxy WebSocket connections (/api/ws/...)
19
  },
20
  '/auth': {
21
  target: 'http://localhost:7860',