Aksel Joonas Reedi commited on
Commit
39742ba
·
2 Parent(s): 0ea33d008153a8

merging frontend to github (png binaries still remain in github history)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +4 -0
  2. agent/context_manager/manager.py +73 -3
  3. agent/core/agent_loop.py +355 -89
  4. agent/core/session.py +48 -8
  5. agent/core/session_uploader.py +2 -4
  6. agent/core/tools.py +13 -6
  7. agent/prompts/system_prompt.yaml +2 -2
  8. agent/prompts/system_prompt_v2.yaml +46 -59
  9. agent/tools/jobs_tool.py +49 -19
  10. agent/tools/sandbox_tool.py +6 -2
  11. backend/dependencies.py +144 -0
  12. backend/main.py +8 -0
  13. backend/models.py +12 -0
  14. backend/routes/agent.py +282 -27
  15. backend/routes/auth.py +74 -51
  16. backend/session_manager.py +114 -14
  17. backend/websocket.py +0 -10
  18. configs/main_agent_config.json +2 -2
  19. frontend/package-lock.json +168 -0
  20. frontend/package.json +2 -0
  21. frontend/src/App.tsx +5 -0
  22. frontend/src/components/ApprovalModal/ApprovalModal.tsx +0 -208
  23. frontend/src/components/Chat/ActivityStatusBar.tsx +57 -0
  24. frontend/src/components/Chat/ApprovalFlow.tsx +0 -515
  25. frontend/src/components/Chat/AssistantMessage.tsx +119 -0
  26. frontend/src/components/Chat/ChatInput.tsx +218 -15
  27. frontend/src/components/Chat/MarkdownContent.tsx +160 -0
  28. frontend/src/components/Chat/MessageBubble.tsx +32 -203
  29. frontend/src/components/Chat/MessageList.tsx +125 -74
  30. frontend/src/components/Chat/ThinkingIndicator.tsx +48 -0
  31. frontend/src/components/Chat/ToolCallGroup.tsx +655 -0
  32. frontend/src/components/Chat/UserMessage.tsx +105 -0
  33. frontend/src/components/CodePanel/CodePanel.tsx +479 -256
  34. frontend/src/components/Layout/AppLayout.tsx +351 -167
  35. frontend/src/components/SessionSidebar/SessionSidebar.tsx +279 -181
  36. frontend/src/components/WelcomeScreen/WelcomeScreen.tsx +247 -0
  37. frontend/src/hooks/useAgentChat.ts +278 -0
  38. frontend/src/hooks/useAgentWebSocket.ts +0 -503
  39. frontend/src/hooks/useAuth.ts +77 -0
  40. frontend/src/lib/chat-message-store.ts +62 -0
  41. frontend/src/lib/ws-chat-transport.ts +593 -0
  42. frontend/src/main.tsx +13 -3
  43. frontend/src/store/agentStore.ts +121 -206
  44. frontend/src/store/layoutStore.ts +28 -10
  45. frontend/src/store/sessionStore.ts +7 -5
  46. frontend/src/theme.ts +202 -137
  47. frontend/src/types/agent.ts +9 -50
  48. frontend/src/types/events.ts +3 -0
  49. frontend/src/utils/api.ts +47 -0
  50. frontend/src/utils/logger.ts +24 -0
README.md CHANGED
@@ -9,7 +9,11 @@ hf_oauth: true
9
  hf_oauth_scopes:
10
  - read-repos
11
  - write-repos
 
 
12
  - inference-api
 
 
13
  ---
14
 
15
  # HF Agent
 
9
  hf_oauth_scopes:
10
  - read-repos
11
  - write-repos
12
+ - contribute-repos
13
+ - manage-repos
14
  - inference-api
15
+ - jobs
16
+ - write-discussions
17
  ---
18
 
19
  # HF Agent
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
@@ -13,6 +14,72 @@ 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 +121,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(
@@ -110,11 +176,15 @@ class ContextManager:
110
  )
111
  )
112
 
 
113
  response = await acompletion(
114
  model=model_name,
115
  messages=messages_to_summarize,
116
  max_completion_tokens=self.compact_size,
117
  tools=tool_specs,
 
 
 
118
  )
119
  summarized_message = Message(
120
  role="assistant", content=response.choices[0].message.content
 
2
  Context management for conversation history
3
  """
4
 
5
+ import logging
6
  import os
7
  import zoneinfo
8
  from datetime import datetime
 
14
  from jinja2 import Template
15
  from litellm import Message, acompletion
16
 
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Module-level cache for HF username — avoids repeating the slow whoami() call
20
+ _hf_username_cache: str | None = None
21
+
22
+ _HF_WHOAMI_URL = "https://huggingface.co/api/whoami-v2"
23
+ _HF_WHOAMI_TIMEOUT = 5 # seconds
24
+
25
+
26
+ def _get_hf_username() -> str:
27
+ """Return the HF username, cached after the first call.
28
+
29
+ Uses subprocess + curl to avoid Python HTTP client IPv6 issues that
30
+ cause 40+ second hangs (httpx/urllib try IPv6 first which times out
31
+ at OS level before falling back to IPv4 — the "Happy Eyeballs" problem).
32
+ """
33
+ import json
34
+ import subprocess
35
+ import time as _t
36
+
37
+ global _hf_username_cache
38
+ if _hf_username_cache is not None:
39
+ return _hf_username_cache
40
+
41
+ hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
42
+ if not hf_token:
43
+ logger.warning("No HF_TOKEN set, using 'unknown' as username")
44
+ _hf_username_cache = "unknown"
45
+ return _hf_username_cache
46
+
47
+ t0 = _t.monotonic()
48
+ try:
49
+ result = subprocess.run(
50
+ [
51
+ "curl",
52
+ "-s",
53
+ "-4", # force IPv4
54
+ "-m",
55
+ str(_HF_WHOAMI_TIMEOUT), # max time
56
+ "-H",
57
+ f"Authorization: Bearer {hf_token}",
58
+ _HF_WHOAMI_URL,
59
+ ],
60
+ capture_output=True,
61
+ text=True,
62
+ timeout=_HF_WHOAMI_TIMEOUT + 2,
63
+ )
64
+ t1 = _t.monotonic()
65
+ if result.returncode == 0 and result.stdout:
66
+ data = json.loads(result.stdout)
67
+ _hf_username_cache = data.get("name", "unknown")
68
+ logger.info(
69
+ f"HF username resolved to '{_hf_username_cache}' in {t1 - t0:.2f}s"
70
+ )
71
+ else:
72
+ logger.warning(
73
+ f"curl whoami failed (rc={result.returncode}) in {t1 - t0:.2f}s"
74
+ )
75
+ _hf_username_cache = "unknown"
76
+ except Exception as e:
77
+ t1 = _t.monotonic()
78
+ logger.warning(f"HF whoami failed in {t1 - t0:.2f}s: {e}")
79
+ _hf_username_cache = "unknown"
80
+
81
+ return _hf_username_cache
82
+
83
 
84
  class ContextManager:
85
  """Manages conversation context and message history for the agent"""
 
121
  current_time = now.strftime("%H:%M:%S.%f")[:-3]
122
  current_timezone = f"{now.strftime('%Z')} (UTC{now.strftime('%z')[:3]}:{now.strftime('%z')[3:]})"
123
 
124
+ # Get HF user info (cached after the first call)
125
+ hf_user_info = _get_hf_username()
 
126
 
127
  template = Template(template_str)
128
  return template.render(
 
176
  )
177
  )
178
 
179
+ hf_key = os.environ.get("INFERENCE_TOKEN")
180
  response = await acompletion(
181
  model=model_name,
182
  messages=messages_to_summarize,
183
  max_completion_tokens=self.compact_size,
184
  tools=tool_specs,
185
+ api_key=hf_key
186
+ if hf_key and model_name.startswith("huggingface/")
187
+ else None,
188
  )
189
  summarized_message = Message(
190
  role="assistant", content=response.choices[0].message.content
agent/core/agent_loop.py CHANGED
@@ -4,8 +4,10 @@ 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 litellm.exceptions import ContextWindowExceededError
10
  from lmnr import observe
11
 
@@ -14,7 +16,42 @@ from agent.core.session import Event, OpType, Session
14
  from agent.core.tools import ToolRouter
15
  from agent.tools.jobs_tool import CPU_FLAVORS
16
 
 
 
17
  ToolCall = ChatCompletionMessageToolCall
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  def _validate_tool_args(tool_args: dict) -> tuple[bool, str | None]:
@@ -130,6 +167,42 @@ async def _compact_and_notify(session: Session) -> None:
130
  class Handlers:
131
  """Handler functions for each operation type"""
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  @staticmethod
134
  @observe(name="run_agent")
135
  async def run_agent(
@@ -145,6 +218,11 @@ class Handlers:
145
 
146
  Laminar.set_trace_session_id(session_id=session.session_id)
147
 
 
 
 
 
 
148
  # Add user message to history only if there's actual content
149
  if text:
150
  user_msg = Message(role="user", content=text)
@@ -165,37 +243,100 @@ class Handlers:
165
 
166
  messages = session.context_manager.get_messages()
167
  tools = session.tool_router.get_tool_specs_for_llm()
168
-
169
  try:
170
- response: ModelResponse = await acompletion(
171
- model=session.config.model_name,
 
172
  messages=messages,
173
  tools=tools,
174
  tool_choice="auto",
 
 
 
175
  )
176
 
177
- # Extract text response, token usage, and tool calls
178
- message = response.choices[0].message
179
- content = message.content
180
- token_count = response.usage.total_tokens
181
- tool_calls: list[ToolCall] = message.get("tool_calls", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
  # If no tool calls, add assistant message and we're done
184
  if not tool_calls:
185
  if content:
186
  assistant_msg = Message(role="assistant", content=content)
187
  session.context_manager.add_message(assistant_msg, token_count)
188
- await session.send_event(
189
- Event(
190
- event_type="assistant_message",
191
- data={"content": content},
192
- )
193
- )
194
  final_response = content
195
  break
196
 
197
  # Add assistant message with tool calls to history
198
- # LiteLLM will format this correctly for the provider
199
  assistant_msg = Message(
200
  role="assistant",
201
  content=content,
@@ -203,66 +344,97 @@ class Handlers:
203
  )
204
  session.context_manager.add_message(assistant_msg, token_count)
205
 
206
- if content:
207
- await session.send_event(
208
- Event(event_type="assistant_message", data={"content": content})
209
- )
210
-
211
  # Separate tools into those requiring approval and those that don't
212
  approval_required_tools = []
213
  non_approval_tools = []
214
 
215
  for tc in tool_calls:
216
  tool_name = tc.function.name
217
- tool_args = json.loads(tc.function.arguments)
 
 
 
 
218
 
219
  if _needs_approval(tool_name, tool_args, session.config):
220
  approval_required_tools.append(tc)
221
  else:
222
  non_approval_tools.append(tc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
- # Execute non-approval tools first
225
- for tc in non_approval_tools:
226
- tool_name = tc.function.name
227
- tool_args = json.loads(tc.function.arguments)
228
-
229
- # Validate tool arguments before calling
230
- args_valid, error_msg = _validate_tool_args(tool_args)
231
- if not args_valid:
232
- # Return error to agent instead of calling tool
233
- output = error_msg
234
- success = False
235
- else:
236
- await session.send_event(
237
- Event(
238
- event_type="tool_call",
239
- data={"tool": tool_name, "arguments": tool_args},
240
  )
241
- )
242
 
243
- output, success = await session.tool_router.call_tool(
244
- tool_name, tool_args, session=session
 
 
 
 
 
 
 
 
 
 
245
  )
 
246
 
247
- # Add tool result to history
248
- tool_msg = Message(
249
- role="tool",
250
- content=output,
251
- tool_call_id=tc.id,
252
- name=tool_name,
253
  )
254
- session.context_manager.add_message(tool_msg)
255
 
256
- await session.send_event(
257
- Event(
258
- event_type="tool_output",
259
- data={
260
- "tool": tool_name,
261
- "output": output,
262
- "success": success,
263
- },
 
 
 
 
 
 
 
 
 
 
 
 
264
  )
265
- )
266
 
267
  # If there are tools requiring approval, ask for batch approval
268
  if approval_required_tools:
@@ -270,7 +442,10 @@ class Handlers:
270
  tools_data = []
271
  for tc in approval_required_tools:
272
  tool_name = tc.function.name
273
- tool_args = json.loads(tc.function.arguments)
 
 
 
274
  tools_data.append(
275
  {
276
  "tool": tool_name,
@@ -339,11 +514,27 @@ class Handlers:
339
 
340
  @staticmethod
341
  async def undo(session: Session) -> None:
342
- """Handle undo (like undo in codex.rs:1314)"""
343
- # Remove last user turn and all following items
344
- # Simplified: just remove last 2 items
345
- for _ in range(min(2, len(session.context_manager.items))):
346
- session.context_manager.items.pop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
  await session.send_event(Event(event_type="undo_complete"))
349
 
@@ -371,6 +562,9 @@ class Handlers:
371
 
372
  # Create a map of tool_call_id -> approval decision
373
  approval_map = {a["tool_call_id"]: a for a in approvals}
 
 
 
374
 
375
  # Separate approved and rejected tool calls
376
  approved_tasks = []
@@ -378,36 +572,99 @@ class Handlers:
378
 
379
  for tc in tool_calls:
380
  tool_name = tc.function.name
381
- tool_args = json.loads(tc.function.arguments)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  approval_decision = approval_map.get(tc.id, {"approved": False})
383
 
384
  if approval_decision.get("approved", False):
385
- approved_tasks.append((tc, tool_name, tool_args))
 
 
 
 
 
 
386
  else:
387
  rejected_tasks.append((tc, tool_name, approval_decision))
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  # Execute all approved tools concurrently
390
- async def execute_tool(tc, tool_name, tool_args):
391
- """Execute a single tool and return its result"""
 
 
 
 
 
392
  await session.send_event(
393
  Event(
394
- event_type="tool_call",
395
- data={"tool": tool_name, "arguments": tool_args},
 
 
 
 
396
  )
397
  )
398
 
399
  output, success = await session.tool_router.call_tool(
400
- tool_name, tool_args, session=session
401
  )
402
 
403
- return (tc, tool_name, output, success)
404
 
405
  # Execute all approved tools concurrently and wait for ALL to complete
406
  if approved_tasks:
407
  results = await asyncio.gather(
408
  *[
409
- execute_tool(tc, tool_name, tool_args)
410
- for tc, tool_name, tool_args in approved_tasks
411
  ],
412
  return_exceptions=True,
413
  )
@@ -416,10 +673,13 @@ class Handlers:
416
  for result in results:
417
  if isinstance(result, Exception):
418
  # Handle execution error
419
- print(f"Tool execution error: {result}")
420
  continue
421
 
422
- tc, tool_name, output, success = result
 
 
 
423
 
424
  # Add tool result to context
425
  tool_msg = Message(
@@ -435,6 +695,7 @@ class Handlers:
435
  event_type="tool_output",
436
  data={
437
  "tool": tool_name,
 
438
  "output": output,
439
  "success": success,
440
  },
@@ -446,7 +707,14 @@ class Handlers:
446
  rejection_msg = "Job execution cancelled by user"
447
  user_feedback = approval_decision.get("feedback")
448
  if user_feedback:
449
- rejection_msg += f". User feedback: {user_feedback}"
 
 
 
 
 
 
 
450
 
451
  tool_msg = Message(
452
  role="tool",
@@ -461,6 +729,7 @@ class Handlers:
461
  event_type="tool_output",
462
  data={
463
  "tool": tool_name,
 
464
  "output": rejection_msg,
465
  "success": False,
466
  },
@@ -478,11 +747,9 @@ class Handlers:
478
  """Handle shutdown (like shutdown in codex.rs:1329)"""
479
  # Save session trajectory if enabled (fire-and-forget, returns immediately)
480
  if session.config.save_sessions:
481
- print("💾 Saving session...")
482
  repo_id = session.config.session_dataset_repo
483
  _ = session.save_and_upload_detached(repo_id)
484
- # if local_path:
485
- # print("✅ Session saved locally, upload in progress")
486
 
487
  session.is_running = False
488
  await session.send_event(Event(event_type="shutdown"))
@@ -497,7 +764,7 @@ async def process_submission(session: Session, submission) -> bool:
497
  bool: True to continue, False to shutdown
498
  """
499
  op = submission.operation
500
- # print(f"📨 Received: {op.op_type.value}")
501
 
502
  if op.op_type == OpType.USER_INPUT:
503
  text = op.data.get("text", "") if op.data else ""
@@ -509,7 +776,6 @@ async def process_submission(session: Session, submission) -> bool:
509
  return True
510
 
511
  if op.op_type == OpType.COMPACT:
512
- # compact from the frontend
513
  await _compact_and_notify(session)
514
  return True
515
 
@@ -525,7 +791,7 @@ async def process_submission(session: Session, submission) -> bool:
525
  if op.op_type == OpType.SHUTDOWN:
526
  return not await Handlers.shutdown(session)
527
 
528
- print(f"⚠️ Unknown operation: {op.op_type}")
529
  return True
530
 
531
 
@@ -543,7 +809,7 @@ async def submission_loop(
543
 
544
  # Create session with tool router
545
  session = Session(event_queue, config=config, tool_router=tool_router)
546
- print("Agent loop started")
547
 
548
  # Retry any failed uploads from previous sessions (fire-and-forget)
549
  if config and config.save_sessions:
@@ -567,25 +833,25 @@ async def submission_loop(
567
  if not should_continue:
568
  break
569
  except asyncio.CancelledError:
570
- print("\n⚠️ Agent loop cancelled")
571
  break
572
  except Exception as e:
573
- print(f"Error in agent loop: {e}")
574
  await session.send_event(
575
  Event(event_type="error", data={"error": str(e)})
576
  )
577
 
578
- print("🛑 Agent loop exited")
579
 
580
  finally:
581
  # Emergency save if session saving is enabled and shutdown wasn't called properly
582
  if session.config.save_sessions and session.is_running:
583
- print("\n💾 Emergency save: preserving session before exit...")
584
  try:
585
  local_path = session.save_and_upload_detached(
586
  session.config.session_dataset_repo
587
  )
588
  if local_path:
589
- print("Emergency save successful, upload in progress")
590
  except Exception as e:
591
- print(f"Emergency save failed: {e}")
 
4
 
5
  import asyncio
6
  import json
7
+ import logging
8
+ import os
9
 
10
+ from litellm import ChatCompletionMessageToolCall, Message, acompletion
11
  from litellm.exceptions import ContextWindowExceededError
12
  from lmnr import observe
13
 
 
16
  from agent.core.tools import ToolRouter
17
  from agent.tools.jobs_tool import CPU_FLAVORS
18
 
19
+ logger = logging.getLogger(__name__)
20
+
21
  ToolCall = ChatCompletionMessageToolCall
22
+ # Explicit inference token — needed because litellm checks HF_TOKEN before
23
+ # HUGGINGFACE_API_KEY, and HF_TOKEN (used for Hub ops) may lack inference permissions.
24
+ _INFERENCE_API_KEY = os.environ.get("INFERENCE_TOKEN")
25
+
26
+
27
+ def _resolve_hf_router_params(model_name: str) -> dict:
28
+ """
29
+ Build LiteLLM kwargs for HuggingFace Router models.
30
+
31
+ api-inference.huggingface.co is deprecated; the new router lives at
32
+ router.huggingface.co/<provider>/v3/openai. LiteLLM's built-in
33
+ ``huggingface/`` provider still targets the old endpoint, so we
34
+ rewrite model names to ``openai/`` and supply the correct api_base.
35
+
36
+ Input format: huggingface/<router_provider>/<org>/<model>
37
+ Example: huggingface/novita/moonshotai/kimi-k2.5
38
+ """
39
+ if not model_name.startswith("huggingface/"):
40
+ return {"model": model_name}
41
+
42
+ parts = model_name.split("/", 2) # ['huggingface', 'novita', 'moonshotai/kimi-k2.5']
43
+ if len(parts) < 3:
44
+ return {"model": model_name}
45
+
46
+ router_provider = parts[1]
47
+ actual_model = parts[2]
48
+ api_key = _INFERENCE_API_KEY or os.environ.get("HF_TOKEN")
49
+
50
+ return {
51
+ "model": f"openai/{actual_model}",
52
+ "api_base": f"https://router.huggingface.co/{router_provider}/v3/openai",
53
+ "api_key": api_key,
54
+ }
55
 
56
 
57
  def _validate_tool_args(tool_args: dict) -> tuple[bool, str | None]:
 
167
  class Handlers:
168
  """Handler functions for each operation type"""
169
 
170
+ @staticmethod
171
+ async def _abandon_pending_approval(session: Session) -> None:
172
+ """Cancel pending approval tools when the user continues the conversation.
173
+
174
+ Injects rejection tool-result messages into the LLM context (so the
175
+ history stays valid) and notifies the frontend that those tools were
176
+ abandoned.
177
+ """
178
+ tool_calls = session.pending_approval.get("tool_calls", [])
179
+ for tc in tool_calls:
180
+ tool_name = tc.function.name
181
+ abandon_msg = "Task abandoned — user continued the conversation without approving."
182
+
183
+ # Keep LLM context valid: every tool_call needs a tool result
184
+ tool_msg = Message(
185
+ role="tool",
186
+ content=abandon_msg,
187
+ tool_call_id=tc.id,
188
+ name=tool_name,
189
+ )
190
+ session.context_manager.add_message(tool_msg)
191
+
192
+ await session.send_event(
193
+ Event(
194
+ event_type="tool_state_change",
195
+ data={
196
+ "tool_call_id": tc.id,
197
+ "tool": tool_name,
198
+ "state": "abandoned",
199
+ },
200
+ )
201
+ )
202
+
203
+ session.pending_approval = None
204
+ logger.info("Abandoned %d pending approval tool(s)", len(tool_calls))
205
+
206
  @staticmethod
207
  @observe(name="run_agent")
208
  async def run_agent(
 
218
 
219
  Laminar.set_trace_session_id(session_id=session.session_id)
220
 
221
+ # If there's a pending approval and the user sent a new message,
222
+ # abandon the pending tools so the LLM context stays valid.
223
+ if text and session.pending_approval:
224
+ await Handlers._abandon_pending_approval(session)
225
+
226
  # Add user message to history only if there's actual content
227
  if text:
228
  user_msg = Message(role="user", content=text)
 
243
 
244
  messages = session.context_manager.get_messages()
245
  tools = session.tool_router.get_tool_specs_for_llm()
 
246
  try:
247
+ # ── Stream the LLM response ──────────────────────────
248
+ llm_params = _resolve_hf_router_params(session.config.model_name)
249
+ response = await acompletion(
250
  messages=messages,
251
  tools=tools,
252
  tool_choice="auto",
253
+ stream=True,
254
+ stream_options={"include_usage": True},
255
+ **llm_params,
256
  )
257
 
258
+ full_content = ""
259
+ tool_calls_acc: dict[int, dict] = {}
260
+ token_count = 0
261
+
262
+ async for chunk in response:
263
+ choice = chunk.choices[0] if chunk.choices else None
264
+ if not choice:
265
+ # Last chunk may carry only usage info
266
+ if hasattr(chunk, "usage") and chunk.usage:
267
+ token_count = chunk.usage.total_tokens
268
+ continue
269
+
270
+ delta = choice.delta
271
+
272
+ # Stream text deltas to the frontend
273
+ if delta.content:
274
+ full_content += delta.content
275
+ await session.send_event(
276
+ Event(
277
+ event_type="assistant_chunk",
278
+ data={"content": delta.content},
279
+ )
280
+ )
281
+
282
+ # Accumulate tool-call deltas (name + args arrive in pieces)
283
+ if delta.tool_calls:
284
+ for tc_delta in delta.tool_calls:
285
+ idx = tc_delta.index
286
+ if idx not in tool_calls_acc:
287
+ tool_calls_acc[idx] = {
288
+ "id": "",
289
+ "type": "function",
290
+ "function": {"name": "", "arguments": ""},
291
+ }
292
+ if tc_delta.id:
293
+ tool_calls_acc[idx]["id"] = tc_delta.id
294
+ if tc_delta.function:
295
+ if tc_delta.function.name:
296
+ tool_calls_acc[idx]["function"]["name"] += (
297
+ tc_delta.function.name
298
+ )
299
+ if tc_delta.function.arguments:
300
+ tool_calls_acc[idx]["function"]["arguments"] += (
301
+ tc_delta.function.arguments
302
+ )
303
+
304
+ # Capture usage from the final chunk
305
+ if hasattr(chunk, "usage") and chunk.usage:
306
+ token_count = chunk.usage.total_tokens
307
+
308
+ # ── Stream finished — reconstruct full message ───────
309
+ content = full_content or None
310
+
311
+ # Build tool_calls list from accumulated deltas
312
+ tool_calls: list[ToolCall] = []
313
+ for idx in sorted(tool_calls_acc.keys()):
314
+ tc_data = tool_calls_acc[idx]
315
+ tool_calls.append(
316
+ ToolCall(
317
+ id=tc_data["id"],
318
+ type="function",
319
+ function={
320
+ "name": tc_data["function"]["name"],
321
+ "arguments": tc_data["function"]["arguments"],
322
+ },
323
+ )
324
+ )
325
+
326
+ # Signal end of streaming to the frontend
327
+ await session.send_event(
328
+ Event(event_type="assistant_stream_end", data={})
329
+ )
330
 
331
  # If no tool calls, add assistant message and we're done
332
  if not tool_calls:
333
  if content:
334
  assistant_msg = Message(role="assistant", content=content)
335
  session.context_manager.add_message(assistant_msg, token_count)
 
 
 
 
 
 
336
  final_response = content
337
  break
338
 
339
  # Add assistant message with tool calls to history
 
340
  assistant_msg = Message(
341
  role="assistant",
342
  content=content,
 
344
  )
345
  session.context_manager.add_message(assistant_msg, token_count)
346
 
 
 
 
 
 
347
  # Separate tools into those requiring approval and those that don't
348
  approval_required_tools = []
349
  non_approval_tools = []
350
 
351
  for tc in tool_calls:
352
  tool_name = tc.function.name
353
+ try:
354
+ tool_args = json.loads(tc.function.arguments)
355
+ except (json.JSONDecodeError, TypeError) as e:
356
+ logger.warning(f"Malformed tool arguments for {tool_name}: {e}")
357
+ tool_args = {}
358
 
359
  if _needs_approval(tool_name, tool_args, session.config):
360
  approval_required_tools.append(tc)
361
  else:
362
  non_approval_tools.append(tc)
363
+ # Execute non-approval tools (in parallel when possible)
364
+ if non_approval_tools:
365
+ # 1. Parse args and validate upfront
366
+ parsed_tools: list[
367
+ tuple[ChatCompletionMessageToolCall, str, dict, bool, str]
368
+ ] = []
369
+ for tc in non_approval_tools:
370
+ tool_name = tc.function.name
371
+ try:
372
+ tool_args = json.loads(tc.function.arguments)
373
+ except (json.JSONDecodeError, TypeError):
374
+ tool_args = {}
375
+
376
+ args_valid, error_msg = _validate_tool_args(tool_args)
377
+ parsed_tools.append(
378
+ (tc, tool_name, tool_args, args_valid, error_msg)
379
+ )
380
 
381
+ # 2. Send all tool_call events upfront (so frontend shows them all)
382
+ for tc, tool_name, tool_args, args_valid, _ in parsed_tools:
383
+ if args_valid:
384
+ await session.send_event(
385
+ Event(
386
+ event_type="tool_call",
387
+ data={
388
+ "tool": tool_name,
389
+ "arguments": tool_args,
390
+ "tool_call_id": tc.id,
391
+ },
392
+ )
 
 
 
 
393
  )
 
394
 
395
+ # 3. Execute all valid tools in parallel
396
+ async def _exec_tool(
397
+ tc: ChatCompletionMessageToolCall,
398
+ name: str,
399
+ args: dict,
400
+ valid: bool,
401
+ err: str,
402
+ ) -> tuple[ChatCompletionMessageToolCall, str, dict, str, bool]:
403
+ if not valid:
404
+ return (tc, name, args, err, False)
405
+ out, ok = await session.tool_router.call_tool(
406
+ name, args, session=session
407
  )
408
+ return (tc, name, args, out, ok)
409
 
410
+ results = await asyncio.gather(
411
+ *[
412
+ _exec_tool(tc, name, args, valid, err)
413
+ for tc, name, args, valid, err in parsed_tools
414
+ ]
 
415
  )
 
416
 
417
+ # 4. Record results and send outputs (order preserved)
418
+ for tc, tool_name, tool_args, output, success in results:
419
+ tool_msg = Message(
420
+ role="tool",
421
+ content=output,
422
+ tool_call_id=tc.id,
423
+ name=tool_name,
424
+ )
425
+ session.context_manager.add_message(tool_msg)
426
+
427
+ await session.send_event(
428
+ Event(
429
+ event_type="tool_output",
430
+ data={
431
+ "tool": tool_name,
432
+ "tool_call_id": tc.id,
433
+ "output": output,
434
+ "success": success,
435
+ },
436
+ )
437
  )
 
438
 
439
  # If there are tools requiring approval, ask for batch approval
440
  if approval_required_tools:
 
442
  tools_data = []
443
  for tc in approval_required_tools:
444
  tool_name = tc.function.name
445
+ try:
446
+ tool_args = json.loads(tc.function.arguments)
447
+ except (json.JSONDecodeError, TypeError):
448
+ tool_args = {}
449
  tools_data.append(
450
  {
451
  "tool": tool_name,
 
514
 
515
  @staticmethod
516
  async def undo(session: Session) -> None:
517
+ """Remove the last complete turn (user msg + all assistant/tool msgs that follow).
518
+
519
+ Anthropic requires every tool_use to have a matching tool_result,
520
+ so we can't just pop 2 items — we must pop everything back to
521
+ (and including) the last user message to keep the history valid.
522
+ """
523
+ items = session.context_manager.items
524
+ if not items:
525
+ await session.send_event(Event(event_type="undo_complete"))
526
+ return
527
+
528
+ # Pop from the end until we've removed the last user message
529
+ removed_user = False
530
+ while items:
531
+ msg = items.pop()
532
+ if getattr(msg, "role", None) == "user":
533
+ removed_user = True
534
+ break
535
+
536
+ if not removed_user:
537
+ logger.warning("Undo: no user message found to remove")
538
 
539
  await session.send_event(Event(event_type="undo_complete"))
540
 
 
562
 
563
  # Create a map of tool_call_id -> approval decision
564
  approval_map = {a["tool_call_id"]: a for a in approvals}
565
+ for a in approvals:
566
+ if a.get("edited_script"):
567
+ logger.info(f"Received edited script for tool_call {a['tool_call_id']} ({len(a['edited_script'])} chars)")
568
 
569
  # Separate approved and rejected tool calls
570
  approved_tasks = []
 
572
 
573
  for tc in tool_calls:
574
  tool_name = tc.function.name
575
+ try:
576
+ tool_args = json.loads(tc.function.arguments)
577
+ except (json.JSONDecodeError, TypeError) as e:
578
+ # Malformed arguments — treat as failed, notify agent
579
+ logger.warning(f"Malformed tool arguments for {tool_name}: {e}")
580
+ tool_msg = Message(
581
+ role="tool",
582
+ content=f"Malformed arguments: {e}",
583
+ tool_call_id=tc.id,
584
+ name=tool_name,
585
+ )
586
+ session.context_manager.add_message(tool_msg)
587
+ await session.send_event(
588
+ Event(
589
+ event_type="tool_output",
590
+ data={
591
+ "tool": tool_name,
592
+ "tool_call_id": tc.id,
593
+ "output": f"Malformed arguments: {e}",
594
+ "success": False,
595
+ },
596
+ )
597
+ )
598
+ continue
599
+
600
  approval_decision = approval_map.get(tc.id, {"approved": False})
601
 
602
  if approval_decision.get("approved", False):
603
+ edited_script = approval_decision.get("edited_script")
604
+ was_edited = False
605
+ if edited_script and "script" in tool_args:
606
+ tool_args["script"] = edited_script
607
+ was_edited = True
608
+ logger.info(f"Using user-edited script for {tool_name} ({tc.id})")
609
+ approved_tasks.append((tc, tool_name, tool_args, was_edited))
610
  else:
611
  rejected_tasks.append((tc, tool_name, approval_decision))
612
 
613
+ # Notify frontend of approval decisions immediately (before execution)
614
+ for tc, tool_name, tool_args, _was_edited in approved_tasks:
615
+ await session.send_event(
616
+ Event(
617
+ event_type="tool_state_change",
618
+ data={
619
+ "tool_call_id": tc.id,
620
+ "tool": tool_name,
621
+ "state": "approved",
622
+ },
623
+ )
624
+ )
625
+ for tc, tool_name, approval_decision in rejected_tasks:
626
+ await session.send_event(
627
+ Event(
628
+ event_type="tool_state_change",
629
+ data={
630
+ "tool_call_id": tc.id,
631
+ "tool": tool_name,
632
+ "state": "rejected",
633
+ },
634
+ )
635
+ )
636
+
637
  # Execute all approved tools concurrently
638
+ async def execute_tool(tc, tool_name, tool_args, was_edited):
639
+ """Execute a single tool and return its result.
640
+
641
+ The TraceLog already exists on the frontend (created by
642
+ approval_required), so we send tool_state_change instead of
643
+ tool_call to avoid creating a duplicate.
644
+ """
645
  await session.send_event(
646
  Event(
647
+ event_type="tool_state_change",
648
+ data={
649
+ "tool_call_id": tc.id,
650
+ "tool": tool_name,
651
+ "state": "running",
652
+ },
653
  )
654
  )
655
 
656
  output, success = await session.tool_router.call_tool(
657
+ tool_name, tool_args, session=session, tool_call_id=tc.id
658
  )
659
 
660
+ return (tc, tool_name, output, success, was_edited)
661
 
662
  # Execute all approved tools concurrently and wait for ALL to complete
663
  if approved_tasks:
664
  results = await asyncio.gather(
665
  *[
666
+ execute_tool(tc, tool_name, tool_args, was_edited)
667
+ for tc, tool_name, tool_args, was_edited in approved_tasks
668
  ],
669
  return_exceptions=True,
670
  )
 
673
  for result in results:
674
  if isinstance(result, Exception):
675
  # Handle execution error
676
+ logger.error(f"Tool execution error: {result}")
677
  continue
678
 
679
+ tc, tool_name, output, success, was_edited = result
680
+
681
+ if was_edited:
682
+ output = f"[Note: The user edited the script before execution. The output below reflects the user-modified version, not your original script.]\n\n{output}"
683
 
684
  # Add tool result to context
685
  tool_msg = Message(
 
695
  event_type="tool_output",
696
  data={
697
  "tool": tool_name,
698
+ "tool_call_id": tc.id,
699
  "output": output,
700
  "success": success,
701
  },
 
707
  rejection_msg = "Job execution cancelled by user"
708
  user_feedback = approval_decision.get("feedback")
709
  if user_feedback:
710
+ # Ensure feedback is a string and sanitize any problematic characters
711
+ feedback_str = str(user_feedback).strip()
712
+ # Remove any control characters that might break JSON parsing
713
+ feedback_str = "".join(char for char in feedback_str if ord(char) >= 32 or char in "\n\t")
714
+ rejection_msg += f". User feedback: {feedback_str}"
715
+
716
+ # Ensure rejection_msg is a clean string
717
+ rejection_msg = str(rejection_msg).strip()
718
 
719
  tool_msg = Message(
720
  role="tool",
 
729
  event_type="tool_output",
730
  data={
731
  "tool": tool_name,
732
+ "tool_call_id": tc.id,
733
  "output": rejection_msg,
734
  "success": False,
735
  },
 
747
  """Handle shutdown (like shutdown in codex.rs:1329)"""
748
  # Save session trajectory if enabled (fire-and-forget, returns immediately)
749
  if session.config.save_sessions:
750
+ logger.info("Saving session...")
751
  repo_id = session.config.session_dataset_repo
752
  _ = session.save_and_upload_detached(repo_id)
 
 
753
 
754
  session.is_running = False
755
  await session.send_event(Event(event_type="shutdown"))
 
764
  bool: True to continue, False to shutdown
765
  """
766
  op = submission.operation
767
+ logger.debug("Received operation: %s", op.op_type.value)
768
 
769
  if op.op_type == OpType.USER_INPUT:
770
  text = op.data.get("text", "") if op.data else ""
 
776
  return True
777
 
778
  if op.op_type == OpType.COMPACT:
 
779
  await _compact_and_notify(session)
780
  return True
781
 
 
791
  if op.op_type == OpType.SHUTDOWN:
792
  return not await Handlers.shutdown(session)
793
 
794
+ logger.warning(f"Unknown operation: {op.op_type}")
795
  return True
796
 
797
 
 
809
 
810
  # Create session with tool router
811
  session = Session(event_queue, config=config, tool_router=tool_router)
812
+ logger.info("Agent loop started")
813
 
814
  # Retry any failed uploads from previous sessions (fire-and-forget)
815
  if config and config.save_sessions:
 
833
  if not should_continue:
834
  break
835
  except asyncio.CancelledError:
836
+ logger.warning("Agent loop cancelled")
837
  break
838
  except Exception as e:
839
+ logger.error(f"Error in agent loop: {e}")
840
  await session.send_event(
841
  Event(event_type="error", data={"error": str(e)})
842
  )
843
 
844
+ logger.info("Agent loop exited")
845
 
846
  finally:
847
  # Emergency save if session saving is enabled and shutdown wasn't called properly
848
  if session.config.save_sessions and session.is_running:
849
+ logger.info("Emergency save: preserving session before exit...")
850
  try:
851
  local_path = session.save_and_upload_detached(
852
  session.config.session_dataset_repo
853
  )
854
  if local_path:
855
+ logger.info("Emergency save successful, upload in progress")
856
  except Exception as e:
857
+ 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,48 @@ 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 +84,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,
@@ -59,6 +97,8 @@ class Session:
59
  self.is_running = True
60
  self.current_task: asyncio.Task | None = None
61
  self.pending_approval: Optional[dict[str, Any]] = None
 
 
62
  self.sandbox = None
63
 
64
  # Session trajectory logging
@@ -100,7 +140,7 @@ class Session:
100
 
101
  turns_since_last_save = self.turn_count - self.last_auto_save_turn
102
  if turns_since_last_save >= interval:
103
- print(f"\n💾 Auto-saving session (turn {self.turn_count})...")
104
  # Fire-and-forget save - returns immediately
105
  self.save_and_upload_detached(self.config.session_dataset_repo)
106
  self.last_auto_save_turn = self.turn_count
@@ -152,7 +192,7 @@ class Session:
152
 
153
  return str(filepath)
154
  except Exception as e:
155
- print(f"Failed to save session locally: {e}")
156
  return None
157
 
158
  def update_local_save_status(
@@ -172,7 +212,7 @@ class Session:
172
 
173
  return True
174
  except Exception as e:
175
- print(f"Failed to update local save status: {e}")
176
  return False
177
 
178
  def save_and_upload_detached(self, repo_id: str) -> Optional[str]:
@@ -203,7 +243,7 @@ class Session:
203
  start_new_session=True, # Detach from parent
204
  )
205
  except Exception as e:
206
- print(f"⚠️ Failed to spawn upload subprocess: {e}")
207
 
208
  return local_path
209
 
@@ -233,4 +273,4 @@ class Session:
233
  start_new_session=True, # Detach from parent
234
  )
235
  except Exception as e:
236
- 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
22
+ "anthropic/claude-opus-4-5-20251101": 200_000,
23
+ "anthropic/claude-sonnet-4-5-20250929": 200_000,
24
+ "anthropic/claude-sonnet-4-20250514": 200_000,
25
+ "anthropic/claude-haiku-3-5-20241022": 200_000,
26
+ "anthropic/claude-3-5-sonnet-20241022": 200_000,
27
+ "anthropic/claude-3-opus-20240229": 200_000,
28
+ "huggingface/novita/minimax/minimax-m2.1": 196_608,
29
+ "huggingface/novita/moonshotai/kimi-k2.5": 262_144,
30
+ "huggingface/novita/zai-org/glm-5": 200_000,
31
+ }
32
+ _DEFAULT_MAX_TOKENS = 200_000
33
+
34
+
35
+ def _get_max_tokens_safe(model_name: str) -> int:
36
+ """Return the max context window for a model without network calls."""
37
+ tokens = _MAX_TOKENS_MAP.get(model_name)
38
+ if tokens:
39
+ return tokens
40
+ # Fallback: try litellm but with a short timeout via threading
41
+ try:
42
+ from litellm import get_max_tokens
43
+
44
+ result = get_max_tokens(model_name)
45
+ if result and isinstance(result, int):
46
+ return result
47
+ logger.warning(
48
+ f"get_max_tokens returned {result} for {model_name}, using default"
49
+ )
50
+ return _DEFAULT_MAX_TOKENS
51
+ except Exception as e:
52
+ logger.warning(f"get_max_tokens failed for {model_name}, using default: {e}")
53
+ return _DEFAULT_MAX_TOKENS
54
+
55
 
56
  class OpType(Enum):
57
  USER_INPUT = "user_input"
 
84
  self.tool_router = tool_router
85
  tool_specs = tool_router.get_tool_specs_for_llm() if tool_router else []
86
  self.context_manager = context_manager or ContextManager(
87
+ max_context=_get_max_tokens_safe(config.model_name),
88
  compact_size=0.1,
89
  untouched_messages=5,
90
  tool_specs=tool_specs,
 
97
  self.is_running = True
98
  self.current_task: asyncio.Task | None = None
99
  self.pending_approval: Optional[dict[str, Any]] = None
100
+ # User's HF OAuth token — set by session_manager after construction
101
+ self.hf_token: Optional[str] = None
102
  self.sandbox = None
103
 
104
  # Session trajectory logging
 
140
 
141
  turns_since_last_save = self.turn_count - self.last_auto_save_turn
142
  if turns_since_last_save >= interval:
143
+ logger.info(f"Auto-saving session (turn {self.turn_count})...")
144
  # Fire-and-forget save - returns immediately
145
  self.save_and_upload_detached(self.config.session_dataset_repo)
146
  self.last_auto_save_turn = self.turn_count
 
192
 
193
  return str(filepath)
194
  except Exception as e:
195
+ logger.error(f"Failed to save session locally: {e}")
196
  return None
197
 
198
  def update_local_save_status(
 
212
 
213
  return True
214
  except Exception as e:
215
+ logger.error(f"Failed to update local save status: {e}")
216
  return False
217
 
218
  def save_and_upload_detached(self, repo_id: str) -> Optional[str]:
 
243
  start_new_session=True, # Detach from parent
244
  )
245
  except Exception as e:
246
+ logger.warning(f"Failed to spawn upload subprocess: {e}")
247
 
248
  return local_path
249
 
 
273
  start_new_session=True, # Detach from parent
274
  )
275
  except Exception as e:
276
+ 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
@@ -132,6 +135,7 @@ class ToolRouter:
132
  for tool in create_builtin_tools():
133
  self.register_tool(tool)
134
 
 
135
  if mcp_servers:
136
  mcp_servers_payload = {}
137
  for name, server in mcp_servers.items():
@@ -159,7 +163,7 @@ class ToolRouter:
159
  handler=None,
160
  )
161
  )
162
- print(
163
  f"Loaded {len(registered_names)} MCP tools: {', '.join(registered_names)} ({skipped_count} disabled)"
164
  )
165
 
@@ -180,7 +184,7 @@ class ToolRouter:
180
  handler=search_openapi_handler,
181
  )
182
  )
183
- print(f"Loaded OpenAPI search tool: {openapi_spec['name']}")
184
 
185
  def get_tool_specs_for_llm(self) -> list[dict[str, Any]]:
186
  """Get tool specifications in OpenAI format"""
@@ -209,7 +213,7 @@ class ToolRouter:
209
  await self.register_openapi_tool()
210
 
211
  total_tools = len(self.tools)
212
- print(f"\nAgent ready with {total_tools} tools total\n")
213
 
214
  return self
215
 
@@ -220,7 +224,7 @@ class ToolRouter:
220
 
221
  @observe(name="call_tool")
222
  async def call_tool(
223
- self, tool_name: str, arguments: dict[str, Any], session: Any = None
224
  ) -> tuple[str, bool]:
225
  """
226
  Call a tool and return (output_string, success_bool).
@@ -236,6 +240,9 @@ class ToolRouter:
236
  # Check if handler accepts session argument
237
  sig = inspect.signature(tool.handler)
238
  if "session" in sig.parameters:
 
 
 
239
  return await tool.handler(arguments, session=session)
240
  return await tool.handler(arguments)
241
 
@@ -328,10 +335,10 @@ def create_builtin_tools() -> list[ToolSpec]:
328
  ),
329
  ]
330
 
331
- # Sandbox tools
332
  tools = get_sandbox_tools() + tools
333
 
334
  tool_names = ", ".join([t.name for t in tools])
335
- print(f"Loaded {len(tools)} built-in tools: {tool_names}")
336
 
337
  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
 
135
  for tool in create_builtin_tools():
136
  self.register_tool(tool)
137
 
138
+ self.mcp_client: Client | None = None
139
  if mcp_servers:
140
  mcp_servers_payload = {}
141
  for name, server in mcp_servers.items():
 
163
  handler=None,
164
  )
165
  )
166
+ logger.info(
167
  f"Loaded {len(registered_names)} MCP tools: {', '.join(registered_names)} ({skipped_count} disabled)"
168
  )
169
 
 
184
  handler=search_openapi_handler,
185
  )
186
  )
187
+ logger.info(f"Loaded OpenAPI search tool: {openapi_spec['name']}")
188
 
189
  def get_tool_specs_for_llm(self) -> list[dict[str, Any]]:
190
  """Get tool specifications in OpenAI format"""
 
213
  await self.register_openapi_tool()
214
 
215
  total_tools = len(self.tools)
216
+ logger.info(f"Agent ready with {total_tools} tools total")
217
 
218
  return self
219
 
 
224
 
225
  @observe(name="call_tool")
226
  async def call_tool(
227
+ self, tool_name: str, arguments: dict[str, Any], session: Any = None, tool_call_id: str | None = None
228
  ) -> tuple[str, bool]:
229
  """
230
  Call a tool and return (output_string, success_bool).
 
240
  # Check if handler accepts session argument
241
  sig = inspect.signature(tool.handler)
242
  if "session" in sig.parameters:
243
+ # Check if handler also accepts tool_call_id parameter
244
+ if "tool_call_id" in sig.parameters:
245
+ return await tool.handler(arguments, session=session, tool_call_id=tool_call_id)
246
  return await tool.handler(arguments, session=session)
247
  return await tool.handler(arguments)
248
 
 
335
  ),
336
  ]
337
 
338
+ # Sandbox tools (highest priority)
339
  tools = get_sandbox_tools() + tools
340
 
341
  tool_names = ", ".join([t.name for t in tools])
342
+ logger.info(f"Loaded {len(tools)} built-in tools: {tool_names}")
343
 
344
  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/prompts/system_prompt_v2.yaml CHANGED
@@ -186,59 +186,61 @@ system_prompt: |
186
  3. ✅ Determine optimal processing approach based on requirements
187
  4. ✅ Plan output format and destination
188
 
189
- ## PHASE 3: IMPLEMENT (Develop in Sandbox, Launch via Jobs)
190
-
191
- ⚠️ **CRITICAL WORKFLOW: Sandbox First, Jobs Second**
192
-
193
- For ANY implementation task (training, data processing, inference), follow this pattern:
194
-
195
- **Step 1: Create a sandbox** — `sandbox_create` with appropriate hardware (cpu-basic for scripting, t4-small for GPU testing)
196
- **Step 2: Develop & iterate** Write scripts, install dependencies, test with small runs, fix errors interactively
197
- **Step 3: Launch via hf_jobs** Once the script works, pass the sandbox file path directly: `hf_jobs(operation="run", script="/app/train.py", ...)`
198
-
199
- This is the CORRECT pattern:
200
- ```
201
- sandbox_create(hardware="t4-small") # interactive dev environment
202
- bash("pip install trl transformers") # install deps
203
- write("/app/train.py", "...") # write training script
204
- bash("cd /app && python train.py --max_steps 10") # test run
205
- edit("/app/train.py", ...) # fix issues
206
- bash("cd /app && python train.py --max_steps 10") # verify fix
207
- hf_jobs(operation="run", script="/app/train.py", hardware_flavor="a10g-large", timeout="4h") # launch at scale
208
- ```
209
-
210
- Do NOT write long inline scripts directly in hf_jobs if necessary — develop in sandbox first.
211
-
212
- ### Training Script Requirements
213
-
214
- **Script MUST Include:**
215
- - Imports from researched documentation (current APIs)
216
- - Trackio initialization with project/run_name/config
217
- - Model and tokenizer loading
218
- - Dataset loading with verified columns and conversational format
219
- - Training config with ALL critical settings:
220
  - `push_to_hub=True` ⚠️ MANDATORY
221
  - `hub_model_id="username/model-name"` ⚠️ MANDATORY
222
  - `report_to=["trackio"]` (for monitoring)
223
  - `output_dir="./output"`
224
  - `num_train_epochs`, `per_device_train_batch_size`, `learning_rate`
225
  - `logging_steps`, `save_steps`
226
- - `trainer.train()` call
227
- - `trainer.push_to_hub()` at end ⚠️ MANDATORY
228
-
229
- **hf_jobs Launch Configuration:**
230
- - `script`: Path to sandbox file (e.g. "/app/train.py") or inline code
231
- - `dependencies`: ['transformers', 'trl', 'torch', 'datasets', 'trackio']
232
- - `hardware_flavor`: Based on model size:
233
- - 1-3B models: `t4-small` or `a10g-small`
234
- - 7-13B models: `a10g-large`
235
- - 30B+ models: `a100-large`
236
- - 70B+ models: `h100` or `h100x8`
237
- - `timeout`: ⚠️ CRITICAL Small (2-4h), Medium (4-8h), Large (8-24h). NEVER default 30m for training.
 
 
 
 
 
 
 
 
238
 
239
  ### For Data Processing Tasks
240
 
241
- **Same pattern:** develop script in sandbox, test on subset, launch via hf_jobs.
 
 
 
 
 
242
  - Use `cpu-upgrade` or `cpu-performance` for most data tasks
243
  - Set timeout based on dataset size (1-4 hours typical)
244
 
@@ -339,21 +341,6 @@ system_prompt: |
339
  - ⚠️ Include HF_TOKEN for Hub operations
340
  - ⚠️ Storage is EPHEMERAL - must push_to_hub
341
 
342
- ## Sandbox (Interactive Development Environment)
343
-
344
- **sandbox_create:**
345
- - ⚠️ **Create a sandbox FIRST for any implementation task** — develop and test before launching jobs
346
- - Persistent remote Linux environment on HF Spaces
347
- - First call sandbox_create with hardware choice, then use bash/read/write/edit freely
348
- - Hardware: cpu-basic (free tier), cpu-upgrade (8vCPU/32GB), t4-small (16GB GPU), a10g-small (24GB GPU), a10g-large (24GB GPU + 46GB RAM), a100-large (80GB GPU)
349
- - `pip install` works out of the box — no special flags needed
350
- - Workflow: sandbox_create → write script → test → fix → hf_jobs(script="/app/script.py") to launch at scale
351
-
352
- **bash / read / write / edit:**
353
- - Available after sandbox_create — no additional approvals needed
354
- - Same semantics as local file/shell operations, but run on the remote sandbox
355
- - bash: run shell commands; read/write/edit: file operations
356
-
357
  **hf_private_repos:**
358
  - Store job outputs persistently in datasets with push_to_hub (jobs lose files after completion)
359
  - Upload logs, scripts, results that can't push_to_hub
 
186
  3. ✅ Determine optimal processing approach based on requirements
187
  4. ✅ Plan output format and destination
188
 
189
+ ## PHASE 3: IMPLEMENT (Execute with Researched Approaches)
190
+
191
+ ### For Training Tasks
192
+
193
+ ⚠️ **TRAINING REQUIREMENTS CHECKLIST:**
194
+
195
+ **Before Submission:**
196
+ - [ ] Researched current TRL documentation
197
+ - [ ] Found and verified base model
198
+ - [ ] Found dataset and VALIDATED columns and conversational format matches method
199
+ - [ ] Selected optimal model + dataset + hardware configuration
200
+ - [ ] Created plan with plan_tool
201
+ - [ ] Researched Trackio monitoring setup
202
+
203
+ **Training Script MUST Include:**
204
+ - [ ] Imports from researched documentation (current APIs)
205
+ - [ ] Trackio initialization with project/run_name/config
206
+ - [ ] Model and tokenizer loading
207
+ - [ ] Dataset loading with verified columns and conversational format
208
+ - [ ] Training config with ALL critical settings:
 
 
 
 
 
 
 
 
 
 
 
209
  - `push_to_hub=True` ⚠️ MANDATORY
210
  - `hub_model_id="username/model-name"` ⚠️ MANDATORY
211
  - `report_to=["trackio"]` (for monitoring)
212
  - `output_dir="./output"`
213
  - `num_train_epochs`, `per_device_train_batch_size`, `learning_rate`
214
  - `logging_steps`, `save_steps`
215
+ - `max_length` if needed (default 1024 usually fine)
216
+ - [ ] Trainer initialization with model, args, dataset, tokenizer
217
+ - [ ] `trainer.train()` call
218
+ - [ ] `trainer.push_to_hub()` at end ⚠️ MANDATORY
219
+ - [ ] `tracker.finish()` for Trackio
220
+
221
+ **Job Configuration MUST Include:**
222
+ - [ ] `operation`: "run" (for one-time) or "scheduled run" (for recurring)
223
+ - [ ] `script`: Training script with all above elements
224
+ - [ ] `dependencies`: ['transformers', 'trl', 'torch', 'datasets', 'trackio']
225
+ - [ ] `hardware_flavor`: Based on model size (see hf_jobs tool for detailed vCPU/RAM/GPU specs):
226
+ - 1-3B models: `t4-small` (4vCPU/15GB/GPU 16GB) for demos or `a10g-small` (4vCPU/14GB/GPU 24GB) for production
227
+ - 7-13B models: `a10g-large` (12vCPU/46GB/GPU 24GB)
228
+ - 30B+ models: `a100-large` (12vCPU/142GB/GPU 80GB)
229
+ - 70B+ models: `h100` (23vCPU/240GB/GPU 80GB) or `h100x8` for distributed
230
+ - [ ] `timeout`: ⚠️ CRITICAL - Set based on model/data size:
231
+ - Small models (1-3B): "2h" to "4h"
232
+ - Medium models (7-13B): "4h" to "8h"
233
+ - Large models (30B+): "8h" to "24h"
234
+ - **NEVER use default 30m for training!**
235
 
236
  ### For Data Processing Tasks
237
 
238
+ **Script Requirements:**
239
+ - Load dataset with `load_dataset`
240
+ - Process according to user requirements
241
+ - Push results with `push_to_hub()` or upload to `hf_private_repos`
242
+
243
+ **Job Configuration:**
244
  - Use `cpu-upgrade` or `cpu-performance` for most data tasks
245
  - Set timeout based on dataset size (1-4 hours typical)
246
 
 
341
  - ⚠️ Include HF_TOKEN for Hub operations
342
  - ⚠️ Storage is EPHEMERAL - must push_to_hub
343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  **hf_private_repos:**
345
  - Store job outputs persistently in datasets with push_to_hub (jobs lose files after completion)
346
  - Upload logs, scripts, results that can't push_to_hub
agent/tools/jobs_tool.py CHANGED
@@ -9,7 +9,9 @@ import base64
9
  import http.client
10
  import os
11
  import re
12
- from typing import Any, Awaitable, Callable, Dict, Literal, Optional
 
 
13
 
14
  import httpx
15
  from huggingface_hub import HfApi
@@ -17,6 +19,8 @@ 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,
@@ -128,8 +132,11 @@ def _add_default_env(params: Dict[str, Any] | None) -> Dict[str, Any]:
128
  return result
129
 
130
 
131
- def _add_environment_variables(params: Dict[str, Any] | None) -> Dict[str, Any]:
132
- token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") or ""
 
 
 
133
 
134
  # Start with user-provided env vars, then force-set token last
135
  result = dict(params or {})
@@ -285,10 +292,15 @@ class HfJobsTool:
285
  hf_token: Optional[str] = None,
286
  namespace: Optional[str] = None,
287
  log_callback: Optional[Callable[[str], Awaitable[None]]] = None,
 
 
288
  ):
 
289
  self.api = HfApi(token=hf_token)
290
  self.namespace = namespace
291
  self.log_callback = log_callback
 
 
292
 
293
  async def execute(self, params: Dict[str, Any]) -> ToolResult:
294
  """Execute the specified operation"""
@@ -384,9 +396,7 @@ class HfJobsTool:
384
  def log_producer():
385
  try:
386
  # fetch_job_logs is a blocking sync generator
387
- logs_gen = self.api.fetch_job_logs(
388
- job_id=job_id, namespace=namespace
389
- )
390
  for line in logs_gen:
391
  # Push line to queue thread-safely
392
  loop.call_soon_threadsafe(queue.put_nowait, line)
@@ -413,7 +423,7 @@ class HfJobsTool:
413
 
414
  # Process log line
415
  log_line = item
416
- print("\t" + log_line)
417
  if self.log_callback:
418
  await self.log_callback(log_line)
419
  all_logs.append(log_line)
@@ -441,19 +451,19 @@ class HfJobsTool:
441
 
442
  if current_status in terminal_states:
443
  # Job finished, no need to retry
444
- print(f"\tJob reached terminal state: {current_status}")
445
  break
446
 
447
  # Job still running, retry connection
448
- print(
449
- f"\tConnection interrupted ({str(e)[:50]}...), reconnecting in {retry_delay}s..."
450
  )
451
  await asyncio.sleep(retry_delay)
452
  continue
453
 
454
  except (ConnectionError, TimeoutError, OSError):
455
  # Can't even check job status, wait and retry
456
- print(f"\tConnection error, retrying in {retry_delay}s...")
457
  await asyncio.sleep(retry_delay)
458
  continue
459
 
@@ -510,15 +520,29 @@ class HfJobsTool:
510
  image=image,
511
  command=command,
512
  env=_add_default_env(args.get("env")),
513
- secrets=_add_environment_variables(args.get("secrets")),
514
  flavor=args.get("hardware_flavor", "cpu-basic"),
515
  timeout=args.get("timeout", "30m"),
516
  namespace=self.namespace,
517
  )
518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  # Wait for completion and stream logs
520
- print(f"{job_type} job started: {job.url}")
521
- print("Streaming logs...\n---\n")
522
 
523
  final_status, all_logs = await self._wait_for_job_completion(
524
  job_id=job.id,
@@ -728,7 +752,7 @@ To verify, call this tool with `{{"operation": "inspect", "job_id": "{job_id}"}}
728
  command=command,
729
  schedule=schedule,
730
  env=_add_default_env(args.get("env")),
731
- secrets=_add_environment_variables(args.get("secrets")),
732
  flavor=args.get("hardware_flavor", "cpu-basic"),
733
  timeout=args.get("timeout", "30m"),
734
  namespace=self.namespace,
@@ -998,7 +1022,7 @@ HF_JOBS_TOOL_SPEC = {
998
 
999
 
1000
  async def hf_jobs_handler(
1001
- arguments: Dict[str, Any], session: Any = None
1002
  ) -> tuple[str, bool]:
1003
  """Handler for agent tool router"""
1004
  try:
@@ -1031,14 +1055,20 @@ async def hf_jobs_handler(
1031
  return f"Failed to read {script} from sandbox: {result.error}", False
1032
  arguments = {**arguments, "script": result.output}
1033
 
1034
- # Get token and namespace from HF token
1035
- hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
1036
- namespace = HfApi(token=hf_token).whoami().get("name") if hf_token else None
 
 
 
 
1037
 
1038
  tool = HfJobsTool(
1039
  namespace=namespace,
1040
  hf_token=hf_token,
1041
  log_callback=log_callback if session else None,
 
 
1042
  )
1043
  result = await tool.execute(arguments)
1044
  return result["formatted"], not result.get("isError", False)
 
9
  import http.client
10
  import os
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
 
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,
 
132
  return result
133
 
134
 
135
+ def _add_environment_variables(
136
+ params: Dict[str, Any] | None, user_token: str | None = None
137
+ ) -> Dict[str, Any]:
138
+ # Prefer the authenticated user's OAuth token, fall back to global env var
139
+ token = user_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") or ""
140
 
141
  # Start with user-provided env vars, then force-set token last
142
  result = dict(params or {})
 
292
  hf_token: Optional[str] = None,
293
  namespace: Optional[str] = None,
294
  log_callback: Optional[Callable[[str], Awaitable[None]]] = None,
295
+ session: Any = None,
296
+ tool_call_id: Optional[str] = None,
297
  ):
298
+ self.hf_token = hf_token
299
  self.api = HfApi(token=hf_token)
300
  self.namespace = namespace
301
  self.log_callback = log_callback
302
+ self.session = session
303
+ self.tool_call_id = tool_call_id
304
 
305
  async def execute(self, params: Dict[str, Any]) -> ToolResult:
306
  """Execute the specified operation"""
 
396
  def log_producer():
397
  try:
398
  # fetch_job_logs is a blocking sync generator
399
+ logs_gen = self.api.fetch_job_logs(job_id=job_id, namespace=namespace)
 
 
400
  for line in logs_gen:
401
  # Push line to queue thread-safely
402
  loop.call_soon_threadsafe(queue.put_nowait, line)
 
423
 
424
  # Process log line
425
  log_line = item
426
+ logger.debug(log_line)
427
  if self.log_callback:
428
  await self.log_callback(log_line)
429
  all_logs.append(log_line)
 
451
 
452
  if current_status in terminal_states:
453
  # Job finished, no need to retry
454
+ logger.info(f"Job reached terminal state: {current_status}")
455
  break
456
 
457
  # Job still running, retry connection
458
+ logger.warning(
459
+ f"Connection interrupted ({str(e)[:50]}...), reconnecting in {retry_delay}s..."
460
  )
461
  await asyncio.sleep(retry_delay)
462
  continue
463
 
464
  except (ConnectionError, TimeoutError, OSError):
465
  # Can't even check job status, wait and retry
466
+ logger.warning(f"Connection error, retrying in {retry_delay}s...")
467
  await asyncio.sleep(retry_delay)
468
  continue
469
 
 
520
  image=image,
521
  command=command,
522
  env=_add_default_env(args.get("env")),
523
+ secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
524
  flavor=args.get("hardware_flavor", "cpu-basic"),
525
  timeout=args.get("timeout", "30m"),
526
  namespace=self.namespace,
527
  )
528
 
529
+ # Send job URL immediately after job creation (before waiting for completion)
530
+ if self.session and self.tool_call_id:
531
+ await self.session.send_event(
532
+ Event(
533
+ event_type="tool_state_change",
534
+ data={
535
+ "tool_call_id": self.tool_call_id,
536
+ "tool": "hf_jobs",
537
+ "state": "running",
538
+ "jobUrl": job.url,
539
+ },
540
+ )
541
+ )
542
+
543
  # Wait for completion and stream logs
544
+ logger.info(f"{job_type} job started: {job.url}")
545
+ logger.info("Streaming logs...")
546
 
547
  final_status, all_logs = await self._wait_for_job_completion(
548
  job_id=job.id,
 
752
  command=command,
753
  schedule=schedule,
754
  env=_add_default_env(args.get("env")),
755
+ secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
756
  flavor=args.get("hardware_flavor", "cpu-basic"),
757
  timeout=args.get("timeout", "30m"),
758
  namespace=self.namespace,
 
1022
 
1023
 
1024
  async def hf_jobs_handler(
1025
+ arguments: Dict[str, Any], session: Any = None, tool_call_id: str | None = None
1026
  ) -> tuple[str, bool]:
1027
  """Handler for agent tool router"""
1028
  try:
 
1055
  return f"Failed to read {script} from sandbox: {result.error}", False
1056
  arguments = {**arguments, "script": result.output}
1057
 
1058
+ # Prefer the authenticated user's OAuth token, fall back to global env
1059
+ hf_token = (
1060
+ (getattr(session, "hf_token", None) if session else None)
1061
+ or os.environ.get("HF_TOKEN")
1062
+ or os.environ.get("HUGGINGFACE_HUB_TOKEN")
1063
+ )
1064
+ namespace = os.environ.get("HF_NAMESPACE") or (HfApi(token=hf_token).whoami().get("name") if hf_token else None)
1065
 
1066
  tool = HfJobsTool(
1067
  namespace=namespace,
1068
  hf_token=hf_token,
1069
  log_callback=log_callback if session else None,
1070
+ session=session,
1071
+ tool_call_id=tool_call_id,
1072
  )
1073
  result = await tool.execute(arguments)
1074
  return result["formatted"], not result.get("isError", False)
agent/tools/sandbox_tool.py CHANGED
@@ -38,9 +38,13 @@ async def _ensure_sandbox(
38
  if not session:
39
  return None, "No session available."
40
 
41
- token = os.environ.get("HF_TOKEN")
 
 
 
 
42
  if not token:
43
- return None, "HF_TOKEN environment variable not set. Cannot create sandbox."
44
 
45
  api = HfApi(token=token)
46
  user_info = api.whoami()
 
38
  if not session:
39
  return None, "No session available."
40
 
41
+ token = (
42
+ getattr(session, "hf_token", None)
43
+ or os.environ.get("HF_TOKEN")
44
+ or os.environ.get("HUGGINGFACE_HUB_TOKEN")
45
+ )
46
  if not token:
47
+ return None, "No HF token available. Cannot create sandbox."
48
 
49
  api = HfApi(token=token)
50
  user_info = api.whoami()
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/main.py CHANGED
@@ -5,6 +5,14 @@ import os
5
  from contextlib import asynccontextmanager
6
  from pathlib import Path
7
 
 
 
 
 
 
 
 
 
8
  from fastapi import FastAPI
9
  from fastapi.middleware.cors import CORSMiddleware
10
  from fastapi.staticfiles import StaticFiles
 
5
  from contextlib import asynccontextmanager
6
  from pathlib import Path
7
 
8
+ from dotenv import load_dotenv
9
+
10
+ load_dotenv()
11
+
12
+ # Ensure HF_TOKEN is set — fall back to HF_ADMIN_TOKEN if available (HF Spaces)
13
+ if not os.environ.get("HF_TOKEN") and os.environ.get("HF_ADMIN_TOKEN"):
14
+ os.environ["HF_TOKEN"] = os.environ["HF_ADMIN_TOKEN"]
15
+
16
  from fastapi import FastAPI
17
  from fastapi.middleware.cors import CORSMiddleware
18
  from fastapi.staticfiles import StaticFiles
backend/models.py CHANGED
@@ -37,6 +37,7 @@ class ToolApproval(BaseModel):
37
  tool_call_id: str
38
  approved: bool
39
  feedback: str | None = None
 
40
 
41
 
42
  class ApprovalRequest(BaseModel):
@@ -67,6 +68,7 @@ class SessionInfo(BaseModel):
67
  created_at: str
68
  is_active: bool
69
  message_count: int
 
70
 
71
 
72
  class HealthResponse(BaseModel):
@@ -74,3 +76,13 @@ class HealthResponse(BaseModel):
74
 
75
  status: str = "ok"
76
  active_sessions: int = 0
 
 
 
 
 
 
 
 
 
 
 
37
  tool_call_id: str
38
  approved: bool
39
  feedback: str | None = None
40
+ edited_script: str | None = None
41
 
42
 
43
  class ApprovalRequest(BaseModel):
 
68
  created_at: str
69
  is_active: bool
70
  message_count: int
71
+ user_id: str = "dev"
72
 
73
 
74
  class HealthResponse(BaseModel):
 
76
 
77
  status: str = "ok"
78
  active_sessions: int = 0
79
+ max_sessions: int = 0
80
+
81
+
82
+ class LLMHealthResponse(BaseModel):
83
+ """LLM provider health check response."""
84
+
85
+ status: str # "ok" | "error"
86
+ model: str
87
+ error: str | None = None
88
+ error_type: str | None = None # "auth" | "credits" | "rate_limit" | "network" | "unknown"
backend/routes/agent.py CHANGED
@@ -1,58 +1,252 @@
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__)
18
 
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 +254,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,13 +266,17 @@ 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,
77
  "approved": a.approved,
78
  "feedback": a.feedback,
 
79
  }
80
  for a in request.approvals
81
  ]
@@ -86,8 +287,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 +299,9 @@ 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 +309,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 +321,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 +334,61 @@ 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
+ import os
9
+ from typing import Any
10
+
11
+ from dependencies import get_current_user, get_ws_user
12
+ from fastapi import (
13
+ APIRouter,
14
+ Depends,
15
+ HTTPException,
16
+ Request,
17
+ WebSocket,
18
+ WebSocketDisconnect,
19
+ )
20
+ from litellm import acompletion
21
 
22
+ from agent.core.agent_loop import _resolve_hf_router_params
23
  from models import (
24
  ApprovalRequest,
25
  HealthResponse,
26
+ LLMHealthResponse,
27
  SessionInfo,
28
  SessionResponse,
29
  SubmitRequest,
30
  )
31
+ from session_manager import MAX_SESSIONS, SessionCapacityError, session_manager
32
  from websocket import manager as ws_manager
33
 
34
  logger = logging.getLogger(__name__)
35
 
36
  router = APIRouter(prefix="/api", tags=["agent"])
37
 
38
+ AVAILABLE_MODELS = [
39
+ {
40
+ "id": "huggingface/novita/minimax/minimax-m2.1",
41
+ "label": "MiniMax M2.1",
42
+ "provider": "huggingface",
43
+ "recommended": True,
44
+ },
45
+ {
46
+ "id": "anthropic/claude-opus-4-5-20251101",
47
+ "label": "Claude Opus 4.5",
48
+ "provider": "anthropic",
49
+ "recommended": True,
50
+ },
51
+ {
52
+ "id": "huggingface/novita/moonshotai/kimi-k2.5",
53
+ "label": "Kimi K2.5",
54
+ "provider": "huggingface",
55
+ },
56
+ {
57
+ "id": "huggingface/novita/zai-org/glm-5",
58
+ "label": "GLM 5",
59
+ "provider": "huggingface",
60
+ },
61
+ ]
62
+
63
+
64
+ def _check_session_access(session_id: str, user: dict[str, Any]) -> None:
65
+ """Verify the user has access to the given session. Raises 403 or 404."""
66
+ info = session_manager.get_session_info(session_id)
67
+ if not info:
68
+ raise HTTPException(status_code=404, detail="Session not found")
69
+ if not session_manager.verify_session_access(session_id, user["user_id"]):
70
+ raise HTTPException(status_code=403, detail="Access denied to this session")
71
+
72
 
73
  @router.get("/health", response_model=HealthResponse)
74
  async def health_check() -> HealthResponse:
75
  """Health check endpoint."""
76
  return HealthResponse(
77
+ status="ok",
78
+ active_sessions=session_manager.active_session_count,
79
+ max_sessions=MAX_SESSIONS,
80
  )
81
 
82
 
83
+ @router.get("/health/llm", response_model=LLMHealthResponse)
84
+ async def llm_health_check() -> LLMHealthResponse:
85
+ """Check if the LLM provider is reachable and the API key is valid.
86
+
87
+ Makes a minimal 1-token completion call. Catches common errors:
88
+ - 401 → invalid API key
89
+ - 402/insufficient_quota → out of credits
90
+ - 429 → rate limited
91
+ - timeout / network → provider unreachable
92
+ """
93
+ model = session_manager.config.model_name
94
+ try:
95
+ llm_params = _resolve_hf_router_params(model)
96
+ await acompletion(
97
+ messages=[{"role": "user", "content": "hi"}],
98
+ max_tokens=1,
99
+ timeout=10,
100
+ **llm_params,
101
+ )
102
+ return LLMHealthResponse(status="ok", model=model)
103
+ except Exception as e:
104
+ err_str = str(e).lower()
105
+ error_type = "unknown"
106
+
107
+ if (
108
+ "401" in err_str
109
+ or "auth" in err_str
110
+ or "invalid" in err_str
111
+ or "api key" in err_str
112
+ ):
113
+ error_type = "auth"
114
+ elif (
115
+ "402" in err_str
116
+ or "credit" in err_str
117
+ or "quota" in err_str
118
+ or "insufficient" in err_str
119
+ or "billing" in err_str
120
+ ):
121
+ error_type = "credits"
122
+ elif "429" in err_str or "rate" in err_str:
123
+ error_type = "rate_limit"
124
+ elif "timeout" in err_str or "connect" in err_str or "network" in err_str:
125
+ error_type = "network"
126
+
127
+ logger.warning(f"LLM health check failed ({error_type}): {e}")
128
+ return LLMHealthResponse(
129
+ status="error",
130
+ model=model,
131
+ error=str(e)[:500],
132
+ error_type=error_type,
133
+ )
134
+
135
+
136
+ @router.get("/config/model")
137
+ async def get_model() -> dict:
138
+ """Get current model and available models. No auth required."""
139
+ return {
140
+ "current": session_manager.config.model_name,
141
+ "available": AVAILABLE_MODELS,
142
+ }
143
+
144
+
145
+ @router.post("/config/model")
146
+ async def set_model(body: dict, user: dict = Depends(get_current_user)) -> dict:
147
+ """Set the LLM model. Applies to new conversations."""
148
+ model_id = body.get("model")
149
+ if not model_id:
150
+ raise HTTPException(status_code=400, detail="Missing 'model' field")
151
+ valid_ids = {m["id"] for m in AVAILABLE_MODELS}
152
+ if model_id not in valid_ids:
153
+ raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
154
+ session_manager.config.model_name = model_id
155
+ logger.info(f"Model changed to {model_id} by {user.get('username', 'unknown')}")
156
+ return {"model": model_id}
157
+
158
+
159
+ @router.post("/title")
160
+ async def generate_title(
161
+ request: SubmitRequest, user: dict = Depends(get_current_user)
162
+ ) -> dict:
163
+ """Generate a short title for a chat session based on the first user message."""
164
+ model = session_manager.config.model_name
165
+ llm_params = _resolve_hf_router_params(model)
166
+ try:
167
+ response = await acompletion(
168
+ messages=[
169
+ {
170
+ "role": "system",
171
+ "content": (
172
+ "Generate a very short title (max 6 words) for a chat conversation "
173
+ "that starts with the following user message. "
174
+ "Reply with ONLY the title, no quotes, no punctuation at the end."
175
+ ),
176
+ },
177
+ {"role": "user", "content": request.text[:500]},
178
+ ],
179
+ max_tokens=20,
180
+ temperature=0.3,
181
+ timeout=8,
182
+ **llm_params,
183
+ )
184
+ title = response.choices[0].message.content.strip().strip('"').strip("'")
185
+ # Safety: cap at 50 chars
186
+ if len(title) > 50:
187
+ title = title[:50].rstrip() + "…"
188
+ return {"title": title}
189
+ except Exception as e:
190
+ logger.warning(f"Title generation failed: {e}")
191
+ # Fallback: truncate the message
192
+ fallback = request.text.strip()
193
+ title = fallback[:40].rstrip() + "…" if len(fallback) > 40 else fallback
194
+ return {"title": title}
195
+
196
+
197
  @router.post("/session", response_model=SessionResponse)
198
+ async def create_session(
199
+ request: Request, user: dict = Depends(get_current_user)
200
+ ) -> SessionResponse:
201
+ """Create a new agent session bound to the authenticated user.
202
+
203
+ The user's HF access token is extracted from the Authorization header
204
+ and stored in the session so that tools (e.g. hf_jobs) can act on
205
+ behalf of the user.
206
+
207
+ Returns 503 if the server or user has reached the session limit.
208
+ """
209
+ # Extract the user's HF token (Bearer header or HttpOnly cookie)
210
+ hf_token = None
211
+ auth_header = request.headers.get("Authorization", "")
212
+ if auth_header.startswith("Bearer "):
213
+ hf_token = auth_header[7:]
214
+ if not hf_token:
215
+ hf_token = request.cookies.get("hf_access_token")
216
+
217
+ try:
218
+ session_id = await session_manager.create_session(
219
+ user_id=user["user_id"], hf_token=hf_token
220
+ )
221
+ except SessionCapacityError as e:
222
+ raise HTTPException(status_code=503, detail=str(e))
223
+
224
  return SessionResponse(session_id=session_id, ready=True)
225
 
226
 
227
  @router.get("/session/{session_id}", response_model=SessionInfo)
228
+ async def get_session(
229
+ session_id: str, user: dict = Depends(get_current_user)
230
+ ) -> SessionInfo:
231
+ """Get session information. Only accessible by the session owner."""
232
+ _check_session_access(session_id, user)
233
  info = session_manager.get_session_info(session_id)
 
 
234
  return SessionInfo(**info)
235
 
236
 
237
  @router.get("/sessions", response_model=list[SessionInfo])
238
+ async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
239
+ """List sessions belonging to the authenticated user."""
240
+ sessions = session_manager.list_sessions(user_id=user["user_id"])
241
  return [SessionInfo(**s) for s in sessions]
242
 
243
 
244
  @router.delete("/session/{session_id}")
245
+ async def delete_session(
246
+ session_id: str, user: dict = Depends(get_current_user)
247
+ ) -> dict:
248
+ """Delete a session. Only accessible by the session owner."""
249
+ _check_session_access(session_id, user)
250
  success = await session_manager.delete_session(session_id)
251
  if not success:
252
  raise HTTPException(status_code=404, detail="Session not found")
 
254
 
255
 
256
  @router.post("/submit")
257
+ async def submit_input(
258
+ request: SubmitRequest, user: dict = Depends(get_current_user)
259
+ ) -> dict:
260
+ """Submit user input to a session. Only accessible by the session owner."""
261
+ _check_session_access(request.session_id, user)
262
  success = await session_manager.submit_user_input(request.session_id, request.text)
263
  if not success:
264
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
266
 
267
 
268
  @router.post("/approve")
269
+ async def submit_approval(
270
+ request: ApprovalRequest, user: dict = Depends(get_current_user)
271
+ ) -> dict:
272
+ """Submit tool approvals to a session. Only accessible by the session owner."""
273
+ _check_session_access(request.session_id, user)
274
  approvals = [
275
  {
276
  "tool_call_id": a.tool_call_id,
277
  "approved": a.approved,
278
  "feedback": a.feedback,
279
+ "edited_script": a.edited_script,
280
  }
281
  for a in request.approvals
282
  ]
 
287
 
288
 
289
  @router.post("/interrupt/{session_id}")
290
+ async def interrupt_session(
291
+ session_id: str, user: dict = Depends(get_current_user)
292
+ ) -> dict:
293
  """Interrupt the current operation in a session."""
294
+ _check_session_access(session_id, user)
295
  success = await session_manager.interrupt(session_id)
296
  if not success:
297
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
299
 
300
 
301
  @router.post("/undo/{session_id}")
302
+ async def undo_session(session_id: str, user: dict = Depends(get_current_user)) -> dict:
303
  """Undo the last turn in a session."""
304
+ _check_session_access(session_id, user)
305
  success = await session_manager.undo(session_id)
306
  if not success:
307
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
309
 
310
 
311
  @router.post("/compact/{session_id}")
312
+ async def compact_session(
313
+ session_id: str, user: dict = Depends(get_current_user)
314
+ ) -> dict:
315
  """Compact the context in a session."""
316
+ _check_session_access(session_id, user)
317
  success = await session_manager.compact(session_id)
318
  if not success:
319
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
321
 
322
 
323
  @router.post("/shutdown/{session_id}")
324
+ async def shutdown_session(
325
+ session_id: str, user: dict = Depends(get_current_user)
326
+ ) -> dict:
327
  """Shutdown a session."""
328
+ _check_session_access(session_id, user)
329
  success = await session_manager.shutdown_session(session_id)
330
  if not success:
331
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
334
 
335
  @router.websocket("/ws/{session_id}")
336
  async def websocket_endpoint(websocket: WebSocket, session_id: str) -> None:
337
+ """WebSocket endpoint for real-time events.
338
+
339
+ Authentication is done via:
340
+ - ?token= query parameter (for browsers that can't send WS headers)
341
+ - Cookie (automatic for same-origin connections)
342
+ - Dev mode bypass (when OAUTH_CLIENT_ID is not set)
343
+
344
+ NOTE: We must accept() before close() so the browser receives our custom
345
+ close codes (4001, 4003, 4004). If we close() before accept(), Starlette
346
+ sends HTTP 403 and the browser only sees code 1006 (abnormal closure).
347
+ """
348
  logger.info(f"WebSocket connection request for session {session_id}")
349
+
350
+ # Authenticate the WebSocket connection
351
+ user = await get_ws_user(websocket)
352
+ if not user:
353
+ logger.warning(
354
+ f"WebSocket rejected: authentication failed for session {session_id}"
355
+ )
356
+ await websocket.accept()
357
+ await websocket.close(code=4001, reason="Authentication required")
358
+ return
359
+
360
  # Verify session exists
361
  info = session_manager.get_session_info(session_id)
362
  if not info:
363
+ logger.warning(f"WebSocket rejected: session {session_id} not found")
364
+ await websocket.accept()
365
  await websocket.close(code=4004, reason="Session not found")
366
  return
367
 
368
+ # Verify user owns the session
369
+ if not session_manager.verify_session_access(session_id, user["user_id"]):
370
+ logger.warning(
371
+ f"WebSocket rejected: user {user['user_id']} denied access to session {session_id}"
372
+ )
373
+ await websocket.accept()
374
+ await websocket.close(code=4003, reason="Access denied")
375
+ return
376
+
377
  await ws_manager.connect(websocket, session_id)
378
 
379
+ # Send "ready" immediately on WebSocket connection so the frontend
380
+ # knows the session is alive. The original ready event from _run_session
381
+ # fires before the WS is connected and is always lost.
382
+ try:
383
+ await websocket.send_json(
384
+ {
385
+ "event_type": "ready",
386
+ "data": {"message": "Agent initialized"},
387
+ }
388
+ )
389
+ except Exception as e:
390
+ logger.error(f"Failed to send ready event for session {session_id}: {e}")
391
+
392
  try:
393
  while True:
394
  # Keep connection alive, handle ping/pong
backend/routes/auth.py CHANGED
@@ -1,11 +1,17 @@
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"])
@@ -15,10 +21,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,17 +53,26 @@ 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 = {
47
  "client_id": OAUTH_CLIENT_ID,
48
  "redirect_uri": get_redirect_uri(request),
49
- "scope": "openid profile",
50
  "response_type": "code",
51
  "state": state,
 
 
 
52
  }
53
  auth_url = f"{OPENID_PROVIDER_URL}/oauth/authorize?{urlencode(params)}"
54
 
@@ -91,58 +115,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 dependencies import AUTH_ENABLED, get_current_user
14
+ from fastapi import APIRouter, Depends, HTTPException, Request
15
  from fastapi.responses import RedirectResponse
16
 
17
  router = APIRouter(prefix="/auth", tags=["auth"])
 
21
  OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
22
  OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
23
 
24
+ # In-memory OAuth state store with expiry (5 min TTL)
25
+ _OAUTH_STATE_TTL = 300
26
  oauth_states: dict[str, dict] = {}
27
 
28
 
29
+ def _cleanup_expired_states() -> None:
30
+ """Remove expired OAuth states to prevent memory growth."""
31
+ now = time.time()
32
+ expired = [k for k, v in oauth_states.items() if now > v.get("expires_at", 0)]
33
+ for k in expired:
34
+ del oauth_states[k]
35
+
36
+
37
  def get_redirect_uri(request: Request) -> str:
38
  """Get the OAuth callback redirect URI."""
39
  # In HF Spaces, use the SPACE_HOST if available
 
53
  detail="OAuth not configured. Set OAUTH_CLIENT_ID environment variable.",
54
  )
55
 
56
+ # Clean up expired states to prevent memory growth
57
+ _cleanup_expired_states()
58
+
59
  # Generate state for CSRF protection
60
  state = secrets.token_urlsafe(32)
61
+ oauth_states[state] = {
62
+ "redirect_uri": get_redirect_uri(request),
63
+ "expires_at": time.time() + _OAUTH_STATE_TTL,
64
+ }
65
 
66
  # Build authorization URL
67
  params = {
68
  "client_id": OAUTH_CLIENT_ID,
69
  "redirect_uri": get_redirect_uri(request),
70
+ "scope": "openid profile read-repos write-repos contribute-repos manage-repos inference-api jobs write-discussions",
71
  "response_type": "code",
72
  "state": state,
73
+ "orgIds": os.environ.get(
74
+ "HF_OAUTH_ORG_ID", "698dbf55845d85df163175f1"
75
+ ), # ml-agent-explorers
76
  }
77
  auth_url = f"{OPENID_PROVIDER_URL}/oauth/authorize?{urlencode(params)}"
78
 
 
115
 
116
  # Get user info
117
  access_token = token_data.get("access_token")
118
+ if not access_token:
119
+ raise HTTPException(
120
+ status_code=500,
121
+ detail="Token exchange succeeded but no access_token was returned.",
122
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
+ # Fetch user info (optional — failure is not fatal)
125
+ async with httpx.AsyncClient() as client:
126
+ try:
127
+ userinfo_response = await client.get(
128
+ f"{OPENID_PROVIDER_URL}/oauth/userinfo",
129
+ headers={"Authorization": f"Bearer {access_token}"},
130
+ )
131
+ userinfo_response.raise_for_status()
132
+ except httpx.HTTPError:
133
+ pass # user_info not required for auth flow
134
+
135
+ # Set access token as HttpOnly cookie (not in URL — avoids leaks via
136
+ # Referrer headers, browser history, and server logs)
137
+ is_production = bool(os.environ.get("SPACE_HOST"))
138
+ response = RedirectResponse(url="/", status_code=302)
139
+ response.set_cookie(
140
+ key="hf_access_token",
141
+ value=access_token,
142
+ httponly=True,
143
+ secure=is_production, # Secure flag only in production (HTTPS)
144
+ samesite="lax",
145
+ max_age=3600 * 24, # 24 hours
146
+ path="/",
147
+ )
148
+ return response
149
 
150
 
151
  @router.get("/logout")
152
  async def logout() -> RedirectResponse:
153
+ """Log out the user by clearing the auth cookie."""
154
+ response = RedirectResponse(url="/")
155
+ response.delete_cookie(key="hf_access_token", path="/")
156
+ return response
157
 
158
 
159
+ @router.get("/status")
160
+ async def auth_status() -> dict:
161
+ """Check if OAuth is enabled on this instance."""
162
+ return {"auth_enabled": AUTH_ENABLED}
 
 
163
 
 
164
 
165
+ @router.get("/me")
166
+ async def get_me(user: dict = Depends(get_current_user)) -> dict:
167
+ """Get current user info. Returns the authenticated user or dev user.
168
+
169
+ Uses the shared auth dependency which handles cookie + Bearer token.
170
+ """
171
+ return user
 
 
 
 
 
 
 
 
 
backend/session_manager.py CHANGED
@@ -48,11 +48,28 @@ 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 +78,69 @@ 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 +148,8 @@ class SessionManager:
81
  session=session,
82
  tool_router=tool_router,
83
  submission_queue=submission_queue,
 
 
84
  )
85
 
86
  async with self._lock:
@@ -92,7 +161,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 +314,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 +346,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
+ hf_token: str | None = None # User's HF OAuth token for tool execution
53
  task: asyncio.Task | None = None
54
  created_at: datetime = field(default_factory=datetime.utcnow)
55
  is_active: bool = True
56
 
57
 
58
+ class SessionCapacityError(Exception):
59
+ """Raised when no more sessions can be created."""
60
+
61
+ def __init__(self, message: str, error_type: str = "global") -> None:
62
+ super().__init__(message)
63
+ self.error_type = error_type # "global" or "per_user"
64
+
65
+
66
+ # ── Capacity limits ─────────────────────────────────────────────────
67
+ # Estimated for HF Spaces cpu-basic (2 vCPU, 16 GB RAM).
68
+ # Each session uses ~10-20 MB (context, tools, queues, task).
69
+ MAX_SESSIONS: int = 50
70
+ MAX_SESSIONS_PER_USER: int = 10
71
+
72
+
73
  class SessionManager:
74
  """Manages multiple concurrent agent sessions."""
75
 
 
78
  self.sessions: dict[str, AgentSession] = {}
79
  self._lock = asyncio.Lock()
80
 
81
+ def _count_user_sessions(self, user_id: str) -> int:
82
+ """Count active sessions owned by a specific user."""
83
+ return sum(
84
+ 1
85
+ for s in self.sessions.values()
86
+ if s.user_id == user_id and s.is_active
87
+ )
88
+
89
+ async def create_session(self, user_id: str = "dev", hf_token: str | None = None) -> str:
90
+ """Create a new agent session and return its ID.
91
+
92
+ Session() and ToolRouter() constructors contain blocking I/O
93
+ (e.g. HfApi().whoami(), litellm.get_max_tokens()) so they are
94
+ executed in a thread pool to avoid freezing the async event loop.
95
+
96
+ Args:
97
+ user_id: The ID of the user who owns this session.
98
+
99
+ Raises:
100
+ SessionCapacityError: If the server or user has reached the
101
+ maximum number of concurrent sessions.
102
+ """
103
+ # ── Capacity checks ──────────────────────────────────────────
104
+ async with self._lock:
105
+ active_count = self.active_session_count
106
+ if active_count >= MAX_SESSIONS:
107
+ raise SessionCapacityError(
108
+ f"Server is at capacity ({active_count}/{MAX_SESSIONS} sessions). "
109
+ "Please try again later.",
110
+ error_type="global",
111
+ )
112
+ if user_id != "dev":
113
+ user_count = self._count_user_sessions(user_id)
114
+ if user_count >= MAX_SESSIONS_PER_USER:
115
+ raise SessionCapacityError(
116
+ f"You have reached the maximum of {MAX_SESSIONS_PER_USER} "
117
+ "concurrent sessions. Please close an existing session first.",
118
+ error_type="per_user",
119
+ )
120
+
121
  session_id = str(uuid.uuid4())
122
 
123
  # Create queues for this session
124
  submission_queue: asyncio.Queue = asyncio.Queue()
125
  event_queue: asyncio.Queue = asyncio.Queue()
126
 
127
+ # Run blocking constructors in a thread to keep the event loop responsive.
128
+ # Without this, Session.__init__ → ContextManager → litellm.get_max_tokens()
129
+ # blocks all HTTP/WebSocket handling.
130
+ import time as _time
131
+
132
+ def _create_session_sync():
133
+ t0 = _time.monotonic()
134
+ tool_router = ToolRouter(self.config.mcpServers)
135
+ session = Session(event_queue, config=self.config, tool_router=tool_router)
136
+ t1 = _time.monotonic()
137
+ logger.info(f"Session initialized in {t1 - t0:.2f}s")
138
+ return tool_router, session
139
 
140
+ tool_router, session = await asyncio.to_thread(_create_session_sync)
141
+
142
+ # Store user's HF token on the session so tools can use it
143
+ session.hf_token = hf_token
144
 
145
  # Create wrapper
146
  agent_session = AgentSession(
 
148
  session=session,
149
  tool_router=tool_router,
150
  submission_queue=submission_queue,
151
+ user_id=user_id,
152
+ hf_token=hf_token,
153
  )
154
 
155
  async with self._lock:
 
161
  )
162
  agent_session.task = task
163
 
164
+ logger.info(f"Created session {session_id} for user {user_id}")
165
  return session_id
166
 
167
  async def _run_session(
 
314
 
315
  return True
316
 
317
+ def get_session_owner(self, session_id: str) -> str | None:
318
+ """Get the user_id that owns a session, or None if session doesn't exist."""
319
+ agent_session = self.sessions.get(session_id)
320
+ if not agent_session:
321
+ return None
322
+ return agent_session.user_id
323
+
324
+ def verify_session_access(self, session_id: str, user_id: str) -> bool:
325
+ """Check if a user has access to a session.
326
+
327
+ Returns True if:
328
+ - The session exists AND the user owns it
329
+ - The user_id is "dev" (dev mode bypass)
330
+ """
331
+ owner = self.get_session_owner(session_id)
332
+ if owner is None:
333
+ return False
334
+ if user_id == "dev" or owner == "dev":
335
+ return True
336
+ return owner == user_id
337
+
338
  def get_session_info(self, session_id: str) -> dict[str, Any] | None:
339
  """Get information about a session."""
340
  agent_session = self.sessions.get(session_id)
 
346
  "created_at": agent_session.created_at.isoformat(),
347
  "is_active": agent_session.is_active,
348
  "message_count": len(agent_session.session.context_manager.items),
349
+ "user_id": agent_session.user_id,
350
  }
351
 
352
+ def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
353
+ """List sessions, optionally filtered by user.
354
+
355
+ Args:
356
+ user_id: If provided, only return sessions owned by this user.
357
+ If "dev", return all sessions (dev mode).
358
+ """
359
+ results = []
360
+ for sid in self.sessions:
361
+ info = self.get_session_info(sid)
362
+ if not info:
363
+ continue
364
+ if user_id and user_id != "dev" and info.get("user_id") != user_id:
365
+ continue
366
+ results.append(info)
367
+ return results
368
 
369
  @property
370
  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()
configs/main_agent_config.json CHANGED
@@ -1,9 +1,9 @@
1
  {
2
- "model_name": "anthropic/claude-opus-4-5-20251101",
3
  "save_sessions": true,
4
  "session_dataset_repo": "akseljoonas/hf-agent-sessions",
5
  "yolo_mode": false,
6
- "confirm_cpu_jobs": false,
7
  "auto_file_upload": true,
8
  "mcpServers": {
9
  "hf-mcp-server": {
 
1
  {
2
+ "model_name": "huggingface/novita/moonshotai/kimi-k2.5",
3
  "save_sessions": true,
4
  "session_dataset_repo": "akseljoonas/hf-agent-sessions",
5
  "yolo_mode": false,
6
+ "confirm_cpu_jobs": true,
7
  "auto_file_upload": true,
8
  "mcpServers": {
9
  "hf-mcp-server": {
frontend/package-lock.json CHANGED
@@ -8,10 +8,12 @@
8
  "name": "hf-agent-frontend",
9
  "version": "1.0.0",
10
  "dependencies": {
 
11
  "@emotion/react": "^11.13.0",
12
  "@emotion/styled": "^11.13.0",
13
  "@mui/icons-material": "^6.1.0",
14
  "@mui/material": "^6.1.0",
 
15
  "react": "^18.3.1",
16
  "react-dom": "^18.3.1",
17
  "react-markdown": "^9.0.1",
@@ -34,6 +36,70 @@
34
  "vite": "^5.4.10"
35
  }
36
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  "node_modules/@babel/code-frame": {
38
  "version": "7.28.6",
39
  "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -1348,6 +1414,15 @@
1348
  }
1349
  }
1350
  },
 
 
 
 
 
 
 
 
 
1351
  "node_modules/@popperjs/core": {
1352
  "version": "2.11.8",
1353
  "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1715,6 +1790,12 @@
1715
  "win32"
1716
  ]
1717
  },
 
 
 
 
 
 
1718
  "node_modules/@types/babel__core": {
1719
  "version": "7.20.5",
1720
  "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2155,6 +2236,15 @@
2155
  "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
2156
  "license": "ISC"
2157
  },
 
 
 
 
 
 
 
 
 
2158
  "node_modules/@vitejs/plugin-react": {
2159
  "version": "4.7.0",
2160
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2200,6 +2290,24 @@
2200
  "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
2201
  }
2202
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2203
  "node_modules/ajv": {
2204
  "version": "6.12.6",
2205
  "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2848,6 +2956,15 @@
2848
  "node": ">=0.10.0"
2849
  }
2850
  },
 
 
 
 
 
 
 
 
 
2851
  "node_modules/extend": {
2852
  "version": "3.0.2",
2853
  "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -3356,6 +3473,12 @@
3356
  "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
3357
  "license": "MIT"
3358
  },
 
 
 
 
 
 
3359
  "node_modules/json-schema-traverse": {
3360
  "version": "0.4.1",
3361
  "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -5052,6 +5175,31 @@
5052
  "url": "https://github.com/sponsors/ljharb"
5053
  }
5054
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5055
  "node_modules/tinyglobby": {
5056
  "version": "0.2.15",
5057
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -5282,6 +5430,16 @@
5282
  "punycode": "^2.1.0"
5283
  }
5284
  },
 
 
 
 
 
 
 
 
 
 
5285
  "node_modules/vfile": {
5286
  "version": "6.0.3",
5287
  "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -5426,6 +5584,16 @@
5426
  "url": "https://github.com/sponsors/sindresorhus"
5427
  }
5428
  },
 
 
 
 
 
 
 
 
 
 
5429
  "node_modules/zustand": {
5430
  "version": "5.0.10",
5431
  "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
 
8
  "name": "hf-agent-frontend",
9
  "version": "1.0.0",
10
  "dependencies": {
11
+ "@ai-sdk/react": "^3.0.93",
12
  "@emotion/react": "^11.13.0",
13
  "@emotion/styled": "^11.13.0",
14
  "@mui/icons-material": "^6.1.0",
15
  "@mui/material": "^6.1.0",
16
+ "ai": "^6.0.91",
17
  "react": "^18.3.1",
18
  "react-dom": "^18.3.1",
19
  "react-markdown": "^9.0.1",
 
36
  "vite": "^5.4.10"
37
  }
38
  },
39
+ "node_modules/@ai-sdk/gateway": {
40
+ "version": "3.0.50",
41
+ "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.50.tgz",
42
+ "integrity": "sha512-Jdd1a8VgbD7l7r+COj0h5SuaYRfPvOJ/AO6l0OrmTPEcI2MUQPr3C4JttfpNkcheEN+gOdy0CtZWuG17bW2fjw==",
43
+ "license": "Apache-2.0",
44
+ "dependencies": {
45
+ "@ai-sdk/provider": "3.0.8",
46
+ "@ai-sdk/provider-utils": "4.0.15",
47
+ "@vercel/oidc": "3.1.0"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "peerDependencies": {
53
+ "zod": "^3.25.76 || ^4.1.8"
54
+ }
55
+ },
56
+ "node_modules/@ai-sdk/provider": {
57
+ "version": "3.0.8",
58
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
59
+ "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
60
+ "license": "Apache-2.0",
61
+ "dependencies": {
62
+ "json-schema": "^0.4.0"
63
+ },
64
+ "engines": {
65
+ "node": ">=18"
66
+ }
67
+ },
68
+ "node_modules/@ai-sdk/provider-utils": {
69
+ "version": "4.0.15",
70
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz",
71
+ "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==",
72
+ "license": "Apache-2.0",
73
+ "dependencies": {
74
+ "@ai-sdk/provider": "3.0.8",
75
+ "@standard-schema/spec": "^1.1.0",
76
+ "eventsource-parser": "^3.0.6"
77
+ },
78
+ "engines": {
79
+ "node": ">=18"
80
+ },
81
+ "peerDependencies": {
82
+ "zod": "^3.25.76 || ^4.1.8"
83
+ }
84
+ },
85
+ "node_modules/@ai-sdk/react": {
86
+ "version": "3.0.93",
87
+ "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.93.tgz",
88
+ "integrity": "sha512-FY1HmeAfCpiAGLhIZh2QR8QFzHFZfhjMmkA9D5KC/O3eGqPeY7CwBABLkzRH+5Gkf+MfxXnEm4VF0MpmvDMjpg==",
89
+ "license": "Apache-2.0",
90
+ "dependencies": {
91
+ "@ai-sdk/provider-utils": "4.0.15",
92
+ "ai": "6.0.91",
93
+ "swr": "^2.2.5",
94
+ "throttleit": "2.1.0"
95
+ },
96
+ "engines": {
97
+ "node": ">=18"
98
+ },
99
+ "peerDependencies": {
100
+ "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
101
+ }
102
+ },
103
  "node_modules/@babel/code-frame": {
104
  "version": "7.28.6",
105
  "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
 
1414
  }
1415
  }
1416
  },
1417
+ "node_modules/@opentelemetry/api": {
1418
+ "version": "1.9.0",
1419
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
1420
+ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
1421
+ "license": "Apache-2.0",
1422
+ "engines": {
1423
+ "node": ">=8.0.0"
1424
+ }
1425
+ },
1426
  "node_modules/@popperjs/core": {
1427
  "version": "2.11.8",
1428
  "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
 
1790
  "win32"
1791
  ]
1792
  },
1793
+ "node_modules/@standard-schema/spec": {
1794
+ "version": "1.1.0",
1795
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1796
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1797
+ "license": "MIT"
1798
+ },
1799
  "node_modules/@types/babel__core": {
1800
  "version": "7.20.5",
1801
  "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
 
2236
  "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
2237
  "license": "ISC"
2238
  },
2239
+ "node_modules/@vercel/oidc": {
2240
+ "version": "3.1.0",
2241
+ "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
2242
+ "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
2243
+ "license": "Apache-2.0",
2244
+ "engines": {
2245
+ "node": ">= 20"
2246
+ }
2247
+ },
2248
  "node_modules/@vitejs/plugin-react": {
2249
  "version": "4.7.0",
2250
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
 
2290
  "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
2291
  }
2292
  },
2293
+ "node_modules/ai": {
2294
+ "version": "6.0.91",
2295
+ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.91.tgz",
2296
+ "integrity": "sha512-k1/8BusZMhYVxxLZt0BUZzm9HVDCCh117nyWfWUx5xjR2+tWisJbXgysL7EBMq2lgyHwgpA1jDR3tVjWSdWZXw==",
2297
+ "license": "Apache-2.0",
2298
+ "dependencies": {
2299
+ "@ai-sdk/gateway": "3.0.50",
2300
+ "@ai-sdk/provider": "3.0.8",
2301
+ "@ai-sdk/provider-utils": "4.0.15",
2302
+ "@opentelemetry/api": "1.9.0"
2303
+ },
2304
+ "engines": {
2305
+ "node": ">=18"
2306
+ },
2307
+ "peerDependencies": {
2308
+ "zod": "^3.25.76 || ^4.1.8"
2309
+ }
2310
+ },
2311
  "node_modules/ajv": {
2312
  "version": "6.12.6",
2313
  "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
 
2956
  "node": ">=0.10.0"
2957
  }
2958
  },
2959
+ "node_modules/eventsource-parser": {
2960
+ "version": "3.0.6",
2961
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
2962
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
2963
+ "license": "MIT",
2964
+ "engines": {
2965
+ "node": ">=18.0.0"
2966
+ }
2967
+ },
2968
  "node_modules/extend": {
2969
  "version": "3.0.2",
2970
  "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
 
3473
  "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
3474
  "license": "MIT"
3475
  },
3476
+ "node_modules/json-schema": {
3477
+ "version": "0.4.0",
3478
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
3479
+ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
3480
+ "license": "(AFL-2.1 OR BSD-3-Clause)"
3481
+ },
3482
  "node_modules/json-schema-traverse": {
3483
  "version": "0.4.1",
3484
  "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
 
5175
  "url": "https://github.com/sponsors/ljharb"
5176
  }
5177
  },
5178
+ "node_modules/swr": {
5179
+ "version": "2.4.0",
5180
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.0.tgz",
5181
+ "integrity": "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==",
5182
+ "license": "MIT",
5183
+ "dependencies": {
5184
+ "dequal": "^2.0.3",
5185
+ "use-sync-external-store": "^1.6.0"
5186
+ },
5187
+ "peerDependencies": {
5188
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5189
+ }
5190
+ },
5191
+ "node_modules/throttleit": {
5192
+ "version": "2.1.0",
5193
+ "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
5194
+ "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
5195
+ "license": "MIT",
5196
+ "engines": {
5197
+ "node": ">=18"
5198
+ },
5199
+ "funding": {
5200
+ "url": "https://github.com/sponsors/sindresorhus"
5201
+ }
5202
+ },
5203
  "node_modules/tinyglobby": {
5204
  "version": "0.2.15",
5205
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
 
5430
  "punycode": "^2.1.0"
5431
  }
5432
  },
5433
+ "node_modules/use-sync-external-store": {
5434
+ "version": "1.6.0",
5435
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
5436
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
5437
+ "license": "MIT",
5438
+ "peer": true,
5439
+ "peerDependencies": {
5440
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5441
+ }
5442
+ },
5443
  "node_modules/vfile": {
5444
  "version": "6.0.3",
5445
  "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
 
5584
  "url": "https://github.com/sponsors/sindresorhus"
5585
  }
5586
  },
5587
+ "node_modules/zod": {
5588
+ "version": "4.3.6",
5589
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
5590
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
5591
+ "license": "MIT",
5592
+ "peer": true,
5593
+ "funding": {
5594
+ "url": "https://github.com/sponsors/colinhacks"
5595
+ }
5596
+ },
5597
  "node_modules/zustand": {
5598
  "version": "5.0.10",
5599
  "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
frontend/package.json CHANGED
@@ -10,10 +10,12 @@
10
  "preview": "vite preview"
11
  },
12
  "dependencies": {
 
13
  "@emotion/react": "^11.13.0",
14
  "@emotion/styled": "^11.13.0",
15
  "@mui/icons-material": "^6.1.0",
16
  "@mui/material": "^6.1.0",
 
17
  "react": "^18.3.1",
18
  "react-dom": "^18.3.1",
19
  "react-markdown": "^9.0.1",
 
10
  "preview": "vite preview"
11
  },
12
  "dependencies": {
13
+ "@ai-sdk/react": "^3.0.93",
14
  "@emotion/react": "^11.13.0",
15
  "@emotion/styled": "^11.13.0",
16
  "@mui/icons-material": "^6.1.0",
17
  "@mui/material": "^6.1.0",
18
+ "ai": "^6.0.91",
19
  "react": "^18.3.1",
20
  "react-dom": "^18.3.1",
21
  "react-markdown": "^9.0.1",
frontend/src/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/ActivityStatusBar.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box, Typography } from '@mui/material';
2
+ import { keyframes } from '@mui/system';
3
+ import { useAgentStore, type ActivityStatus } from '@/store/agentStore';
4
+
5
+ const shimmer = keyframes`
6
+ 0% { background-position: -100% center; }
7
+ 50% { background-position: 200% center; }
8
+ 100% { background-position: -100% center; }
9
+ `;
10
+
11
+ const TOOL_LABELS: Record<string, string> = {
12
+ hf_jobs: 'Running job',
13
+ hf_repo_files: 'Uploading file',
14
+ hf_repo_git: 'Git operation',
15
+ hf_inspect_dataset: 'Inspecting dataset',
16
+ hf_search: 'Searching',
17
+ plan_tool: 'Planning',
18
+ };
19
+
20
+ function statusLabel(status: ActivityStatus): string {
21
+ switch (status.type) {
22
+ case 'thinking': return 'Thinking';
23
+ case 'streaming': return 'Writing';
24
+ case 'tool': return TOOL_LABELS[status.toolName] || `Running ${status.toolName}`;
25
+ case 'waiting-approval': return 'Waiting for approval';
26
+ default: return '';
27
+ }
28
+ }
29
+
30
+ export default function ActivityStatusBar() {
31
+ const activityStatus = useAgentStore(s => s.activityStatus);
32
+
33
+ if (activityStatus.type === 'idle') return null;
34
+
35
+ const label = statusLabel(activityStatus);
36
+
37
+ return (
38
+ <Box sx={{ px: 2, py: 0.5, minHeight: 28, display: 'flex', alignItems: 'center' }}>
39
+ <Typography
40
+ sx={{
41
+ fontFamily: 'monospace',
42
+ fontSize: '0.72rem',
43
+ fontWeight: 500,
44
+ letterSpacing: '0.02em',
45
+ background: 'linear-gradient(90deg, var(--muted-text) 30%, var(--text) 50%, var(--muted-text) 70%)',
46
+ backgroundSize: '250% 100%',
47
+ backgroundClip: 'text',
48
+ WebkitBackgroundClip: 'text',
49
+ WebkitTextFillColor: 'transparent',
50
+ animation: `${shimmer} 4s ease-in-out infinite`,
51
+ }}
52
+ >
53
+ {label}…
54
+ </Typography>
55
+ </Box>
56
+ );
57
+ }
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,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from 'react';
2
+ import { Box, Stack, Typography } from '@mui/material';
3
+ import MarkdownContent from './MarkdownContent';
4
+ import ToolCallGroup from './ToolCallGroup';
5
+ import type { UIMessage } from 'ai';
6
+ import type { MessageMeta } from '@/types/agent';
7
+
8
+ interface AssistantMessageProps {
9
+ message: UIMessage;
10
+ isStreaming?: boolean;
11
+ approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
12
+ }
13
+
14
+ /**
15
+ * Groups consecutive tool parts together so they render as a single
16
+ * ToolCallGroup (visually identical to the old segments approach).
17
+ */
18
+ type DynamicToolPart = Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>;
19
+
20
+ function groupParts(parts: UIMessage['parts']) {
21
+ const groups: Array<
22
+ | { kind: 'text'; text: string; idx: number }
23
+ | { kind: 'tools'; tools: DynamicToolPart[]; idx: number }
24
+ > = [];
25
+
26
+ for (let i = 0; i < parts.length; i++) {
27
+ const part = parts[i];
28
+
29
+ if (part.type === 'text') {
30
+ groups.push({ kind: 'text', text: part.text, idx: i });
31
+ } else if (part.type === 'dynamic-tool') {
32
+ const toolPart = part as DynamicToolPart;
33
+ const last = groups[groups.length - 1];
34
+ if (last?.kind === 'tools') {
35
+ last.tools.push(toolPart);
36
+ } else {
37
+ groups.push({ kind: 'tools', tools: [toolPart], idx: i });
38
+ }
39
+ }
40
+ // step-start, step-end, etc. are ignored visually
41
+ }
42
+
43
+ return groups;
44
+ }
45
+
46
+ export default function AssistantMessage({ message, isStreaming = false, approveTools }: AssistantMessageProps) {
47
+ const groups = useMemo(() => groupParts(message.parts), [message.parts]);
48
+
49
+ // Find the last text group index for streaming cursor
50
+ let lastTextIdx = -1;
51
+ for (let i = groups.length - 1; i >= 0; i--) {
52
+ if (groups[i].kind === 'text') { lastTextIdx = i; break; }
53
+ }
54
+
55
+ const meta = message.metadata as MessageMeta | undefined;
56
+ const timeStr = meta?.createdAt
57
+ ? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
58
+ : null;
59
+
60
+ if (groups.length === 0) return null;
61
+
62
+ return (
63
+ <Box sx={{ minWidth: 0 }}>
64
+ <Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
65
+ <Typography
66
+ variant="caption"
67
+ sx={{
68
+ fontWeight: 700,
69
+ fontSize: '0.72rem',
70
+ color: 'var(--muted-text)',
71
+ textTransform: 'uppercase',
72
+ letterSpacing: '0.04em',
73
+ }}
74
+ >
75
+ Assistant
76
+ </Typography>
77
+ {timeStr && (
78
+ <Typography variant="caption" sx={{ color: 'var(--muted-text)', fontSize: '0.7rem' }}>
79
+ {timeStr}
80
+ </Typography>
81
+ )}
82
+ </Stack>
83
+
84
+ <Box
85
+ sx={{
86
+ maxWidth: { xs: '95%', md: '85%' },
87
+ bgcolor: 'var(--surface)',
88
+ borderRadius: 1.5,
89
+ borderTopLeftRadius: 4,
90
+ px: { xs: 1.5, md: 2.5 },
91
+ py: 1.5,
92
+ border: '1px solid var(--border)',
93
+ }}
94
+ >
95
+ {groups.map((group, i) => {
96
+ if (group.kind === 'text' && group.text) {
97
+ return (
98
+ <MarkdownContent
99
+ key={group.idx}
100
+ content={group.text}
101
+ isStreaming={isStreaming && i === lastTextIdx}
102
+ />
103
+ );
104
+ }
105
+ if (group.kind === 'tools' && group.tools.length > 0) {
106
+ return (
107
+ <ToolCallGroup
108
+ key={group.idx}
109
+ tools={group.tools}
110
+ approveTools={approveTools}
111
+ />
112
+ );
113
+ }
114
+ return null;
115
+ })}
116
+ </Box>
117
+ </Box>
118
+ );
119
+ }
frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -1,14 +1,103 @@
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
 
5
  interface ChatInputProps {
6
  onSend: (text: string) => void;
7
  disabled?: boolean;
 
8
  }
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) {
@@ -27,26 +116,48 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
27
  [handleSend]
28
  );
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)',
@@ -61,9 +172,10 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
61
  value={input}
62
  onChange={(e) => setInput(e.target.value)}
63
  onKeyDown={handleKeyDown}
64
- placeholder="Ask anything..."
65
  disabled={disabled}
66
  variant="standard"
 
67
  InputProps={{
68
  disableUnderline: true,
69
  sx: {
@@ -72,7 +184,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 +211,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,
@@ -109,17 +221,108 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
109
  {disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
110
  </IconButton>
111
  </Box>
112
-
113
  {/* Powered By Badge */}
114
- <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mt: 1.5, gap: 0.8, opacity: 0.5 }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
 
122
  </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  </Box>
124
  </Box>
125
  );
 
1
+ import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
2
+ import { Box, TextField, IconButton, CircularProgress, Typography, Menu, MenuItem, ListItemIcon, ListItemText, Chip } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
4
+ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
5
+ import { apiFetch } from '@/utils/api';
6
+
7
+ // Model configuration
8
+ interface ModelOption {
9
+ id: string;
10
+ name: string;
11
+ description: string;
12
+ modelPath: string;
13
+ avatarUrl: string;
14
+ recommended?: boolean;
15
+ }
16
+
17
+ const getHfAvatarUrl = (modelId: string) => {
18
+ const org = modelId.split('/')[0];
19
+ return `https://huggingface.co/api/avatars/${org}`;
20
+ };
21
+
22
+ const MODEL_OPTIONS: ModelOption[] = [
23
+ {
24
+ id: 'minimax-m2.1',
25
+ name: 'MiniMax M2.1',
26
+ description: 'Via Novita',
27
+ modelPath: 'huggingface/novita/minimax/minimax-m2.1',
28
+ avatarUrl: getHfAvatarUrl('MiniMaxAI/MiniMax-M2.1'),
29
+ recommended: true,
30
+ },
31
+ {
32
+ id: 'claude-opus',
33
+ name: 'Claude Opus 4.5',
34
+ description: 'Anthropic',
35
+ modelPath: 'anthropic/claude-opus-4-5-20251101',
36
+ avatarUrl: 'https://huggingface.co/api/avatars/Anthropic',
37
+ recommended: true,
38
+ },
39
+ {
40
+ id: 'kimi-k2.5',
41
+ name: 'Kimi K2.5',
42
+ description: 'Via Novita',
43
+ modelPath: 'huggingface/novita/moonshotai/kimi-k2.5',
44
+ avatarUrl: getHfAvatarUrl('moonshotai/Kimi-K2.5'),
45
+ },
46
+ {
47
+ id: 'glm-5',
48
+ name: 'GLM 5',
49
+ description: 'Via Novita',
50
+ modelPath: 'huggingface/novita/zai-org/glm-5',
51
+ avatarUrl: getHfAvatarUrl('zai-org/GLM-5'),
52
+ },
53
+ ];
54
+
55
+ const findModelByPath = (path: string): ModelOption | undefined => {
56
+ return MODEL_OPTIONS.find(m => m.modelPath === path || path?.includes(m.id));
57
+ };
58
 
59
  interface ChatInputProps {
60
  onSend: (text: string) => void;
61
  disabled?: boolean;
62
+ placeholder?: string;
63
  }
64
 
65
+ export default function ChatInput({ onSend, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
66
  const [input, setInput] = useState('');
67
+ const inputRef = useRef<HTMLTextAreaElement>(null);
68
+ const [selectedModelId, setSelectedModelId] = useState<string>(() => {
69
+ try {
70
+ const stored = localStorage.getItem('hf-agent-model');
71
+ if (stored && MODEL_OPTIONS.some(m => m.id === stored)) return stored;
72
+ } catch { /* localStorage unavailable */ }
73
+ return MODEL_OPTIONS[0].id;
74
+ });
75
+ const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
76
+
77
+ // Sync with backend on mount (backend is source of truth, localStorage is just a cache)
78
+ useEffect(() => {
79
+ fetch('/api/config/model')
80
+ .then((res) => (res.ok ? res.json() : null))
81
+ .then((data) => {
82
+ if (data?.current) {
83
+ const model = findModelByPath(data.current);
84
+ if (model) {
85
+ setSelectedModelId(model.id);
86
+ try { localStorage.setItem('hf-agent-model', model.id); } catch { /* ignore */ }
87
+ }
88
+ }
89
+ })
90
+ .catch(() => { /* ignore */ });
91
+ }, []);
92
+
93
+ const selectedModel = MODEL_OPTIONS.find(m => m.id === selectedModelId) || MODEL_OPTIONS[0];
94
+
95
+ // Auto-focus the textarea when the session becomes ready (disabled -> false)
96
+ useEffect(() => {
97
+ if (!disabled && inputRef.current) {
98
+ inputRef.current.focus();
99
+ }
100
+ }, [disabled]);
101
 
102
  const handleSend = useCallback(() => {
103
  if (input.trim() && !disabled) {
 
116
  [handleSend]
117
  );
118
 
119
+ const handleModelClick = (event: React.MouseEvent<HTMLElement>) => {
120
+ setModelAnchorEl(event.currentTarget);
121
+ };
122
+
123
+ const handleModelClose = () => {
124
+ setModelAnchorEl(null);
125
+ };
126
+
127
+ const handleSelectModel = async (model: ModelOption) => {
128
+ handleModelClose();
129
+ try {
130
+ const res = await apiFetch('/api/config/model', {
131
+ method: 'POST',
132
+ body: JSON.stringify({ model: model.modelPath }),
133
+ });
134
+ if (res.ok) {
135
+ setSelectedModelId(model.id);
136
+ try { localStorage.setItem('hf-agent-model', model.id); } catch { /* ignore */ }
137
+ }
138
+ } catch { /* ignore */ }
139
+ };
140
+
141
  return (
142
  <Box
143
  sx={{
144
+ pb: { xs: 2, md: 4 },
145
+ pt: { xs: 1, md: 2 },
146
  position: 'relative',
147
  zIndex: 10,
148
  }}
149
  >
150
+ <Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: { xs: 0, sm: 1, md: 2 } }}>
151
  <Box
152
  className="composer"
153
  sx={{
154
  display: 'flex',
155
  gap: '10px',
156
  alignItems: 'flex-start',
157
+ bgcolor: 'var(--composer-bg)',
158
  borderRadius: 'var(--radius-md)',
159
  p: '12px',
160
+ border: '1px solid var(--border)',
161
  transition: 'box-shadow 0.2s ease, border-color 0.2s ease',
162
  '&:focus-within': {
163
  borderColor: 'var(--accent-yellow)',
 
172
  value={input}
173
  onChange={(e) => setInput(e.target.value)}
174
  onKeyDown={handleKeyDown}
175
+ placeholder={placeholder}
176
  disabled={disabled}
177
  variant="standard"
178
+ inputRef={inputRef}
179
  InputProps={{
180
  disableUnderline: true,
181
  sx: {
 
184
  fontFamily: 'inherit',
185
  padding: 0,
186
  lineHeight: 1.5,
187
+ minHeight: { xs: '44px', md: '56px' },
188
  alignItems: 'flex-start',
189
  }
190
  }}
 
211
  transition: 'all 0.2s',
212
  '&:hover': {
213
  color: 'var(--accent-yellow)',
214
+ bgcolor: 'var(--hover-bg)',
215
  },
216
  '&.Mui-disabled': {
217
  opacity: 0.3,
 
221
  {disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
222
  </IconButton>
223
  </Box>
224
+
225
  {/* Powered By Badge */}
226
+ <Box
227
+ onClick={handleModelClick}
228
+ sx={{
229
+ display: 'flex',
230
+ alignItems: 'center',
231
+ justifyContent: 'center',
232
+ mt: 1.5,
233
+ gap: 0.8,
234
+ opacity: 0.6,
235
+ cursor: 'pointer',
236
+ transition: 'opacity 0.2s',
237
+ '&:hover': {
238
+ opacity: 1
239
+ }
240
+ }}
241
+ >
242
  <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
243
  powered by
244
  </Typography>
245
+ <img
246
+ src={selectedModel.avatarUrl}
247
+ alt={selectedModel.name}
248
+ style={{ height: '14px', width: '14px', objectFit: 'contain', borderRadius: '2px' }}
249
+ />
250
  <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
251
+ {selectedModel.name}
252
  </Typography>
253
+ <ArrowDropDownIcon sx={{ fontSize: '14px', color: 'var(--muted-text)' }} />
254
  </Box>
255
+
256
+ {/* Model Selection Menu */}
257
+ <Menu
258
+ anchorEl={modelAnchorEl}
259
+ open={Boolean(modelAnchorEl)}
260
+ onClose={handleModelClose}
261
+ anchorOrigin={{
262
+ vertical: 'top',
263
+ horizontal: 'center',
264
+ }}
265
+ transformOrigin={{
266
+ vertical: 'bottom',
267
+ horizontal: 'center',
268
+ }}
269
+ slotProps={{
270
+ paper: {
271
+ sx: {
272
+ bgcolor: 'var(--panel)',
273
+ border: '1px solid var(--divider)',
274
+ mb: 1,
275
+ maxHeight: '400px',
276
+ }
277
+ }
278
+ }}
279
+ >
280
+ {MODEL_OPTIONS.map((model) => (
281
+ <MenuItem
282
+ key={model.id}
283
+ onClick={() => handleSelectModel(model)}
284
+ selected={selectedModelId === model.id}
285
+ sx={{
286
+ py: 1.5,
287
+ '&.Mui-selected': {
288
+ bgcolor: 'rgba(255,255,255,0.05)',
289
+ }
290
+ }}
291
+ >
292
+ <ListItemIcon>
293
+ <img
294
+ src={model.avatarUrl}
295
+ alt={model.name}
296
+ style={{ width: 24, height: 24, borderRadius: '4px', objectFit: 'cover' }}
297
+ />
298
+ </ListItemIcon>
299
+ <ListItemText
300
+ primary={
301
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
302
+ {model.name}
303
+ {model.recommended && (
304
+ <Chip
305
+ label="Recommended"
306
+ size="small"
307
+ sx={{
308
+ height: '18px',
309
+ fontSize: '10px',
310
+ bgcolor: 'var(--accent-yellow)',
311
+ color: '#000',
312
+ fontWeight: 600,
313
+ }}
314
+ />
315
+ )}
316
+ </Box>
317
+ }
318
+ secondary={model.description}
319
+ secondaryTypographyProps={{
320
+ sx: { fontSize: '12px', color: 'var(--muted-text)' }
321
+ }}
322
+ />
323
+ </MenuItem>
324
+ ))}
325
+ </Menu>
326
  </Box>
327
  </Box>
328
  );
frontend/src/components/Chat/MarkdownContent.tsx ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ /**
98
+ * Throttled content for streaming: render the full markdown through
99
+ * ReactMarkdown but only re-parse every ~80ms to avoid layout thrashing.
100
+ * This is the Claude approach — always render as markdown, never split
101
+ * into raw text. The parser handles incomplete tables gracefully.
102
+ */
103
+ function useThrottledValue(value: string, isStreaming: boolean, intervalMs = 80): string {
104
+ const [throttled, setThrottled] = useState(value);
105
+ const lastUpdate = useRef(0);
106
+ const pending = useRef<ReturnType<typeof setTimeout> | null>(null);
107
+ const latestValue = useRef(value);
108
+ latestValue.current = value;
109
+
110
+ useEffect(() => {
111
+ if (!isStreaming) {
112
+ // Not streaming — always use latest value immediately
113
+ setThrottled(value);
114
+ return;
115
+ }
116
+
117
+ const now = Date.now();
118
+ const elapsed = now - lastUpdate.current;
119
+
120
+ if (elapsed >= intervalMs) {
121
+ // Enough time passed — update immediately
122
+ setThrottled(value);
123
+ lastUpdate.current = now;
124
+ } else {
125
+ // Schedule an update for the remaining time
126
+ if (pending.current) clearTimeout(pending.current);
127
+ pending.current = setTimeout(() => {
128
+ setThrottled(latestValue.current);
129
+ lastUpdate.current = Date.now();
130
+ pending.current = null;
131
+ }, intervalMs - elapsed);
132
+ }
133
+
134
+ return () => {
135
+ if (pending.current) clearTimeout(pending.current);
136
+ };
137
+ }, [value, isStreaming, intervalMs]);
138
+
139
+ // When streaming ends, flush immediately
140
+ useEffect(() => {
141
+ if (!isStreaming) {
142
+ setThrottled(latestValue.current);
143
+ }
144
+ }, [isStreaming]);
145
+
146
+ return throttled;
147
+ }
148
+
149
+ export default function MarkdownContent({ content, sx, isStreaming = false }: MarkdownContentProps) {
150
+ // Throttle re-parses during streaming to ~12fps (every 80ms)
151
+ const displayContent = useThrottledValue(content, isStreaming);
152
+
153
+ const remarkPlugins = useMemo(() => [remarkGfm], []);
154
+
155
+ return (
156
+ <Box sx={[markdownSx, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}>
157
+ <ReactMarkdown remarkPlugins={remarkPlugins}>{displayContent}</ReactMarkdown>
158
+ </Box>
159
+ );
160
+ }
frontend/src/components/Chat/MessageBubble.tsx CHANGED
@@ -1,215 +1,44 @@
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 { UIMessage } from 'ai';
 
 
 
 
4
 
5
  interface MessageBubbleProps {
6
+ message: UIMessage;
7
+ isLastTurn?: boolean;
8
+ onUndoTurn?: () => void;
9
+ isProcessing?: boolean;
10
+ isStreaming?: boolean;
11
+ approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
12
  }
13
 
14
+ export default function MessageBubble({
15
+ message,
16
+ isLastTurn = false,
17
+ onUndoTurn,
18
+ isProcessing = false,
19
+ isStreaming = false,
20
+ approveTools,
21
+ }: MessageBubbleProps) {
22
+ if (message.role === 'user') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  return (
24
+ <UserMessage
25
+ message={message}
26
+ isLastTurn={isLastTurn}
27
+ onUndoTurn={onUndoTurn}
28
+ isProcessing={isProcessing}
29
+ />
30
  );
31
  }
32
 
33
+ if (message.role === 'assistant') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  return (
35
+ <AssistantMessage
36
+ message={message}
37
+ isStreaming={isStreaming}
38
+ approveTools={approveTools}
39
+ />
40
  );
41
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
+ return null;
 
 
 
 
 
 
 
 
 
44
  }
frontend/src/components/Chat/MessageList.tsx CHANGED
@@ -1,100 +1,151 @@
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 {
8
- messages: Message[];
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 { useCallback, useEffect, useRef, useMemo } from 'react';
2
+ import { Box, Stack, Typography } from '@mui/material';
 
3
  import MessageBubble from './MessageBubble';
4
+ import ActivityStatusBar from './ActivityStatusBar';
5
+ import { useAgentStore } from '@/store/agentStore';
6
+ import type { UIMessage } from 'ai';
7
 
8
  interface MessageListProps {
9
+ messages: UIMessage[];
10
  isProcessing: boolean;
11
+ approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
12
+ onUndoLastTurn: () => void | Promise<void>;
13
  }
14
 
15
+ function getGreeting(): string {
16
+ const h = new Date().getHours();
17
+ if (h < 12) return 'Morning';
18
+ if (h < 17) return 'Afternoon';
19
+ return 'Evening';
20
+ }
21
+
22
+ function WelcomeGreeting() {
23
+ const { user } = useAgentStore();
24
+ const firstName = user?.name?.split(' ')[0] || user?.username;
25
+ const greeting = firstName ? `${getGreeting()}, ${firstName}` : getGreeting();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
+ return (
28
+ <Box
29
+ sx={{
30
+ flex: 1,
31
+ display: 'flex',
32
+ flexDirection: 'column',
33
+ alignItems: 'center',
34
+ justifyContent: 'center',
35
+ py: 8,
36
+ gap: 1.5,
37
+ }}
38
+ >
39
+ <Typography
40
+ sx={{
41
+ fontFamily: 'monospace',
42
+ fontSize: '1.6rem',
43
+ color: 'var(--text)',
44
+ fontWeight: 600,
45
+ }}
46
+ >
47
+ {greeting}
48
+ </Typography>
49
+ <Typography
50
+ color="text.secondary"
51
+ sx={{ fontFamily: 'monospace', fontSize: '0.9rem' }}
52
+ >
53
+ Let's build something impressive?
54
+ </Typography>
55
+ </Box>
56
+ );
57
+ }
58
+
59
+ export default function MessageList({ messages, isProcessing, approveTools, onUndoLastTurn }: MessageListProps) {
60
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
61
+ const stickToBottom = useRef(true);
62
+
63
+ const scrollToBottom = useCallback(() => {
64
+ const el = scrollContainerRef.current;
65
+ if (el) el.scrollTop = el.scrollHeight;
66
+ }, []);
67
+
68
+ useEffect(() => {
69
+ const el = scrollContainerRef.current;
70
+ if (!el) return;
71
+ const onScroll = () => {
72
+ const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
73
+ stickToBottom.current = distFromBottom < 80;
74
+ };
75
+ el.addEventListener('scroll', onScroll, { passive: true });
76
+ return () => el.removeEventListener('scroll', onScroll);
77
+ }, []);
78
+
79
+ useEffect(() => {
80
+ if (stickToBottom.current) scrollToBottom();
81
+ }, [messages, isProcessing, scrollToBottom]);
82
 
 
83
  useEffect(() => {
84
+ const el = scrollContainerRef.current;
85
+ if (!el) return;
86
+ const observer = new MutationObserver(() => {
87
+ if (stickToBottom.current) el.scrollTop = el.scrollHeight;
88
+ });
89
+ observer.observe(el, { childList: true, subtree: true, characterData: true });
90
+ return () => observer.disconnect();
91
+ }, []);
92
+
93
+ const lastUserMsgId = useMemo(() => {
94
+ for (let i = messages.length - 1; i >= 0; i--) {
95
+ if (messages[i].role === 'user') return messages[i].id;
96
+ }
97
+ return null;
98
+ }, [messages]);
99
+
100
+ // The last assistant message is "streaming" when we're processing
101
+ const lastAssistantId = useMemo(() => {
102
+ for (let i = messages.length - 1; i >= 0; i--) {
103
+ if (messages[i].role === 'assistant') return messages[i].id;
104
+ }
105
+ return null;
106
+ }, [messages]);
107
 
108
  return (
109
  <Box
110
+ ref={scrollContainerRef}
111
  sx={{
112
  flex: 1,
113
  overflow: 'auto',
114
+ px: { xs: 0.5, sm: 1, md: 2 },
115
+ py: { xs: 2, md: 3 },
116
  display: 'flex',
117
  flexDirection: 'column',
118
  }}
119
  >
120
+ <Stack
121
+ spacing={3}
122
+ sx={{
123
+ maxWidth: 880,
124
+ mx: 'auto',
125
+ width: '100%',
126
+ flex: messages.length === 0 && !isProcessing ? 1 : undefined,
127
+ }}
128
+ >
129
  {messages.length === 0 && !isProcessing ? (
130
+ <WelcomeGreeting />
 
 
 
 
 
 
 
 
 
 
 
 
131
  ) : (
132
+ messages.map((msg) => (
133
+ <MessageBubble
134
+ key={msg.id}
135
+ message={msg}
136
+ isLastTurn={msg.id === lastUserMsgId}
137
+ onUndoTurn={onUndoLastTurn}
138
+ isProcessing={isProcessing}
139
+ isStreaming={isProcessing && msg.id === lastAssistantId}
140
+ approveTools={approveTools}
141
+ />
142
  ))
143
  )}
 
 
 
 
 
 
 
 
 
 
 
144
 
145
+ <ActivityStatusBar />
146
+
147
+ <div />
148
+ </Stack>
 
 
 
149
  </Box>
150
  );
151
+ }
frontend/src/components/Chat/ThinkingIndicator.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box, Typography } from '@mui/material';
2
+
3
+ /** Pulsing dots shown while the agent is processing. */
4
+ export default function ThinkingIndicator() {
5
+ return (
6
+ <Box sx={{ pt: 0.75 }}>
7
+ <Typography
8
+ variant="caption"
9
+ sx={{
10
+ fontWeight: 700,
11
+ fontSize: '0.72rem',
12
+ color: 'var(--muted-text)',
13
+ textTransform: 'uppercase',
14
+ letterSpacing: '0.04em',
15
+ display: 'flex',
16
+ alignItems: 'center',
17
+ gap: 0.75,
18
+ }}
19
+ >
20
+ Thinking
21
+ <Box
22
+ component="span"
23
+ sx={{
24
+ display: 'inline-flex',
25
+ gap: '3px',
26
+ '& span': {
27
+ width: 4,
28
+ height: 4,
29
+ borderRadius: '50%',
30
+ bgcolor: 'primary.main',
31
+ animation: 'dotPulse 1.4s ease-in-out infinite',
32
+ },
33
+ '& span:nth-of-type(2)': { animationDelay: '0.2s' },
34
+ '& span:nth-of-type(3)': { animationDelay: '0.4s' },
35
+ '@keyframes dotPulse': {
36
+ '0%, 80%, 100%': { opacity: 0.25, transform: 'scale(0.8)' },
37
+ '40%': { opacity: 1, transform: 'scale(1)' },
38
+ },
39
+ }}
40
+ >
41
+ <span />
42
+ <span />
43
+ <span />
44
+ </Box>
45
+ </Typography>
46
+ </Box>
47
+ );
48
+ }
frontend/src/components/Chat/ToolCallGroup.tsx ADDED
@@ -0,0 +1,655 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+ import { Box, Stack, Typography, Chip, Button, TextField, IconButton, Link, CircularProgress } from '@mui/material';
3
+ import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
4
+ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
5
+ import OpenInNewIcon from '@mui/icons-material/OpenInNew';
6
+ import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
7
+ import LaunchIcon from '@mui/icons-material/Launch';
8
+ import SendIcon from '@mui/icons-material/Send';
9
+ import BlockIcon from '@mui/icons-material/Block';
10
+ import { useAgentStore } from '@/store/agentStore';
11
+ import { useLayoutStore } from '@/store/layoutStore';
12
+ import { logger } from '@/utils/logger';
13
+ import type { UIMessage } from 'ai';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Type helpers — extract the dynamic-tool part type from UIMessage
17
+ // ---------------------------------------------------------------------------
18
+ type DynamicToolPart = Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>;
19
+
20
+ type ToolPartState = DynamicToolPart['state'];
21
+
22
+ interface ToolCallGroupProps {
23
+ tools: DynamicToolPart[];
24
+ approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => Promise<boolean>;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Visual helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function StatusIcon({ state }: { state: ToolPartState }) {
32
+ switch (state) {
33
+ case 'approval-requested':
34
+ return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
35
+ case 'output-available':
36
+ return <CheckCircleOutlineIcon sx={{ fontSize: 16, color: 'success.main' }} />;
37
+ case 'output-error':
38
+ return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
39
+ case 'output-denied':
40
+ return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
41
+ case 'input-streaming':
42
+ case 'input-available':
43
+ default:
44
+ return <CircularProgress size={14} thickness={5} sx={{ color: 'var(--accent-yellow)' }} />;
45
+ }
46
+ }
47
+
48
+ function statusLabel(state: ToolPartState): string | null {
49
+ switch (state) {
50
+ case 'approval-requested': return 'awaiting approval';
51
+ case 'input-streaming':
52
+ case 'input-available': return 'running';
53
+ case 'output-denied': return 'denied';
54
+ case 'output-error': return 'error';
55
+ default: return null;
56
+ }
57
+ }
58
+
59
+ function statusColor(state: ToolPartState): string {
60
+ switch (state) {
61
+ case 'approval-requested': return 'var(--accent-yellow)';
62
+ case 'output-available': return 'var(--accent-green)';
63
+ case 'output-error': return 'var(--accent-red)';
64
+ case 'output-denied': return 'var(--muted-text)';
65
+ default: return 'var(--accent-yellow)';
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Inline approval UI (per-tool)
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function InlineApproval({
74
+ toolCallId,
75
+ toolName,
76
+ input,
77
+ scriptLabel,
78
+ onResolve,
79
+ }: {
80
+ toolCallId: string;
81
+ toolName: string;
82
+ input: unknown;
83
+ scriptLabel: string;
84
+ onResolve: (toolCallId: string, approved: boolean, feedback?: string) => void;
85
+ }) {
86
+ const [feedback, setFeedback] = useState('');
87
+ const args = input as Record<string, unknown> | undefined;
88
+ const { setPanel, getEditedScript } = useAgentStore();
89
+ const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
90
+ const hasEditedScript = !!getEditedScript(toolCallId);
91
+
92
+ const handleScriptClick = useCallback(() => {
93
+ if (toolName === 'hf_jobs' && args?.script) {
94
+ const scriptContent = getEditedScript(toolCallId) || String(args.script);
95
+ setPanel(
96
+ { title: scriptLabel, script: { content: scriptContent, language: 'python' }, parameters: { tool_call_id: toolCallId } },
97
+ 'script',
98
+ true,
99
+ );
100
+ setRightPanelOpen(true);
101
+ setLeftSidebarOpen(false);
102
+ }
103
+ }, [toolCallId, toolName, args, scriptLabel, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen]);
104
+
105
+ return (
106
+ <Box sx={{ px: 1.5, py: 1.5, borderTop: '1px solid var(--tool-border)' }}>
107
+ {toolName === 'hf_jobs' && args && (
108
+ <Box sx={{ mb: 1.5 }}>
109
+ <Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.75rem', mb: 1 }}>
110
+ Execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{scriptLabel.replace('Script', 'Job')}</Box> on{' '}
111
+ <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
112
+ {String(args.hardware_flavor || 'default')}
113
+ </Box>
114
+ {!!args.timeout && (
115
+ <> with timeout <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
116
+ {String(args.timeout)}
117
+ </Box></>
118
+ )}
119
+ </Typography>
120
+ {typeof args.script === 'string' && args.script && (
121
+ <Box
122
+ onClick={handleScriptClick}
123
+ sx={{
124
+ mt: 0.5,
125
+ p: 1.5,
126
+ bgcolor: 'var(--code-panel-bg)',
127
+ border: '1px solid var(--tool-border)',
128
+ borderRadius: '8px',
129
+ cursor: 'pointer',
130
+ transition: 'border-color 0.15s ease',
131
+ '&:hover': { borderColor: 'var(--accent-yellow)' },
132
+ }}
133
+ >
134
+ <Box
135
+ component="pre"
136
+ sx={{
137
+ m: 0,
138
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
139
+ fontSize: '0.7rem',
140
+ lineHeight: 1.5,
141
+ color: 'var(--text)',
142
+ overflow: 'hidden',
143
+ display: '-webkit-box',
144
+ WebkitLineClamp: 3,
145
+ WebkitBoxOrient: 'vertical',
146
+ whiteSpace: 'pre-wrap',
147
+ wordBreak: 'break-all',
148
+ }}
149
+ >
150
+ {String(args.script).trim()}
151
+ </Box>
152
+ <Typography
153
+ variant="caption"
154
+ sx={{
155
+ display: 'flex',
156
+ alignItems: 'center',
157
+ gap: 0.5,
158
+ mt: 1,
159
+ fontSize: '0.65rem',
160
+ color: 'var(--muted-text)',
161
+ '&:hover': { color: 'var(--accent-yellow)' },
162
+ }}
163
+ >
164
+ Click to view & edit
165
+ </Typography>
166
+ </Box>
167
+ )}
168
+ </Box>
169
+ )}
170
+
171
+ <Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
172
+ <TextField
173
+ fullWidth
174
+ size="small"
175
+ placeholder="Feedback (optional)"
176
+ value={feedback}
177
+ onChange={(e) => setFeedback(e.target.value)}
178
+ variant="outlined"
179
+ sx={{
180
+ '& .MuiOutlinedInput-root': {
181
+ bgcolor: 'var(--hover-bg)',
182
+ fontFamily: 'inherit',
183
+ fontSize: '0.8rem',
184
+ '& fieldset': { borderColor: 'var(--tool-border)' },
185
+ '&:hover fieldset': { borderColor: 'var(--border-hover)' },
186
+ '&.Mui-focused fieldset': { borderColor: 'var(--accent-yellow)' },
187
+ },
188
+ '& .MuiOutlinedInput-input': {
189
+ color: 'var(--text)',
190
+ '&::placeholder': { color: 'var(--muted-text)', opacity: 0.7 },
191
+ },
192
+ }}
193
+ />
194
+ <IconButton
195
+ onClick={() => onResolve(toolCallId, false, feedback || 'Rejected by user')}
196
+ disabled={!feedback}
197
+ size="small"
198
+ sx={{
199
+ color: 'var(--accent-red)',
200
+ border: '1px solid var(--tool-border)',
201
+ borderRadius: '6px',
202
+ '&:hover': { bgcolor: 'rgba(224,90,79,0.1)', borderColor: 'var(--accent-red)' },
203
+ '&.Mui-disabled': { color: 'var(--muted-text)', opacity: 0.3 },
204
+ }}
205
+ >
206
+ <SendIcon sx={{ fontSize: 14 }} />
207
+ </IconButton>
208
+ </Box>
209
+
210
+ <Box sx={{ display: 'flex', gap: 1 }}>
211
+ <Button
212
+ size="small"
213
+ onClick={() => onResolve(toolCallId, false, feedback || 'Rejected by user')}
214
+ sx={{
215
+ flex: 1,
216
+ textTransform: 'none',
217
+ border: '1px solid rgba(255,255,255,0.05)',
218
+ color: 'var(--accent-red)',
219
+ fontSize: '0.75rem',
220
+ py: 0.75,
221
+ borderRadius: '8px',
222
+ '&:hover': { bgcolor: 'rgba(224,90,79,0.05)', borderColor: 'var(--accent-red)' },
223
+ }}
224
+ >
225
+ Reject
226
+ </Button>
227
+ <Button
228
+ size="small"
229
+ onClick={() => onResolve(toolCallId, true)}
230
+ sx={{
231
+ flex: 1,
232
+ textTransform: 'none',
233
+ border: hasEditedScript ? '1px solid var(--accent-green)' : '1px solid rgba(255,255,255,0.05)',
234
+ color: 'var(--accent-green)',
235
+ fontSize: '0.75rem',
236
+ py: 0.75,
237
+ borderRadius: '8px',
238
+ bgcolor: hasEditedScript ? 'rgba(47,204,113,0.08)' : 'transparent',
239
+ '&:hover': { bgcolor: 'rgba(47,204,113,0.05)', borderColor: 'var(--accent-green)' },
240
+ }}
241
+ >
242
+ {hasEditedScript ? 'Approve (edited)' : 'Approve'}
243
+ </Button>
244
+ </Box>
245
+ </Box>
246
+ );
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Main component
251
+ // ---------------------------------------------------------------------------
252
+
253
+ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
254
+ const { setPanel, lockPanel, getJobUrl, getEditedScript } = useAgentStore();
255
+ const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
256
+
257
+ // ── Batch approval state ─────────────────��────────────────────────
258
+ const pendingTools = useMemo(
259
+ () => tools.filter(t => t.state === 'approval-requested'),
260
+ [tools],
261
+ );
262
+
263
+ const [decisions, setDecisions] = useState<Record<string, { approved: boolean; feedback?: string }>>({});
264
+ const [isSubmitting, setIsSubmitting] = useState(false);
265
+ const submittingRef = useRef(false);
266
+
267
+ const { scriptLabelMap, toolDisplayMap } = useMemo(() => {
268
+ const hfJobs = tools.filter(t => t.toolName === 'hf_jobs' && (t.input as Record<string, unknown>)?.script);
269
+ const scriptMap: Record<string, string> = {};
270
+ const displayMap: Record<string, string> = {};
271
+ for (let i = 0; i < hfJobs.length; i++) {
272
+ const id = hfJobs[i].toolCallId;
273
+ if (hfJobs.length > 1) {
274
+ scriptMap[id] = `Script ${i + 1}`;
275
+ displayMap[id] = `hf_jobs #${i + 1}`;
276
+ } else {
277
+ scriptMap[id] = 'Script';
278
+ displayMap[id] = 'hf_jobs';
279
+ }
280
+ }
281
+ return { scriptLabelMap: scriptMap, toolDisplayMap: displayMap };
282
+ }, [tools]);
283
+
284
+ // ── Send all decisions as a single batch ──────────────────────────
285
+ const sendBatch = useCallback(
286
+ async (batch: Record<string, { approved: boolean; feedback?: string }>) => {
287
+ if (submittingRef.current) return;
288
+ submittingRef.current = true;
289
+ setIsSubmitting(true);
290
+
291
+ const approvals = Object.entries(batch).map(([toolCallId, d]) => {
292
+ const editedScript = d.approved ? (getEditedScript(toolCallId) ?? null) : null;
293
+ if (editedScript) {
294
+ logger.log(`Sending edited script for ${toolCallId} (${editedScript.length} chars)`);
295
+ }
296
+ return {
297
+ tool_call_id: toolCallId,
298
+ approved: d.approved,
299
+ feedback: d.approved ? null : (d.feedback || 'Rejected by user'),
300
+ edited_script: editedScript,
301
+ };
302
+ });
303
+
304
+ const ok = await approveTools(approvals);
305
+ if (ok) {
306
+ lockPanel();
307
+ } else {
308
+ logger.error('Batch approval failed');
309
+ submittingRef.current = false;
310
+ setIsSubmitting(false);
311
+ }
312
+ },
313
+ [approveTools, lockPanel, getEditedScript],
314
+ );
315
+
316
+ const handleApproveAll = useCallback(() => {
317
+ const batch: Record<string, { approved: boolean }> = {};
318
+ for (const t of pendingTools) batch[t.toolCallId] = { approved: true };
319
+ sendBatch(batch);
320
+ }, [pendingTools, sendBatch]);
321
+
322
+ const handleRejectAll = useCallback(() => {
323
+ const batch: Record<string, { approved: boolean }> = {};
324
+ for (const t of pendingTools) batch[t.toolCallId] = { approved: false };
325
+ sendBatch(batch);
326
+ }, [pendingTools, sendBatch]);
327
+
328
+ const handleIndividualDecision = useCallback(
329
+ (toolCallId: string, approved: boolean, feedback?: string) => {
330
+ setDecisions(prev => {
331
+ const next = { ...prev, [toolCallId]: { approved, feedback } };
332
+ if (pendingTools.every(t => next[t.toolCallId])) {
333
+ queueMicrotask(() => sendBatch(next));
334
+ }
335
+ return next;
336
+ });
337
+ },
338
+ [pendingTools, sendBatch],
339
+ );
340
+
341
+ const undoDecision = useCallback((toolCallId: string) => {
342
+ setDecisions(prev => {
343
+ const next = { ...prev };
344
+ delete next[toolCallId];
345
+ return next;
346
+ });
347
+ }, []);
348
+
349
+ // ── Panel click handler ───────────────────────────────────────────
350
+ const handleClick = useCallback(
351
+ (tool: DynamicToolPart) => {
352
+ const args = tool.input as Record<string, unknown> | undefined;
353
+ const displayName = toolDisplayMap[tool.toolCallId] || tool.toolName;
354
+
355
+ if (tool.toolName === 'hf_jobs' && args?.script) {
356
+ const hasOutput = (tool.state === 'output-available' || tool.state === 'output-error') && tool.output;
357
+ const scriptContent = getEditedScript(tool.toolCallId) || String(args.script);
358
+ setPanel(
359
+ {
360
+ title: displayName,
361
+ script: { content: scriptContent, language: 'python' },
362
+ ...(hasOutput ? { output: { content: String(tool.output), language: 'markdown' } } : {}),
363
+ parameters: { tool_call_id: tool.toolCallId },
364
+ },
365
+ hasOutput ? 'output' : 'script',
366
+ );
367
+ setRightPanelOpen(true);
368
+ setLeftSidebarOpen(false);
369
+ return;
370
+ }
371
+
372
+ if ((tool.state === 'output-available' || tool.state === 'output-error') && tool.output) {
373
+ let language = 'text';
374
+ const content = String(tool.output);
375
+ if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
376
+ else if (content.includes('```')) language = 'markdown';
377
+
378
+ setPanel({ title: displayName, output: { content, language } }, 'output');
379
+ setRightPanelOpen(true);
380
+ } else if (args) {
381
+ const content = JSON.stringify(args, null, 2);
382
+ setPanel({ title: displayName, output: { content, language: 'json' } }, 'output');
383
+ setRightPanelOpen(true);
384
+ }
385
+ },
386
+ [toolDisplayMap, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen],
387
+ );
388
+
389
+ // ── Parse hf_jobs metadata from output ────────────────────────────
390
+ function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } {
391
+ if (typeof output !== 'string') return {};
392
+ const urlMatch = output.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
393
+ const statusMatch = output.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
394
+ return {
395
+ jobUrl: urlMatch?.[1],
396
+ jobStatus: statusMatch?.[1]?.trim(),
397
+ };
398
+ }
399
+
400
+ // ── Render ────────────────────────────────────────────────────────
401
+ const decidedCount = pendingTools.filter(t => decisions[t.toolCallId]).length;
402
+
403
+ return (
404
+ <Box
405
+ sx={{
406
+ borderRadius: 2,
407
+ border: '1px solid var(--tool-border)',
408
+ bgcolor: 'var(--tool-bg)',
409
+ overflow: 'hidden',
410
+ my: 1,
411
+ }}
412
+ >
413
+ {/* Batch approval header — hidden once user starts deciding individually */}
414
+ {pendingTools.length > 1 && !isSubmitting && decidedCount === 0 && (
415
+ <Box
416
+ sx={{
417
+ display: 'flex',
418
+ alignItems: 'center',
419
+ gap: 1,
420
+ px: 1.5,
421
+ py: 1,
422
+ borderBottom: '1px solid var(--tool-border)',
423
+ }}
424
+ >
425
+ <Typography
426
+ variant="body2"
427
+ sx={{ fontSize: '0.72rem', color: 'var(--muted-text)', mr: 'auto', whiteSpace: 'nowrap' }}
428
+ >
429
+ {`${pendingTools.length} tool${pendingTools.length > 1 ? 's' : ''} pending`}
430
+ </Typography>
431
+ <Button
432
+ size="small"
433
+ onClick={handleRejectAll}
434
+ sx={{
435
+ textTransform: 'none',
436
+ color: 'var(--accent-red)',
437
+ border: '1px solid rgba(255,255,255,0.05)',
438
+ fontSize: '0.72rem',
439
+ py: 0.5,
440
+ px: 1.5,
441
+ borderRadius: '8px',
442
+ '&:hover': { bgcolor: 'rgba(224,90,79,0.05)', borderColor: 'var(--accent-red)' },
443
+ }}
444
+ >
445
+ Reject all
446
+ </Button>
447
+ <Button
448
+ size="small"
449
+ onClick={handleApproveAll}
450
+ sx={{
451
+ textTransform: 'none',
452
+ color: 'var(--accent-green)',
453
+ border: '1px solid var(--accent-green)',
454
+ fontSize: '0.72rem',
455
+ fontWeight: 600,
456
+ py: 0.5,
457
+ px: 1.5,
458
+ borderRadius: '8px',
459
+ '&:hover': { bgcolor: 'rgba(47,204,113,0.1)' },
460
+ }}
461
+ >
462
+ Approve all{pendingTools.length > 1 ? ` (${pendingTools.length})` : ''}
463
+ </Button>
464
+ </Box>
465
+ )}
466
+
467
+ {/* Tool list */}
468
+ <Stack divider={<Box sx={{ borderBottom: '1px solid var(--tool-border)' }} />}>
469
+ {tools.map((tool) => {
470
+ const state = tool.state;
471
+ const isPending = state === 'approval-requested';
472
+ const clickable =
473
+ state === 'output-available' ||
474
+ state === 'output-error' ||
475
+ !!tool.input;
476
+ const localDecision = decisions[tool.toolCallId];
477
+
478
+ const displayState = isPending && localDecision
479
+ ? (localDecision.approved ? 'input-available' : 'output-denied')
480
+ : state;
481
+ const label = statusLabel(displayState as ToolPartState);
482
+
483
+ // Parse job metadata from hf_jobs output and store
484
+ const jobUrlFromStore = tool.toolName === 'hf_jobs' ? getJobUrl(tool.toolCallId) : undefined;
485
+ const jobMetaFromOutput = tool.toolName === 'hf_jobs' && tool.state === 'output-available'
486
+ ? parseJobMeta(tool.output)
487
+ : {};
488
+
489
+ // Combine job URL from store (available immediately) with output metadata (available at completion)
490
+ const jobMeta = {
491
+ jobUrl: jobUrlFromStore || jobMetaFromOutput.jobUrl,
492
+ jobStatus: jobMetaFromOutput.jobStatus,
493
+ };
494
+
495
+ return (
496
+ <Box key={tool.toolCallId}>
497
+ {/* Main tool row */}
498
+ <Stack
499
+ direction="row"
500
+ alignItems="center"
501
+ spacing={1}
502
+ onClick={() => !isPending && handleClick(tool)}
503
+ sx={{
504
+ px: 1.5,
505
+ py: 1,
506
+ cursor: isPending ? 'default' : clickable ? 'pointer' : 'default',
507
+ transition: 'background-color 0.15s',
508
+ '&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
509
+ }}
510
+ >
511
+ <StatusIcon state={
512
+ (tool.toolName === 'hf_jobs' && jobMeta.jobStatus && ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus) && displayState === 'output-available')
513
+ ? 'output-error'
514
+ : displayState as ToolPartState
515
+ } />
516
+
517
+ <Typography
518
+ variant="body2"
519
+ sx={{
520
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
521
+ fontWeight: 600,
522
+ fontSize: '0.78rem',
523
+ color: 'var(--text)',
524
+ flex: 1,
525
+ minWidth: 0,
526
+ overflow: 'hidden',
527
+ textOverflow: 'ellipsis',
528
+ whiteSpace: 'nowrap',
529
+ }}
530
+ >
531
+ {toolDisplayMap[tool.toolCallId] || tool.toolName}
532
+ </Typography>
533
+
534
+ {/* Status chip (non hf_jobs, or hf_jobs without final status) */}
535
+ {label && !(tool.toolName === 'hf_jobs' && jobMeta.jobStatus) && (
536
+ <Chip
537
+ label={label}
538
+ size="small"
539
+ sx={{
540
+ height: 20,
541
+ fontSize: '0.65rem',
542
+ fontWeight: 600,
543
+ bgcolor: displayState === 'output-error' ? 'rgba(224,90,79,0.12)'
544
+ : displayState === 'output-denied' ? 'rgba(255,255,255,0.05)'
545
+ : 'var(--accent-yellow-weak)',
546
+ color: statusColor(displayState as ToolPartState),
547
+ letterSpacing: '0.03em',
548
+ }}
549
+ />
550
+ )}
551
+
552
+ {/* HF Jobs: final status chip from job metadata */}
553
+ {tool.toolName === 'hf_jobs' && jobMeta.jobStatus && (
554
+ <Chip
555
+ label={jobMeta.jobStatus}
556
+ size="small"
557
+ sx={{
558
+ height: 20,
559
+ fontSize: '0.65rem',
560
+ fontWeight: 600,
561
+ bgcolor: jobMeta.jobStatus === 'COMPLETED'
562
+ ? 'rgba(47,204,113,0.12)'
563
+ : ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus!)
564
+ ? 'rgba(224,90,79,0.12)'
565
+ : 'rgba(255,193,59,0.12)',
566
+ color: jobMeta.jobStatus === 'COMPLETED'
567
+ ? 'var(--accent-green)'
568
+ : ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus!)
569
+ ? 'var(--accent-red)'
570
+ : 'var(--accent-yellow)',
571
+ letterSpacing: '0.03em',
572
+ }}
573
+ />
574
+ )}
575
+
576
+ {/* View on HF link — single place, shown whenever URL is available */}
577
+ {tool.toolName === 'hf_jobs' && jobMeta.jobUrl && (
578
+ <Link
579
+ href={jobMeta.jobUrl}
580
+ target="_blank"
581
+ rel="noopener noreferrer"
582
+ onClick={(e) => e.stopPropagation()}
583
+ sx={{
584
+ display: 'inline-flex',
585
+ alignItems: 'center',
586
+ gap: 0.5,
587
+ color: 'var(--accent-yellow)',
588
+ fontSize: '0.68rem',
589
+ textDecoration: 'none',
590
+ ml: 0.5,
591
+ '&:hover': { textDecoration: 'underline' },
592
+ }}
593
+ >
594
+ <LaunchIcon sx={{ fontSize: 12 }} />
595
+ View on HF
596
+ </Link>
597
+ )}
598
+
599
+ {clickable && !isPending && (
600
+ <OpenInNewIcon sx={{ fontSize: 14, color: 'var(--muted-text)', opacity: 0.6 }} />
601
+ )}
602
+ </Stack>
603
+
604
+
605
+ {/* Per-tool approval: undecided */}
606
+ {isPending && !localDecision && !isSubmitting && (
607
+ <InlineApproval
608
+ toolCallId={tool.toolCallId}
609
+ toolName={tool.toolName}
610
+ input={tool.input}
611
+ scriptLabel={scriptLabelMap[tool.toolCallId] || 'Script'}
612
+ onResolve={handleIndividualDecision}
613
+ />
614
+ )}
615
+
616
+ {/* Per-tool approval: locally decided (undo available) */}
617
+ {isPending && localDecision && !isSubmitting && (
618
+ <Box
619
+ sx={{
620
+ display: 'flex',
621
+ alignItems: 'center',
622
+ justifyContent: 'space-between',
623
+ px: 1.5,
624
+ py: 0.75,
625
+ borderTop: '1px solid var(--tool-border)',
626
+ }}
627
+ >
628
+ <Typography variant="body2" sx={{ fontSize: '0.72rem', color: 'var(--muted-text)' }}>
629
+ {localDecision.approved
630
+ ? 'Marked for approval'
631
+ : `Marked for rejection${localDecision.feedback ? `: ${localDecision.feedback}` : ''}`}
632
+ </Typography>
633
+ <Button
634
+ size="small"
635
+ onClick={() => undoDecision(tool.toolCallId)}
636
+ sx={{
637
+ textTransform: 'none',
638
+ fontSize: '0.7rem',
639
+ color: 'var(--muted-text)',
640
+ minWidth: 'auto',
641
+ px: 1,
642
+ '&:hover': { color: 'var(--text)' },
643
+ }}
644
+ >
645
+ Undo
646
+ </Button>
647
+ </Box>
648
+ )}
649
+ </Box>
650
+ );
651
+ })}
652
+ </Stack>
653
+ </Box>
654
+ );
655
+ }
frontend/src/components/Chat/UserMessage.tsx ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box, Stack, Typography, IconButton, Tooltip } from '@mui/material';
2
+ import CloseIcon from '@mui/icons-material/Close';
3
+ import type { UIMessage } from 'ai';
4
+ import type { MessageMeta } from '@/types/agent';
5
+
6
+ interface UserMessageProps {
7
+ message: UIMessage;
8
+ isLastTurn?: boolean;
9
+ onUndoTurn?: () => void;
10
+ isProcessing?: boolean;
11
+ }
12
+
13
+ function extractText(message: UIMessage): string {
14
+ return message.parts
15
+ .filter((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text')
16
+ .map(p => p.text)
17
+ .join('');
18
+ }
19
+
20
+ export default function UserMessage({
21
+ message,
22
+ isLastTurn = false,
23
+ onUndoTurn,
24
+ isProcessing = false,
25
+ }: UserMessageProps) {
26
+ const showUndo = isLastTurn && !isProcessing && !!onUndoTurn;
27
+ const text = extractText(message);
28
+ const meta = message.metadata as MessageMeta | undefined;
29
+ const timeStr = meta?.createdAt
30
+ ? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
31
+ : null;
32
+ return (
33
+ <Stack
34
+ direction="row"
35
+ spacing={1.5}
36
+ justifyContent="flex-end"
37
+ alignItems="flex-start"
38
+ sx={{
39
+ '& .undo-btn': {
40
+ opacity: 0,
41
+ transition: 'opacity 0.15s ease',
42
+ },
43
+ '&:hover .undo-btn': {
44
+ opacity: 1,
45
+ },
46
+ }}
47
+ >
48
+ {showUndo && (
49
+ <Box className="undo-btn" sx={{ display: 'flex', alignItems: 'center', mt: 0.75 }}>
50
+ <Tooltip title="Remove this turn" placement="left">
51
+ <IconButton
52
+ onClick={onUndoTurn}
53
+ size="small"
54
+ sx={{
55
+ width: 24,
56
+ height: 24,
57
+ color: 'var(--muted-text)',
58
+ '&:hover': {
59
+ color: 'var(--accent-red)',
60
+ bgcolor: 'rgba(244,67,54,0.08)',
61
+ },
62
+ }}
63
+ >
64
+ <CloseIcon sx={{ fontSize: 14 }} />
65
+ </IconButton>
66
+ </Tooltip>
67
+ </Box>
68
+ )}
69
+
70
+ <Box
71
+ sx={{
72
+ maxWidth: { xs: '88%', md: '72%' },
73
+ bgcolor: 'var(--surface)',
74
+ borderRadius: 1.5,
75
+ borderTopRightRadius: 4,
76
+ px: { xs: 1.5, md: 2.5 },
77
+ py: 1.5,
78
+ border: '1px solid var(--border)',
79
+ }}
80
+ >
81
+ <Typography
82
+ variant="body1"
83
+ sx={{
84
+ fontSize: '0.925rem',
85
+ lineHeight: 1.65,
86
+ color: 'var(--text)',
87
+ whiteSpace: 'pre-wrap',
88
+ wordBreak: 'break-word',
89
+ }}
90
+ >
91
+ {text}
92
+ </Typography>
93
+
94
+ {timeStr && (
95
+ <Typography
96
+ variant="caption"
97
+ sx={{ color: 'var(--muted-text)', mt: 0.5, display: 'block', textAlign: 'right', fontSize: '0.7rem' }}
98
+ >
99
+ {timeStr}
100
+ </Typography>
101
+ )}
102
+ </Box>
103
+ </Stack>
104
+ );
105
+ }
frontend/src/components/CodePanel/CodePanel.tsx CHANGED
@@ -1,138 +1,463 @@
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';
6
  import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
7
  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
  }
33
- return currentContent.content;
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}
70
- onClick={() => setActivePanelTab(tab.id)}
71
- sx={{
72
- display: 'flex',
73
- alignItems: 'center',
74
- gap: 0.5,
75
- px: 1.5,
76
- py: 0.75,
77
- borderRadius: 1,
78
- cursor: 'pointer',
79
- fontSize: '0.7rem',
80
- fontWeight: 600,
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"
97
- onClick={(e) => {
98
- e.stopPropagation();
99
- removePanelTab(tab.id);
100
- }}
101
- sx={{
102
- display: 'flex',
103
- alignItems: 'center',
104
- justifyContent: 'center',
105
- ml: 0.5,
106
- width: 16,
107
- height: 16,
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
-
118
- </Box>
119
  </Box>
120
- );
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 }}>
137
  <Typography variant="body2" color="text.secondary" sx={{ opacity: 0.5 }}>
138
  NO DATA LOADED
@@ -144,174 +469,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, useState, useCallback } from 'react';
2
+ import { Box, Stack, Typography, IconButton, Button, Tooltip } 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';
6
  import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
7
  import CodeIcon from '@mui/icons-material/Code';
 
8
  import ArticleIcon from '@mui/icons-material/Article';
9
+ import EditIcon from '@mui/icons-material/Edit';
10
+ import UndoIcon from '@mui/icons-material/Undo';
11
+ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
12
+ import CheckIcon from '@mui/icons-material/Check';
13
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
14
+ import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
15
  import ReactMarkdown from 'react-markdown';
16
  import remarkGfm from 'remark-gfm';
17
  import { useAgentStore } from '@/store/agentStore';
18
  import { useLayoutStore } from '@/store/layoutStore';
19
  import { processLogs } from '@/utils/logProcessor';
20
+ import type { PanelView } from '@/store/agentStore';
21
+
22
+ // ── Helpers ──────────────────────────────────────────────────────
23
+
24
+ function PlanStatusIcon({ status }: { status: string }) {
25
+ if (status === 'completed') return <CheckCircleIcon sx={{ fontSize: 16, color: 'var(--accent-green)' }} />;
26
+ if (status === 'in_progress') return <PlayCircleOutlineIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
27
+ return <RadioButtonUncheckedIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.5 }} />;
28
+ }
29
+
30
+ // ── Markdown styles (adapts via CSS vars) ────────────────────────
31
+ const markdownSx = {
32
+ color: 'var(--text)',
33
+ fontSize: '13px',
34
+ lineHeight: 1.6,
35
+ '& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } },
36
+ '& pre': {
37
+ bgcolor: 'var(--code-bg)',
38
+ p: 1.5,
39
+ borderRadius: 1,
40
+ overflow: 'auto',
41
+ fontSize: '12px',
42
+ border: '1px solid var(--tool-border)',
43
+ },
44
+ '& code': {
45
+ bgcolor: 'var(--hover-bg)',
46
+ px: 0.5,
47
+ py: 0.25,
48
+ borderRadius: 0.5,
49
+ fontSize: '12px',
50
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
51
+ },
52
+ '& pre code': { bgcolor: 'transparent', p: 0 },
53
+ '& a': {
54
+ color: 'var(--accent-yellow)',
55
+ textDecoration: 'none',
56
+ '&:hover': { textDecoration: 'underline' },
57
+ },
58
+ '& ul, & ol': { pl: 2.5, my: 1 },
59
+ '& li': { mb: 0.5 },
60
+ '& table': {
61
+ borderCollapse: 'collapse',
62
+ width: '100%',
63
+ my: 2,
64
+ fontSize: '12px',
65
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
66
+ },
67
+ '& th': {
68
+ borderBottom: '2px solid var(--border-hover)',
69
+ textAlign: 'left',
70
+ p: 1,
71
+ fontWeight: 600,
72
+ },
73
+ '& td': {
74
+ borderBottom: '1px solid var(--tool-border)',
75
+ p: 1,
76
+ },
77
+ '& h1, & h2, & h3, & h4': { mt: 2, mb: 1, fontWeight: 600 },
78
+ '& h1': { fontSize: '1.25rem' },
79
+ '& h2': { fontSize: '1.1rem' },
80
+ '& h3': { fontSize: '1rem' },
81
+ '& blockquote': {
82
+ borderLeft: '3px solid var(--accent-yellow)',
83
+ pl: 2,
84
+ ml: 0,
85
+ color: 'var(--muted-text)',
86
+ },
87
+ } as const;
88
+
89
+ // ── View toggle button ──────────────────────────────────────────
90
+
91
+ function ViewToggle({ view, icon, label, isActive, onClick }: {
92
+ view: PanelView;
93
+ icon: React.ReactNode;
94
+ label: string;
95
+ isActive: boolean;
96
+ onClick: (v: PanelView) => void;
97
+ }) {
98
+ return (
99
+ <Box
100
+ onClick={() => onClick(view)}
101
+ sx={{
102
+ display: 'flex',
103
+ alignItems: 'center',
104
+ gap: 0.5,
105
+ px: 1.5,
106
+ py: 0.75,
107
+ borderRadius: 1,
108
+ cursor: 'pointer',
109
+ fontSize: '0.7rem',
110
+ fontWeight: 600,
111
+ textTransform: 'uppercase',
112
+ letterSpacing: '0.05em',
113
+ whiteSpace: 'nowrap',
114
+ color: isActive ? 'var(--text)' : 'var(--muted-text)',
115
+ bgcolor: isActive ? 'var(--tab-active-bg)' : 'transparent',
116
+ border: '1px solid',
117
+ borderColor: isActive ? 'var(--tab-active-border)' : 'transparent',
118
+ transition: 'all 0.15s ease',
119
+ '&:hover': { bgcolor: 'var(--tab-hover-bg)' },
120
+ }}
121
+ >
122
+ {icon}
123
+ <span>{label}</span>
124
+ </Box>
125
+ );
126
+ }
127
+
128
+ // ── Component ────────────────────────────────────────────────────
129
 
130
  export default function CodePanel() {
131
+ const { panelData, panelView, panelEditable, setPanelView, updatePanelScript, setEditedScript, plan } =
132
+ useAgentStore();
133
+ const { setRightPanelOpen, themeMode } = useLayoutStore();
134
  const scrollRef = useRef<HTMLDivElement>(null);
135
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
136
+ const [isEditing, setIsEditing] = useState(false);
137
+ const [editedContent, setEditedContent] = useState('');
138
+ const [originalContent, setOriginalContent] = useState('');
139
+ const [copied, setCopied] = useState(false);
140
+
141
+ const isDark = themeMode === 'dark';
142
+ const syntaxTheme = isDark ? vscDarkPlus : vs;
143
+
144
+ const activeSection = panelView === 'script' ? panelData?.script : panelData?.output;
145
+ const hasScript = !!panelData?.script;
146
+ const hasOutput = !!panelData?.output;
147
+ const hasBothViews = hasScript && hasOutput;
148
+
149
+ const isEditableScript = panelView === 'script' && panelEditable;
150
+ const hasUnsavedChanges = isEditing && editedContent !== originalContent;
151
+
152
+ // Sync edited content when panel data changes
153
+ useEffect(() => {
154
+ if (panelData?.script?.content && panelView === 'script' && panelEditable) {
155
+ setOriginalContent(panelData.script.content);
156
+ if (!isEditing) {
157
+ setEditedContent(panelData.script.content);
158
+ }
159
+ }
160
+ }, [panelData?.script?.content, panelView, panelEditable, isEditing]);
161
+
162
+ // Exit editing when switching away from script view or losing editable
163
+ useEffect(() => {
164
+ if (!isEditableScript && isEditing) {
165
+ setIsEditing(false);
166
+ }
167
+ }, [isEditableScript, isEditing]);
168
+
169
+ const handleStartEdit = useCallback(() => {
170
+ if (panelData?.script?.content) {
171
+ setEditedContent(panelData.script.content);
172
+ setOriginalContent(panelData.script.content);
173
+ setIsEditing(true);
174
+ setTimeout(() => textareaRef.current?.focus(), 0);
175
+ }
176
+ }, [panelData?.script?.content]);
177
+
178
+ const handleCancelEdit = useCallback(() => {
179
+ setEditedContent(originalContent);
180
+ setIsEditing(false);
181
+ }, [originalContent]);
182
+
183
+ const handleSaveEdit = useCallback(() => {
184
+ if (editedContent !== originalContent) {
185
+ updatePanelScript(editedContent);
186
+ const toolCallId = panelData?.parameters?.tool_call_id as string | undefined;
187
+ if (toolCallId) {
188
+ setEditedScript(toolCallId, editedContent);
189
+ }
190
+ setOriginalContent(editedContent);
191
+ }
192
+ setIsEditing(false);
193
+ }, [panelData?.parameters?.tool_call_id, editedContent, originalContent, updatePanelScript, setEditedScript]);
194
 
195
+ const handleCopy = useCallback(async () => {
196
+ const contentToCopy = isEditing ? editedContent : (activeSection?.content || '');
197
+ if (contentToCopy) {
198
+ try {
199
+ await navigator.clipboard.writeText(contentToCopy);
200
+ setCopied(true);
201
+ setTimeout(() => setCopied(false), 2000);
202
+ } catch (err) {
203
+ console.error('Failed to copy:', err);
204
+ }
205
+ }
206
+ }, [isEditing, editedContent, activeSection?.content]);
207
 
208
  const displayContent = useMemo(() => {
209
+ if (!activeSection?.content) return '';
210
+ if (!activeSection.language || activeSection.language === 'text') {
211
+ return processLogs(activeSection.content);
 
212
  }
213
+ return activeSection.content;
214
+ }, [activeSection?.content, activeSection?.language]);
215
 
216
  useEffect(() => {
217
+ if (scrollRef.current && panelView === 'output') {
 
218
  scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
219
  }
220
+ }, [displayContent, panelView]);
221
+
222
+ // ── Syntax-highlighted code block (DRY) ────────────────────────
223
+ const renderSyntaxBlock = (language: string) => (
224
+ <SyntaxHighlighter
225
+ language={language}
226
+ style={syntaxTheme}
227
+ customStyle={{
228
+ margin: 0,
229
+ padding: 0,
230
+ background: 'transparent',
231
+ fontSize: '13px',
232
+ fontFamily: 'inherit',
233
+ }}
234
+ wrapLines
235
+ wrapLongLines
236
+ >
237
+ {displayContent}
238
+ </SyntaxHighlighter>
239
+ );
240
+
241
+ // ── Content renderer ───────────────────────────────────────────
242
+ const renderContent = () => {
243
+ if (!activeSection?.content) {
244
+ return (
245
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
246
+ <Typography variant="caption">NO CONTENT TO DISPLAY</Typography>
247
+ </Box>
248
+ );
249
+ }
250
+
251
+ if (isEditing && isEditableScript) {
252
+ return (
253
+ <Box sx={{ position: 'relative', width: '100%', height: '100%' }}>
254
+ <SyntaxHighlighter
255
+ language={activeSection?.language === 'python' ? 'python' : activeSection?.language === 'json' ? 'json' : 'text'}
256
+ style={syntaxTheme}
257
+ customStyle={{
258
+ margin: 0,
259
+ padding: 0,
260
+ background: 'transparent',
261
+ fontSize: '13px',
262
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
263
+ lineHeight: 1.55,
264
+ pointerEvents: 'none',
265
+ }}
266
+ wrapLines
267
+ wrapLongLines
268
+ >
269
+ {editedContent || ' '}
270
+ </SyntaxHighlighter>
271
+ <textarea
272
+ ref={textareaRef}
273
+ value={editedContent}
274
+ onChange={(e) => setEditedContent(e.target.value)}
275
+ spellCheck={false}
276
+ style={{
277
+ position: 'absolute',
278
+ top: 0,
279
+ left: 0,
280
+ width: '100%',
281
+ height: '100%',
282
+ background: 'transparent',
283
+ border: 'none',
284
+ outline: 'none',
285
+ resize: 'none',
286
+ color: 'transparent',
287
+ caretColor: 'var(--text)',
288
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
289
+ fontSize: '13px',
290
+ lineHeight: 1.55,
291
+ overflow: 'hidden',
292
+ }}
293
+ />
294
+ </Box>
295
+ );
296
+ }
297
 
298
+ const lang = activeSection.language;
299
+ if (lang === 'python') return renderSyntaxBlock('python');
300
+ if (lang === 'json') return renderSyntaxBlock('json');
301
+
302
+ if (lang === 'markdown') {
303
+ return (
304
+ <Box sx={markdownSx}>
305
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{displayContent}</ReactMarkdown>
306
+ </Box>
307
+ );
308
+ }
309
+
310
+ return (
311
+ <Box
312
+ component="pre"
313
+ sx={{ m: 0, fontFamily: 'inherit', color: 'var(--text)', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
314
+ >
315
+ <code>{displayContent}</code>
316
+ </Box>
317
+ );
318
+ };
319
 
320
  return (
321
  <Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
322
+ {/* ── Header ─────────────────────────────────────────────── */}
323
+ <Box
324
+ sx={{
325
+ height: 60,
326
+ display: 'flex',
327
+ alignItems: 'center',
328
+ justifyContent: 'space-between',
329
+ px: 2,
330
+ borderBottom: '1px solid var(--border)',
331
+ flexShrink: 0,
332
+ }}
333
+ >
334
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, minWidth: 0 }}>
335
+ {panelData ? (
336
+ <>
337
+ <Typography
338
+ variant="caption"
339
+ sx={{
340
+ fontWeight: 600,
341
+ color: 'var(--muted-text)',
342
+ textTransform: 'uppercase',
343
+ letterSpacing: '0.05em',
344
+ fontSize: '0.7rem',
345
+ flexShrink: 0,
346
+ }}
347
+ >
348
+ {panelData.title}
349
+ </Typography>
350
+ {hasBothViews && (
351
+ <Box sx={{ display: 'flex', gap: 0.5, ml: 1 }}>
352
+ <ViewToggle
353
+ view="script"
354
+ icon={<CodeIcon sx={{ fontSize: 14 }} />}
355
+ label="Script"
356
+ isActive={panelView === 'script'}
357
+ onClick={setPanelView}
358
+ />
359
+ <ViewToggle
360
+ view="output"
361
+ icon={<ArticleIcon sx={{ fontSize: 14 }} />}
362
+ label="Result"
363
+ isActive={panelView === 'output'}
364
+ onClick={setPanelView}
365
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  </Box>
367
+ )}
368
+ </>
369
+ ) : (
370
+ <Typography
371
+ variant="caption"
372
+ sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
373
+ >
374
+ Code Panel
375
+ </Typography>
376
+ )}
377
+ </Box>
378
+
379
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
380
+ {activeSection?.content && (
381
+ <Tooltip title={copied ? 'Copied!' : 'Copy'} placement="top">
382
+ <IconButton
383
+ size="small"
384
+ onClick={handleCopy}
385
+ sx={{
386
+ color: copied ? 'var(--accent-green)' : 'var(--muted-text)',
387
+ '&:hover': { color: 'var(--accent-yellow)', bgcolor: 'var(--hover-bg)' },
388
+ }}
389
+ >
390
+ {copied ? <CheckIcon sx={{ fontSize: 18 }} /> : <ContentCopyIcon sx={{ fontSize: 18 }} />}
391
+ </IconButton>
392
+ </Tooltip>
393
+ )}
394
+ {isEditableScript && !isEditing && (
395
+ <Button
396
+ size="small"
397
+ startIcon={<EditIcon sx={{ fontSize: 14 }} />}
398
+ onClick={handleStartEdit}
399
+ sx={{
400
+ textTransform: 'none',
401
+ color: 'var(--muted-text)',
402
+ fontSize: '0.75rem',
403
+ py: 0.5,
404
+ '&:hover': { color: 'var(--accent-yellow)', bgcolor: 'var(--hover-bg)' },
405
+ }}
406
+ >
407
+ Edit
408
+ </Button>
409
+ )}
410
+ {isEditing && (
411
+ <>
412
+ <Button
413
+ size="small"
414
+ startIcon={<UndoIcon sx={{ fontSize: 14 }} />}
415
+ onClick={handleCancelEdit}
416
+ sx={{
417
+ textTransform: 'none',
418
+ color: 'var(--muted-text)',
419
+ fontSize: '0.75rem',
420
+ py: 0.5,
421
+ '&:hover': { color: 'var(--accent-red)', bgcolor: 'var(--hover-bg)' },
422
+ }}
423
+ >
424
+ Cancel
425
+ </Button>
426
+ <Button
427
+ size="small"
428
+ variant="contained"
429
+ onClick={handleSaveEdit}
430
+ disabled={!hasUnsavedChanges}
431
+ sx={{
432
+ textTransform: 'none',
433
+ fontSize: '0.75rem',
434
+ py: 0.5,
435
+ bgcolor: hasUnsavedChanges ? 'var(--accent-yellow)' : 'var(--hover-bg)',
436
+ color: hasUnsavedChanges ? '#000' : 'var(--muted-text)',
437
+ '&:hover': {
438
+ bgcolor: hasUnsavedChanges ? 'var(--accent-yellow)' : 'var(--hover-bg)',
439
+ opacity: 0.9,
440
+ },
441
+ '&.Mui-disabled': {
442
+ bgcolor: 'var(--hover-bg)',
443
+ color: 'var(--muted-text)',
444
+ opacity: 0.5,
445
+ },
446
+ }}
447
+ >
448
+ Save
449
+ </Button>
450
+ </>
451
+ )}
452
+ <IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
453
+ <CloseIcon fontSize="small" />
454
+ </IconButton>
455
+ </Box>
456
  </Box>
457
 
458
+ {/* ── Main content area ─────────────────────────────────── */}
459
  <Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
460
+ {!panelData ? (
461
  <Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
462
  <Typography variant="body2" color="text.secondary" sx={{ opacity: 0.5 }}>
463
  NO DATA LOADED
 
469
  ref={scrollRef}
470
  className="code-panel"
471
  sx={{
472
+ bgcolor: 'var(--code-panel-bg)',
473
  borderRadius: 'var(--radius-md)',
474
+ p: '18px',
475
+ border: '1px solid var(--border)',
476
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
477
  fontSize: '13px',
478
  lineHeight: 1.55,
479
  height: '100%',
480
  overflow: 'auto',
481
  }}
482
  >
483
+ {renderContent()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  </Box>
485
  </Box>
486
  )}
487
  </Box>
488
 
489
+ {/* ── Plan display (bottom) ─────────────────────────────── */}
490
  {plan && plan.length > 0 && (
491
+ <Box
492
+ sx={{
493
+ borderTop: '1px solid var(--border)',
494
+ bgcolor: 'var(--plan-bg)',
495
  maxHeight: '30%',
496
  display: 'flex',
497
+ flexDirection: 'column',
498
+ }}
499
+ >
500
+ <Box
501
+ sx={{
502
+ p: 1.5,
503
+ borderBottom: '1px solid var(--border)',
504
+ display: 'flex',
505
+ alignItems: 'center',
506
+ gap: 1,
507
+ }}
508
+ >
509
+ <Typography
510
+ variant="caption"
511
+ sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
512
+ >
513
+ CURRENT PLAN
514
+ </Typography>
515
+ </Box>
516
+
517
+ <Stack spacing={1} sx={{ p: 2, overflow: 'auto' }}>
518
+ {plan.map((item) => (
519
+ <Stack key={item.id} direction="row" alignItems="flex-start" spacing={1.5}>
520
+ <Box sx={{ mt: 0.2 }}>
521
+ <PlanStatusIcon status={item.status} />
522
+ </Box>
523
+ <Typography
524
+ variant="body2"
525
+ sx={{
526
+ fontSize: '13px',
527
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
528
+ color: item.status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
529
+ textDecoration: item.status === 'completed' ? 'line-through' : 'none',
530
+ opacity: item.status === 'pending' ? 0.7 : 1,
531
+ }}
532
+ >
533
+ {item.content}
534
  </Typography>
535
+ </Stack>
536
+ ))}
537
+ </Stack>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  </Box>
539
  )}
540
  </Box>
frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -1,65 +1,83 @@
1
- import { useCallback, useRef, useEffect } from 'react';
2
  import {
 
3
  Box,
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';
14
  import { useLayoutStore } from '@/store/layoutStore';
15
- import { useAgentWebSocket } from '@/hooks/useAgentWebSocket';
16
  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,75 +85,157 @@ 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',
85
- content: text.trim(),
86
- timestamp: new Date().toISOString(),
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 +243,226 @@ 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
  }
 
1
+ import { useCallback, useRef, useEffect, useState } from 'react';
2
  import {
3
+ Avatar,
4
  Box,
5
  Drawer,
6
  Typography,
7
  IconButton,
8
+ Alert,
9
+ AlertTitle,
10
+ Snackbar,
11
+ useMediaQuery,
12
+ useTheme,
13
  } from '@mui/material';
14
  import MenuIcon from '@mui/icons-material/Menu';
15
  import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
16
  import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
17
+ import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
18
+ import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
19
+ import { logger } from '@/utils/logger';
20
 
21
  import { useSessionStore } from '@/store/sessionStore';
22
  import { useAgentStore } from '@/store/agentStore';
23
  import { useLayoutStore } from '@/store/layoutStore';
24
+ import { useAgentChat } from '@/hooks/useAgentChat';
25
  import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
26
  import CodePanel from '@/components/CodePanel/CodePanel';
27
  import ChatInput from '@/components/Chat/ChatInput';
28
  import MessageList from '@/components/Chat/MessageList';
29
+ import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
30
+ import { apiFetch } from '@/utils/api';
31
 
32
  const DRAWER_WIDTH = 260;
33
 
34
  export default function AppLayout() {
35
+ const { sessions, activeSessionId, deleteSession, updateSessionTitle } = useSessionStore();
36
+ const { isConnected, isProcessing, setProcessing, activityStatus, llmHealthError, setLlmHealthError, user } = useAgentStore();
37
  const {
38
  isLeftSidebarOpen,
39
  isRightPanelOpen,
40
  rightPanelWidth,
41
+ themeMode,
42
  setRightPanelWidth,
43
+ setLeftSidebarOpen,
44
  toggleLeftSidebar,
45
+ toggleTheme,
46
  } = useLayoutStore();
47
 
48
+ const theme = useTheme();
49
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
50
 
51
+ const [showExpiredToast, setShowExpiredToast] = useState(false);
52
+ const disconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
 
 
 
 
 
53
 
54
+ const isResizing = useRef(false);
 
 
 
 
 
55
 
56
  const handleMouseMove = useCallback((e: MouseEvent) => {
57
  if (!isResizing.current) return;
58
  const newWidth = window.innerWidth - e.clientX;
59
+ const maxWidth = window.innerWidth * 0.6;
60
  const minWidth = 300;
61
  if (newWidth > minWidth && newWidth < maxWidth) {
62
  setRightPanelWidth(newWidth);
63
  }
64
  }, [setRightPanelWidth]);
65
 
66
+ const stopResizing = useCallback(() => {
67
+ isResizing.current = false;
68
+ document.removeEventListener('mousemove', handleMouseMove);
69
+ document.removeEventListener('mouseup', stopResizing);
70
+ document.body.style.cursor = 'default';
71
+ }, [handleMouseMove]);
72
+
73
+ const startResizing = useCallback((e: React.MouseEvent) => {
74
+ e.preventDefault();
75
+ isResizing.current = true;
76
+ document.addEventListener('mousemove', handleMouseMove);
77
+ document.addEventListener('mouseup', stopResizing);
78
+ document.body.style.cursor = 'col-resize';
79
+ }, [handleMouseMove, stopResizing]);
80
+
81
  useEffect(() => {
82
  return () => {
83
  document.removeEventListener('mousemove', handleMouseMove);
 
85
  };
86
  }, [handleMouseMove, stopResizing]);
87
 
88
+ // ── LLM health check on mount ───────────────────────────────────
89
+ useEffect(() => {
90
+ let cancelled = false;
91
+ (async () => {
92
+ try {
93
+ const res = await apiFetch('/api/health/llm');
94
+ const data = await res.json();
95
+ if (!cancelled && data.status === 'error') {
96
+ setLlmHealthError({
97
+ error: data.error || 'Unknown LLM error',
98
+ errorType: data.error_type || 'unknown',
99
+ model: data.model,
100
+ });
101
+ } else if (!cancelled) {
102
+ setLlmHealthError(null);
103
+ }
104
+ } catch {
105
+ // Backend unreachable — not an LLM issue, ignore
106
+ }
107
+ })();
108
+ return () => { cancelled = true; };
109
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
110
+
111
+ const hasAnySessions = sessions.length > 0;
112
 
113
+ const { messages, sendMessage, undoLastTurn, approveTools } = useAgentChat({
114
  sessionId: activeSessionId,
115
+ onReady: () => logger.log('Agent ready'),
116
+ onError: (error) => logger.error('Agent error:', error),
117
+ onSessionDead: (deadSessionId) => {
118
+ logger.log('Removing dead session:', deadSessionId);
119
+ deleteSession(deadSessionId);
120
+ },
121
  });
122
 
123
+ // Debounced "session expired" toast — only fires after 2s of sustained disconnect
124
+ useEffect(() => {
125
+ if (!isConnected && messages.length > 0 && activeSessionId) {
126
+ disconnectTimer.current = setTimeout(() => setShowExpiredToast(true), 2000);
127
+ } else {
128
+ if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
129
+ disconnectTimer.current = null;
130
+ setShowExpiredToast(false);
131
+ }
132
+ return () => {
133
+ if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
134
+ };
135
+ }, [isConnected, messages.length, activeSessionId]);
136
+
137
  const handleSendMessage = useCallback(
138
  async (text: string) => {
139
+ if (!activeSessionId || !text.trim() || isProcessing) return;
 
 
 
 
 
 
 
 
140
 
141
+ setProcessing(true);
142
+ sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
143
+
144
+ // Auto-title the session from the first user message (async, non-blocking)
145
+ const isFirstMessage = messages.filter((m) => m.role === 'user').length <= 1;
146
+ if (isFirstMessage) {
147
+ const sessionId = activeSessionId;
148
+ apiFetch('/api/title', {
149
  method: 'POST',
150
+ body: JSON.stringify({ session_id: sessionId, text: text.trim() }),
151
+ })
152
+ .then((res) => res.json())
153
+ .then((data) => {
154
+ if (data.title) updateSessionTitle(sessionId, data.title);
155
+ })
156
+ .catch(() => {
157
+ const raw = text.trim();
158
+ updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '…' : raw);
159
+ });
160
  }
161
  },
162
+ [activeSessionId, sendMessage, messages, updateSessionTitle, isProcessing, setProcessing],
163
  );
164
 
165
+ // Close sidebar on mobile after selecting a session
166
+ const handleSidebarClose = useCallback(() => {
167
+ if (isMobile) setLeftSidebarOpen(false);
168
+ }, [isMobile, setLeftSidebarOpen]);
169
+
170
+ // ── LLM error toast helper ──────────────────────────────────────────
171
+ const llmErrorTitle = llmHealthError
172
+ ? llmHealthError.errorType === 'credits'
173
+ ? 'API Credits Exhausted'
174
+ : llmHealthError.errorType === 'auth'
175
+ ? 'Invalid API Key'
176
+ : llmHealthError.errorType === 'rate_limit'
177
+ ? 'Rate Limited'
178
+ : llmHealthError.errorType === 'network'
179
+ ? 'LLM Provider Unreachable'
180
+ : 'LLM Error'
181
+ : '';
182
+
183
+ // ── Welcome screen: no sessions at all ────────────────────────────
184
+ if (!hasAnySessions) {
185
+ return (
186
+ <Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
187
+ <WelcomeScreen />
188
+ </Box>
189
+ );
190
+ }
191
+
192
+ // ── Sidebar drawer ────────────────────────────────────────────────
193
+ const sidebarDrawer = (
194
+ <Drawer
195
+ variant={isMobile ? 'temporary' : 'persistent'}
196
+ anchor="left"
197
+ open={isLeftSidebarOpen}
198
+ onClose={() => setLeftSidebarOpen(false)}
199
+ ModalProps={{ keepMounted: true }} // Better mobile perf
200
+ sx={{
201
+ '& .MuiDrawer-paper': {
202
+ boxSizing: 'border-box',
203
+ width: DRAWER_WIDTH,
204
+ borderRight: '1px solid',
205
+ borderColor: 'divider',
206
+ top: 0,
207
+ height: '100%',
208
+ bgcolor: 'var(--panel)',
209
+ },
210
+ }}
211
+ >
212
+ <SessionSidebar onClose={handleSidebarClose} />
213
+ </Drawer>
214
+ );
215
+
216
+ // ── Main chat interface ───────────────────────────────────────────
217
  return (
218
  <Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
219
+ {/* ── Left Sidebar ─────────────────────────────────────────── */}
220
+ {isMobile ? (
221
+ // Mobile: temporary overlay drawer (no reserved width)
222
+ sidebarDrawer
223
+ ) : (
224
+ // Desktop: persistent drawer with reserved width
225
+ <Box
226
+ component="nav"
 
 
 
 
227
  sx={{
228
+ width: isLeftSidebarOpen ? DRAWER_WIDTH : 0,
229
+ flexShrink: 0,
230
+ transition: isResizing.current ? 'none' : 'width 0.2s',
231
+ overflow: 'hidden',
 
 
 
 
 
 
232
  }}
 
233
  >
234
+ {sidebarDrawer}
235
+ </Box>
236
+ )}
237
 
238
+ {/* ── Main Content (header + chat + code panel) ────────────── */}
239
  <Box
240
  sx={{
241
  flexGrow: 1,
 
243
  display: 'flex',
244
  flexDirection: 'column',
245
  transition: isResizing.current ? 'none' : 'width 0.2s',
 
246
  overflow: 'hidden',
247
+ minWidth: 0,
248
  }}
249
  >
250
+ {/* ── Top Header Bar ─────────────────────────────────────── */}
251
  <Box sx={{
252
+ height: { xs: 52, md: 60 },
253
+ px: { xs: 1, md: 2 },
254
  display: 'flex',
255
  alignItems: 'center',
256
  borderBottom: 1,
257
  borderColor: 'divider',
258
  bgcolor: 'background.default',
259
  zIndex: 1200,
260
+ flexShrink: 0,
261
  }}>
262
  <IconButton onClick={toggleLeftSidebar} size="small">
263
+ {isLeftSidebarOpen && !isMobile ? <ChevronLeftIcon /> : <MenuIcon />}
264
  </IconButton>
265
 
266
+ <Box sx={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 0.75 }}>
267
+ <Box
268
+ component="img"
269
+ src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
270
+ alt="HF"
271
+ sx={{ width: { xs: 20, md: 22 }, height: { xs: 20, md: 22 } }}
272
  />
273
+ <Typography
274
+ variant="subtitle1"
275
+ sx={{
276
+ fontWeight: 700,
277
+ color: 'var(--text)',
278
+ letterSpacing: '-0.01em',
279
+ fontSize: { xs: '0.88rem', md: '0.95rem' },
280
+ }}
281
+ >
282
+ HF Agent
283
+ </Typography>
284
  </Box>
285
 
286
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
287
+ <IconButton
288
+ onClick={toggleTheme}
289
+ size="small"
290
+ sx={{
291
+ color: 'text.secondary',
292
+ '&:hover': { color: 'primary.main' },
293
+ }}
294
+ >
295
+ {themeMode === 'dark' ? <LightModeOutlinedIcon fontSize="small" /> : <DarkModeOutlinedIcon fontSize="small" />}
296
+ </IconButton>
297
+
298
+ {user?.picture ? (
299
+ <Avatar
300
+ src={user.picture}
301
+ alt={user.username || 'User'}
302
+ sx={{ width: 28, height: 28, ml: 0.5 }}
303
+ />
304
+ ) : user?.username ? (
305
+ <Avatar
306
+ sx={{
307
+ width: 28,
308
+ height: 28,
309
+ ml: 0.5,
310
+ bgcolor: 'primary.main',
311
+ fontSize: '0.75rem',
312
+ fontWeight: 700,
313
+ }}
314
+ >
315
+ {user.username[0].toUpperCase()}
316
+ </Avatar>
317
+ ) : null}
318
+ </Box>
319
  </Box>
320
 
321
+ {/* ── Chat + Code Panel ─────────────────────────���────────── */}
322
  <Box
 
 
323
  sx={{
324
  flexGrow: 1,
325
  display: 'flex',
 
326
  overflow: 'hidden',
 
 
327
  }}
328
  >
329
+ {/* Chat area */}
330
+ <Box
331
+ component="main"
332
+ className="chat-pane"
333
+ sx={{
334
+ flexGrow: 1,
335
+ display: 'flex',
336
+ flexDirection: 'column',
337
+ overflow: 'hidden',
338
+ background: 'var(--body-gradient)',
339
+ p: { xs: 1.5, sm: 2, md: 3 },
340
+ minWidth: 0,
341
+ }}
342
+ >
343
+ {activeSessionId ? (
344
+ <>
345
+ <MessageList messages={messages} isProcessing={isProcessing} approveTools={approveTools} onUndoLastTurn={undoLastTurn} />
346
+ <ChatInput
347
+ onSend={handleSendMessage}
348
+ disabled={isProcessing || !isConnected || activityStatus.type === 'waiting-approval'}
349
+ placeholder={activityStatus.type === 'waiting-approval' ? 'Approve or reject pending tools first...' : undefined}
350
+ />
351
+ </>
352
+ ) : (
353
+ <Box
354
+ sx={{
355
+ flex: 1,
356
+ display: 'flex',
357
+ alignItems: 'center',
358
+ justifyContent: 'center',
359
+ flexDirection: 'column',
360
+ gap: 2,
361
+ px: 2,
362
+ }}
363
+ >
364
+ <Typography variant="h5" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '1rem', md: '1.5rem' } }}>
365
+ NO SESSION SELECTED
366
+ </Typography>
367
+ <Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '0.75rem', md: '0.875rem' } }}>
368
+ Initialize a session via the sidebar
369
+ </Typography>
370
+ </Box>
371
+ )}
372
+ </Box>
373
+
374
+ {/* Code panel — inline on desktop, overlay drawer on mobile */}
375
+ {isRightPanelOpen && !isMobile && (
376
  <>
377
+ <Box
378
+ onMouseDown={startResizing}
379
+ sx={{
380
+ width: '4px',
381
+ cursor: 'col-resize',
382
+ bgcolor: 'divider',
383
+ display: 'flex',
384
+ alignItems: 'center',
385
+ justifyContent: 'center',
386
+ transition: 'background-color 0.2s',
387
+ flexShrink: 0,
388
+ '&:hover': { bgcolor: 'primary.main' },
389
+ }}
390
+ >
391
+ <DragIndicatorIcon
392
+ sx={{ fontSize: '0.8rem', color: 'text.secondary', pointerEvents: 'none' }}
393
+ />
394
+ </Box>
395
+ <Box
396
+ sx={{
397
+ width: rightPanelWidth,
398
+ flexShrink: 0,
399
+ height: '100%',
400
+ overflow: 'hidden',
401
+ borderLeft: '1px solid',
402
+ borderColor: 'divider',
403
+ bgcolor: 'var(--panel)',
404
+ }}
405
+ >
406
+ <CodePanel />
407
+ </Box>
408
  </>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  )}
410
  </Box>
411
  </Box>
412
 
413
+ {/* Code panel — drawer overlay on mobile */}
414
+ {isMobile && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  <Drawer
416
+ anchor="bottom"
417
+ open={isRightPanelOpen}
418
+ onClose={() => useLayoutStore.getState().setRightPanelOpen(false)}
419
  sx={{
 
420
  '& .MuiDrawer-paper': {
421
+ height: '75vh',
422
+ borderTopLeftRadius: 16,
423
+ borderTopRightRadius: 16,
 
 
424
  bgcolor: 'var(--panel)',
425
  },
426
  }}
 
427
  >
428
  <CodePanel />
429
  </Drawer>
430
+ )}
431
+ <Snackbar
432
+ open={showExpiredToast}
433
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
434
+ onClose={() => setShowExpiredToast(false)}
435
+ >
436
+ <Alert
437
+ severity="warning"
438
+ variant="filled"
439
+ onClose={() => setShowExpiredToast(false)}
440
+ sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}
441
+ >
442
+ Session expired — create a new session to continue.
443
+ </Alert>
444
+ </Snackbar>
445
+ <Snackbar
446
+ open={!!llmHealthError}
447
+ anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
448
+ onClose={() => setLlmHealthError(null)}
449
+ >
450
+ <Alert
451
+ severity="error"
452
+ variant="filled"
453
+ onClose={() => setLlmHealthError(null)}
454
+ sx={{ fontSize: '0.8rem', maxWidth: 480 }}
455
+ >
456
+ <AlertTitle sx={{ fontWeight: 700, fontSize: '0.85rem' }}>
457
+ {llmErrorTitle}
458
+ </AlertTitle>
459
+ {llmHealthError && (
460
+ <Typography variant="body2" sx={{ fontSize: '0.78rem', opacity: 0.9 }}>
461
+ {llmHealthError.model} — {llmHealthError.error.slice(0, 150)}
462
+ </Typography>
463
+ )}
464
+ </Alert>
465
+ </Snackbar>
466
  </Box>
467
  );
468
  }
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, clearPanel } =
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
+ clearPanel();
60
  onClose?.();
61
+ } catch {
62
+ setCapacityError('Failed to create session.');
63
+ } finally {
64
+ setIsCreatingSession(false);
65
  }
66
+ }, [isCreatingSession, createSession, setPlan, clearPanel, 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
+ clearPanel();
87
  onClose?.();
88
  },
89
+ [switchSession, setPlan, clearPanel, 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,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from 'react';
2
+ import {
3
+ Box,
4
+ Typography,
5
+ Button,
6
+ CircularProgress,
7
+ Alert,
8
+ } from '@mui/material';
9
+ import OpenInNewIcon from '@mui/icons-material/OpenInNew';
10
+ import { useSessionStore } from '@/store/sessionStore';
11
+ import { useAgentStore } from '@/store/agentStore';
12
+ import { apiFetch } from '@/utils/api';
13
+ import { isInIframe, triggerLogin } from '@/hooks/useAuth';
14
+
15
+ /** HF brand orange */
16
+ const HF_ORANGE = '#FF9D00';
17
+
18
+ export default function WelcomeScreen() {
19
+ const { createSession } = useSessionStore();
20
+ const { setPlan, clearPanel, user } = useAgentStore();
21
+ const [isCreating, setIsCreating] = useState(false);
22
+ const [error, setError] = useState<string | null>(null);
23
+
24
+ const inIframe = isInIframe();
25
+ const isAuthenticated = user?.authenticated;
26
+ const isDevUser = user?.username === 'dev';
27
+
28
+ const handleStart = useCallback(async () => {
29
+ if (isCreating) return;
30
+
31
+ // Not authenticated and not dev → need to login
32
+ if (!isAuthenticated && !isDevUser) {
33
+ // In iframe: can't redirect (cookies blocked) — user needs to open in new tab
34
+ // This shouldn't happen because we show a different button in iframe
35
+ // But just in case:
36
+ if (inIframe) return;
37
+ triggerLogin();
38
+ return;
39
+ }
40
+
41
+ setIsCreating(true);
42
+ setError(null);
43
+
44
+ try {
45
+ const response = await apiFetch('/api/session', { method: 'POST' });
46
+ if (response.status === 503) {
47
+ const data = await response.json();
48
+ setError(data.detail || 'Server is at capacity. Please try again later.');
49
+ return;
50
+ }
51
+ if (response.status === 401) {
52
+ triggerLogin();
53
+ return;
54
+ }
55
+ if (!response.ok) {
56
+ setError('Failed to create session. Please try again.');
57
+ return;
58
+ }
59
+ const data = await response.json();
60
+ createSession(data.session_id);
61
+ setPlan([]);
62
+ clearPanel();
63
+ } catch {
64
+ // Redirect may throw — ignore
65
+ } finally {
66
+ setIsCreating(false);
67
+ }
68
+ }, [isCreating, createSession, setPlan, clearPanel, isAuthenticated, isDevUser, inIframe]);
69
+
70
+ // Build the direct Space URL for the "open in new tab" link
71
+ const spaceHost = typeof window !== 'undefined'
72
+ ? window.location.hostname.includes('.hf.space')
73
+ ? window.location.origin
74
+ : `https://smolagents-ml-agent.hf.space`
75
+ : '';
76
+
77
+ return (
78
+ <Box
79
+ sx={{
80
+ width: '100%',
81
+ height: '100%',
82
+ display: 'flex',
83
+ flexDirection: 'column',
84
+ alignItems: 'center',
85
+ justifyContent: 'center',
86
+ background: 'var(--body-gradient)',
87
+ py: 8,
88
+ }}
89
+ >
90
+ {/* HF Logo */}
91
+ <Box
92
+ component="img"
93
+ src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
94
+ alt="Hugging Face"
95
+ sx={{ width: 96, height: 96, mb: 3, display: 'block' }}
96
+ />
97
+
98
+ {/* Title */}
99
+ <Typography
100
+ variant="h2"
101
+ sx={{
102
+ fontWeight: 800,
103
+ color: 'var(--text)',
104
+ mb: 1.5,
105
+ letterSpacing: '-0.02em',
106
+ fontSize: { xs: '2rem', md: '2.8rem' },
107
+ }}
108
+ >
109
+ HF Agent
110
+ </Typography>
111
+
112
+ {/* Description */}
113
+ <Typography
114
+ variant="body1"
115
+ sx={{
116
+ color: 'var(--muted-text)',
117
+ maxWidth: 520,
118
+ mb: 5,
119
+ lineHeight: 1.8,
120
+ fontSize: '0.95rem',
121
+ textAlign: 'center',
122
+ px: 2,
123
+ '& strong': { color: 'var(--text)', fontWeight: 600 },
124
+ }}
125
+ >
126
+ A general-purpose AI agent for <strong>machine learning engineering</strong>.
127
+ It browses <strong>Hugging Face documentation</strong>, manages{' '}
128
+ <strong>repositories</strong>, launches <strong>training jobs</strong>,
129
+ and explores <strong>datasets</strong> — all through natural conversation.
130
+ </Typography>
131
+
132
+ {/* Action button — depends on context */}
133
+ {inIframe && !isAuthenticated && !isDevUser ? (
134
+ // In iframe + not logged in → link to open Space directly
135
+ <Button
136
+ variant="contained"
137
+ size="large"
138
+ component="a"
139
+ href={spaceHost}
140
+ target="_blank"
141
+ rel="noopener noreferrer"
142
+ endIcon={<OpenInNewIcon />}
143
+ sx={{
144
+ px: 5,
145
+ py: 1.5,
146
+ fontSize: '1rem',
147
+ fontWeight: 700,
148
+ textTransform: 'none',
149
+ borderRadius: '12px',
150
+ bgcolor: HF_ORANGE,
151
+ color: '#000',
152
+ boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
153
+ textDecoration: 'none',
154
+ '&:hover': {
155
+ bgcolor: '#FFB340',
156
+ boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
157
+ },
158
+ }}
159
+ >
160
+ Open HF Agent
161
+ </Button>
162
+ ) : !isAuthenticated && !isDevUser ? (
163
+ // Direct access + not logged in → sign in button
164
+ <Button
165
+ variant="contained"
166
+ size="large"
167
+ onClick={() => triggerLogin()}
168
+ sx={{
169
+ px: 5,
170
+ py: 1.5,
171
+ fontSize: '1rem',
172
+ fontWeight: 700,
173
+ textTransform: 'none',
174
+ borderRadius: '12px',
175
+ bgcolor: HF_ORANGE,
176
+ color: '#000',
177
+ boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
178
+ '&:hover': {
179
+ bgcolor: '#FFB340',
180
+ boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
181
+ },
182
+ }}
183
+ >
184
+ Sign in with Hugging Face
185
+ </Button>
186
+ ) : (
187
+ // Authenticated or dev → start session
188
+ <Button
189
+ variant="contained"
190
+ size="large"
191
+ onClick={handleStart}
192
+ disabled={isCreating}
193
+ startIcon={
194
+ isCreating ? <CircularProgress size={20} color="inherit" /> : null
195
+ }
196
+ sx={{
197
+ px: 5,
198
+ py: 1.5,
199
+ fontSize: '1rem',
200
+ fontWeight: 700,
201
+ textTransform: 'none',
202
+ borderRadius: '12px',
203
+ bgcolor: HF_ORANGE,
204
+ color: '#000',
205
+ boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
206
+ '&:hover': {
207
+ bgcolor: '#FFB340',
208
+ boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
209
+ },
210
+ '&.Mui-disabled': {
211
+ bgcolor: 'rgba(255, 157, 0, 0.35)',
212
+ color: 'rgba(0,0,0,0.45)',
213
+ },
214
+ }}
215
+ >
216
+ {isCreating ? 'Initializing...' : 'Start Session'}
217
+ </Button>
218
+ )}
219
+
220
+ {/* Error */}
221
+ {error && (
222
+ <Alert
223
+ severity="warning"
224
+ variant="outlined"
225
+ onClose={() => setError(null)}
226
+ sx={{
227
+ mt: 3,
228
+ maxWidth: 400,
229
+ fontSize: '0.8rem',
230
+ borderColor: HF_ORANGE,
231
+ color: 'var(--text)',
232
+ }}
233
+ >
234
+ {error}
235
+ </Alert>
236
+ )}
237
+
238
+ {/* Footnote */}
239
+ <Typography
240
+ variant="caption"
241
+ sx={{ mt: 5, color: 'var(--muted-text)', opacity: 0.5, fontSize: '0.7rem' }}
242
+ >
243
+ Conversations are stored locally in your browser.
244
+ </Typography>
245
+ </Box>
246
+ );
247
+ }
frontend/src/hooks/useAgentChat.ts ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Central hook wiring the Vercel AI SDK's useChat with our custom
3
+ * WebSocketChatTransport. Replaces the old useAgentWebSocket + agentStore
4
+ * message management.
5
+ */
6
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
7
+ import { useChat } from '@ai-sdk/react';
8
+ import type { UIMessage } from 'ai';
9
+ import { WebSocketChatTransport, type SideChannelCallbacks } from '@/lib/ws-chat-transport';
10
+ import { loadMessages, saveMessages } from '@/lib/chat-message-store';
11
+ import { apiFetch } from '@/utils/api';
12
+ import { useAgentStore } from '@/store/agentStore';
13
+ import { useSessionStore } from '@/store/sessionStore';
14
+ import { useLayoutStore } from '@/store/layoutStore';
15
+ import { logger } from '@/utils/logger';
16
+
17
+ interface UseAgentChatOptions {
18
+ sessionId: string | null;
19
+ onReady?: () => void;
20
+ onError?: (error: string) => void;
21
+ onSessionDead?: (sessionId: string) => void;
22
+ }
23
+
24
+ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: UseAgentChatOptions) {
25
+ const callbacksRef = useRef({ onReady, onError, onSessionDead });
26
+ callbacksRef.current = { onReady, onError, onSessionDead };
27
+
28
+ const {
29
+ setProcessing,
30
+ setConnected,
31
+ setActivityStatus,
32
+ setError,
33
+ setPanel,
34
+ setPanelOutput,
35
+ } = useAgentStore();
36
+
37
+ const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
38
+ const { setSessionActive } = useSessionStore();
39
+
40
+ // ── Build side-channel callbacks (stable ref) ────────────────────
41
+ const sideChannel = useMemo<SideChannelCallbacks>(
42
+ () => ({
43
+ onReady: () => {
44
+ setConnected(true);
45
+ setProcessing(false);
46
+ if (sessionId) setSessionActive(sessionId, true);
47
+ callbacksRef.current.onReady?.();
48
+ },
49
+ onShutdown: () => {
50
+ setConnected(false);
51
+ setProcessing(false);
52
+ },
53
+ onError: (error: string) => {
54
+ setError(error);
55
+ setProcessing(false);
56
+ callbacksRef.current.onError?.(error);
57
+ },
58
+ onProcessing: () => {
59
+ setProcessing(true);
60
+ setActivityStatus({ type: 'thinking' });
61
+ },
62
+ onProcessingDone: () => {
63
+ setProcessing(false);
64
+ },
65
+ onUndoComplete: () => {
66
+ setProcessing(false);
67
+ // Remove the last turn (user msg + assistant response) from useChat state
68
+ const setMsgs = chatActionsRef.current.setMessages;
69
+ const msgs = chatActionsRef.current.messages;
70
+ if (setMsgs && msgs.length > 0) {
71
+ let lastUserIdx = -1;
72
+ for (let i = msgs.length - 1; i >= 0; i--) {
73
+ if (msgs[i].role === 'user') { lastUserIdx = i; break; }
74
+ }
75
+ const updated = lastUserIdx > 0 ? msgs.slice(0, lastUserIdx) : [];
76
+ setMsgs(updated);
77
+ if (sessionId) saveMessages(sessionId, updated);
78
+ }
79
+ },
80
+ onCompacted: (oldTokens: number, newTokens: number) => {
81
+ logger.log(`Context compacted: ${oldTokens} → ${newTokens} tokens`);
82
+ },
83
+ onPlanUpdate: (plan) => {
84
+ useAgentStore.getState().setPlan(plan as Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }>);
85
+ if (!useLayoutStore.getState().isRightPanelOpen) {
86
+ setRightPanelOpen(true);
87
+ }
88
+ },
89
+ onToolLog: (tool: string, log: string) => {
90
+ if (tool === 'hf_jobs') {
91
+ const state = useAgentStore.getState();
92
+ const existingOutput = state.panelData?.output?.content || '';
93
+ const newContent = existingOutput
94
+ ? existingOutput + '\n' + log
95
+ : '--- Job execution started ---\n' + log;
96
+
97
+ setPanelOutput({ content: newContent, language: 'text' });
98
+
99
+ if (!useLayoutStore.getState().isRightPanelOpen) {
100
+ setRightPanelOpen(true);
101
+ }
102
+ }
103
+ },
104
+ onConnectionChange: (connected: boolean) => {
105
+ setConnected(connected);
106
+ },
107
+ onSessionDead: (deadSessionId: string) => {
108
+ logger.warn(`Session ${deadSessionId} dead, removing`);
109
+ callbacksRef.current.onSessionDead?.(deadSessionId);
110
+ },
111
+ onApprovalRequired: (tools) => {
112
+ if (!tools.length) return;
113
+ setActivityStatus({ type: 'waiting-approval' });
114
+ const firstTool = tools[0];
115
+ const args = firstTool.arguments as Record<string, string | undefined>;
116
+
117
+ if (firstTool.tool === 'hf_jobs' && args.script) {
118
+ setPanel(
119
+ { title: 'Script', script: { content: args.script, language: 'python' }, parameters: firstTool.arguments as Record<string, unknown> },
120
+ 'script',
121
+ true,
122
+ );
123
+ } else if (firstTool.tool === 'hf_repo_files' && args.content) {
124
+ const filename = args.path || 'file';
125
+ setPanel({
126
+ title: filename.split('/').pop() || 'Content',
127
+ script: { content: args.content, language: filename.endsWith('.py') ? 'python' : 'text' },
128
+ parameters: firstTool.arguments as Record<string, unknown>,
129
+ });
130
+ } else {
131
+ setPanel({
132
+ title: firstTool.tool,
133
+ output: { content: JSON.stringify(firstTool.arguments, null, 2), language: 'json' },
134
+ }, 'output');
135
+ }
136
+
137
+ setRightPanelOpen(true);
138
+ setLeftSidebarOpen(false);
139
+ },
140
+ onToolCallPanel: (toolName: string, args: Record<string, unknown>) => {
141
+ if (toolName === 'hf_jobs' && args.operation && args.script) {
142
+ setPanel(
143
+ { title: 'Script', script: { content: String(args.script), language: 'python' }, parameters: args },
144
+ 'script',
145
+ );
146
+ setRightPanelOpen(true);
147
+ setLeftSidebarOpen(false);
148
+ } else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
149
+ setPanel({
150
+ title: `File Upload: ${String(args.path || 'unnamed')}`,
151
+ script: { content: String(args.content), language: String(args.path || '').endsWith('.py') ? 'python' : 'text' },
152
+ parameters: args,
153
+ });
154
+ setRightPanelOpen(true);
155
+ setLeftSidebarOpen(false);
156
+ }
157
+ },
158
+ onToolOutputPanel: (toolName: string, _toolCallId: string, output: string, success: boolean) => {
159
+ if (toolName === 'hf_jobs' && output) {
160
+ setPanelOutput({ content: output, language: 'markdown' });
161
+ if (!success) useAgentStore.getState().setPanelView('output');
162
+ }
163
+ },
164
+ onStreaming: () => {
165
+ setActivityStatus({ type: 'streaming' });
166
+ },
167
+ onToolRunning: (toolName: string) => {
168
+ setActivityStatus({ type: 'tool', toolName });
169
+ },
170
+ }),
171
+ // Zustand setters are stable
172
+ // eslint-disable-next-line react-hooks/exhaustive-deps
173
+ [sessionId],
174
+ );
175
+
176
+ // ── Create transport (single stable instance for the lifetime of this hook) ──
177
+ const transportRef = useRef<WebSocketChatTransport | null>(null);
178
+ if (!transportRef.current) {
179
+ transportRef.current = new WebSocketChatTransport({ sideChannel });
180
+ }
181
+
182
+ // Keep side-channel callbacks in sync (they capture sessionId)
183
+ useEffect(() => {
184
+ transportRef.current?.updateSideChannel(sideChannel);
185
+ }, [sideChannel]);
186
+
187
+ // Connect / disconnect WebSocket when session changes
188
+ useEffect(() => {
189
+ transportRef.current?.connectToSession(sessionId);
190
+ return () => {
191
+ transportRef.current?.connectToSession(null);
192
+ };
193
+ }, [sessionId]);
194
+
195
+ // ── Restore persisted messages for this session ─────────────────
196
+ const initialMessages = useMemo(
197
+ () => (sessionId ? loadMessages(sessionId) : []),
198
+ [sessionId],
199
+ );
200
+
201
+ // ── Ref for chat actions (used by sideChannel callbacks created before chat) ──
202
+ const chatActionsRef = useRef<{
203
+ setMessages: ((msgs: UIMessage[]) => void) | null;
204
+ messages: UIMessage[];
205
+ }>({ setMessages: null, messages: [] });
206
+
207
+ // ── useChat from Vercel AI SDK ───────────────────────────────────
208
+ const chat = useChat({
209
+ id: sessionId || '__no_session__',
210
+ messages: initialMessages,
211
+ transport: transportRef.current!,
212
+ experimental_throttle: 80,
213
+ onFinish: ({ messages, isAbort, isError }) => {
214
+ if (isAbort || isError) return;
215
+ if (sessionId && messages.length > 0) {
216
+ saveMessages(sessionId, messages);
217
+ }
218
+ },
219
+ onError: (error) => {
220
+ logger.error('useChat error:', error);
221
+ setError(error.message);
222
+ setProcessing(false);
223
+ },
224
+ });
225
+
226
+ // Keep chatActionsRef in sync every render
227
+ chatActionsRef.current.setMessages = chat.setMessages;
228
+ chatActionsRef.current.messages = chat.messages;
229
+
230
+ // ── Persist messages on every user send (onFinish covers assistant turns) ──
231
+ const prevLenRef = useRef(initialMessages.length);
232
+ useEffect(() => {
233
+ if (!sessionId || chat.messages.length === 0) return;
234
+ if (chat.messages.length !== prevLenRef.current) {
235
+ prevLenRef.current = chat.messages.length;
236
+ saveMessages(sessionId, chat.messages);
237
+ }
238
+ }, [sessionId, chat.messages]);
239
+
240
+ // ── Undo last turn (calls backend + syncs useChat + localStorage) ──
241
+ const undoLastTurn = useCallback(async () => {
242
+ if (!sessionId) return;
243
+ try {
244
+ const res = await apiFetch(`/api/undo/${sessionId}`, { method: 'POST' });
245
+ if (!res.ok) {
246
+ logger.error('Undo API returned', res.status);
247
+ return;
248
+ }
249
+ } catch (e) {
250
+ logger.error('Undo failed:', e);
251
+ }
252
+ // Backend will also send undo_complete, but we apply optimistically
253
+ // so the UI updates immediately.
254
+ }, [sessionId]);
255
+
256
+ // ── Convenience: approve tools via transport ─────────────────────
257
+ const approveTools = useCallback(
258
+ async (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => {
259
+ if (!sessionId || !transportRef.current) return false;
260
+ const ok = await transportRef.current.approveTools(sessionId, approvals);
261
+ if (ok) {
262
+ const hasApproved = approvals.some(a => a.approved);
263
+ if (hasApproved) setProcessing(true);
264
+ }
265
+ return ok;
266
+ },
267
+ [sessionId, setProcessing],
268
+ );
269
+
270
+ return {
271
+ messages: chat.messages,
272
+ sendMessage: chat.sendMessage,
273
+ status: chat.status,
274
+ undoLastTurn,
275
+ approveTools,
276
+ transport: transportRef.current,
277
+ };
278
+ }
frontend/src/hooks/useAgentWebSocket.ts DELETED
@@ -1,503 +0,0 @@
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,
35
- clearTraceLogs,
36
- setPanelContent,
37
- setPanelTab,
38
- setActivePanelTab,
39
- clearPanelTabs,
40
- setPlan,
41
- setCurrentTurnMessageId,
42
- updateCurrentTurnTrace,
43
- } = useAgentStore();
44
-
45
- const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
46
-
47
- const { setSessionActive } = useSessionStore();
48
-
49
- const handleEvent = useCallback(
50
- (event: AgentEvent) => {
51
- if (!sessionId) return;
52
-
53
- switch (event.event_type) {
54
- case 'ready':
55
- setConnected(true);
56
- setProcessing(false);
57
- setSessionActive(sessionId, true);
58
- onReady?.();
59
- break;
60
-
61
- case 'processing':
62
- setProcessing(true);
63
- clearTraceLogs();
64
- // Don't clear panel tabs here - they should persist during approval flow
65
- // Tabs will be cleared when a new tool_call sets up new content
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;
72
- const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
73
-
74
- if (currentTurnMsgId) {
75
- // Update existing message - add segments chronologically
76
- const messages = useAgentStore.getState().getMessages(sessionId);
77
- const existingMsg = messages.find(m => m.id === currentTurnMsgId);
78
-
79
- if (existingMsg) {
80
- const segments = existingMsg.segments ? [...existingMsg.segments] : [];
81
-
82
- // If there are pending traces, add them as a tools segment first
83
- if (currentTrace.length > 0) {
84
- segments.push({ type: 'tools', tools: [...currentTrace] });
85
- clearTraceLogs();
86
- }
87
-
88
- // Add the new text segment
89
- if (content) {
90
- segments.push({ type: 'text', content });
91
- }
92
-
93
- updateMessage(sessionId, currentTurnMsgId, {
94
- content: existingMsg.content + '\n\n' + content,
95
- segments,
96
- });
97
- }
98
- } else {
99
- // Create new message
100
- const messageId = `msg_${Date.now()}`;
101
- const segments: Array<{ type: 'text' | 'tools'; content?: string; tools?: typeof currentTrace }> = [];
102
-
103
- // Add any pending traces first
104
- if (currentTrace.length > 0) {
105
- segments.push({ type: 'tools', tools: [...currentTrace] });
106
- clearTraceLogs();
107
- }
108
-
109
- // Add the text
110
- if (content) {
111
- segments.push({ type: 'text', content });
112
- }
113
-
114
- const message: Message = {
115
- id: messageId,
116
- role: 'assistant',
117
- content,
118
- timestamp: new Date().toISOString(),
119
- segments,
120
- };
121
- addMessage(sessionId, message);
122
- setCurrentTurnMessageId(messageId);
123
- }
124
- break;
125
- }
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
149
- if (toolName === 'hf_jobs' && (args.operation === 'run' || args.operation === 'scheduled run') && args.script) {
150
- // Clear any existing tabs from previous jobs before setting new script
151
- clearPanelTabs();
152
- // Use tab system for jobs - add script tab immediately
153
- setPanelTab({
154
- id: 'script',
155
- title: 'Script',
156
- content: args.script,
157
- language: 'python',
158
- parameters: args
159
- });
160
- setActivePanelTab('script');
161
- setRightPanelOpen(true);
162
- setLeftSidebarOpen(false);
163
- } else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
164
- setPanelContent({
165
- title: `File Upload: ${args.path || 'unnamed'}`,
166
- content: args.content,
167
- parameters: args,
168
- language: args.path?.endsWith('.py') ? 'python' : undefined
169
- });
170
- setRightPanelOpen(true);
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
-
239
- case 'tool_log': {
240
- const toolName = (event.data?.tool as string) || 'unknown';
241
- const log = (event.data?.log as string) || '';
242
-
243
- if (toolName === 'hf_jobs') {
244
- const currentTabs = useAgentStore.getState().panelTabs;
245
- const logsTab = currentTabs.find(t => t.id === 'logs');
246
-
247
- // Append to existing logs tab or create new one
248
- const newContent = logsTab
249
- ? logsTab.content + '\n' + log
250
- : '--- Job execution started ---\n' + log;
251
-
252
- setPanelTab({
253
- id: 'logs',
254
- title: 'Logs',
255
- content: newContent,
256
- language: 'text'
257
- });
258
-
259
- // Auto-switch to logs tab when logs start streaming
260
- setActivePanelTab('logs');
261
-
262
- if (!useLayoutStore.getState().isRightPanelOpen) {
263
- setRightPanelOpen(true);
264
- }
265
- }
266
- break;
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);
274
- }
275
- break;
276
- }
277
-
278
- case 'approval_required': {
279
- const tools = event.data?.tools as Array<{
280
- tool: string;
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
-
306
- if (firstTool.tool === 'hf_jobs' && args.script) {
307
- setPanelTab({
308
- id: 'script',
309
- title: 'Script',
310
- content: args.script,
311
- language: 'python',
312
- parameters: args
313
- });
314
- setActivePanelTab('script');
315
- } else if (firstTool.tool === 'hf_repo_files' && args.content) {
316
- const filename = args.path || 'file';
317
- const isPython = filename.endsWith('.py');
318
- setPanelTab({
319
- id: 'content',
320
- title: filename.split('/').pop() || 'Content',
321
- content: args.content,
322
- language: isPython ? 'python' : 'text',
323
- parameters: args
324
- });
325
- setActivePanelTab('content');
326
- } else {
327
- // For other tools, show args as JSON
328
- setPanelTab({
329
- id: 'args',
330
- title: firstTool.tool,
331
- content: JSON.stringify(args, null, 2),
332
- language: 'json',
333
- parameters: args
334
- });
335
- setActivePanelTab('args');
336
- }
337
-
338
- setRightPanelOpen(true);
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
- }
350
-
351
- case 'turn_complete':
352
- setProcessing(false);
353
- setCurrentTurnMessageId(null); // Clear the current turn
354
- break;
355
-
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
-
363
- case 'error': {
364
- const errorMsg = (event.data?.error as string) || 'Unknown error';
365
- setError(errorMsg);
366
- setProcessing(false);
367
- onError?.(errorMsg);
368
- break;
369
- }
370
-
371
- case 'shutdown':
372
- setConnected(false);
373
- setProcessing(false);
374
- break;
375
-
376
- case 'interrupted':
377
- setProcessing(false);
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(() => {
394
- if (!sessionId) return;
395
-
396
- // Don't connect if already connected or connecting
397
- if (wsRef.current?.readyState === WebSocket.OPEN ||
398
- wsRef.current?.readyState === WebSocket.CONNECTING) {
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) => {
420
- try {
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);
441
- }
442
- reconnectTimeoutRef.current = window.setTimeout(() => {
443
- reconnectDelayRef.current = Math.min(
444
- reconnectDelayRef.current * 2,
445
- WS_MAX_RECONNECT_DELAY
446
- );
447
- connect();
448
- }, reconnectDelayRef.current);
449
- }
450
- };
451
-
452
- wsRef.current = ws;
453
- }, [sessionId, handleEvent]);
454
-
455
- const disconnect = useCallback(() => {
456
- if (reconnectTimeoutRef.current) {
457
- clearTimeout(reconnectTimeoutRef.current);
458
- reconnectTimeoutRef.current = null;
459
- }
460
- if (wsRef.current) {
461
- wsRef.current.close();
462
- wsRef.current = null;
463
- }
464
- setConnected(false);
465
- }, []);
466
-
467
- const sendPing = useCallback(() => {
468
- if (wsRef.current?.readyState === WebSocket.OPEN) {
469
- wsRef.current.send(JSON.stringify({ type: 'ping' }));
470
- }
471
- }, []);
472
-
473
- // Connect when sessionId changes (with a small delay to ensure session is ready)
474
- useEffect(() => {
475
- if (!sessionId) {
476
- disconnect();
477
- return;
478
- }
479
-
480
- // Small delay to ensure session is fully created on backend
481
- const timeoutId = setTimeout(() => {
482
- connect();
483
- }, 100);
484
-
485
- return () => {
486
- clearTimeout(timeoutId);
487
- disconnect();
488
- };
489
- // eslint-disable-next-line react-hooks/exhaustive-deps
490
- }, [sessionId]);
491
-
492
- // Heartbeat
493
- useEffect(() => {
494
- const interval = setInterval(sendPing, 30000);
495
- return () => clearInterval(interval);
496
- }, [sendPing]);
497
-
498
- return {
499
- isConnected: wsRef.current?.readyState === WebSocket.OPEN,
500
- connect,
501
- disconnect,
502
- };
503
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/hooks/useAuth.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Authentication hook — simple server-side OAuth.
3
+ *
4
+ * - Hors iframe: /auth/login redirect (cookies work fine)
5
+ * - Dans iframe: show "Open in full page" link
6
+ *
7
+ * Token is stored via HttpOnly cookie by the backend.
8
+ * In dev mode (no OAUTH_CLIENT_ID), auth is bypassed.
9
+ */
10
+
11
+ import { useEffect } from 'react';
12
+ import { useAgentStore } from '@/store/agentStore';
13
+ import { logger } from '@/utils/logger';
14
+
15
+ /** Check if we're running inside an iframe. */
16
+ export function isInIframe(): boolean {
17
+ try {
18
+ return window.top !== window.self;
19
+ } catch {
20
+ return true; // SecurityError = cross-origin iframe
21
+ }
22
+ }
23
+
24
+ /** Redirect to the server-side OAuth login. */
25
+ export function triggerLogin(): void {
26
+ window.location.href = '/auth/login';
27
+ }
28
+
29
+ /**
30
+ * Hook: on mount, check if user is authenticated.
31
+ * Sets user in the agent store.
32
+ */
33
+ export function useAuth() {
34
+ const setUser = useAgentStore((s) => s.setUser);
35
+
36
+ useEffect(() => {
37
+ let cancelled = false;
38
+
39
+ async function checkAuth() {
40
+ try {
41
+ // Check if user is already authenticated (cookie-based)
42
+ const response = await fetch('/auth/me', { credentials: 'include' });
43
+ if (response.ok) {
44
+ const data = await response.json();
45
+ if (!cancelled && data.authenticated) {
46
+ setUser({
47
+ authenticated: true,
48
+ username: data.username,
49
+ name: data.name,
50
+ picture: data.picture,
51
+ });
52
+ logger.log('Authenticated as', data.username);
53
+ return;
54
+ }
55
+ }
56
+
57
+ // Not authenticated — check if auth is enabled
58
+ const statusRes = await fetch('/auth/status', { credentials: 'include' });
59
+ const statusData = await statusRes.json();
60
+ if (!statusData.auth_enabled) {
61
+ // Dev mode — no OAuth configured
62
+ if (!cancelled) setUser({ authenticated: true, username: 'dev' });
63
+ return;
64
+ }
65
+
66
+ // Auth enabled but not logged in — welcome screen will handle it
67
+ if (!cancelled) setUser(null);
68
+ } catch {
69
+ // Backend unreachable — assume dev mode
70
+ if (!cancelled) setUser({ authenticated: true, username: 'dev' });
71
+ }
72
+ }
73
+
74
+ checkAuth();
75
+ return () => { cancelled = true; };
76
+ }, [setUser]);
77
+ }
frontend/src/lib/chat-message-store.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Lightweight localStorage persistence for UIMessage arrays,
3
+ * keyed by session ID.
4
+ *
5
+ * Uses the same storage namespace (`hf-agent-messages`) that the
6
+ * old Zustand-based store used, so existing data is compatible.
7
+ */
8
+ import type { UIMessage } from 'ai';
9
+ import { logger } from '@/utils/logger';
10
+
11
+ const STORAGE_KEY = 'hf-agent-messages';
12
+ const MAX_SESSIONS = 50;
13
+
14
+ type MessagesMap = Record<string, UIMessage[]>;
15
+
16
+ function readAll(): MessagesMap {
17
+ try {
18
+ const raw = localStorage.getItem(STORAGE_KEY);
19
+ if (!raw) return {};
20
+ const parsed = JSON.parse(raw);
21
+ // Legacy format was { messagesBySession: {...} }
22
+ if (parsed.messagesBySession) return parsed.messagesBySession;
23
+ // New flat format
24
+ if (typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
25
+ return {};
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function writeAll(map: MessagesMap): void {
32
+ try {
33
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
34
+ } catch (e) {
35
+ logger.warn('Failed to persist messages:', e);
36
+ }
37
+ }
38
+
39
+ export function loadMessages(sessionId: string): UIMessage[] {
40
+ const map = readAll();
41
+ return map[sessionId] ?? [];
42
+ }
43
+
44
+ export function saveMessages(sessionId: string, messages: UIMessage[]): void {
45
+ const map = readAll();
46
+ map[sessionId] = messages;
47
+
48
+ // Evict oldest sessions if we exceed the cap
49
+ const keys = Object.keys(map);
50
+ if (keys.length > MAX_SESSIONS) {
51
+ const toRemove = keys.slice(0, keys.length - MAX_SESSIONS);
52
+ for (const k of toRemove) delete map[k];
53
+ }
54
+
55
+ writeAll(map);
56
+ }
57
+
58
+ export function deleteMessages(sessionId: string): void {
59
+ const map = readAll();
60
+ delete map[sessionId];
61
+ writeAll(map);
62
+ }
frontend/src/lib/ws-chat-transport.ts ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Custom ChatTransport that bridges our WebSocket-based backend protocol
3
+ * to the Vercel AI SDK's UIMessageChunk streaming interface.
4
+ *
5
+ * The backend stays unchanged — this adapter translates WebSocket events
6
+ * into the chunk types that useChat() expects.
7
+ */
8
+ import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from 'ai';
9
+ import { apiFetch, getWebSocketUrl } from '@/utils/api';
10
+ import { logger } from '@/utils/logger';
11
+ import type { AgentEvent } from '@/types/events';
12
+ import { useAgentStore } from '@/store/agentStore';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Side-channel callback interface (non-chat events forwarded to the store)
16
+ // ---------------------------------------------------------------------------
17
+ export interface SideChannelCallbacks {
18
+ onReady: () => void;
19
+ onShutdown: () => void;
20
+ onError: (error: string) => void;
21
+ onProcessing: () => void;
22
+ onProcessingDone: () => void;
23
+ onUndoComplete: () => void;
24
+ onCompacted: (oldTokens: number, newTokens: number) => void;
25
+ onPlanUpdate: (plan: Array<{ id: string; content: string; status: string }>) => void;
26
+ onToolLog: (tool: string, log: string) => void;
27
+ onConnectionChange: (connected: boolean) => void;
28
+ onSessionDead: (sessionId: string) => void;
29
+ /** Called when approval_required arrives — lets the store manage panels */
30
+ onApprovalRequired: (tools: Array<{ tool: string; arguments: Record<string, unknown>; tool_call_id: string }>) => void;
31
+ /** Called when a tool_call arrives with panel-relevant args */
32
+ onToolCallPanel: (tool: string, args: Record<string, unknown>) => void;
33
+ /** Called when tool_output arrives with panel-relevant data */
34
+ onToolOutputPanel: (tool: string, toolCallId: string, output: string, success: boolean) => void;
35
+ /** Called when assistant text starts streaming */
36
+ onStreaming: () => void;
37
+ /** Called when a tool starts running (non-plan) */
38
+ onToolRunning: (toolName: string) => void;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Transport options
43
+ // ---------------------------------------------------------------------------
44
+ export interface WebSocketChatTransportOptions {
45
+ sideChannel: SideChannelCallbacks;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Constants
50
+ // ---------------------------------------------------------------------------
51
+ const WS_RECONNECT_DELAY = 1000;
52
+ const WS_MAX_RECONNECT_DELAY = 30000;
53
+ const WS_MAX_RETRIES = 5;
54
+ const WS_PING_INTERVAL = 30000;
55
+
56
+ let partIdCounter = 0;
57
+ function nextPartId(prefix: string): string {
58
+ return `${prefix}-${Date.now()}-${++partIdCounter}`;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Transport implementation
63
+ // ---------------------------------------------------------------------------
64
+ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
65
+ private ws: WebSocket | null = null;
66
+ private currentSessionId: string | null = null;
67
+ private sideChannel: SideChannelCallbacks;
68
+
69
+ private streamController: ReadableStreamDefaultController<UIMessageChunk> | null = null;
70
+ private streamGeneration = 0;
71
+ private abortedGeneration = 0;
72
+ private textPartId: string | null = null;
73
+ private awaitingProcessing = false;
74
+
75
+ private connectTimeout: ReturnType<typeof setTimeout> | null = null;
76
+ private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
77
+ private reconnectDelay = WS_RECONNECT_DELAY;
78
+ private retries = 0;
79
+ private pingInterval: ReturnType<typeof setInterval> | null = null;
80
+ private boundVisibilityHandler: (() => void) | null = null;
81
+ private wasHidden = false;
82
+
83
+ constructor({ sideChannel }: WebSocketChatTransportOptions) {
84
+ this.sideChannel = sideChannel;
85
+ this.setupVisibilityHandler();
86
+ }
87
+
88
+ private setupVisibilityHandler(): void {
89
+ this.boundVisibilityHandler = () => {
90
+ if (document.visibilityState === 'hidden') {
91
+ this.wasHidden = true;
92
+ return;
93
+ }
94
+
95
+ if (document.visibilityState === 'visible' && this.currentSessionId) {
96
+ const wsState = this.ws?.readyState;
97
+ if (wsState !== WebSocket.OPEN && wsState !== WebSocket.CONNECTING) {
98
+ logger.log('Tab visible: WS is dead, reconnecting immediately');
99
+ this.retries = 0;
100
+ this.reconnectDelay = WS_RECONNECT_DELAY;
101
+ this.createWebSocket(this.currentSessionId);
102
+
103
+ if (this.wasHidden) {
104
+ const store = useAgentStore.getState();
105
+ if (store.isProcessing) {
106
+ logger.log('Tab visible after WS drop: resetting stale processing state');
107
+ store.setProcessing(false);
108
+ this.closeActiveStream();
109
+ }
110
+ }
111
+ } else if (wsState === WebSocket.OPEN) {
112
+ this.ws!.send(JSON.stringify({ type: 'ping' }));
113
+ }
114
+ this.wasHidden = false;
115
+ }
116
+ };
117
+ document.addEventListener('visibilitychange', this.boundVisibilityHandler);
118
+ }
119
+
120
+ /** Update side-channel callbacks (e.g. when sessionId changes). */
121
+ updateSideChannel(sideChannel: SideChannelCallbacks): void {
122
+ this.sideChannel = sideChannel;
123
+ }
124
+
125
+ // ── Public API ──────────────────────────────────────────────────────
126
+
127
+ /** Connect (or reconnect) to a session's WebSocket. */
128
+ connectToSession(sessionId: string | null): void {
129
+ if (this.connectTimeout) {
130
+ clearTimeout(this.connectTimeout);
131
+ this.connectTimeout = null;
132
+ }
133
+ this.disconnectWebSocket();
134
+ this.currentSessionId = sessionId;
135
+ if (sessionId) {
136
+ this.retries = 0;
137
+ this.reconnectDelay = WS_RECONNECT_DELAY;
138
+ this.connectTimeout = setTimeout(() => {
139
+ this.connectTimeout = null;
140
+ if (this.currentSessionId === sessionId) {
141
+ this.createWebSocket(sessionId);
142
+ }
143
+ }, 100);
144
+ }
145
+ }
146
+
147
+ /** Approve / reject tools. Called directly from the UI. */
148
+ async approveTools(
149
+ sessionId: string,
150
+ approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>,
151
+ ): Promise<boolean> {
152
+ try {
153
+ const res = await apiFetch('/api/approve', {
154
+ method: 'POST',
155
+ body: JSON.stringify({ session_id: sessionId, approvals }),
156
+ });
157
+ return res.ok;
158
+ } catch (e) {
159
+ logger.error('Approval request failed:', e);
160
+ return false;
161
+ }
162
+ }
163
+
164
+ /** Clean up everything. */
165
+ destroy(): void {
166
+ if (this.connectTimeout) {
167
+ clearTimeout(this.connectTimeout);
168
+ this.connectTimeout = null;
169
+ }
170
+ if (this.boundVisibilityHandler) {
171
+ document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
172
+ this.boundVisibilityHandler = null;
173
+ }
174
+ this.disconnectWebSocket();
175
+ this.closeActiveStream();
176
+ }
177
+
178
+ // ── ChatTransport interface ─────────────────────────────────────────
179
+
180
+ async sendMessages(
181
+ options: {
182
+ trigger: 'submit-message' | 'regenerate-message';
183
+ chatId: string;
184
+ messageId: string | undefined;
185
+ messages: UIMessage[];
186
+ abortSignal: AbortSignal | undefined;
187
+ } & ChatRequestOptions,
188
+ ): Promise<ReadableStream<UIMessageChunk>> {
189
+ const sessionId = options.chatId;
190
+
191
+ // Close any previously active stream (e.g. user sent new msg during approval)
192
+ this.closeActiveStream();
193
+
194
+ // Track generation to protect against late cancel from a stale stream
195
+ const gen = ++this.streamGeneration;
196
+ logger.log(`sendMessages: gen=${gen}, awaitingProcessing=${this.awaitingProcessing}, abortedGen=${this.abortedGeneration}`);
197
+
198
+ // Wire up abort signal to interrupt the backend and close the stream
199
+ if (options.abortSignal) {
200
+ const onAbort = () => {
201
+ if (this.streamGeneration !== gen) return;
202
+ logger.log(`Stream aborted by user (gen=${gen})`);
203
+ this.interruptBackend(sessionId);
204
+ this.endTextPart();
205
+ if (this.streamController) {
206
+ this.enqueue({ type: 'finish-step' });
207
+ this.enqueue({ type: 'finish', finishReason: 'stop' });
208
+ this.closeActiveStream();
209
+ }
210
+ this.awaitingProcessing = true;
211
+ this.abortedGeneration = this.streamGeneration;
212
+ logger.log(`Abort complete: awaitingProcessing=true, abortedGen=${this.abortedGeneration}`);
213
+ this.sideChannel.onProcessingDone();
214
+ };
215
+ if (options.abortSignal.aborted) {
216
+ onAbort();
217
+ } else {
218
+ options.abortSignal.addEventListener('abort', onAbort, { once: true });
219
+ }
220
+ }
221
+
222
+ // Create the stream BEFORE the POST so WebSocket events arriving
223
+ // while the HTTP request is in-flight are captured immediately.
224
+ const stream = new ReadableStream<UIMessageChunk>({
225
+ start: (controller) => {
226
+ this.streamController = controller;
227
+ this.textPartId = null;
228
+ },
229
+ cancel: () => {
230
+ if (this.streamGeneration === gen) {
231
+ this.streamController = null;
232
+ this.textPartId = null;
233
+ }
234
+ },
235
+ });
236
+
237
+ // Extract the latest user text from the messages array
238
+ const lastUserMsg = [...options.messages].reverse().find(m => m.role === 'user');
239
+ const text = lastUserMsg
240
+ ? lastUserMsg.parts
241
+ .filter((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text')
242
+ .map(p => p.text)
243
+ .join('')
244
+ : '';
245
+
246
+ // POST to the existing backend endpoint
247
+ try {
248
+ await apiFetch('/api/submit', {
249
+ method: 'POST',
250
+ body: JSON.stringify({ session_id: sessionId, text }),
251
+ });
252
+ } catch (e) {
253
+ logger.error('Submit failed:', e);
254
+ this.enqueue({ type: 'error', errorText: 'Failed to send message' });
255
+ this.closeActiveStream();
256
+ }
257
+
258
+ return stream;
259
+ }
260
+
261
+ async reconnectToStream(): Promise<ReadableStream<UIMessageChunk> | null> {
262
+ return null;
263
+ }
264
+
265
+ /** Ask the backend to interrupt the current generation. Fire-and-forget. */
266
+ private interruptBackend(sessionId: string): void {
267
+ apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch((e) =>
268
+ logger.warn('Interrupt request failed:', e),
269
+ );
270
+ }
271
+
272
+ // ── WebSocket lifecycle ─────────────────────────────────────────────
273
+
274
+ private createWebSocket(sessionId: string): void {
275
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
276
+ return;
277
+ }
278
+
279
+ const wsUrl = getWebSocketUrl(sessionId);
280
+ logger.log('WS transport connecting:', wsUrl);
281
+ const ws = new WebSocket(wsUrl);
282
+
283
+ ws.onopen = () => {
284
+ logger.log('WS transport connected');
285
+ this.sideChannel.onConnectionChange(true);
286
+ this.reconnectDelay = WS_RECONNECT_DELAY;
287
+ this.retries = 0;
288
+ this.startPing();
289
+ };
290
+
291
+ ws.onmessage = (evt) => {
292
+ try {
293
+ const raw = JSON.parse(evt.data);
294
+ if (raw.type === 'pong') return;
295
+ this.handleEvent(raw as AgentEvent);
296
+ } catch (e) {
297
+ logger.error('WS parse error:', e);
298
+ }
299
+ };
300
+
301
+ ws.onerror = (err) => logger.error('WS error:', err);
302
+
303
+ ws.onclose = (evt) => {
304
+ logger.log('WS closed', evt.code, evt.reason);
305
+ this.sideChannel.onConnectionChange(false);
306
+ this.stopPing();
307
+
308
+ const noRetry = [1000, 4001, 4003, 4004];
309
+ if (evt.code === 4004 && sessionId) {
310
+ this.sideChannel.onSessionDead(sessionId);
311
+ return;
312
+ }
313
+ if (!noRetry.includes(evt.code) && this.currentSessionId === sessionId) {
314
+ this.retries += 1;
315
+ if (this.retries > WS_MAX_RETRIES) {
316
+ logger.warn('WS max retries reached');
317
+ this.sideChannel.onSessionDead(sessionId);
318
+ return;
319
+ }
320
+ this.reconnectTimeout = setTimeout(() => {
321
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, WS_MAX_RECONNECT_DELAY);
322
+ this.createWebSocket(sessionId);
323
+ }, this.reconnectDelay);
324
+ }
325
+ };
326
+
327
+ this.ws = ws;
328
+ }
329
+
330
+ private disconnectWebSocket(): void {
331
+ if (this.reconnectTimeout) {
332
+ clearTimeout(this.reconnectTimeout);
333
+ this.reconnectTimeout = null;
334
+ }
335
+ this.stopPing();
336
+ if (this.ws) {
337
+ this.ws.close();
338
+ this.ws = null;
339
+ }
340
+ this.sideChannel.onConnectionChange(false);
341
+ }
342
+
343
+ private startPing(): void {
344
+ this.stopPing();
345
+ this.pingInterval = setInterval(() => {
346
+ if (this.ws?.readyState === WebSocket.OPEN) {
347
+ this.ws.send(JSON.stringify({ type: 'ping' }));
348
+ }
349
+ }, WS_PING_INTERVAL);
350
+ }
351
+
352
+ private stopPing(): void {
353
+ if (this.pingInterval) {
354
+ clearInterval(this.pingInterval);
355
+ this.pingInterval = null;
356
+ }
357
+ }
358
+
359
+ // ── Stream helpers ──────────────────────────────────────────────────
360
+
361
+ private closeActiveStream(): void {
362
+ if (this.streamController) {
363
+ try {
364
+ this.streamController.close();
365
+ } catch {
366
+ // already closed
367
+ }
368
+ this.streamController = null;
369
+ this.textPartId = null;
370
+ }
371
+ }
372
+
373
+ private enqueue(chunk: UIMessageChunk): void {
374
+ try {
375
+ this.streamController?.enqueue(chunk);
376
+ } catch {
377
+ // stream already closed
378
+ }
379
+ }
380
+
381
+ private endTextPart(): void {
382
+ if (this.textPartId) {
383
+ this.enqueue({ type: 'text-end', id: this.textPartId });
384
+ this.textPartId = null;
385
+ }
386
+ }
387
+
388
+ // ── Event → UIMessageChunk mapping ──────────────────────────────────
389
+
390
+ private static readonly STREAM_EVENTS = new Set([
391
+ 'assistant_chunk', 'assistant_stream_end', 'assistant_message',
392
+ 'tool_call', 'tool_output', 'approval_required', 'tool_state_change',
393
+ 'turn_complete', 'error',
394
+ ]);
395
+
396
+ private handleEvent(event: AgentEvent): void {
397
+ // After an abort, ignore stale stream events until the next 'processing'
398
+ if (this.awaitingProcessing && WebSocketChatTransport.STREAM_EVENTS.has(event.event_type)) {
399
+ logger.log(`Filtering stale "${event.event_type}" (gen=${this.streamGeneration}, aborted=${this.abortedGeneration})`);
400
+ return;
401
+ }
402
+
403
+ switch (event.event_type) {
404
+ // ── Side-channel only events ────────────────────────────────
405
+ case 'ready':
406
+ this.sideChannel.onReady();
407
+ break;
408
+
409
+ case 'shutdown':
410
+ this.sideChannel.onShutdown();
411
+ this.closeActiveStream();
412
+ break;
413
+
414
+ case 'interrupted':
415
+ // Don't close the stream here — the abort handler already did, and
416
+ // a new stream for the next user message may already exist.
417
+ // Closing here would destroy the NEWER stream, causing the next
418
+ // response to be silently dropped.
419
+ this.sideChannel.onProcessingDone();
420
+ break;
421
+
422
+ case 'undo_complete':
423
+ this.endTextPart();
424
+ this.closeActiveStream();
425
+ this.sideChannel.onUndoComplete();
426
+ break;
427
+
428
+ case 'compacted':
429
+ this.sideChannel.onCompacted(
430
+ (event.data?.old_tokens as number) || 0,
431
+ (event.data?.new_tokens as number) || 0,
432
+ );
433
+ break;
434
+
435
+ case 'plan_update':
436
+ this.sideChannel.onPlanUpdate(
437
+ (event.data?.plan as Array<{ id: string; content: string; status: string }>) || [],
438
+ );
439
+ break;
440
+
441
+ case 'tool_log':
442
+ this.sideChannel.onToolLog(
443
+ (event.data?.tool as string) || '',
444
+ (event.data?.log as string) || '',
445
+ );
446
+ break;
447
+
448
+ // ── Chat stream events ──────────────────────────────────────
449
+ case 'processing':
450
+ if (this.awaitingProcessing) {
451
+ if (this.streamGeneration <= this.abortedGeneration) {
452
+ logger.log(`Ignoring stale "processing" (gen=${this.streamGeneration} <= aborted=${this.abortedGeneration})`);
453
+ break;
454
+ }
455
+ logger.log(`Accepting "processing" for new generation (gen=${this.streamGeneration}, aborted=${this.abortedGeneration})`);
456
+ this.awaitingProcessing = false;
457
+ }
458
+ this.sideChannel.onProcessing();
459
+ if (this.streamController) {
460
+ this.enqueue({
461
+ type: 'start',
462
+ messageMetadata: { createdAt: new Date().toISOString() },
463
+ });
464
+ this.enqueue({ type: 'start-step' });
465
+ }
466
+ break;
467
+
468
+ case 'assistant_chunk': {
469
+ const delta = (event.data?.content as string) || '';
470
+ if (!delta || !this.streamController) break;
471
+
472
+ if (!this.textPartId) {
473
+ this.textPartId = nextPartId('text');
474
+ this.enqueue({ type: 'text-start', id: this.textPartId });
475
+ this.sideChannel.onStreaming();
476
+ }
477
+ this.enqueue({ type: 'text-delta', id: this.textPartId, delta });
478
+ break;
479
+ }
480
+
481
+ case 'assistant_stream_end':
482
+ this.endTextPart();
483
+ break;
484
+
485
+ case 'assistant_message': {
486
+ const content = (event.data?.content as string) || '';
487
+ if (!content || !this.streamController) break;
488
+ const id = nextPartId('text');
489
+ this.enqueue({ type: 'text-start', id });
490
+ this.enqueue({ type: 'text-delta', id, delta: content });
491
+ this.enqueue({ type: 'text-end', id });
492
+ break;
493
+ }
494
+
495
+ case 'tool_call': {
496
+ if (!this.streamController) break;
497
+ const toolName = (event.data?.tool as string) || 'unknown';
498
+ const toolCallId = (event.data?.tool_call_id as string) || '';
499
+ const args = (event.data?.arguments as Record<string, unknown>) || {};
500
+
501
+ if (toolName === 'plan_tool') break;
502
+
503
+ this.endTextPart();
504
+ this.enqueue({ type: 'tool-input-start', toolCallId, toolName, dynamic: true });
505
+ this.enqueue({ type: 'tool-input-available', toolCallId, toolName, input: args, dynamic: true });
506
+
507
+ this.sideChannel.onToolRunning(toolName);
508
+ this.sideChannel.onToolCallPanel(toolName, args as Record<string, unknown>);
509
+ break;
510
+ }
511
+
512
+ case 'tool_output': {
513
+ if (!this.streamController) break;
514
+ const toolCallId = (event.data?.tool_call_id as string) || '';
515
+ const output = (event.data?.output as string) || '';
516
+ const success = event.data?.success as boolean;
517
+ const toolName = (event.data?.tool as string) || '';
518
+
519
+ if (toolName === 'plan_tool' || toolCallId.startsWith('plan_tool')) break;
520
+
521
+ if (success) {
522
+ this.enqueue({ type: 'tool-output-available', toolCallId, output, dynamic: true });
523
+ } else {
524
+ this.enqueue({ type: 'tool-output-error', toolCallId, errorText: output, dynamic: true });
525
+ }
526
+
527
+ this.sideChannel.onToolOutputPanel(toolName, toolCallId, output, success);
528
+ break;
529
+ }
530
+
531
+ case 'approval_required': {
532
+ const tools = event.data?.tools as Array<{
533
+ tool: string;
534
+ arguments: Record<string, unknown>;
535
+ tool_call_id: string;
536
+ }>;
537
+ if (!tools || !this.streamController) break;
538
+
539
+ this.endTextPart();
540
+
541
+ for (const t of tools) {
542
+ this.enqueue({ type: 'tool-input-start', toolCallId: t.tool_call_id, toolName: t.tool, dynamic: true });
543
+ this.enqueue({ type: 'tool-input-available', toolCallId: t.tool_call_id, toolName: t.tool, input: t.arguments, dynamic: true });
544
+ this.enqueue({ type: 'tool-approval-request', approvalId: `approval-${t.tool_call_id}`, toolCallId: t.tool_call_id });
545
+ }
546
+
547
+ this.sideChannel.onApprovalRequired(tools);
548
+ this.sideChannel.onProcessingDone();
549
+ break;
550
+ }
551
+
552
+ case 'tool_state_change': {
553
+ const tcId = (event.data?.tool_call_id as string) || '';
554
+ const state = (event.data?.state as string) || '';
555
+ const jobUrl = (event.data?.jobUrl as string) || undefined;
556
+
557
+ if (tcId.startsWith('plan_tool')) break;
558
+
559
+ if (jobUrl && tcId) {
560
+ useAgentStore.getState().setJobUrl(tcId, jobUrl);
561
+ }
562
+
563
+ if (this.streamController && (state === 'rejected' || state === 'abandoned')) {
564
+ this.enqueue({ type: 'tool-output-denied', toolCallId: tcId });
565
+ }
566
+ break;
567
+ }
568
+
569
+ case 'turn_complete':
570
+ this.endTextPart();
571
+ if (this.streamController) {
572
+ this.enqueue({ type: 'finish-step' });
573
+ this.enqueue({ type: 'finish', finishReason: 'stop' });
574
+ this.closeActiveStream();
575
+ }
576
+ this.sideChannel.onProcessingDone();
577
+ break;
578
+
579
+ case 'error': {
580
+ const errorMsg = (event.data?.error as string) || 'Unknown error';
581
+ this.sideChannel.onError(errorMsg);
582
+ if (this.streamController) {
583
+ this.enqueue({ type: 'error', errorText: errorMsg });
584
+ }
585
+ this.sideChannel.onProcessingDone();
586
+ break;
587
+ }
588
+
589
+ default:
590
+ logger.log('WS transport: unknown event', event);
591
+ }
592
+ }
593
+ }
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,16 @@
 
 
 
 
 
 
 
 
 
 
 
1
  import { create } from 'zustand';
2
- import type { Message, ApprovalBatch, User, TraceLog } from '@/types/agent';
3
 
4
  export interface PlanItem {
5
  id: string;
@@ -7,254 +18,158 @@ export interface PlanItem {
7
  status: 'pending' | 'in_progress' | 'completed';
8
  }
9
 
10
- interface PanelTab {
11
- id: string;
12
- title: string;
13
  content: string;
14
- language?: string;
15
- parameters?: any;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
 
 
 
 
 
 
 
 
18
  interface AgentStore {
19
- // State per session (keyed by session ID)
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[];
31
- currentTurnMessageId: string | null; // Track the current turn's assistant message
 
 
 
 
 
32
 
33
  // Actions
34
- addMessage: (sessionId: string, message: Message) => void;
35
- updateMessage: (sessionId: string, messageId: string, updates: Partial<Message>) => void;
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;
50
- removePanelTab: (tabId: string) => void;
51
  setPlan: (plan: PlanItem[]) => void;
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: [],
67
- activePanelTab: null,
68
- plan: [],
69
- currentTurnMessageId: null,
70
-
71
- addMessage: (sessionId: string, message: Message) => {
72
- set((state) => {
73
- const currentMessages = state.messagesBySession[sessionId] || [];
74
- return {
75
- messagesBySession: {
76
- ...state.messagesBySession,
77
- [sessionId]: [...currentMessages, message],
78
- },
79
- };
80
- });
81
- },
82
 
83
- updateMessage: (sessionId: string, messageId: string, updates: Partial<Message>) => {
84
- set((state) => {
85
- const currentMessages = state.messagesBySession[sessionId] || [];
86
- const updatedMessages = currentMessages.map((msg) =>
87
- msg.id === messageId ? { ...msg, ...updates } : msg
88
- );
89
- return {
90
- messagesBySession: {
91
- ...state.messagesBySession,
92
- [sessionId]: updatedMessages,
93
- },
94
- };
95
- });
96
- },
97
 
98
- clearMessages: (sessionId: string) => {
99
- set((state) => ({
100
- messagesBySession: {
101
- ...state.messagesBySession,
102
- [sessionId]: [],
103
- },
104
- }));
105
- },
106
 
107
- setProcessing: (isProcessing: boolean) => {
108
- set({ isProcessing });
109
- },
110
 
111
- setConnected: (isConnected: boolean) => {
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
- },
122
 
123
- setError: (error: string | null) => {
124
- set({ error });
125
- },
 
 
126
 
127
- getMessages: (sessionId: string) => {
128
- return get().messagesBySession[sessionId] || [];
129
- },
130
 
131
- addTraceLog: (log: TraceLog) => {
132
- set((state) => ({
133
- traceLogs: [...state.traceLogs, log],
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 };
148
- });
149
- },
150
 
151
- clearTraceLogs: () => {
152
- set({ traceLogs: [] });
153
- },
154
 
155
- setPanelContent: (content) => {
156
- set({ panelContent: content });
157
- },
158
 
159
- setPanelTab: (tab: PanelTab) => {
160
- set((state) => {
161
- const existingIndex = state.panelTabs.findIndex(t => t.id === tab.id);
162
- let newTabs: PanelTab[];
163
- if (existingIndex >= 0) {
164
- // Update existing tab
165
- newTabs = [...state.panelTabs];
166
- newTabs[existingIndex] = tab;
167
- } else {
168
- // Add new tab
169
- newTabs = [...state.panelTabs, tab];
170
- }
171
- return {
172
- panelTabs: newTabs,
173
- activePanelTab: state.activePanelTab || tab.id, // Auto-select first tab
174
- };
175
- });
176
- },
177
 
178
- setActivePanelTab: (tabId: string) => {
179
- set({ activePanelTab: tabId });
180
- },
181
 
182
- clearPanelTabs: () => {
183
- set({ panelTabs: [], activePanelTab: null });
184
- },
185
 
186
- removePanelTab: (tabId: string) => {
187
- set((state) => {
188
- const newTabs = state.panelTabs.filter(t => t.id !== tabId);
189
- // If we removed the active tab, switch to another tab or null
190
- let newActiveTab = state.activePanelTab;
191
- if (state.activePanelTab === tabId) {
192
- newActiveTab = newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null;
193
- }
194
- return {
195
- panelTabs: newTabs,
196
- activePanelTab: newActiveTab,
197
- };
198
- });
199
  },
200
 
201
- setPlan: (plan: PlanItem[]) => {
202
- set({ plan });
203
- },
204
 
205
- setCurrentTurnMessageId: (id: string | null) => {
206
- set({ currentTurnMessageId: id });
207
- },
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) => {
228
- // Show tool output in the right panel - only ONE tool output tab at a time
229
- const state = get();
230
-
231
- // Determine language based on content
232
- let language = 'text';
233
- const content = log.output || '';
234
-
235
- // Check if content looks like JSON
236
- if (content.trim().startsWith('{') || content.trim().startsWith('[') || content.includes('```json')) {
237
- language = 'json';
238
- }
239
- // Check if content has markdown tables or formatting
240
- else if (content.includes('|') && content.includes('---') || content.includes('```')) {
241
- language = 'markdown';
242
- }
243
-
244
- // Remove any existing tool output tab (only keep one)
245
- const otherTabs = state.panelTabs.filter(t => t.id !== 'tool_output');
246
-
247
- // Create/replace the single tool output tab
248
- const newTab = {
249
- id: 'tool_output',
250
- title: log.tool,
251
- content: content || 'No output available',
252
- language,
253
- };
254
-
255
- set({
256
- panelTabs: [...otherTabs, newTab],
257
- activePanelTab: 'tool_output',
258
- });
259
  },
 
 
260
  }));
 
1
+ /**
2
+ * Agent store — manages UI state that is NOT handled by the Vercel AI SDK.
3
+ *
4
+ * Message state (messages, streaming, tool calls) is now managed by useChat().
5
+ * This store only handles:
6
+ * - Connection / processing flags
7
+ * - Panel state (right panel — single-artifact pattern)
8
+ * - Plan state
9
+ * - User info / error banners
10
+ * - Edited scripts (for hf_jobs code editing)
11
+ */
12
  import { create } from 'zustand';
13
+ import type { User } from '@/types/agent';
14
 
15
  export interface PlanItem {
16
  id: string;
 
18
  status: 'pending' | 'in_progress' | 'completed';
19
  }
20
 
21
+ export interface PanelSection {
 
 
22
  content: string;
23
+ language: string;
24
+ }
25
+
26
+ export interface PanelData {
27
+ title: string;
28
+ script?: PanelSection;
29
+ output?: PanelSection;
30
+ parameters?: Record<string, unknown>;
31
+ }
32
+
33
+ export type PanelView = 'script' | 'output';
34
+
35
+ export interface LLMHealthError {
36
+ error: string;
37
+ errorType: 'auth' | 'credits' | 'rate_limit' | 'network' | 'unknown';
38
+ model: string;
39
  }
40
 
41
+ export type ActivityStatus =
42
+ | { type: 'idle' }
43
+ | { type: 'thinking' }
44
+ | { type: 'tool'; toolName: string }
45
+ | { type: 'waiting-approval' }
46
+ | { type: 'streaming' };
47
+
48
  interface AgentStore {
49
+ // Global UI flags
 
50
  isProcessing: boolean;
51
  isConnected: boolean;
52
+ activityStatus: ActivityStatus;
53
  user: User | null;
54
  error: string | null;
55
+ llmHealthError: LLMHealthError | null;
56
+
57
+ // Right panel (single-artifact pattern)
58
+ panelData: PanelData | null;
59
+ panelView: PanelView;
60
+ panelEditable: boolean;
61
+
62
+ // Plan
63
  plan: PlanItem[];
64
+
65
+ // Edited scripts (tool_call_id -> edited content)
66
+ editedScripts: Record<string, string>;
67
+
68
+ // Job URLs (tool_call_id -> job URL) for HF jobs
69
+ jobUrls: Record<string, string>;
70
 
71
  // Actions
 
 
 
72
  setProcessing: (isProcessing: boolean) => void;
73
  setConnected: (isConnected: boolean) => void;
74
+ setActivityStatus: (status: ActivityStatus) => void;
75
  setUser: (user: User | null) => void;
76
  setError: (error: string | null) => void;
77
+ setLlmHealthError: (error: LLMHealthError | null) => void;
78
+
79
+ setPanel: (data: PanelData, view?: PanelView, editable?: boolean) => void;
80
+ setPanelView: (view: PanelView) => void;
81
+ setPanelOutput: (output: PanelSection) => void;
82
+ updatePanelScript: (content: string) => void;
83
+ lockPanel: () => void;
84
+ clearPanel: () => void;
85
+
86
  setPlan: (plan: PlanItem[]) => void;
87
+
88
+ setEditedScript: (toolCallId: string, content: string) => void;
89
+ getEditedScript: (toolCallId: string) => string | undefined;
90
+ clearEditedScripts: () => void;
91
+
92
+ setJobUrl: (toolCallId: string, jobUrl: string) => void;
93
+ getJobUrl: (toolCallId: string) => string | undefined;
94
  }
95
 
96
+ export const useAgentStore = create<AgentStore>()((set, get) => ({
 
97
  isProcessing: false,
98
  isConnected: false,
99
+ activityStatus: { type: 'idle' },
100
  user: null,
101
  error: null,
102
+ llmHealthError: null,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
+ panelData: null,
105
+ panelView: 'script',
106
+ panelEditable: false,
 
 
 
 
 
 
 
 
 
 
 
107
 
108
+ plan: [],
 
 
 
 
 
 
 
109
 
110
+ editedScripts: {},
111
+ jobUrls: {},
 
112
 
113
+ // ── Global flags ──────────────────────────────────────────────────
 
 
114
 
115
+ setProcessing: (isProcessing) => {
116
+ const current = get().activityStatus;
117
+ const preserveStatus = current.type === 'waiting-approval';
118
+ set({ isProcessing, ...(!isProcessing && !preserveStatus ? { activityStatus: { type: 'idle' } } : {}) });
119
  },
120
+ setConnected: (isConnected) => set({ isConnected }),
121
+ setActivityStatus: (status) => set({ activityStatus: status }),
122
+ setUser: (user) => set({ user }),
123
+ setError: (error) => set({ error }),
124
+ setLlmHealthError: (error) => set({ llmHealthError: error }),
125
 
126
+ // ── Panel (single-artifact) ───────────────────────────────────────
 
 
127
 
128
+ setPanel: (data, view, editable) => set({
129
+ panelData: data,
130
+ panelView: view ?? (data.script ? 'script' : 'output'),
131
+ panelEditable: editable ?? false,
132
+ }),
133
 
134
+ setPanelView: (view) => set({ panelView: view }),
 
 
135
 
136
+ setPanelOutput: (output) => set((state) => ({
137
+ panelData: state.panelData ? { ...state.panelData, output } : null,
138
+ })),
 
 
139
 
140
+ updatePanelScript: (content) => set((state) => ({
141
+ panelData: state.panelData?.script
142
+ ? { ...state.panelData, script: { ...state.panelData.script, content } }
143
+ : state.panelData,
144
+ })),
 
 
 
 
 
 
 
 
145
 
146
+ lockPanel: () => set({ panelEditable: false }),
 
 
147
 
148
+ clearPanel: () => set({ panelData: null, panelView: 'script', panelEditable: false }),
 
 
149
 
150
+ // ── Plan ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
+ setPlan: (plan) => set({ plan }),
 
 
153
 
154
+ // ── Edited scripts ────────────────────────────────────────────────
 
 
155
 
156
+ setEditedScript: (toolCallId, content) => {
157
+ set((state) => ({
158
+ editedScripts: { ...state.editedScripts, [toolCallId]: content },
159
+ }));
 
 
 
 
 
 
 
 
 
160
  },
161
 
162
+ getEditedScript: (toolCallId) => get().editedScripts[toolCallId],
 
 
163
 
164
+ clearEditedScripts: () => set({ editedScripts: {} }),
 
 
165
 
166
+ // ── Job URLs ────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
+ setJobUrl: (toolCallId, jobUrl) => {
169
+ set((state) => ({
170
+ jobUrls: { ...state.jobUrls, [toolCallId]: jobUrl },
171
+ }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  },
173
+
174
+ getJobUrl: (toolCallId) => get().jobUrls[toolCallId],
175
  }));
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,7 @@ 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 +53,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 { deleteMessages } from '@/lib/chat-message-store';
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
+ deleteMessages(id);
39
  set((state) => {
40
  const newSessions = state.sessions.filter((s) => s.id !== id);
41
  const newActiveId =
 
53
  set({ activeSessionId: id });
54
  },
55
 
56
+ setSessionActive: (id: string, isActive: boolean) => {
57
  set((state) => ({
58
  sessions: state.sessions.map((s) =>
59
+ s.id === id ? { ...s, isActive } : s
60
  ),
61
  }));
62
  },
63
 
64
+ updateSessionTitle: (id: string, title: string) => {
65
  set((state) => ({
66
  sessions: state.sessions.map((s) =>
67
+ s.id === id ? { ...s, title } : s
68
  ),
69
  }));
70
  },
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' },
 
 
 
 
 
 
 
 
 
 
 
 
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', light: '#FFB740', dark: '#E08C00', contrastText: '#fff' },
180
+ secondary: { main: '#FF9D00' },
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', light: '#FFB740', dark: '#E08C00', contrastText: '#fff' },
203
+ secondary: { main: '#E08C00' },
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
@@ -1,7 +1,15 @@
1
  /**
2
- * Agent-related types
 
 
 
3
  */
4
 
 
 
 
 
 
5
  export interface SessionMeta {
6
  id: string;
7
  title: string;
@@ -9,61 +17,12 @@ export interface SessionMeta {
9
  isActive: boolean;
10
  }
11
 
12
- export interface MessageSegment {
13
- type: 'text' | 'tools';
14
- content?: string;
15
- tools?: TraceLog[];
16
- }
17
-
18
- export interface Message {
19
- id: string;
20
- role: 'user' | 'assistant' | 'tool';
21
- content: string;
22
- timestamp: string;
23
- segments?: MessageSegment[];
24
- approval?: {
25
- status: 'pending' | 'approved' | 'rejected';
26
- batch: ApprovalBatch;
27
- decisions?: ToolApproval[];
28
- };
29
- toolOutput?: string;
30
- }
31
-
32
- export interface ToolCall {
33
- id: string;
34
- tool: string;
35
- arguments: Record<string, unknown>;
36
- status: 'pending' | 'running' | 'completed' | 'failed';
37
- output?: string;
38
- }
39
-
40
  export interface ToolApproval {
41
  tool_call_id: string;
42
  approved: boolean;
43
  feedback?: string | null;
44
  }
45
 
46
- export interface ApprovalBatch {
47
- tools: Array<{
48
- tool: string;
49
- arguments: Record<string, unknown>;
50
- tool_call_id: string;
51
- }>;
52
- count: number;
53
- }
54
-
55
- export interface TraceLog {
56
- id: string;
57
- type: 'call' | 'output';
58
- text: string;
59
- tool: string;
60
- timestamp: string;
61
- completed?: boolean;
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 {
68
  authenticated: boolean;
69
  username?: string;
 
1
  /**
2
+ * Agent-related types.
3
+ *
4
+ * Message and tool-call types are now provided by the Vercel AI SDK
5
+ * (UIMessage, UIMessagePart, etc.). Only non-SDK types remain here.
6
  */
7
 
8
+ /** Custom metadata attached to every UIMessage via the `metadata` field. */
9
+ export interface MessageMeta {
10
+ createdAt?: string;
11
+ }
12
+
13
  export interface SessionMeta {
14
  id: string;
15
  title: string;
 
17
  isActive: boolean;
18
  }
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  export interface ToolApproval {
21
  tool_call_id: string;
22
  approved: boolean;
23
  feedback?: string | null;
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  export interface User {
27
  authenticated: boolean;
28
  username?: string;
frontend/src/types/events.ts CHANGED
@@ -6,10 +6,13 @@ export type EventType =
6
  | 'ready'
7
  | 'processing'
8
  | 'assistant_message'
 
 
9
  | 'tool_call'
10
  | 'tool_output'
11
  | 'tool_log'
12
  | 'approval_required'
 
13
  | 'turn_complete'
14
  | 'compacted'
15
  | 'error'
 
6
  | 'ready'
7
  | 'processing'
8
  | 'assistant_message'
9
+ | 'assistant_chunk'
10
+ | 'assistant_stream_end'
11
  | 'tool_call'
12
  | 'tool_output'
13
  | 'tool_log'
14
  | 'approval_required'
15
+ | 'tool_state_change'
16
  | 'turn_complete'
17
  | 'compacted'
18
  | 'error'
frontend/src/utils/api.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Centralized API utilities.
3
+ *
4
+ * In production: HttpOnly cookie (hf_access_token) is sent automatically.
5
+ * In development: auth is bypassed on the backend.
6
+ */
7
+
8
+ import { triggerLogin } from '@/hooks/useAuth';
9
+
10
+ /** Wrapper around fetch with credentials and common headers. */
11
+ export async function apiFetch(
12
+ path: string,
13
+ options: RequestInit = {}
14
+ ): Promise<Response> {
15
+ const headers: Record<string, string> = {
16
+ 'Content-Type': 'application/json',
17
+ ...(options.headers as Record<string, string>),
18
+ };
19
+
20
+ const response = await fetch(path, {
21
+ ...options,
22
+ headers,
23
+ credentials: 'include', // Send cookies with every request
24
+ });
25
+
26
+ // Handle 401 — redirect to login
27
+ if (response.status === 401) {
28
+ try {
29
+ const authStatus = await fetch('/auth/status', { credentials: 'include' });
30
+ const data = await authStatus.json();
31
+ if (data.auth_enabled) {
32
+ triggerLogin();
33
+ throw new Error('Authentication required — redirecting to login.');
34
+ }
35
+ } catch (e) {
36
+ if (e instanceof Error && e.message.includes('redirecting')) throw e;
37
+ }
38
+ }
39
+
40
+ return response;
41
+ }
42
+
43
+ /** Build the WebSocket URL for a session. */
44
+ export function getWebSocketUrl(sessionId: string): string {
45
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
46
+ return `${protocol}//${window.location.host}/api/ws/${sessionId}`;
47
+ }
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
+ };