Aksel Joonas Reedi commited on
Commit
7b5b0b5
Β·
2 Parent(s): 296c38434fe7a0

Cherry-picked today's changes from HF Space development:

Browse files
agent/context_manager/manager.py CHANGED
@@ -243,6 +243,25 @@ class ContextManager:
243
 
244
  return False
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  async def compact(
247
  self, model_name: str, tool_specs: list[dict] | None = None
248
  ) -> None:
 
243
 
244
  return False
245
 
246
+ def truncate_to_user_message(self, user_message_index: int) -> bool:
247
+ """Truncate history to just before the Nth user message (0-indexed).
248
+
249
+ Removes that user message and everything after it.
250
+ System message (index 0) is never removed.
251
+
252
+ Returns True if the target user message was found and removed.
253
+ """
254
+ count = 0
255
+ for i, msg in enumerate(self.items):
256
+ if i == 0:
257
+ continue # skip system message
258
+ if getattr(msg, "role", None) == "user":
259
+ if count == user_message_index:
260
+ self.items = self.items[:i]
261
+ return True
262
+ count += 1
263
+ return False
264
+
265
  async def compact(
266
  self, model_name: str, tool_specs: list[dict] | None = None
267
  ) -> None:
agent/prompts/system_prompt_v3.yaml CHANGED
@@ -15,6 +15,12 @@ system_prompt: |
15
 
16
  The sub-agent knows how to use github_find_examples, github_read_file, explore_hf_docs, fetch_hf_docs, hf_inspect_dataset, and hf_papers. Be specific in your task description.
17
 
 
 
 
 
 
 
18
  You can also call research tools directly (explore_hf_docs, github_read_file, etc.) for quick lookups.
19
 
20
  Skip research only for trivial non-code operations.
 
15
 
16
  The sub-agent knows how to use github_find_examples, github_read_file, explore_hf_docs, fetch_hf_docs, hf_inspect_dataset, and hf_papers. Be specific in your task description.
17
 
18
+ When researching an ML task, include a SOTA check: tell the research sub-agent to search for recent papers on the task or technique to find what approaches, architectures, and hyperparameters are currently achieving the best results. This prevents you from using outdated methods when better ones exist.
19
+
20
+ ```
21
+ research({"task": "Find SOTA approaches for [task]. Search recent papers for best-performing methods, key hyperparameters, and tricks. Also find working code examples using current TRL/Transformers APIs.", "context": "User wants to [goal]."})
22
+ ```
23
+
24
  You can also call research tools directly (explore_hf_docs, github_read_file, etc.) for quick lookups.
25
 
26
  Skip research only for trivial non-code operations.
agent/tools/papers_tool.py CHANGED
@@ -2,11 +2,14 @@
2
  HF Papers Tool β€” Discover papers, read their contents, and find linked resources.
3
 
4
  Operations: trending, search, paper_details, read_paper,
5
- find_datasets, find_models, find_collections, find_all_resources
 
6
  """
7
 
8
  import asyncio
 
9
  import re
 
10
  from typing import Any
11
 
12
  import httpx
@@ -30,6 +33,101 @@ SORT_MAP = {
30
  "trending": "trendingScore",
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  # ---------------------------------------------------------------------------
35
  # HTML paper parsing
@@ -193,7 +291,7 @@ def _format_paper_list(
193
  return "\n".join(lines)
194
 
195
 
196
- def _format_paper_detail(paper: dict) -> str:
197
  arxiv_id = paper.get("id", "")
198
  title = paper.get("title", "Unknown")
199
  upvotes = paper.get("upvotes", 0)
@@ -205,7 +303,12 @@ def _format_paper_detail(paper: dict) -> str:
205
  authors = paper.get("authors") or []
206
 
207
  lines = [f"# {title}"]
208
- lines.append(f"**arxiv_id:** {arxiv_id} | **upvotes:** {upvotes}")
 
 
 
 
 
209
  lines.append(f"https://huggingface.co/papers/{arxiv_id}")
210
  lines.append(f"https://arxiv.org/abs/{arxiv_id}")
211
 
@@ -218,16 +321,27 @@ def _format_paper_detail(paper: dict) -> str:
218
 
219
  if keywords:
220
  lines.append(f"**Keywords:** {', '.join(keywords)}")
 
 
 
 
 
 
221
  if github:
222
  lines.append(f"**GitHub:** {github} ({stars} stars)")
223
 
 
 
 
 
224
  if ai_summary:
225
  lines.append(f"\n## AI Summary\n{ai_summary}")
226
  if summary:
227
  lines.append(f"\n## Abstract\n{_truncate(summary, 500)}")
228
 
229
  lines.append(
230
- "\n**Next:** Use read_paper to read specific sections, or find_all_resources to discover linked datasets/models."
 
231
  )
232
  return "\n".join(lines)
233
 
@@ -441,11 +555,101 @@ async def _op_trending(args: dict[str, Any], limit: int) -> ToolResult:
441
  }
442
 
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  async def _op_search(args: dict[str, Any], limit: int) -> ToolResult:
445
  query = args.get("query")
446
  if not query:
447
  return _error("'query' is required for search operation.")
448
 
 
 
 
 
 
 
 
 
449
  async with httpx.AsyncClient(timeout=15) as client:
450
  resp = await client.get(
451
  f"{HF_API}/papers/search", params={"q": query, "limit": limit}
@@ -545,6 +749,108 @@ async def _op_read_paper(args: dict[str, Any], limit: int) -> ToolResult:
545
  return {"formatted": formatted, "totalResults": 1, "resultsShared": 1}
546
 
547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  async def _op_find_datasets(args: dict[str, Any], limit: int) -> ToolResult:
549
  arxiv_id = _validate_arxiv_id(args)
550
  if not arxiv_id:
@@ -703,6 +1009,136 @@ async def _op_find_all_resources(args: dict[str, Any], limit: int) -> ToolResult
703
  return {"formatted": formatted, "totalResults": total, "resultsShared": total}
704
 
705
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  # ---------------------------------------------------------------------------
707
  # Operation dispatch
708
  # ---------------------------------------------------------------------------
@@ -712,6 +1148,9 @@ _OPERATIONS = {
712
  "search": _op_search,
713
  "paper_details": _op_paper_details,
714
  "read_paper": _op_read_paper,
 
 
 
715
  "find_datasets": _op_find_datasets,
716
  "find_models": _op_find_models,
717
  "find_collections": _op_find_collections,
@@ -726,22 +1165,25 @@ _OPERATIONS = {
726
  HF_PAPERS_TOOL_SPEC = {
727
  "name": "hf_papers",
728
  "description": (
729
- "Discover ML research papers, find their linked resources (datasets, models, collections), "
730
- "and read paper contents on HuggingFace Hub and arXiv.\n\n"
731
- "Use this when exploring a research area, looking for datasets for a task, "
732
- "implementing a paper's approach, or trying to improve performance on something. "
733
- "Typical flow:\n"
734
- " hf_papers(search/trending) β†’ hf_papers(read_paper) β†’ hf_papers(find_all_resources) β†’ hf_inspect_dataset\n\n"
 
735
  "Operations:\n"
736
  "- trending: Get trending daily papers, optionally filter by topic keyword\n"
737
- "- search: Full-text search for papers by query\n"
738
- "- paper_details: Get metadata, abstract, AI summary, and github link for a paper\n"
739
- "- read_paper: Read paper contents β€” without section: returns abstract + table of contents; "
740
- "with section: returns full section text\n"
 
 
741
  "- find_datasets: Find datasets linked to a paper\n"
742
  "- find_models: Find models linked to a paper\n"
743
  "- find_collections: Find collections that include a paper\n"
744
- "- find_all_resources: Parallel fetch of datasets + models + collections for a paper (unified view)"
745
  ),
746
  "parameters": {
747
  "type": "object",
@@ -754,36 +1196,69 @@ HF_PAPERS_TOOL_SPEC = {
754
  "query": {
755
  "type": "string",
756
  "description": (
757
- "Search query. Required for: search. "
758
- "Optional for: trending (filters results by keyword match on title, summary, and AI-generated keywords)."
 
759
  ),
760
  },
761
  "arxiv_id": {
762
  "type": "string",
763
  "description": (
764
  "ArXiv paper ID (e.g. '2305.18290'). "
765
- "Required for: paper_details, read_paper, find_datasets, find_models, find_collections, find_all_resources. "
766
- "Get IDs from trending or search results first."
767
  ),
768
  },
769
  "section": {
770
  "type": "string",
771
  "description": (
772
  "Section name or number to read (e.g. '3', 'Experiments', '4.2'). "
773
- "Optional for: read_paper. Without this, read_paper returns the abstract + table of contents "
774
- "so you can choose which section to read."
775
  ),
776
  },
 
 
 
 
 
777
  "date": {
778
  "type": "string",
779
  "description": "Date in YYYY-MM-DD format. Optional for: trending (defaults to recent papers).",
780
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  "sort": {
782
  "type": "string",
783
  "enum": ["downloads", "likes", "trending"],
784
  "description": (
785
- "Sort order for find_datasets and find_models. Default: downloads. "
786
- "Use 'downloads' for most-used, 'likes' for community favorites, 'trending' for recently popular."
787
  ),
788
  },
789
  "limit": {
 
2
  HF Papers Tool β€” Discover papers, read their contents, and find linked resources.
3
 
4
  Operations: trending, search, paper_details, read_paper,
5
+ find_datasets, find_models, find_collections, find_all_resources,
6
+ citation_graph, snippet_search, recommend
7
  """
8
 
9
  import asyncio
10
+ import os
11
  import re
12
+ import time
13
  from typing import Any
14
 
15
  import httpx
 
33
  "trending": "trendingScore",
34
  }
35
 
36
+ # ---------------------------------------------------------------------------
37
+ # Semantic Scholar API
38
+ # ---------------------------------------------------------------------------
39
+
40
+ S2_API = "https://api.semanticscholar.org"
41
+ S2_API_KEY = os.environ.get("S2_API_KEY")
42
+ S2_HEADERS: dict[str, str] = {"x-api-key": S2_API_KEY} if S2_API_KEY else {}
43
+ S2_TIMEOUT = 12
44
+ _s2_last_request: float = 0.0
45
+
46
+ # Shared response cache (survives across sessions, keyed by (path, params_tuple))
47
+ _s2_cache: dict[str, Any] = {}
48
+ _S2_CACHE_MAX = 500
49
+
50
+
51
+ def _s2_paper_id(arxiv_id: str) -> str:
52
+ """Convert bare arxiv ID to S2 format."""
53
+ return f"ARXIV:{arxiv_id}"
54
+
55
+
56
+ def _s2_cache_key(path: str, params: dict | None) -> str:
57
+ """Build a hashable cache key from path + sorted params."""
58
+ p = tuple(sorted((params or {}).items()))
59
+ return f"{path}:{p}"
60
+
61
+
62
+ async def _s2_request(
63
+ client: httpx.AsyncClient,
64
+ method: str,
65
+ path: str,
66
+ **kwargs: Any,
67
+ ) -> httpx.Response | None:
68
+ """S2 request with 2 retries on 429/5xx. Rate-limited only when using API key."""
69
+ global _s2_last_request
70
+ url = f"{S2_API}{path}"
71
+ kwargs.setdefault("headers", {}).update(S2_HEADERS)
72
+ kwargs.setdefault("timeout", S2_TIMEOUT)
73
+
74
+ for attempt in range(3):
75
+ # Rate limit only when authenticated (1 req/s for search, 10 req/s for others)
76
+ if S2_API_KEY:
77
+ min_interval = 1.0 if "search" in path else 0.1
78
+ elapsed = time.monotonic() - _s2_last_request
79
+ if elapsed < min_interval:
80
+ await asyncio.sleep(min_interval - elapsed)
81
+ _s2_last_request = time.monotonic()
82
+
83
+ try:
84
+ resp = await client.request(method, url, **kwargs)
85
+ if resp.status_code == 429:
86
+ if attempt < 2:
87
+ await asyncio.sleep(60)
88
+ continue
89
+ return None
90
+ if resp.status_code >= 500:
91
+ if attempt < 2:
92
+ await asyncio.sleep(3)
93
+ continue
94
+ return None
95
+ return resp
96
+ except (httpx.RequestError, httpx.HTTPStatusError):
97
+ if attempt < 2:
98
+ await asyncio.sleep(3)
99
+ continue
100
+ return None
101
+ return None
102
+
103
+
104
+ async def _s2_get_json(
105
+ client: httpx.AsyncClient, path: str, params: dict | None = None,
106
+ ) -> dict | None:
107
+ """Cached S2 GET returning parsed JSON or None."""
108
+ key = _s2_cache_key(path, params)
109
+ if key in _s2_cache:
110
+ return _s2_cache[key]
111
+
112
+ resp = await _s2_request(client, "GET", path, params=params or {})
113
+ if resp and resp.status_code == 200:
114
+ data = resp.json()
115
+ if len(_s2_cache) < _S2_CACHE_MAX:
116
+ _s2_cache[key] = data
117
+ return data
118
+ return None
119
+
120
+
121
+ async def _s2_get_paper(
122
+ client: httpx.AsyncClient, arxiv_id: str, fields: str,
123
+ ) -> dict | None:
124
+ """Fetch a single paper from S2 by arxiv ID. Returns None on failure."""
125
+ return await _s2_get_json(
126
+ client,
127
+ f"/graph/v1/paper/{_s2_paper_id(arxiv_id)}",
128
+ {"fields": fields},
129
+ )
130
+
131
 
132
  # ---------------------------------------------------------------------------
133
  # HTML paper parsing
 
291
  return "\n".join(lines)
292
 
293
 
294
+ def _format_paper_detail(paper: dict, s2_data: dict | None = None) -> str:
295
  arxiv_id = paper.get("id", "")
296
  title = paper.get("title", "Unknown")
297
  upvotes = paper.get("upvotes", 0)
 
303
  authors = paper.get("authors") or []
304
 
305
  lines = [f"# {title}"]
306
+ meta_parts = [f"**arxiv_id:** {arxiv_id}", f"**upvotes:** {upvotes}"]
307
+ if s2_data:
308
+ cites = s2_data.get("citationCount", 0)
309
+ influential = s2_data.get("influentialCitationCount", 0)
310
+ meta_parts.append(f"**citations:** {cites} ({influential} influential)")
311
+ lines.append(" | ".join(meta_parts))
312
  lines.append(f"https://huggingface.co/papers/{arxiv_id}")
313
  lines.append(f"https://arxiv.org/abs/{arxiv_id}")
314
 
 
321
 
322
  if keywords:
323
  lines.append(f"**Keywords:** {', '.join(keywords)}")
324
+ if s2_data and s2_data.get("s2FieldsOfStudy"):
325
+ fields = [f["category"] for f in s2_data["s2FieldsOfStudy"] if f.get("category")]
326
+ if fields:
327
+ lines.append(f"**Fields:** {', '.join(fields)}")
328
+ if s2_data and s2_data.get("venue"):
329
+ lines.append(f"**Venue:** {s2_data['venue']}")
330
  if github:
331
  lines.append(f"**GitHub:** {github} ({stars} stars)")
332
 
333
+ if s2_data and s2_data.get("tldr"):
334
+ tldr_text = s2_data["tldr"].get("text", "")
335
+ if tldr_text:
336
+ lines.append(f"\n## TL;DR\n{tldr_text}")
337
  if ai_summary:
338
  lines.append(f"\n## AI Summary\n{ai_summary}")
339
  if summary:
340
  lines.append(f"\n## Abstract\n{_truncate(summary, 500)}")
341
 
342
  lines.append(
343
+ "\n**Next:** Use read_paper to read specific sections, find_all_resources for linked datasets/models, "
344
+ "or citation_graph to trace references and citations."
345
  )
346
  return "\n".join(lines)
347
 
 
555
  }
556
 
557
 
558
+ def _format_s2_paper_list(papers: list[dict], title: str) -> str:
559
+ """Format a list of S2 paper results."""
560
+ lines = [f"# {title}"]
561
+ lines.append(f"Showing {len(papers)} result(s)\n")
562
+
563
+ for i, paper in enumerate(papers, 1):
564
+ ptitle = paper.get("title") or "(untitled)"
565
+ year = paper.get("year") or "?"
566
+ cites = paper.get("citationCount", 0)
567
+ venue = paper.get("venue") or ""
568
+ ext_ids = paper.get("externalIds") or {}
569
+ aid = ext_ids.get("ArXiv", "")
570
+ tldr = (paper.get("tldr") or {}).get("text", "")
571
+
572
+ lines.append(f"### {i}. {ptitle}")
573
+ meta = [f"Year: {year}", f"Citations: {cites}"]
574
+ if venue:
575
+ meta.append(f"Venue: {venue}")
576
+ if aid:
577
+ meta.append(f"arxiv_id: {aid}")
578
+ lines.append(" | ".join(meta))
579
+ if aid:
580
+ lines.append(f"https://arxiv.org/abs/{aid}")
581
+ if tldr:
582
+ lines.append(f"**TL;DR:** {tldr}")
583
+ lines.append("")
584
+
585
+ lines.append("Use paper_details with arxiv_id for full info, or read_paper to read sections.")
586
+ return "\n".join(lines)
587
+
588
+
589
+ async def _s2_bulk_search(query: str, args: dict[str, Any], limit: int) -> ToolResult | None:
590
+ """Search via S2 bulk endpoint with filters. Returns None on failure."""
591
+ params: dict[str, Any] = {
592
+ "query": query,
593
+ "limit": limit,
594
+ "fields": "title,externalIds,year,citationCount,tldr,venue,publicationDate",
595
+ }
596
+
597
+ # Date filter
598
+ date_from = args.get("date_from", "")
599
+ date_to = args.get("date_to", "")
600
+ if date_from or date_to:
601
+ params["publicationDateOrYear"] = f"{date_from}:{date_to}"
602
+
603
+ # Fields of study
604
+ categories = args.get("categories")
605
+ if categories:
606
+ params["fieldsOfStudy"] = categories
607
+
608
+ # Min citations
609
+ min_cites = args.get("min_citations")
610
+ if min_cites:
611
+ params["minCitationCount"] = str(min_cites)
612
+
613
+ # Sort
614
+ sort_by = args.get("sort_by")
615
+ if sort_by and sort_by != "relevance":
616
+ params["sort"] = f"{sort_by}:desc"
617
+
618
+ async with httpx.AsyncClient(timeout=15) as client:
619
+ resp = await _s2_request(client, "GET", "/graph/v1/paper/search/bulk", params=params)
620
+ if not resp or resp.status_code != 200:
621
+ return None
622
+ data = resp.json()
623
+
624
+ papers = data.get("data") or []
625
+ if not papers:
626
+ return {
627
+ "formatted": f"No papers found for '{query}' with the given filters.",
628
+ "totalResults": 0,
629
+ "resultsShared": 0,
630
+ }
631
+
632
+ formatted = _format_s2_paper_list(papers[:limit], f"Papers matching '{query}' (Semantic Scholar)")
633
+ return {
634
+ "formatted": formatted,
635
+ "totalResults": data.get("total", len(papers)),
636
+ "resultsShared": min(limit, len(papers)),
637
+ }
638
+
639
+
640
  async def _op_search(args: dict[str, Any], limit: int) -> ToolResult:
641
  query = args.get("query")
642
  if not query:
643
  return _error("'query' is required for search operation.")
644
 
645
+ # Route to S2 when filters are present
646
+ use_s2 = any(args.get(k) for k in ("date_from", "date_to", "categories", "min_citations", "sort_by"))
647
+ if use_s2:
648
+ result = await _s2_bulk_search(query, args, limit)
649
+ if result is not None:
650
+ return result
651
+ # Fall back to HF search (without filters) if S2 fails
652
+
653
  async with httpx.AsyncClient(timeout=15) as client:
654
  resp = await client.get(
655
  f"{HF_API}/papers/search", params={"q": query, "limit": limit}
 
749
  return {"formatted": formatted, "totalResults": 1, "resultsShared": 1}
750
 
751
 
752
+ # ---------------------------------------------------------------------------
753
+ # Citation graph (Semantic Scholar)
754
+ # ---------------------------------------------------------------------------
755
+
756
+
757
+ def _format_citation_entry(entry: dict, show_context: bool = False) -> str:
758
+ """Format a single citation/reference entry."""
759
+ paper = entry.get("citingPaper") or entry.get("citedPaper") or {}
760
+ title = paper.get("title") or "(untitled)"
761
+ year = paper.get("year") or "?"
762
+ cites = paper.get("citationCount", 0)
763
+ ext_ids = paper.get("externalIds") or {}
764
+ aid = ext_ids.get("ArXiv", "")
765
+ influential = " **[influential]**" if entry.get("isInfluential") else ""
766
+
767
+ parts = [f"- **{title}** ({year}, {cites} cites){influential}"]
768
+ if aid:
769
+ parts[0] += f" arxiv:{aid}"
770
+
771
+ if show_context:
772
+ intents = entry.get("intents") or []
773
+ if intents:
774
+ parts.append(f" Intent: {', '.join(intents)}")
775
+ contexts = entry.get("contexts") or []
776
+ for ctx in contexts[:2]:
777
+ if ctx:
778
+ parts.append(f" > {_truncate(ctx, 200)}")
779
+
780
+ return "\n".join(parts)
781
+
782
+
783
+ def _format_citation_graph(
784
+ arxiv_id: str,
785
+ references: list[dict] | None,
786
+ citations: list[dict] | None,
787
+ ) -> str:
788
+ lines = [f"# Citation Graph for {arxiv_id}"]
789
+ lines.append(f"https://arxiv.org/abs/{arxiv_id}\n")
790
+
791
+ if references is not None:
792
+ lines.append(f"## References ({len(references)})")
793
+ if references:
794
+ for entry in references:
795
+ lines.append(_format_citation_entry(entry))
796
+ else:
797
+ lines.append("No references found.")
798
+ lines.append("")
799
+
800
+ if citations is not None:
801
+ lines.append(f"## Citations ({len(citations)})")
802
+ if citations:
803
+ for entry in citations:
804
+ lines.append(_format_citation_entry(entry, show_context=True))
805
+ else:
806
+ lines.append("No citations found.")
807
+ lines.append("")
808
+
809
+ lines.append("**Tip:** Use paper_details with an arxiv_id from above to explore further.")
810
+ return "\n".join(lines)
811
+
812
+
813
+ async def _op_citation_graph(args: dict[str, Any], limit: int) -> ToolResult:
814
+ arxiv_id = _validate_arxiv_id(args)
815
+ if not arxiv_id:
816
+ return _error("'arxiv_id' is required for citation_graph.")
817
+
818
+ direction = args.get("direction", "both")
819
+ s2_id = _s2_paper_id(arxiv_id)
820
+ fields = "title,externalIds,year,citationCount,influentialCitationCount,contexts,intents,isInfluential"
821
+ params = {"fields": fields, "limit": limit}
822
+
823
+ async with httpx.AsyncClient(timeout=15) as client:
824
+ refs, cites = None, None
825
+ coros = []
826
+ if direction in ("references", "both"):
827
+ coros.append(_s2_get_json(client, f"/graph/v1/paper/{s2_id}/references", params))
828
+ if direction in ("citations", "both"):
829
+ coros.append(_s2_get_json(client, f"/graph/v1/paper/{s2_id}/citations", params))
830
+
831
+ results = await asyncio.gather(*coros, return_exceptions=True)
832
+ idx = 0
833
+ if direction in ("references", "both"):
834
+ r = results[idx]
835
+ if isinstance(r, dict):
836
+ refs = r.get("data", [])
837
+ idx += 1
838
+ if direction in ("citations", "both"):
839
+ r = results[idx]
840
+ if isinstance(r, dict):
841
+ cites = r.get("data", [])
842
+
843
+ if refs is None and cites is None:
844
+ return _error(f"Could not fetch citation data for {arxiv_id}. Paper may not be indexed by Semantic Scholar.")
845
+
846
+ total = (len(refs) if refs else 0) + (len(cites) if cites else 0)
847
+ return {
848
+ "formatted": _format_citation_graph(arxiv_id, refs, cites),
849
+ "totalResults": total,
850
+ "resultsShared": total,
851
+ }
852
+
853
+
854
  async def _op_find_datasets(args: dict[str, Any], limit: int) -> ToolResult:
855
  arxiv_id = _validate_arxiv_id(args)
856
  if not arxiv_id:
 
1009
  return {"formatted": formatted, "totalResults": total, "resultsShared": total}
1010
 
1011
 
1012
+ # ---------------------------------------------------------------------------
1013
+ # Snippet search (Semantic Scholar)
1014
+ # ---------------------------------------------------------------------------
1015
+
1016
+
1017
+ def _format_snippets(snippets: list[dict], query: str) -> str:
1018
+ lines = [f"# Snippet Search: '{query}'"]
1019
+ lines.append(f"Found {len(snippets)} matching passage(s)\n")
1020
+
1021
+ for i, item in enumerate(snippets, 1):
1022
+ paper = item.get("paper") or {}
1023
+ ptitle = paper.get("title") or "(untitled)"
1024
+ year = paper.get("year") or "?"
1025
+ cites = paper.get("citationCount", 0)
1026
+ ext_ids = paper.get("externalIds") or {}
1027
+ aid = ext_ids.get("ArXiv", "")
1028
+
1029
+ snippet = item.get("snippet") or {}
1030
+ text = snippet.get("text", "")
1031
+ section = snippet.get("section") or ""
1032
+
1033
+ lines.append(f"### {i}. {ptitle} ({year}, {cites} cites)")
1034
+ if aid:
1035
+ lines.append(f"arxiv:{aid}")
1036
+ if section:
1037
+ lines.append(f"Section: {section}")
1038
+ if text:
1039
+ lines.append(f"> {_truncate(text, 400)}")
1040
+ lines.append("")
1041
+
1042
+ lines.append("Use paper_details or read_paper with arxiv_id to explore a paper further.")
1043
+ return "\n".join(lines)
1044
+
1045
+
1046
+ async def _op_snippet_search(args: dict[str, Any], limit: int) -> ToolResult:
1047
+ query = args.get("query")
1048
+ if not query:
1049
+ return _error("'query' is required for snippet_search.")
1050
+
1051
+ params: dict[str, Any] = {
1052
+ "query": query,
1053
+ "limit": limit,
1054
+ "fields": "title,externalIds,year,citationCount",
1055
+ }
1056
+
1057
+ # Optional filters (same as search)
1058
+ date_from = args.get("date_from", "")
1059
+ date_to = args.get("date_to", "")
1060
+ if date_from or date_to:
1061
+ params["publicationDateOrYear"] = f"{date_from}:{date_to}"
1062
+ if args.get("categories"):
1063
+ params["fieldsOfStudy"] = args["categories"]
1064
+ if args.get("min_citations"):
1065
+ params["minCitationCount"] = str(args["min_citations"])
1066
+
1067
+ async with httpx.AsyncClient(timeout=15) as client:
1068
+ resp = await _s2_request(client, "GET", "/graph/v1/snippet/search", params=params)
1069
+ if not resp or resp.status_code != 200:
1070
+ return _error("Snippet search failed. Semantic Scholar may be unavailable.")
1071
+ data = resp.json()
1072
+
1073
+ snippets = data.get("data") or []
1074
+ if not snippets:
1075
+ return {
1076
+ "formatted": f"No snippets found for '{query}'.",
1077
+ "totalResults": 0,
1078
+ "resultsShared": 0,
1079
+ }
1080
+
1081
+ return {
1082
+ "formatted": _format_snippets(snippets, query),
1083
+ "totalResults": len(snippets),
1084
+ "resultsShared": len(snippets),
1085
+ }
1086
+
1087
+
1088
+ # ---------------------------------------------------------------------------
1089
+ # Recommendations (Semantic Scholar)
1090
+ # ---------------------------------------------------------------------------
1091
+
1092
+
1093
+ async def _op_recommend(args: dict[str, Any], limit: int) -> ToolResult:
1094
+ positive_ids = args.get("positive_ids")
1095
+ arxiv_id = _validate_arxiv_id(args)
1096
+
1097
+ if not arxiv_id and not positive_ids:
1098
+ return _error("'arxiv_id' or 'positive_ids' is required for recommend.")
1099
+
1100
+ fields = "title,externalIds,year,citationCount,tldr,venue"
1101
+
1102
+ async with httpx.AsyncClient(timeout=15) as client:
1103
+ if positive_ids and not arxiv_id:
1104
+ # Multi-paper recommendations (POST, not cached)
1105
+ pos = [_s2_paper_id(pid.strip()) for pid in positive_ids.split(",") if pid.strip()]
1106
+ neg_raw = args.get("negative_ids", "")
1107
+ neg = [_s2_paper_id(pid.strip()) for pid in neg_raw.split(",") if pid.strip()] if neg_raw else []
1108
+ resp = await _s2_request(
1109
+ client, "POST", "/recommendations/v1/papers/",
1110
+ json={"positivePaperIds": pos, "negativePaperIds": neg},
1111
+ params={"fields": fields, "limit": limit},
1112
+ )
1113
+ if not resp or resp.status_code != 200:
1114
+ return _error("Recommendation request failed. Semantic Scholar may be unavailable.")
1115
+ data = resp.json()
1116
+ else:
1117
+ # Single-paper recommendations (cached)
1118
+ data = await _s2_get_json(
1119
+ client,
1120
+ f"/recommendations/v1/papers/forpaper/{_s2_paper_id(arxiv_id)}",
1121
+ {"fields": fields, "limit": limit, "from": "recent"},
1122
+ )
1123
+ if not data:
1124
+ return _error("Recommendation request failed. Semantic Scholar may be unavailable.")
1125
+
1126
+ papers = data.get("recommendedPapers") or []
1127
+ if not papers:
1128
+ return {
1129
+ "formatted": "No recommendations found.",
1130
+ "totalResults": 0,
1131
+ "resultsShared": 0,
1132
+ }
1133
+
1134
+ title = f"Recommended papers based on {arxiv_id or positive_ids}"
1135
+ return {
1136
+ "formatted": _format_s2_paper_list(papers[:limit], title),
1137
+ "totalResults": len(papers),
1138
+ "resultsShared": min(limit, len(papers)),
1139
+ }
1140
+
1141
+
1142
  # ---------------------------------------------------------------------------
1143
  # Operation dispatch
1144
  # ---------------------------------------------------------------------------
 
1148
  "search": _op_search,
1149
  "paper_details": _op_paper_details,
1150
  "read_paper": _op_read_paper,
1151
+ "citation_graph": _op_citation_graph,
1152
+ "snippet_search": _op_snippet_search,
1153
+ "recommend": _op_recommend,
1154
  "find_datasets": _op_find_datasets,
1155
  "find_models": _op_find_models,
1156
  "find_collections": _op_find_collections,
 
1165
  HF_PAPERS_TOOL_SPEC = {
1166
  "name": "hf_papers",
1167
  "description": (
1168
+ "Discover ML research papers, analyze citations, search paper contents, and find linked resources.\n\n"
1169
+ "Combines HuggingFace Hub, arXiv, and Semantic Scholar. Use for exploring research areas, "
1170
+ "finding datasets for a task, tracing citation chains, or implementing a paper's approach.\n\n"
1171
+ "Typical flows:\n"
1172
+ " search β†’ read_paper β†’ find_all_resources β†’ hf_inspect_dataset\n"
1173
+ " search β†’ paper_details β†’ citation_graph β†’ read_paper (trace influence)\n"
1174
+ " snippet_search β†’ paper_details β†’ read_paper (find specific claims)\n\n"
1175
  "Operations:\n"
1176
  "- trending: Get trending daily papers, optionally filter by topic keyword\n"
1177
+ "- search: Search papers. Uses HF by default (ML-tuned). Add date_from/min_citations/categories to use Semantic Scholar with filters\n"
1178
+ "- paper_details: Metadata, abstract, AI summary, github link\n"
1179
+ "- read_paper: Read paper contents β€” without section: abstract + TOC; with section: full text\n"
1180
+ "- citation_graph: Get references and citations for a paper with influence flags and citation intents\n"
1181
+ "- snippet_search: Semantic search over full-text passages from 12M+ papers\n"
1182
+ "- recommend: Find similar papers (single paper or positive/negative examples)\n"
1183
  "- find_datasets: Find datasets linked to a paper\n"
1184
  "- find_models: Find models linked to a paper\n"
1185
  "- find_collections: Find collections that include a paper\n"
1186
+ "- find_all_resources: Parallel fetch of datasets + models + collections for a paper"
1187
  ),
1188
  "parameters": {
1189
  "type": "object",
 
1196
  "query": {
1197
  "type": "string",
1198
  "description": (
1199
+ "Search query. Required for: search, snippet_search. "
1200
+ "Optional for: trending (filters by keyword). "
1201
+ "Supports boolean syntax for Semantic Scholar: '\"exact phrase\" term1 | term2'."
1202
  ),
1203
  },
1204
  "arxiv_id": {
1205
  "type": "string",
1206
  "description": (
1207
  "ArXiv paper ID (e.g. '2305.18290'). "
1208
+ "Required for: paper_details, read_paper, citation_graph, find_datasets, find_models, find_collections, find_all_resources. "
1209
+ "Optional for: recommend (single-paper recs). Get IDs from search results first."
1210
  ),
1211
  },
1212
  "section": {
1213
  "type": "string",
1214
  "description": (
1215
  "Section name or number to read (e.g. '3', 'Experiments', '4.2'). "
1216
+ "Optional for: read_paper. Without this, returns abstract + TOC."
 
1217
  ),
1218
  },
1219
+ "direction": {
1220
+ "type": "string",
1221
+ "enum": ["citations", "references", "both"],
1222
+ "description": "Direction for citation_graph. Default: both.",
1223
+ },
1224
  "date": {
1225
  "type": "string",
1226
  "description": "Date in YYYY-MM-DD format. Optional for: trending (defaults to recent papers).",
1227
  },
1228
+ "date_from": {
1229
+ "type": "string",
1230
+ "description": "Start date (YYYY-MM-DD). Triggers Semantic Scholar search. For: search, snippet_search.",
1231
+ },
1232
+ "date_to": {
1233
+ "type": "string",
1234
+ "description": "End date (YYYY-MM-DD). Triggers Semantic Scholar search. For: search, snippet_search.",
1235
+ },
1236
+ "categories": {
1237
+ "type": "string",
1238
+ "description": "Field of study filter (e.g. 'Computer Science'). Triggers Semantic Scholar search.",
1239
+ },
1240
+ "min_citations": {
1241
+ "type": "integer",
1242
+ "description": "Minimum citation count filter. Triggers Semantic Scholar search.",
1243
+ },
1244
+ "sort_by": {
1245
+ "type": "string",
1246
+ "enum": ["relevance", "citationCount", "publicationDate"],
1247
+ "description": "Sort order for Semantic Scholar search. Default: relevance.",
1248
+ },
1249
+ "positive_ids": {
1250
+ "type": "string",
1251
+ "description": "Comma-separated arxiv IDs for multi-paper recommendations. For: recommend.",
1252
+ },
1253
+ "negative_ids": {
1254
+ "type": "string",
1255
+ "description": "Comma-separated arxiv IDs as negative examples. For: recommend.",
1256
+ },
1257
  "sort": {
1258
  "type": "string",
1259
  "enum": ["downloads", "likes", "trending"],
1260
  "description": (
1261
+ "Sort order for find_datasets and find_models. Default: downloads."
 
1262
  ),
1263
  },
1264
  "limit": {
agent/tools/research_tool.py CHANGED
@@ -46,12 +46,22 @@ Your job: explore documentation, code examples, APIs, and repos,
46
  then return a concise, actionable summary. The main agent will use
47
  your findings to implement the actual solution.
48
 
 
 
 
 
 
 
 
 
 
 
49
  # Research methodology
50
 
51
- 1. **Discovery**: Find relevant entry points β€” example scripts, doc pages, API endpoints
52
  2. **Tracing**: Follow the chain from entry point to implementation detail
53
- 3. **Analysis**: Identify patterns, current API usage, key dependencies
54
- 4. **Synthesis**: Summarize findings in a structured format
55
 
56
  # How to use your tools
57
 
@@ -75,12 +85,30 @@ your findings to implement the actual solution.
75
  - DPO: needs "prompt", "chosen", "rejected"
76
  - GRPO: needs "prompt" only
77
 
78
- ## Papers
79
- - `hf_papers`: Search papers, get details, find linked datasets/models
 
 
 
 
 
 
80
 
81
  ## Hub repo inspection
82
  - `hf_repo_files`: List/read files in any HF repo (model, dataset, space)
83
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  # Correct research pattern for ML tasks
85
 
86
  ```
@@ -101,11 +129,12 @@ hf_inspect_dataset({"dataset": "org/name", "split": "train", "sample_rows": 3})
101
  # Output format
102
 
103
  Your output MUST include:
 
104
  - **Key findings**: The most important things you discovered (current API usage, working patterns)
105
  - **Essential references**: Specific file paths, URLs, function names, doc sections, code snippets
106
  that the main agent should use directly
107
  - **Code patterns**: Key imports, configurations, and usage patterns from working examples
108
- - **Recommendations**: What to do next based on your findings
109
 
110
  Be concise. Your output goes into another agent's context β€” every token counts.
111
  Aim for 500-1500 words max. Include actual code snippets from examples you read,
 
46
  then return a concise, actionable summary. The main agent will use
47
  your findings to implement the actual solution.
48
 
49
+ # Being up to date is critical
50
+
51
+ Always prioritize finding the most current, state-of-the-art approaches.
52
+ ML moves fast β€” a method from 6 months ago may already be obsolete.
53
+
54
+ - Search for **recent papers** (use `hf_papers`) to find SOTA methods, models, and datasets for the task
55
+ - Compare what you find in docs/examples against what recent papers recommend β€” prefer the newer approach
56
+ - When multiple approaches exist, identify which is SOTA and why (benchmark results, adoption, recency)
57
+ - Include in your findings: what is the current best model, dataset, and method for the task
58
+
59
  # Research methodology
60
 
61
+ 1. **Discovery**: Find relevant entry points β€” example scripts, doc pages, API endpoints, **and recent papers for SOTA approaches**
62
  2. **Tracing**: Follow the chain from entry point to implementation detail
63
+ 3. **Analysis**: Identify patterns, current API usage, key dependencies. **Compare against SOTA from recent papers**
64
+ 4. **Synthesis**: Summarize findings in a structured format, highlighting what is current best practice vs. outdated
65
 
66
  # How to use your tools
67
 
 
85
  - DPO: needs "prompt", "chosen", "rejected"
86
  - GRPO: needs "prompt" only
87
 
88
+ ## Papers & citations
89
+ - `hf_papers(operation="search", query=...)`: Search papers (HF-tuned for ML)
90
+ - `hf_papers(operation="search", query=..., min_citations=50, sort_by="citationCount")`: Find highly-cited papers via Semantic Scholar
91
+ - `hf_papers(operation="search", query=..., date_from="2024-01-01")`: Search with date filter
92
+ - `hf_papers(operation="paper_details", arxiv_id=...)`: Metadata, citations, TL;DR
93
+ - `hf_papers(operation="citation_graph", arxiv_id=...)`: References + citations with influence flags and intents
94
+ - `hf_papers(operation="snippet_search", query=...)`: Semantic search across 12M+ full-text paper passages
95
+ - `hf_papers(operation="recommend", arxiv_id=...)`: Find related papers
96
 
97
  ## Hub repo inspection
98
  - `hf_repo_files`: List/read files in any HF repo (model, dataset, space)
99
 
100
+ # Paper analysis checklist
101
+
102
+ When reading a paper, always extract:
103
+ - **Key claims**: What does the paper propose or demonstrate?
104
+ - **Methodology**: Architecture, training setup, key techniques
105
+ - **Results**: Benchmark numbers, comparisons to baselines
106
+ - **Limitations**: What the authors acknowledge or what seems missing
107
+
108
+ Use `citation_graph` to trace influence: check what a breakthrough paper cites (foundations)
109
+ and who cites it (impact and extensions). Use `snippet_search` to verify claims across
110
+ papers (e.g., "does method X consistently outperform Y?").
111
+
112
  # Correct research pattern for ML tasks
113
 
114
  ```
 
129
  # Output format
130
 
131
  Your output MUST include:
132
+ - **SOTA landscape**: Current best models, datasets, and methods for the task (from recent papers). Flag anything outdated.
133
  - **Key findings**: The most important things you discovered (current API usage, working patterns)
134
  - **Essential references**: Specific file paths, URLs, function names, doc sections, code snippets
135
  that the main agent should use directly
136
  - **Code patterns**: Key imports, configurations, and usage patterns from working examples
137
+ - **Recommendations**: What to do next based on your findings, preferring SOTA approaches
138
 
139
  Be concise. Your output goes into another agent's context β€” every token counts.
140
  Aim for 500-1500 words max. Include actual code snippets from examples you read,
agent/tools/sandbox_tool.py CHANGED
@@ -12,7 +12,6 @@ a cpu-basic sandbox is auto-created (no approval needed).
12
  from __future__ import annotations
13
 
14
  import asyncio
15
- import shlex
16
  import threading
17
  from typing import Any
18
 
@@ -49,9 +48,15 @@ async def resolve_sandbox_script(
49
  if not sandbox or not _looks_like_path(script):
50
  return None, None
51
  try:
52
- result = await asyncio.to_thread(sandbox.bash, f"cat {shlex.quote(script)}")
 
53
  if result.success and result.output:
54
- return result.output, None
 
 
 
 
 
55
  return None, f"Failed to read {script} from sandbox: {result.error}"
56
  except Exception as e:
57
  return None, f"Failed to read {script} from sandbox: {e}"
 
12
  from __future__ import annotations
13
 
14
  import asyncio
 
15
  import threading
16
  from typing import Any
17
 
 
48
  if not sandbox or not _looks_like_path(script):
49
  return None, None
50
  try:
51
+ # Use the read endpoint instead of bash("cat ...") which truncates at 25KB.
52
+ result = await asyncio.to_thread(sandbox.read, script, limit=100_000)
53
  if result.success and result.output:
54
+ # Strip line number prefixes (read returns "N\tcontent" format)
55
+ lines = []
56
+ for line in result.output.split("\n"):
57
+ parts = line.split("\t", 1)
58
+ lines.append(parts[1] if len(parts) == 2 else line)
59
+ return "\n".join(lines), None
60
  return None, f"Failed to read {script} from sandbox: {result.error}"
61
  except Exception as e:
62
  return None, f"Failed to read {script} from sandbox: {e}"
backend/main.py CHANGED
@@ -12,7 +12,8 @@ from fastapi.staticfiles import StaticFiles
12
  from routes.agent import router as agent_router
13
  from routes.auth import router as auth_router
14
 
15
- load_dotenv()
 
16
 
17
  # Configure logging
18
  logging.basicConfig(
 
12
  from routes.agent import router as agent_router
13
  from routes.auth import router as auth_router
14
 
15
+ # Load .env from project root (parent directory)
16
+ load_dotenv(Path(__file__).parent.parent / ".env")
17
 
18
  # Configure logging
19
  logging.basicConfig(
backend/models.py CHANGED
@@ -54,6 +54,12 @@ class SubmitRequest(BaseModel):
54
  text: str
55
 
56
 
 
 
 
 
 
 
57
  class SessionResponse(BaseModel):
58
  """Response when creating a new session."""
59
 
 
54
  text: str
55
 
56
 
57
+ class TruncateRequest(BaseModel):
58
+ """Request to truncate conversation history to before a specific user message."""
59
+
60
+ user_message_index: int
61
+
62
+
63
  class SessionResponse(BaseModel):
64
  """Response when creating a new session."""
65
 
backend/routes/agent.py CHANGED
@@ -7,6 +7,7 @@ dependency. In dev mode (no OAUTH_CLIENT_ID), auth is bypassed automatically.
7
  import asyncio
8
  import json
9
  import logging
 
10
  from typing import Any
11
 
12
  from dependencies import get_current_user
@@ -25,6 +26,7 @@ from models import (
25
  SessionInfo,
26
  SessionResponse,
27
  SubmitRequest,
 
28
  )
29
  from session_manager import MAX_SESSIONS, SessionCapacityError, session_manager
30
 
@@ -205,13 +207,15 @@ async def create_session(
205
 
206
  Returns 503 if the server or user has reached the session limit.
207
  """
208
- # Extract the user's HF token (Bearer header or HttpOnly cookie)
209
  hf_token = None
210
  auth_header = request.headers.get("Authorization", "")
211
  if auth_header.startswith("Bearer "):
212
  hf_token = auth_header[7:]
213
  if not hf_token:
214
  hf_token = request.cookies.get("hf_access_token")
 
 
215
 
216
  try:
217
  session_id = await session_manager.create_session(
@@ -435,6 +439,18 @@ async def undo_session(session_id: str, user: dict = Depends(get_current_user))
435
  return {"status": "undo_requested", "session_id": session_id}
436
 
437
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  @router.post("/compact/{session_id}")
439
  async def compact_session(
440
  session_id: str, user: dict = Depends(get_current_user)
 
7
  import asyncio
8
  import json
9
  import logging
10
+ import os
11
  from typing import Any
12
 
13
  from dependencies import get_current_user
 
26
  SessionInfo,
27
  SessionResponse,
28
  SubmitRequest,
29
+ TruncateRequest,
30
  )
31
  from session_manager import MAX_SESSIONS, SessionCapacityError, session_manager
32
 
 
207
 
208
  Returns 503 if the server or user has reached the session limit.
209
  """
210
+ # Extract the user's HF token (Bearer header, HttpOnly cookie, or env var)
211
  hf_token = None
212
  auth_header = request.headers.get("Authorization", "")
213
  if auth_header.startswith("Bearer "):
214
  hf_token = auth_header[7:]
215
  if not hf_token:
216
  hf_token = request.cookies.get("hf_access_token")
217
+ if not hf_token:
218
+ hf_token = os.environ.get("HF_TOKEN")
219
 
220
  try:
221
  session_id = await session_manager.create_session(
 
439
  return {"status": "undo_requested", "session_id": session_id}
440
 
441
 
442
+ @router.post("/truncate/{session_id}")
443
+ async def truncate_session(
444
+ session_id: str, body: TruncateRequest, user: dict = Depends(get_current_user)
445
+ ) -> dict:
446
+ """Truncate conversation to before a specific user message."""
447
+ _check_session_access(session_id, user)
448
+ success = await session_manager.truncate(session_id, body.user_message_index)
449
+ if not success:
450
+ raise HTTPException(status_code=404, detail="Session not found, inactive, or message index out of range")
451
+ return {"status": "truncated", "session_id": session_id}
452
+
453
+
454
  @router.post("/compact/{session_id}")
455
  async def compact_session(
456
  session_id: str, user: dict = Depends(get_current_user)
backend/routes/auth.py CHANGED
@@ -10,7 +10,7 @@ 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
 
@@ -169,3 +169,20 @@ async def get_me(user: dict = Depends(get_current_user)) -> dict:
169
  Uses the shared auth dependency which handles cookie + Bearer token.
170
  """
171
  return user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  from urllib.parse import urlencode
11
 
12
  import httpx
13
+ from dependencies import AUTH_ENABLED, check_org_membership, get_current_user
14
  from fastapi import APIRouter, Depends, HTTPException, Request
15
  from fastapi.responses import RedirectResponse
16
 
 
169
  Uses the shared auth dependency which handles cookie + Bearer token.
170
  """
171
  return user
172
+
173
+
174
+ ORG_NAME = "ml-agent-explorers"
175
+
176
+
177
+ @router.get("/org-membership")
178
+ async def org_membership(
179
+ request: Request, user: dict = Depends(get_current_user)
180
+ ) -> dict:
181
+ """Check if the authenticated user belongs to the ml-agent-explorers org."""
182
+ if not AUTH_ENABLED:
183
+ return {"is_member": True}
184
+ token = request.cookies.get("hf_access_token") or ""
185
+ if not token:
186
+ return {"is_member": False}
187
+ is_member = await check_org_membership(token, ORG_NAME)
188
+ return {"is_member": is_member}
backend/session_manager.py CHANGED
@@ -319,6 +319,14 @@ class SessionManager:
319
  operation = Operation(op_type=OpType.UNDO)
320
  return await self.submit(session_id, operation)
321
 
 
 
 
 
 
 
 
 
322
  async def compact(self, session_id: str) -> bool:
323
  """Compact context in a session."""
324
  operation = Operation(op_type=OpType.COMPACT)
 
319
  operation = Operation(op_type=OpType.UNDO)
320
  return await self.submit(session_id, operation)
321
 
322
+ async def truncate(self, session_id: str, user_message_index: int) -> bool:
323
+ """Truncate conversation to before a specific user message (direct, no queue)."""
324
+ async with self._lock:
325
+ agent_session = self.sessions.get(session_id)
326
+ if not agent_session or not agent_session.is_active:
327
+ return False
328
+ return agent_session.session.context_manager.truncate_to_user_message(user_message_index)
329
+
330
  async def compact(self, session_id: str) -> bool:
331
  """Compact context in a session."""
332
  operation = Operation(op_type=OpType.COMPACT)
frontend/package-lock.json CHANGED
@@ -130,7 +130,6 @@
130
  "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
131
  "dev": true,
132
  "license": "MIT",
133
- "peer": true,
134
  "dependencies": {
135
  "@babel/code-frame": "^7.28.6",
136
  "@babel/generator": "^7.28.6",
@@ -447,7 +446,6 @@
447
  "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
448
  "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
449
  "license": "MIT",
450
- "peer": true,
451
  "dependencies": {
452
  "@babel/runtime": "^7.18.3",
453
  "@emotion/babel-plugin": "^11.13.5",
@@ -491,7 +489,6 @@
491
  "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
492
  "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
493
  "license": "MIT",
494
- "peer": true,
495
  "dependencies": {
496
  "@babel/runtime": "^7.18.3",
497
  "@emotion/babel-plugin": "^11.13.5",
@@ -1224,7 +1221,6 @@
1224
  "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz",
1225
  "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==",
1226
  "license": "MIT",
1227
- "peer": true,
1228
  "dependencies": {
1229
  "@babel/runtime": "^7.26.0",
1230
  "@mui/core-downloads-tracker": "^6.5.0",
@@ -1919,7 +1915,6 @@
1919
  "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
1920
  "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
1921
  "license": "MIT",
1922
- "peer": true,
1923
  "dependencies": {
1924
  "@types/prop-types": "*",
1925
  "csstype": "^3.2.2"
@@ -2005,7 +2000,6 @@
2005
  "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
2006
  "dev": true,
2007
  "license": "MIT",
2008
- "peer": true,
2009
  "dependencies": {
2010
  "@typescript-eslint/scope-manager": "8.53.0",
2011
  "@typescript-eslint/types": "8.53.0",
@@ -2272,7 +2266,6 @@
2272
  "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
2273
  "dev": true,
2274
  "license": "MIT",
2275
- "peer": true,
2276
  "bin": {
2277
  "acorn": "bin/acorn"
2278
  },
@@ -2421,7 +2414,6 @@
2421
  }
2422
  ],
2423
  "license": "MIT",
2424
- "peer": true,
2425
  "dependencies": {
2426
  "baseline-browser-mapping": "^2.9.0",
2427
  "caniuse-lite": "^1.0.30001759",
@@ -2774,7 +2766,6 @@
2774
  "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
2775
  "dev": true,
2776
  "license": "MIT",
2777
- "peer": true,
2778
  "dependencies": {
2779
  "@eslint-community/eslint-utils": "^4.8.0",
2780
  "@eslint-community/regexpp": "^4.12.1",
@@ -4673,7 +4664,6 @@
4673
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
4674
  "dev": true,
4675
  "license": "MIT",
4676
- "peer": true,
4677
  "engines": {
4678
  "node": ">=12"
4679
  },
@@ -4771,7 +4761,6 @@
4771
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
4772
  "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
4773
  "license": "MIT",
4774
- "peer": true,
4775
  "dependencies": {
4776
  "loose-envify": "^1.1.0"
4777
  },
@@ -4784,7 +4773,6 @@
4784
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
4785
  "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
4786
  "license": "MIT",
4787
- "peer": true,
4788
  "dependencies": {
4789
  "loose-envify": "^1.1.0",
4790
  "scheduler": "^0.23.2"
@@ -5269,7 +5257,6 @@
5269
  "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
5270
  "dev": true,
5271
  "license": "Apache-2.0",
5272
- "peer": true,
5273
  "bin": {
5274
  "tsc": "bin/tsc",
5275
  "tsserver": "bin/tsserver"
@@ -5435,7 +5422,6 @@
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
  }
@@ -5474,7 +5460,6 @@
5474
  "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
5475
  "dev": true,
5476
  "license": "MIT",
5477
- "peer": true,
5478
  "dependencies": {
5479
  "esbuild": "^0.21.3",
5480
  "postcss": "^8.4.43",
 
130
  "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
131
  "dev": true,
132
  "license": "MIT",
 
133
  "dependencies": {
134
  "@babel/code-frame": "^7.28.6",
135
  "@babel/generator": "^7.28.6",
 
446
  "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
447
  "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
448
  "license": "MIT",
 
449
  "dependencies": {
450
  "@babel/runtime": "^7.18.3",
451
  "@emotion/babel-plugin": "^11.13.5",
 
489
  "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
490
  "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
491
  "license": "MIT",
 
492
  "dependencies": {
493
  "@babel/runtime": "^7.18.3",
494
  "@emotion/babel-plugin": "^11.13.5",
 
1221
  "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz",
1222
  "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==",
1223
  "license": "MIT",
 
1224
  "dependencies": {
1225
  "@babel/runtime": "^7.26.0",
1226
  "@mui/core-downloads-tracker": "^6.5.0",
 
1915
  "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
1916
  "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
1917
  "license": "MIT",
 
1918
  "dependencies": {
1919
  "@types/prop-types": "*",
1920
  "csstype": "^3.2.2"
 
2000
  "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
2001
  "dev": true,
2002
  "license": "MIT",
 
2003
  "dependencies": {
2004
  "@typescript-eslint/scope-manager": "8.53.0",
2005
  "@typescript-eslint/types": "8.53.0",
 
2266
  "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
2267
  "dev": true,
2268
  "license": "MIT",
 
2269
  "bin": {
2270
  "acorn": "bin/acorn"
2271
  },
 
2414
  }
2415
  ],
2416
  "license": "MIT",
 
2417
  "dependencies": {
2418
  "baseline-browser-mapping": "^2.9.0",
2419
  "caniuse-lite": "^1.0.30001759",
 
2766
  "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
2767
  "dev": true,
2768
  "license": "MIT",
 
2769
  "dependencies": {
2770
  "@eslint-community/eslint-utils": "^4.8.0",
2771
  "@eslint-community/regexpp": "^4.12.1",
 
4664
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
4665
  "dev": true,
4666
  "license": "MIT",
 
4667
  "engines": {
4668
  "node": ">=12"
4669
  },
 
4761
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
4762
  "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
4763
  "license": "MIT",
 
4764
  "dependencies": {
4765
  "loose-envify": "^1.1.0"
4766
  },
 
4773
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
4774
  "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
4775
  "license": "MIT",
 
4776
  "dependencies": {
4777
  "loose-envify": "^1.1.0",
4778
  "scheduler": "^0.23.2"
 
5257
  "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
5258
  "dev": true,
5259
  "license": "Apache-2.0",
 
5260
  "bin": {
5261
  "tsc": "bin/tsc",
5262
  "tsserver": "bin/tsserver"
 
5422
  "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
5423
  "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
5424
  "license": "MIT",
 
5425
  "peerDependencies": {
5426
  "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5427
  }
 
5460
  "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
5461
  "dev": true,
5462
  "license": "MIT",
 
5463
  "dependencies": {
5464
  "esbuild": "^0.21.3",
5465
  "postcss": "^8.4.43",
frontend/src/components/Chat/ActivityStatusBar.tsx CHANGED
@@ -20,11 +20,90 @@ const TOOL_LABELS: Record<string, string> = {
20
  research: 'Researching',
21
  };
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  function statusLabel(status: ActivityStatus): string {
24
  switch (status.type) {
25
  case 'thinking': return 'Thinking';
26
  case 'streaming': return 'Writing';
27
  case 'tool': {
 
 
 
28
  const base = status.description || TOOL_LABELS[status.toolName] || `Running ${status.toolName}`;
29
  if (status.toolName === 'bash' && status.description && /install/i.test(status.description)) {
30
  return `${base} β€” this can take a few minutes, sit tight`;
@@ -32,6 +111,7 @@ function statusLabel(status: ActivityStatus): string {
32
  return base;
33
  }
34
  case 'waiting-approval': return 'Waiting for approval';
 
35
  default: return '';
36
  }
37
  }
@@ -59,7 +139,7 @@ export default function ActivityStatusBar() {
59
  animation: `${shimmer} 4s ease-in-out infinite`,
60
  }}
61
  >
62
- {label}…
63
  </Typography>
64
  </Box>
65
  );
 
20
  research: 'Researching',
21
  };
22
 
23
+ /** Format raw research log into a clean status label. */
24
+ function formatResearchStatus(raw: string): string {
25
+ const s = raw.replace(/^β–Έ\s*/, '');
26
+ const jsonStart = s.indexOf('{');
27
+ const toolName = jsonStart > 0 ? s.slice(0, jsonStart).trim() : s.trim();
28
+ let args: Record<string, string> = {};
29
+ if (jsonStart > 0) {
30
+ const jsonStr = s.slice(jsonStart);
31
+ try {
32
+ const parsed = JSON.parse(jsonStr);
33
+ for (const [k, v] of Object.entries(parsed)) {
34
+ if (typeof v === 'string') args[k] = v;
35
+ }
36
+ } catch {
37
+ // JSON is likely truncated β€” extract complete "key": "value" pairs
38
+ for (const m of jsonStr.matchAll(/"(\w+)":\s*"([^"]*)"/g)) {
39
+ args[m[1]] = m[2];
40
+ }
41
+ // Also try to extract a truncated value for known keys if not found yet
42
+ if (!args.query && !args.arxiv_id) {
43
+ const partial = jsonStr.match(/"(query|arxiv_id)":\s*"([^"]*)/);
44
+ if (partial) args[partial[1]] = partial[2];
45
+ }
46
+ }
47
+ }
48
+
49
+ if (toolName === 'github_find_examples') {
50
+ const d = (args.keyword) || (args.repo);
51
+ return d ? `Finding examples: ${d}` : 'Finding examples';
52
+ }
53
+ if (toolName === 'github_read_file') {
54
+ const f = ((args.path) || '').split('/').pop();
55
+ return f ? `Reading ${f}` : 'Reading file';
56
+ }
57
+ if (toolName === 'explore_hf_docs') {
58
+ const d = (args.endpoint) || (args.query);
59
+ return d ? `Exploring docs: ${d}` : 'Exploring docs';
60
+ }
61
+ if (toolName === 'fetch_hf_docs') {
62
+ const p = ((args.url) || '').split('/').pop()?.replace(/\.md$/, '');
63
+ return p ? `Reading docs: ${p}` : 'Fetching docs';
64
+ }
65
+ if (toolName === 'hf_inspect_dataset') {
66
+ const d = args.dataset as string;
67
+ return d ? `Inspecting dataset: ${d}` : 'Inspecting dataset';
68
+ }
69
+ if (toolName === 'hf_papers') {
70
+ const op = args.operation as string;
71
+ const detail = (args.query) || (args.arxiv_id) || (args.positive_ids);
72
+ const opLabels: Record<string, string> = {
73
+ trending: 'Browsing trending papers',
74
+ search: 'Searching papers',
75
+ paper_details: 'Reading paper details',
76
+ read_paper: 'Reading paper',
77
+ citation_graph: 'Tracing citations',
78
+ snippet_search: 'Searching paper passages',
79
+ recommend: 'Finding similar papers',
80
+ find_datasets: 'Finding paper datasets',
81
+ find_models: 'Finding paper models',
82
+ find_collections: 'Finding paper collections',
83
+ find_all_resources: 'Finding paper resources',
84
+ };
85
+ const base = (op && opLabels[op]) || 'Searching papers';
86
+ return detail ? `${base}: ${detail}` : base;
87
+ }
88
+ if (toolName === 'find_hf_api') {
89
+ const d = (args.query) || (args.tag);
90
+ return d ? `Finding API: ${d}` : 'Finding API endpoints';
91
+ }
92
+ if (toolName === 'hf_repo_files') {
93
+ const d = (args.repo_id) || (args.repo);
94
+ return d ? `Reading ${d} files` : 'Reading repo files';
95
+ }
96
+ return 'Researching';
97
+ }
98
+
99
  function statusLabel(status: ActivityStatus): string {
100
  switch (status.type) {
101
  case 'thinking': return 'Thinking';
102
  case 'streaming': return 'Writing';
103
  case 'tool': {
104
+ if (status.toolName === 'research' && status.description) {
105
+ return formatResearchStatus(status.description);
106
+ }
107
  const base = status.description || TOOL_LABELS[status.toolName] || `Running ${status.toolName}`;
108
  if (status.toolName === 'bash' && status.description && /install/i.test(status.description)) {
109
  return `${base} β€” this can take a few minutes, sit tight`;
 
111
  return base;
112
  }
113
  case 'waiting-approval': return 'Waiting for approval';
114
+ case 'cancelled': return 'What should the agent do instead?';
115
  default: return '';
116
  }
117
  }
 
139
  animation: `${shimmer} 4s ease-in-out infinite`,
140
  }}
141
  >
142
+ {label}{activityStatus.type !== 'cancelled' && '…'}
143
  </Typography>
144
  </Box>
145
  );
frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -2,7 +2,7 @@ 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 CloseIcon from '@mui/icons-material/Close';
6
  import { apiFetch } from '@/utils/api';
7
 
8
  // Model configuration
@@ -67,7 +67,6 @@ interface ChatInputProps {
67
 
68
  export default function ChatInput({ onSend, onStop, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
69
  const [input, setInput] = useState('');
70
- const [stopHovered, setStopHovered] = useState(false);
71
  const inputRef = useRef<HTMLTextAreaElement>(null);
72
  const [selectedModelId, setSelectedModelId] = useState<string>(() => {
73
  try {
@@ -207,20 +206,23 @@ export default function ChatInput({ onSend, onStop, isProcessing = false, disabl
207
  {isProcessing ? (
208
  <IconButton
209
  onClick={onStop}
210
- onMouseEnter={() => setStopHovered(true)}
211
- onMouseLeave={() => setStopHovered(false)}
212
  sx={{
213
  mt: 1,
214
- p: 1,
215
  borderRadius: '10px',
216
- color: stopHovered ? 'var(--accent-yellow)' : 'var(--muted-text)',
217
  transition: 'all 0.2s',
 
218
  '&:hover': {
219
  bgcolor: 'var(--hover-bg)',
 
220
  },
221
  }}
222
  >
223
- {stopHovered ? <CloseIcon fontSize="small" /> : <CircularProgress size={20} color="inherit" />}
 
 
 
224
  </IconButton>
225
  ) : (
226
  <IconButton
 
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 StopIcon from '@mui/icons-material/Stop';
6
  import { apiFetch } from '@/utils/api';
7
 
8
  // Model configuration
 
67
 
68
  export default function ChatInput({ onSend, onStop, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
69
  const [input, setInput] = useState('');
 
70
  const inputRef = useRef<HTMLTextAreaElement>(null);
71
  const [selectedModelId, setSelectedModelId] = useState<string>(() => {
72
  try {
 
206
  {isProcessing ? (
207
  <IconButton
208
  onClick={onStop}
 
 
209
  sx={{
210
  mt: 1,
211
+ p: 1.5,
212
  borderRadius: '10px',
213
+ color: 'var(--muted-text)',
214
  transition: 'all 0.2s',
215
+ position: 'relative',
216
  '&:hover': {
217
  bgcolor: 'var(--hover-bg)',
218
+ color: 'var(--accent-red)',
219
  },
220
  }}
221
  >
222
+ <Box sx={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
223
+ <CircularProgress size={28} thickness={3} sx={{ color: 'inherit', position: 'absolute' }} />
224
+ <StopIcon sx={{ fontSize: 16 }} />
225
+ </Box>
226
  </IconButton>
227
  ) : (
228
  <IconButton
frontend/src/components/Chat/MessageBubble.tsx CHANGED
@@ -6,6 +6,7 @@ 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>;
@@ -15,6 +16,7 @@ export default function MessageBubble({
15
  message,
16
  isLastTurn = false,
17
  onUndoTurn,
 
18
  isProcessing = false,
19
  isStreaming = false,
20
  approveTools,
@@ -25,6 +27,7 @@ export default function MessageBubble({
25
  message={message}
26
  isLastTurn={isLastTurn}
27
  onUndoTurn={onUndoTurn}
 
28
  isProcessing={isProcessing}
29
  />
30
  );
 
6
  message: UIMessage;
7
  isLastTurn?: boolean;
8
  onUndoTurn?: () => void;
9
+ onEditAndRegenerate?: (messageId: string, newText: string) => void | Promise<void>;
10
  isProcessing?: boolean;
11
  isStreaming?: boolean;
12
  approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
 
16
  message,
17
  isLastTurn = false,
18
  onUndoTurn,
19
+ onEditAndRegenerate,
20
  isProcessing = false,
21
  isStreaming = false,
22
  approveTools,
 
27
  message={message}
28
  isLastTurn={isLastTurn}
29
  onUndoTurn={onUndoTurn}
30
+ onEditAndRegenerate={onEditAndRegenerate}
31
  isProcessing={isProcessing}
32
  />
33
  );
frontend/src/components/Chat/MessageList.tsx CHANGED
@@ -10,6 +10,7 @@ interface MessageListProps {
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 {
@@ -56,7 +57,7 @@ function WelcomeGreeting() {
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
 
@@ -135,6 +136,7 @@ export default function MessageList({ messages, isProcessing, approveTools, onUn
135
  message={msg}
136
  isLastTurn={msg.id === lastUserMsgId}
137
  onUndoTurn={onUndoLastTurn}
 
138
  isProcessing={isProcessing}
139
  isStreaming={isProcessing && msg.id === lastAssistantId}
140
  approveTools={approveTools}
 
10
  isProcessing: boolean;
11
  approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
12
  onUndoLastTurn: () => void | Promise<void>;
13
+ onEditAndRegenerate?: (messageId: string, newText: string) => void | Promise<void>;
14
  }
15
 
16
  function getGreeting(): string {
 
57
  );
58
  }
59
 
60
+ export default function MessageList({ messages, isProcessing, approveTools, onUndoLastTurn, onEditAndRegenerate }: MessageListProps) {
61
  const scrollContainerRef = useRef<HTMLDivElement>(null);
62
  const stickToBottom = useRef(true);
63
 
 
136
  message={msg}
137
  isLastTurn={msg.id === lastUserMsgId}
138
  onUndoTurn={onUndoLastTurn}
139
+ onEditAndRegenerate={onEditAndRegenerate}
140
  isProcessing={isProcessing}
141
  isStreaming={isProcessing && msg.id === lastAssistantId}
142
  approveTools={approveTools}
frontend/src/components/Chat/ToolCallGroup.tsx CHANGED
@@ -10,6 +10,7 @@ import BlockIcon from '@mui/icons-material/Block';
10
  import { useAgentStore } from '@/store/agentStore';
11
  import { useLayoutStore } from '@/store/layoutStore';
12
  import { logger } from '@/utils/logger';
 
13
  import type { UIMessage } from 'ai';
14
 
15
  // ---------------------------------------------------------------------------
@@ -35,37 +36,153 @@ interface ToolCallGroupProps {
35
  // Research sub-steps (inline under the research tool row)
36
  // ---------------------------------------------------------------------------
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  /** Pretty labels for research sub-agent tool calls */
39
- function formatResearchStep(step: string): { icon: string; label: string } {
40
- if (step === 'Starting research sub-agent...') return { icon: 'πŸ”', label: 'Starting research' };
41
- if (step === 'Research complete.') return { icon: 'βœ“', label: 'Research complete' };
42
- if (step.startsWith('github_find_examples')) return { icon: 'πŸ“‚', label: step.replace('github_find_examples', 'Finding examples') };
 
 
 
 
 
43
  if (step.startsWith('github_read_file')) {
44
- const path = step.match(/\(([^)]+)\)/)?.[1] || '';
45
  const filename = path.split('/').pop() || path;
46
- return { icon: 'πŸ“„', label: `Reading ${filename}` };
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
- if (step.startsWith('explore_hf_docs')) return { icon: 'πŸ“š', label: step.replace('explore_hf_docs', 'Exploring docs') };
49
- if (step.startsWith('fetch_hf_docs')) return { icon: 'πŸ“–', label: step.replace('fetch_hf_docs', 'Fetching docs') };
50
- if (step.startsWith('hf_inspect_dataset')) return { icon: 'πŸ—ƒοΈ', label: step.replace('hf_inspect_dataset', 'Inspecting dataset') };
51
- if (step.startsWith('hf_papers')) return { icon: 'πŸ“‘', label: 'Searching papers' };
52
- if (step.startsWith('find_hf_api')) return { icon: 'πŸ”Œ', label: 'Finding API endpoints' };
53
- if (step.startsWith('hf_repo_files')) return { icon: 'πŸ“', label: 'Reading repo files' };
54
- return { icon: 'β†’', label: step };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
 
 
57
  function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boolean }) {
58
- // Filter out the "Starting..." and "complete" meta-steps for the list
59
- const toolSteps = steps.filter(
60
- s => s !== 'Starting research sub-agent...' && s !== 'Research complete.',
61
- );
62
- if (toolSteps.length === 0) return null;
63
 
64
  return (
65
  <Box sx={{ pl: 4.5, pr: 1.5, pb: 1, pt: 0.25 }}>
66
- {toolSteps.map((step, i) => {
67
- const { icon, label } = formatResearchStep(step);
68
- const isLast = i === toolSteps.length - 1;
69
  return (
70
  <Stack
71
  key={i}
@@ -74,17 +191,16 @@ function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boole
74
  spacing={0.75}
75
  sx={{ py: 0.2 }}
76
  >
77
- <Typography sx={{ fontSize: '0.65rem', lineHeight: 1, width: 14, textAlign: 'center', flexShrink: 0 }}>
78
- {isLast && isRunning ? '' : icon}
79
- </Typography>
80
- {isLast && isRunning && (
81
  <CircularProgress size={10} thickness={5} sx={{ color: 'var(--accent-yellow)', flexShrink: 0 }} />
 
 
82
  )}
83
  <Typography
84
  sx={{
85
  fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
86
  fontSize: '0.68rem',
87
- color: isLast && isRunning ? 'var(--text)' : 'var(--muted-text)',
88
  overflow: 'hidden',
89
  textOverflow: 'ellipsis',
90
  whiteSpace: 'nowrap',
@@ -132,8 +248,8 @@ function costLabel(hardware: string): string | null {
132
  // Visual helpers
133
  // ---------------------------------------------------------------------------
134
 
135
- function StatusIcon({ state, cancelled }: { state: ToolPartState; cancelled?: boolean }) {
136
- if (cancelled) {
137
  return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
138
  }
139
  switch (state) {
@@ -397,11 +513,17 @@ function InlineApproval({
397
  // ---------------------------------------------------------------------------
398
 
399
  export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
400
- const { setPanel, lockPanel, getJobUrl, getEditedScript } = useAgentStore();
401
  const researchSteps = useAgentStore(s => {
402
  const activeId = s.activeSessionId;
403
  return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined;
404
  }) ?? EMPTY_STEPS;
 
 
 
 
 
 
405
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
406
 
407
  // ── Batch approval state ──────────────────────────────────────────
@@ -417,6 +539,9 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
417
  // Track which toolCallIds we've already submitted so we can detect new approval rounds
418
  const submittedIdsRef = useRef<Set<string>>(new Set());
419
 
 
 
 
420
  // Reset submission state when new (unseen) pending tools arrive β€” e.g. second approval round
421
  useEffect(() => {
422
  if (!isSubmitting || pendingTools.length === 0) return;
@@ -428,6 +553,35 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
428
  }
429
  }, [pendingTools, isSubmitting]);
430
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  const { scriptLabelMap, toolDisplayMap } = useMemo(() => {
432
  const hfJobs = tools.filter(t => t.toolName === 'hf_jobs' && (t.input as Record<string, unknown>)?.script);
433
  const scriptMap: Record<string, string> = {};
@@ -463,6 +617,10 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
463
  if (editedScript) {
464
  logger.log(`Sending edited script for ${toolCallId} (${editedScript.length} chars)`);
465
  }
 
 
 
 
466
  return {
467
  tool_call_id: toolCallId,
468
  approved: d.approved,
@@ -482,7 +640,7 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
482
  setIsSubmitting(false);
483
  }
484
  },
485
- [approveTools, lockPanel, getEditedScript],
486
  );
487
 
488
  const handleApproveAll = useCallback(() => {
@@ -518,8 +676,8 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
518
  });
519
  }, []);
520
 
521
- // ── Panel click handler ───────────────────────────────────────────
522
- const handleClick = useCallback(
523
  (tool: DynamicToolPart) => {
524
  const args = tool.input as Record<string, unknown> | undefined;
525
  const displayName = toolDisplayMap[tool.toolCallId] || tool.toolName;
@@ -545,7 +703,13 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
545
  const inputSection = args ? { content: JSON.stringify(args, null, 2), language: 'json' } : undefined;
546
 
547
  const outputText = tool.output ?? (tool.state === 'output-error' ? (tool as Record<string, unknown>).errorText : undefined);
548
- if ((tool.state === 'output-available' || tool.state === 'output-error') && outputText) {
 
 
 
 
 
 
549
  let language = 'text';
550
  const content = String(outputText);
551
  if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
@@ -557,14 +721,79 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
557
  const content = `Tool \`${tool.toolName}\` returned an error with no output message.`;
558
  setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output');
559
  setRightPanelOpen(true);
560
- } else if (args) {
 
561
  setPanel({ title: displayName, output: { content: JSON.stringify(args, null, 2), language: 'json' }, input: inputSection }, 'output');
562
  setRightPanelOpen(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  }
564
  },
565
  [toolDisplayMap, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen],
566
  );
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  // ── Parse hf_jobs metadata from output ────────────────────────────
569
  function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } {
570
  if (typeof output !== 'string') return {};
@@ -651,25 +880,46 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
651
  const clickable =
652
  state === 'output-available' ||
653
  state === 'output-error' ||
654
- !!tool.input;
 
655
  const localDecision = decisions[tool.toolCallId];
656
 
657
  const cancelled = isCancelledTool(tool);
658
- const displayState = isPending && localDecision
659
- ? (localDecision.approved ? 'input-available' : 'output-denied')
660
- : state;
661
- const label = cancelled ? 'cancelled' : statusLabel(displayState as ToolPartState);
 
 
 
 
 
 
 
 
 
 
 
 
662
 
663
  // Parse job metadata from hf_jobs output and store
664
  const jobUrlFromStore = tool.toolName === 'hf_jobs' ? getJobUrl(tool.toolCallId) : undefined;
665
- const jobMetaFromOutput = tool.toolName === 'hf_jobs' && tool.state === 'output-available'
666
- ? parseJobMeta(tool.output)
 
 
667
  : {};
668
-
669
- // Combine job URL from store (available immediately) with output metadata (available at completion)
 
 
 
 
 
 
670
  const jobMeta = {
671
  jobUrl: jobUrlFromStore || jobMetaFromOutput.jobUrl,
672
- jobStatus: jobMetaFromOutput.jobStatus,
673
  };
674
 
675
  return (
@@ -685,15 +935,20 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
685
  py: 1,
686
  cursor: isPending ? 'default' : clickable ? 'pointer' : 'default',
687
  transition: 'background-color 0.15s',
 
 
688
  '&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
689
  }}
690
  >
691
  <StatusIcon
692
  cancelled={cancelled}
 
693
  state={
694
- (tool.toolName === 'hf_jobs' && jobMeta.jobStatus && ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus) && displayState === 'output-available')
695
  ? 'output-error'
696
- : displayState as ToolPartState
 
 
697
  }
698
  />
699
 
@@ -715,23 +970,37 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
715
  </Typography>
716
 
717
  {/* Status chip (non hf_jobs, or hf_jobs without final status) */}
718
- {label && !(tool.toolName === 'hf_jobs' && jobMeta.jobStatus) && (
719
- <Chip
720
- label={label}
721
- size="small"
722
- sx={{
723
- height: 20,
724
- fontSize: '0.65rem',
725
- fontWeight: 600,
726
- bgcolor: cancelled ? 'rgba(255,255,255,0.05)'
727
- : displayState === 'output-error' ? 'rgba(224,90,79,0.12)'
728
- : displayState === 'output-denied' ? 'rgba(255,255,255,0.05)'
729
- : 'var(--accent-yellow-weak)',
730
- color: cancelled ? 'var(--muted-text)' : statusColor(displayState as ToolPartState),
731
- letterSpacing: '0.03em',
732
- }}
733
- />
734
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
 
736
  {/* HF Jobs: final status chip from job metadata */}
737
  {tool.toolName === 'hf_jobs' && jobMeta.jobStatus && (
@@ -785,11 +1054,11 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
785
  )}
786
  </Stack>
787
 
788
- {/* Research sub-agent steps */}
789
- {tool.toolName === 'research' && researchSteps.length > 0 && (
790
  <ResearchSteps
791
  steps={researchSteps}
792
- isRunning={state === 'input-streaming' || state === 'input-available'}
793
  />
794
  )}
795
 
 
10
  import { useAgentStore } from '@/store/agentStore';
11
  import { useLayoutStore } from '@/store/layoutStore';
12
  import { logger } from '@/utils/logger';
13
+ import { RESEARCH_MAX_STEPS } from '@/lib/research-store';
14
  import type { UIMessage } from 'ai';
15
 
16
  // ---------------------------------------------------------------------------
 
36
  // Research sub-steps (inline under the research tool row)
37
  // ---------------------------------------------------------------------------
38
 
39
+ /** Hook that ticks every second while startedAt is set, returning elapsed seconds. */
40
+ function useElapsed(startedAt: number | null): number | null {
41
+ const [elapsed, setElapsed] = useState<number | null>(null);
42
+ useEffect(() => {
43
+ if (startedAt === null) { setElapsed(null); return; }
44
+ setElapsed(Math.round((Date.now() - startedAt) / 1000));
45
+ const id = setInterval(() => setElapsed(Math.round((Date.now() - startedAt) / 1000)), 1000);
46
+ return () => clearInterval(id);
47
+ }, [startedAt]);
48
+ return elapsed;
49
+ }
50
+
51
+ /** Format token count like the CLI: "12.4k" or "800". */
52
+ function formatTokens(tokens: number): string {
53
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
54
+ }
55
+
56
+ /** Format elapsed seconds like the CLI: "18s" or "2m 5s". */
57
+ function formatElapsed(seconds: number): string {
58
+ if (seconds < 60) return `${seconds}s`;
59
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
60
+ }
61
+
62
+ /** Build the research stats chip label. */
63
+ function researchChipLabel(
64
+ stats: { toolCount: number; tokenCount: number; startedAt: number | null; finalElapsed: number | null },
65
+ liveElapsed: number | null,
66
+ ): string | null {
67
+ const elapsed = stats.finalElapsed ?? liveElapsed;
68
+ if (elapsed === null && stats.toolCount === 0) return null;
69
+ const parts: string[] = [];
70
+ if (stats.startedAt !== null) parts.push('running');
71
+ if (stats.toolCount > 0) parts.push(`${stats.toolCount} tools`);
72
+ if (stats.tokenCount > 0) parts.push(`${formatTokens(stats.tokenCount)} tokens`);
73
+ if (elapsed !== null) parts.push(formatElapsed(elapsed));
74
+ return parts.join(' \u00B7 ');
75
+ }
76
+
77
+ /** Parse JSON args from a step string like "tool_name {json}" (may be truncated at 80 chars). */
78
+ function parseStepArgs(step: string): Record<string, string> {
79
+ const jsonStart = step.indexOf('{');
80
+ if (jsonStart < 0) return {};
81
+ const jsonStr = step.slice(jsonStart);
82
+ try {
83
+ const parsed = JSON.parse(jsonStr);
84
+ const result: Record<string, string> = {};
85
+ for (const [k, v] of Object.entries(parsed)) {
86
+ if (typeof v === 'string') result[k] = v;
87
+ }
88
+ return result;
89
+ } catch {
90
+ // JSON likely truncated β€” extract key-value pairs via regex
91
+ const result: Record<string, string> = {};
92
+ // Match complete "key": "value" pairs
93
+ for (const m of jsonStr.matchAll(/"(\w+)":\s*"([^"]*)"/g)) {
94
+ result[m[1]] = m[2];
95
+ }
96
+ // Match truncated trailing value: "key": "value... (no closing quote)
97
+ if (Object.keys(result).length === 0 || !result.query) {
98
+ const trunc = jsonStr.match(/"(\w+)":\s*"([^"]+)$/);
99
+ if (trunc && !result[trunc[1]]) {
100
+ result[trunc[1]] = trunc[2];
101
+ }
102
+ }
103
+ return result;
104
+ }
105
+ }
106
+
107
  /** Pretty labels for research sub-agent tool calls */
108
+ function formatResearchStep(raw: string): { label: string } {
109
+ // Backend sends logs like "β–Έ tool_name {args}" β€” strip the prefix
110
+ const step = raw.replace(/^β–Έ\s*/, '');
111
+ const args = parseStepArgs(step);
112
+
113
+ if (step.startsWith('github_find_examples')) {
114
+ const detail = (args.keyword) || (args.repo);
115
+ return { label: detail ? `Finding examples: ${detail}` : 'Finding examples' };
116
+ }
117
  if (step.startsWith('github_read_file')) {
118
+ const path = (args.path) || '';
119
  const filename = path.split('/').pop() || path;
120
+ return { label: filename ? `Reading ${filename}` : 'Reading file' };
121
+ }
122
+ if (step.startsWith('explore_hf_docs')) {
123
+ const endpoint = (args.endpoint) || (args.query);
124
+ return { label: endpoint ? `Exploring docs: ${endpoint}` : 'Exploring docs' };
125
+ }
126
+ if (step.startsWith('fetch_hf_docs')) {
127
+ const url = (args.url) || '';
128
+ const page = url.split('/').pop()?.replace(/\.md$/, '');
129
+ return { label: page ? `Reading docs: ${page}` : 'Fetching docs' };
130
+ }
131
+ if (step.startsWith('hf_inspect_dataset')) {
132
+ const dataset = (args.dataset);
133
+ return { label: dataset ? `Inspecting dataset: ${dataset}` : 'Inspecting dataset' };
134
  }
135
+ if (step.startsWith('hf_papers')) {
136
+ const op = args.operation as string;
137
+ const detail = (args.query) || (args.arxiv_id);
138
+ const opLabels: Record<string, string> = {
139
+ trending: 'Browsing trending papers',
140
+ search: 'Searching papers',
141
+ paper_details: 'Reading paper details',
142
+ read_paper: 'Reading paper',
143
+ citation_graph: 'Tracing citations',
144
+ snippet_search: 'Searching paper snippets',
145
+ recommend: 'Finding related papers',
146
+ find_datasets: 'Finding paper datasets',
147
+ find_models: 'Finding paper models',
148
+ find_collections: 'Finding paper collections',
149
+ find_all_resources: 'Finding paper resources',
150
+ };
151
+ const base = (op && opLabels[op]) || 'Searching papers';
152
+ return { label: detail ? `${base}: ${detail}` : base };
153
+ }
154
+ if (step.startsWith('find_hf_api')) {
155
+ const detail = (args.query) || (args.tag);
156
+ return { label: detail ? `Finding API: ${detail}` : 'Finding API endpoints' };
157
+ }
158
+ if (step.startsWith('hf_repo_files')) {
159
+ const repo = (args.repo_id) || (args.repo);
160
+ return { label: repo ? `Reading ${repo} files` : 'Reading repo files' };
161
+ }
162
+ if (step.startsWith('read')) {
163
+ const path = (args.path) || '';
164
+ const filename = path.split('/').pop();
165
+ return { label: filename ? `Reading ${filename}` : 'Reading file' };
166
+ }
167
+ if (step.startsWith('bash')) {
168
+ const cmd = args.command as string;
169
+ const short = cmd && cmd.length > 40 ? cmd.slice(0, 40) + '...' : cmd;
170
+ return { label: short ? `Running: ${short}` : 'Running command' };
171
+ }
172
+ return { label: step.replace(/^β–Έ\s*/, '') };
173
  }
174
 
175
+ /** Rolling 2-line display of research sub-tool calls β€” hidden when complete. */
176
  function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boolean }) {
177
+ if (!isRunning) return null;
178
+ const visible = steps.slice(-RESEARCH_MAX_STEPS);
179
+ if (visible.length === 0) return null;
 
 
180
 
181
  return (
182
  <Box sx={{ pl: 4.5, pr: 1.5, pb: 1, pt: 0.25 }}>
183
+ {visible.map((step, i) => {
184
+ const { label } = formatResearchStep(step);
185
+ const isLast = i === visible.length - 1;
186
  return (
187
  <Stack
188
  key={i}
 
191
  spacing={0.75}
192
  sx={{ py: 0.2 }}
193
  >
194
+ {isLast ? (
 
 
 
195
  <CircularProgress size={10} thickness={5} sx={{ color: 'var(--accent-yellow)', flexShrink: 0 }} />
196
+ ) : (
197
+ <CheckCircleOutlineIcon sx={{ fontSize: 12, color: 'var(--muted-text)', flexShrink: 0 }} />
198
  )}
199
  <Typography
200
  sx={{
201
  fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
202
  fontSize: '0.68rem',
203
+ color: isLast ? 'var(--text)' : 'var(--muted-text)',
204
  overflow: 'hidden',
205
  textOverflow: 'ellipsis',
206
  whiteSpace: 'nowrap',
 
248
  // Visual helpers
249
  // ---------------------------------------------------------------------------
250
 
251
+ function StatusIcon({ state, cancelled, isRejected }: { state: ToolPartState; cancelled?: boolean; isRejected?: boolean }) {
252
+ if (cancelled || isRejected) {
253
  return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
254
  }
255
  switch (state) {
 
513
  // ---------------------------------------------------------------------------
514
 
515
  export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
516
+ const { setPanel, lockPanel, getJobUrl, getEditedScript, setJobStatus, getJobStatus, setToolError, getToolError, setToolRejected, getToolRejected } = useAgentStore();
517
  const researchSteps = useAgentStore(s => {
518
  const activeId = s.activeSessionId;
519
  return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined;
520
  }) ?? EMPTY_STEPS;
521
+ const researchStats = useAgentStore(s => {
522
+ const activeId = s.activeSessionId;
523
+ return activeId ? s.sessionStates[activeId]?.researchStats : undefined;
524
+ }) ?? { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null };
525
+ const liveElapsed = useElapsed(researchStats.startedAt);
526
+ const isProcessing = useAgentStore(s => s.isProcessing);
527
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
528
 
529
  // ── Batch approval state ──────────────────────────────────────────
 
539
  // Track which toolCallIds we've already submitted so we can detect new approval rounds
540
  const submittedIdsRef = useRef<Set<string>>(new Set());
541
 
542
+ // ── Panel lock state (for auto-follow vs user-selected) ───────────
543
+ const [lockedToolId, setLockedToolId] = useState<string | null>(null);
544
+
545
  // Reset submission state when new (unseen) pending tools arrive β€” e.g. second approval round
546
  useEffect(() => {
547
  if (!isSubmitting || pendingTools.length === 0) return;
 
553
  }
554
  }, [pendingTools, isSubmitting]);
555
 
556
+ // Clean up stale decisions for tools that are no longer pending
557
+ useEffect(() => {
558
+ const pendingIds = new Set(pendingTools.map(t => t.toolCallId));
559
+ const decisionIds = Object.keys(decisions);
560
+ const hasStale = decisionIds.some(id => !pendingIds.has(id));
561
+ if (hasStale) {
562
+ setDecisions(prev => {
563
+ const cleaned = { ...prev };
564
+ for (const id of decisionIds) {
565
+ if (!pendingIds.has(id)) delete cleaned[id];
566
+ }
567
+ return cleaned;
568
+ });
569
+ }
570
+ }, [pendingTools, decisions]);
571
+
572
+ // Persist error states when tools error
573
+ useEffect(() => {
574
+ for (const tool of tools) {
575
+ const currentlyHasError = tool.state === 'output-error';
576
+ const persistedError = getToolError(tool.toolCallId);
577
+
578
+ // Persist error state if we detect it and haven't already
579
+ if (currentlyHasError && !persistedError) {
580
+ setToolError(tool.toolCallId, true);
581
+ }
582
+ }
583
+ }, [tools, setToolError, getToolError]);
584
+
585
  const { scriptLabelMap, toolDisplayMap } = useMemo(() => {
586
  const hfJobs = tools.filter(t => t.toolName === 'hf_jobs' && (t.input as Record<string, unknown>)?.script);
587
  const scriptMap: Record<string, string> = {};
 
617
  if (editedScript) {
618
  logger.log(`Sending edited script for ${toolCallId} (${editedScript.length} chars)`);
619
  }
620
+ // Mark tool as rejected if not approved
621
+ if (!d.approved) {
622
+ setToolRejected(toolCallId, true);
623
+ }
624
  return {
625
  tool_call_id: toolCallId,
626
  approved: d.approved,
 
640
  setIsSubmitting(false);
641
  }
642
  },
643
+ [approveTools, lockPanel, getEditedScript, setToolRejected],
644
  );
645
 
646
  const handleApproveAll = useCallback(() => {
 
676
  });
677
  }, []);
678
 
679
+ // ── Show tool panel (shared logic) ────────────────────────────────
680
+ const showToolPanel = useCallback(
681
  (tool: DynamicToolPart) => {
682
  const args = tool.input as Record<string, unknown> | undefined;
683
  const displayName = toolDisplayMap[tool.toolCallId] || tool.toolName;
 
703
  const inputSection = args ? { content: JSON.stringify(args, null, 2), language: 'json' } : undefined;
704
 
705
  const outputText = tool.output ?? (tool.state === 'output-error' ? (tool as Record<string, unknown>).errorText : undefined);
706
+
707
+ // Determine if tool is still running or has completed
708
+ const isRunning = tool.state === 'input-available' || tool.state === 'input-streaming' || tool.state === 'approval-responded';
709
+ const hasCompleted = tool.state === 'output-available' || tool.state === 'output-error' || tool.state === 'output-denied';
710
+
711
+ if (outputText) {
712
+ // Tool has output - show it (regardless of state)
713
  let language = 'text';
714
  const content = String(outputText);
715
  if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
 
721
  const content = `Tool \`${tool.toolName}\` returned an error with no output message.`;
722
  setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output');
723
  setRightPanelOpen(true);
724
+ } else if (hasCompleted && args) {
725
+ // Tool completed but has no output - show input as fallback
726
  setPanel({ title: displayName, output: { content: JSON.stringify(args, null, 2), language: 'json' }, input: inputSection }, 'output');
727
  setRightPanelOpen(true);
728
+ } else if (isRunning && args) {
729
+ // Tool is still running - show running message
730
+ const content = `Tool \`${tool.toolName}\` is still running...\n\nClick the input tab to view the tool arguments.`;
731
+ setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output');
732
+ setRightPanelOpen(true);
733
+ } else if (args) {
734
+ const runningMessages = [
735
+ 'Crunching numbers and herding tensors...',
736
+ 'Teaching the model some new tricks...',
737
+ 'Consulting the GPU oracle...',
738
+ 'Wrangling data into submission...',
739
+ 'Brewing a fresh batch of predictions...',
740
+ 'Negotiating with the transformer heads...',
741
+ 'Polishing the attention weights...',
742
+ 'Aligning the embedding stars...',
743
+ ];
744
+ const funMsg = runningMessages[Math.floor(Math.random() * runningMessages.length)];
745
+ setPanel({ title: displayName, output: { content: funMsg, language: 'text' }, input: inputSection }, 'output');
746
+ setRightPanelOpen(true);
747
  }
748
  },
749
  [toolDisplayMap, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen],
750
  );
751
 
752
+ // ── Panel click handler ───────────────────────────────────────────
753
+ const handleClick = useCallback(
754
+ (tool: DynamicToolPart) => {
755
+ // Toggle lock: if clicking the same tool that's already locked, unlock it
756
+ if (lockedToolId === tool.toolCallId) {
757
+ setLockedToolId(null);
758
+ return;
759
+ }
760
+
761
+ // Lock this tool
762
+ setLockedToolId(tool.toolCallId);
763
+
764
+ // Show the panel
765
+ showToolPanel(tool);
766
+ },
767
+ [lockedToolId, showToolPanel],
768
+ );
769
+
770
+ // ── Auto-follow currently active tool when not locked ─────────────
771
+ const activeToolIdRef = useRef<string | null>(null);
772
+
773
+ useEffect(() => {
774
+ if (lockedToolId !== null) return; // User has locked a tool, don't auto-follow
775
+
776
+ // Find the currently running tool (latest tool that's in progress)
777
+ const runningTool = tools.slice().reverse().find(t =>
778
+ t.state === 'input-available' ||
779
+ t.state === 'input-streaming' ||
780
+ t.state === 'approval-responded'
781
+ );
782
+
783
+ if (runningTool) {
784
+ // Track this as the active tool and show its panel
785
+ activeToolIdRef.current = runningTool.toolCallId;
786
+ showToolPanel(runningTool);
787
+ } else if (activeToolIdRef.current) {
788
+ // No running tool, but we were following one - check if it completed
789
+ const completedTool = tools.find(t => t.toolCallId === activeToolIdRef.current);
790
+ if (completedTool && (completedTool.state === 'output-available' || completedTool.state === 'output-error')) {
791
+ // The tool we were following has completed - update its panel
792
+ showToolPanel(completedTool);
793
+ }
794
+ }
795
+ }, [tools, lockedToolId, showToolPanel]);
796
+
797
  // ── Parse hf_jobs metadata from output ────────────────────────────
798
  function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } {
799
  if (typeof output !== 'string') return {};
 
880
  const clickable =
881
  state === 'output-available' ||
882
  state === 'output-error' ||
883
+ !!tool.input ||
884
+ (!isProcessing && (state === 'input-available' || state === 'input-streaming'));
885
  const localDecision = decisions[tool.toolCallId];
886
 
887
  const cancelled = isCancelledTool(tool);
888
+ const currentlyHasError = state === 'output-error';
889
+ const persistedError = getToolError(tool.toolCallId);
890
+ const persistedRejection = getToolRejected(tool.toolCallId);
891
+
892
+ // Stale in-progress tools after page reload: treat as completed
893
+ const stale = !isProcessing && (state === 'input-available' || state === 'input-streaming');
894
+ const displayState = stale ? 'output-available'
895
+ : isPending && localDecision
896
+ ? (localDecision.approved ? 'input-available' : 'output-denied')
897
+ : state;
898
+ const isRejected = displayState === 'output-denied' || persistedRejection;
899
+ const hasError = (persistedError || currentlyHasError) && !isRejected;
900
+ const label = cancelled ? 'cancelled'
901
+ : isRejected ? 'rejected'
902
+ : hasError ? 'error'
903
+ : statusLabel(displayState as ToolPartState);
904
 
905
  // Parse job metadata from hf_jobs output and store
906
  const jobUrlFromStore = tool.toolName === 'hf_jobs' ? getJobUrl(tool.toolCallId) : undefined;
907
+ const jobStatusFromStore = tool.toolName === 'hf_jobs' ? getJobStatus(tool.toolCallId) : undefined;
908
+
909
+ const jobMetaFromOutput = tool.toolName === 'hf_jobs' && (tool.output || (tool as Record<string, unknown>).errorText)
910
+ ? parseJobMeta(tool.output ?? (tool as Record<string, unknown>).errorText)
911
  : {};
912
+
913
+ // Store job status if we just parsed it and don't have it stored yet
914
+ if (tool.toolName === 'hf_jobs' && jobMetaFromOutput.jobStatus && !jobStatusFromStore) {
915
+ setJobStatus(tool.toolCallId, jobMetaFromOutput.jobStatus);
916
+ }
917
+
918
+ // Combine job URL and status from store (persisted) with output metadata (freshly parsed)
919
+ // Prefer stored values to ensure they persist across renders
920
  const jobMeta = {
921
  jobUrl: jobUrlFromStore || jobMetaFromOutput.jobUrl,
922
+ jobStatus: jobStatusFromStore || jobMetaFromOutput.jobStatus,
923
  };
924
 
925
  return (
 
935
  py: 1,
936
  cursor: isPending ? 'default' : clickable ? 'pointer' : 'default',
937
  transition: 'background-color 0.15s',
938
+ bgcolor: lockedToolId === tool.toolCallId ? 'var(--hover-bg)' : 'transparent',
939
+ borderLeft: lockedToolId === tool.toolCallId ? '3px solid var(--accent-yellow)' : '3px solid transparent',
940
  '&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
941
  }}
942
  >
943
  <StatusIcon
944
  cancelled={cancelled}
945
+ isRejected={isRejected}
946
  state={
947
+ hasError
948
  ? 'output-error'
949
+ : ((tool.toolName === 'hf_jobs' && jobMeta.jobStatus && ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus))
950
+ ? 'output-error'
951
+ : displayState as ToolPartState)
952
  }
953
  />
954
 
 
970
  </Typography>
971
 
972
  {/* Status chip (non hf_jobs, or hf_jobs without final status) */}
973
+ {(() => {
974
+ // Research tool: override chip label with live stats (but not if cancelled/done)
975
+ const researchDone = cancelled || state === 'output-available' || state === 'output-error' || state === 'output-denied';
976
+ const researchLabel = tool.toolName === 'research' && !researchDone
977
+ ? researchChipLabel(researchStats, liveElapsed)
978
+ : (tool.toolName === 'research' && researchDone && researchStats.finalElapsed !== null)
979
+ ? researchChipLabel({ ...researchStats, startedAt: null }, null)
980
+ : null;
981
+ const chipLabel = researchLabel || label;
982
+ if (!chipLabel || (tool.toolName === 'hf_jobs' && jobMeta.jobStatus)) return null;
983
+
984
+ return (
985
+ <Chip
986
+ label={chipLabel}
987
+ size="small"
988
+ sx={{
989
+ height: 20,
990
+ fontSize: '0.65rem',
991
+ fontWeight: 600,
992
+ bgcolor: (cancelled || isRejected) ? 'rgba(255,255,255,0.05)'
993
+ : hasError ? 'rgba(224,90,79,0.12)'
994
+ : (researchLabel && displayState === 'output-available') ? 'rgba(47,204,113,0.12)'
995
+ : 'var(--accent-yellow-weak)',
996
+ color: (cancelled || isRejected) ? 'var(--muted-text)'
997
+ : hasError ? 'var(--accent-red)'
998
+ : statusColor(displayState as ToolPartState),
999
+ letterSpacing: '0.03em',
1000
+ }}
1001
+ />
1002
+ );
1003
+ })()}
1004
 
1005
  {/* HF Jobs: final status chip from job metadata */}
1006
  {tool.toolName === 'hf_jobs' && jobMeta.jobStatus && (
 
1054
  )}
1055
  </Stack>
1056
 
1057
+ {/* Research sub-agent rolling steps (visible only while running) */}
1058
+ {tool.toolName === 'research' && !cancelled && state !== 'output-available' && state !== 'output-error' && state !== 'output-denied' && (
1059
  <ResearchSteps
1060
  steps={researchSteps}
1061
+ isRunning={researchStats.startedAt !== null}
1062
  />
1063
  )}
1064
 
frontend/src/components/Chat/UserMessage.tsx CHANGED
@@ -1,5 +1,8 @@
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
 
@@ -7,6 +10,7 @@ interface UserMessageProps {
7
  message: UIMessage;
8
  isLastTurn?: boolean;
9
  onUndoTurn?: () => void;
 
10
  isProcessing?: boolean;
11
  }
12
 
@@ -21,14 +25,57 @@ 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"
@@ -36,35 +83,56 @@ export default function UserMessage({
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
@@ -78,20 +146,66 @@ export default function UserMessage({
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' }}
 
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { Box, Stack, Typography, IconButton, Tooltip, TextField } from '@mui/material';
3
  import CloseIcon from '@mui/icons-material/Close';
4
+ import EditIcon from '@mui/icons-material/Edit';
5
+ import CheckIcon from '@mui/icons-material/Check';
6
  import type { UIMessage } from 'ai';
7
  import type { MessageMeta } from '@/types/agent';
8
 
 
10
  message: UIMessage;
11
  isLastTurn?: boolean;
12
  onUndoTurn?: () => void;
13
+ onEditAndRegenerate?: (messageId: string, newText: string) => void | Promise<void>;
14
  isProcessing?: boolean;
15
  }
16
 
 
25
  message,
26
  isLastTurn = false,
27
  onUndoTurn,
28
+ onEditAndRegenerate,
29
  isProcessing = false,
30
  }: UserMessageProps) {
31
  const showUndo = isLastTurn && !isProcessing && !!onUndoTurn;
32
+ const showEdit = !isProcessing && !!onEditAndRegenerate;
33
  const text = extractText(message);
34
  const meta = message.metadata as MessageMeta | undefined;
35
  const timeStr = meta?.createdAt
36
  ? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
37
  : null;
38
+
39
+ const [isEditing, setIsEditing] = useState(false);
40
+ const [editText, setEditText] = useState(text);
41
+ const inputRef = useRef<HTMLTextAreaElement>(null);
42
+
43
+ useEffect(() => {
44
+ if (isEditing && inputRef.current) {
45
+ inputRef.current.focus();
46
+ inputRef.current.selectionStart = inputRef.current.value.length;
47
+ }
48
+ }, [isEditing]);
49
+
50
+ const handleStartEdit = () => {
51
+ setEditText(text);
52
+ setIsEditing(true);
53
+ };
54
+
55
+ const handleConfirmEdit = () => {
56
+ const trimmed = editText.trim();
57
+ if (!trimmed || trimmed === text) {
58
+ setIsEditing(false);
59
+ return;
60
+ }
61
+ setIsEditing(false);
62
+ onEditAndRegenerate?.(message.id, trimmed);
63
+ };
64
+
65
+ const handleCancelEdit = () => {
66
+ setIsEditing(false);
67
+ setEditText(text);
68
+ };
69
+
70
+ const handleKeyDown = (e: React.KeyboardEvent) => {
71
+ if (e.key === 'Enter' && !e.shiftKey) {
72
+ e.preventDefault();
73
+ handleConfirmEdit();
74
+ } else if (e.key === 'Escape') {
75
+ handleCancelEdit();
76
+ }
77
+ };
78
+
79
  return (
80
  <Stack
81
  direction="row"
 
83
  justifyContent="flex-end"
84
  alignItems="flex-start"
85
  sx={{
86
+ '& .action-btn': {
87
  opacity: 0,
88
  transition: 'opacity 0.15s ease',
89
  },
90
+ '&:hover .action-btn': {
91
  opacity: 1,
92
  },
93
  }}
94
  >
95
+ {!isEditing && (showUndo || showEdit) && (
96
+ <Stack className="action-btn" direction="row" spacing={0.25} sx={{ mt: 0.75 }}>
97
+ {showEdit && (
98
+ <Tooltip title="Edit & regenerate" placement="left">
99
+ <IconButton
100
+ onClick={handleStartEdit}
101
+ size="small"
102
+ sx={{
103
+ width: 24,
104
+ height: 24,
105
+ color: 'var(--muted-text)',
106
+ '&:hover': {
107
+ color: 'var(--accent-yellow)',
108
+ bgcolor: 'rgba(255,157,0,0.08)',
109
+ },
110
+ }}
111
+ >
112
+ <EditIcon sx={{ fontSize: 14 }} />
113
+ </IconButton>
114
+ </Tooltip>
115
+ )}
116
+ {showUndo && (
117
+ <Tooltip title="Remove this turn" placement="left">
118
+ <IconButton
119
+ onClick={onUndoTurn}
120
+ size="small"
121
+ sx={{
122
+ width: 24,
123
+ height: 24,
124
+ color: 'var(--muted-text)',
125
+ '&:hover': {
126
+ color: 'var(--accent-red)',
127
+ bgcolor: 'rgba(244,67,54,0.08)',
128
+ },
129
+ }}
130
+ >
131
+ <CloseIcon sx={{ fontSize: 14 }} />
132
+ </IconButton>
133
+ </Tooltip>
134
+ )}
135
+ </Stack>
136
  )}
137
 
138
  <Box
 
146
  border: '1px solid var(--border)',
147
  }}
148
  >
149
+ {isEditing ? (
150
+ <Stack spacing={1}>
151
+ <TextField
152
+ inputRef={inputRef}
153
+ multiline
154
+ fullWidth
155
+ value={editText}
156
+ onChange={(e) => setEditText(e.target.value)}
157
+ onKeyDown={handleKeyDown}
158
+ variant="outlined"
159
+ size="small"
160
+ sx={{
161
+ '& .MuiOutlinedInput-root': {
162
+ fontFamily: 'inherit',
163
+ fontSize: '0.925rem',
164
+ lineHeight: 1.65,
165
+ color: 'var(--text)',
166
+ '& fieldset': { borderColor: 'var(--accent-yellow)', borderWidth: 1.5 },
167
+ '&:hover fieldset': { borderColor: 'var(--accent-yellow)' },
168
+ '&.Mui-focused fieldset': { borderColor: 'var(--accent-yellow)' },
169
+ },
170
+ }}
171
+ />
172
+ <Stack direction="row" spacing={0.5} justifyContent="flex-end">
173
+ <Tooltip title="Cancel (Esc)">
174
+ <IconButton
175
+ onClick={handleCancelEdit}
176
+ size="small"
177
+ sx={{ color: 'var(--muted-text)', '&:hover': { color: 'var(--accent-red)' } }}
178
+ >
179
+ <CloseIcon sx={{ fontSize: 16 }} />
180
+ </IconButton>
181
+ </Tooltip>
182
+ <Tooltip title="Confirm (Enter)">
183
+ <IconButton
184
+ onClick={handleConfirmEdit}
185
+ size="small"
186
+ sx={{ color: 'var(--accent-green)', '&:hover': { bgcolor: 'rgba(47,204,113,0.1)' } }}
187
+ >
188
+ <CheckIcon sx={{ fontSize: 16 }} />
189
+ </IconButton>
190
+ </Tooltip>
191
+ </Stack>
192
+ </Stack>
193
+ ) : (
194
+ <Typography
195
+ variant="body1"
196
+ sx={{
197
+ fontSize: '0.925rem',
198
+ lineHeight: 1.65,
199
+ color: 'var(--text)',
200
+ whiteSpace: 'pre-wrap',
201
+ wordBreak: 'break-word',
202
+ }}
203
+ >
204
+ {text}
205
+ </Typography>
206
+ )}
207
 
208
+ {timeStr && !isEditing && (
209
  <Typography
210
  variant="caption"
211
  sx={{ color: 'var(--muted-text)', mt: 0.5, display: 'block', textAlign: 'right', fontSize: '0.7rem' }}
frontend/src/components/SessionChat.tsx CHANGED
@@ -5,7 +5,7 @@
5
  * runs β€” processing events β€” but only the active session renders visible
6
  * UI (MessageList + ChatInput).
7
  */
8
- import { useCallback, useEffect, useState } from 'react';
9
  import { useAgentChat } from '@/hooks/useAgentChat';
10
  import { useAgentStore } from '@/store/agentStore';
11
  import { useSessionStore } from '@/store/sessionStore';
@@ -24,9 +24,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
24
  const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
25
  const { updateSessionTitle } = useSessionStore();
26
 
27
- const [wasCancelled, setWasCancelled] = useState(false);
28
-
29
- const { messages, sendMessage, stop, status, undoLastTurn, approveTools } = useAgentChat({
30
  sessionId,
31
  isActive,
32
  onReady: () => logger.log(`Session ${sessionId} ready`),
@@ -57,11 +55,11 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
57
  return () => document.removeEventListener('visibilitychange', onVisible);
58
  }, [isActive, sessionId]);
59
 
60
- // Wrap stop to track cancellation
61
  const handleStop = useCallback(() => {
62
  stop();
63
- setWasCancelled(true);
64
- }, [stop]);
65
 
66
  // SDK status is the ground truth β€” if it's streaming/submitted, agent is busy
67
  const sdkBusy = status === 'streaming' || status === 'submitted';
@@ -71,12 +69,11 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
71
  async (text: string) => {
72
  if (!text.trim() || busy) return;
73
 
74
- setWasCancelled(false);
75
- updateSession(sessionId, { isProcessing: true });
76
  sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
77
 
78
  // Auto-title the session from the first user message
79
- const isFirstMessage = messages.filter((m) => m.role === 'user').length <= 1;
80
  if (isFirstMessage) {
81
  apiFetch('/api/title', {
82
  method: 'POST',
@@ -105,6 +102,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
105
  isProcessing={busy}
106
  approveTools={approveTools}
107
  onUndoLastTurn={undoLastTurn}
 
108
  />
109
  <ChatInput
110
  onSend={handleSendMessage}
@@ -114,9 +112,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
114
  placeholder={
115
  activityStatus.type === 'waiting-approval'
116
  ? 'Approve or reject pending tools first...'
117
- : wasCancelled
118
- ? 'What should the agent do instead?'
119
- : undefined
120
  }
121
  />
122
  </>
 
5
  * runs β€” processing events β€” but only the active session renders visible
6
  * UI (MessageList + ChatInput).
7
  */
8
+ import { useCallback, useEffect } from 'react';
9
  import { useAgentChat } from '@/hooks/useAgentChat';
10
  import { useAgentStore } from '@/store/agentStore';
11
  import { useSessionStore } from '@/store/sessionStore';
 
24
  const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
25
  const { updateSessionTitle } = useSessionStore();
26
 
27
+ const { messages, sendMessage, stop, status, undoLastTurn, editAndRegenerate, approveTools } = useAgentChat({
 
 
28
  sessionId,
29
  isActive,
30
  onReady: () => logger.log(`Session ${sessionId} ready`),
 
55
  return () => document.removeEventListener('visibilitychange', onVisible);
56
  }, [isActive, sessionId]);
57
 
58
+ // Wrap stop to show cancelled shimmer
59
  const handleStop = useCallback(() => {
60
  stop();
61
+ updateSession(sessionId, { activityStatus: { type: 'cancelled' } });
62
+ }, [stop, updateSession, sessionId]);
63
 
64
  // SDK status is the ground truth β€” if it's streaming/submitted, agent is busy
65
  const sdkBusy = status === 'streaming' || status === 'submitted';
 
69
  async (text: string) => {
70
  if (!text.trim() || busy) return;
71
 
72
+ updateSession(sessionId, { isProcessing: true, activityStatus: { type: 'thinking' } });
 
73
  sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
74
 
75
  // Auto-title the session from the first user message
76
+ const isFirstMessage = messages.filter((m) => m.role === 'user').length === 0;
77
  if (isFirstMessage) {
78
  apiFetch('/api/title', {
79
  method: 'POST',
 
102
  isProcessing={busy}
103
  approveTools={approveTools}
104
  onUndoLastTurn={undoLastTurn}
105
+ onEditAndRegenerate={editAndRegenerate}
106
  />
107
  <ChatInput
108
  onSend={handleSendMessage}
 
112
  placeholder={
113
  activityStatus.type === 'waiting-approval'
114
  ? 'Approve or reject pending tools first...'
115
+ : undefined
 
 
116
  }
117
  />
118
  </>
frontend/src/components/SessionSidebar/SessionSidebar.tsx CHANGED
@@ -2,6 +2,12 @@ import { useCallback, useState } from 'react';
2
  import {
3
  Alert,
4
  Box,
 
 
 
 
 
 
5
  IconButton,
6
  Typography,
7
  CircularProgress,
@@ -51,20 +57,52 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
51
  }
52
  }, [isCreatingSession, createSession, setPlan, clearPanel, onClose]);
53
 
54
- const handleDelete = useCallback(
55
- async (sessionId: string, e: React.MouseEvent) => {
 
 
 
 
56
  e.stopPropagation();
57
- useAgentStore.getState().clearSessionState(sessionId);
58
- try {
59
- await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
60
- deleteSession(sessionId);
61
- } catch {
62
- deleteSession(sessionId);
63
- }
64
  },
65
- [deleteSession],
66
  );
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  const handleSelect = useCallback(
69
  (sessionId: string) => {
70
  switchSession(sessionId);
@@ -181,6 +219,7 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
181
  px: 1.5,
182
  py: 0.875,
183
  mx: 0.75,
 
184
  borderRadius: '10px',
185
  cursor: 'pointer',
186
  transition: 'background-color 0.12s ease',
@@ -256,7 +295,7 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
256
  <IconButton
257
  className="delete-btn"
258
  size="small"
259
- onClick={(e) => handleDelete(session.id, e)}
260
  sx={{
261
  color: 'var(--muted-text)',
262
  width: 26,
@@ -328,6 +367,89 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
328
  </Box>
329
 
330
  </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  </Box>
332
  );
333
  }
 
2
  import {
3
  Alert,
4
  Box,
5
+ Button,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ DialogContentText,
10
+ DialogTitle,
11
  IconButton,
12
  Typography,
13
  CircularProgress,
 
57
  }
58
  }, [isCreatingSession, createSession, setPlan, clearPanel, onClose]);
59
 
60
+ // -- Delete with dialog confirmation ------------------------------------
61
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
62
+ const [isDeleting, setIsDeleting] = useState(false);
63
+
64
+ const handleDeleteClick = useCallback(
65
+ (sessionId: string, e: React.MouseEvent) => {
66
  e.stopPropagation();
67
+ setConfirmDeleteId(sessionId);
 
 
 
 
 
 
68
  },
69
+ [],
70
  );
71
 
72
+ const handleDeleteConfirm = useCallback(async () => {
73
+ if (!confirmDeleteId || isDeleting) return;
74
+ const sessionId = confirmDeleteId;
75
+ setIsDeleting(true);
76
+
77
+ const isLastSession = sessions.length === 1;
78
+
79
+ useAgentStore.getState().clearSessionState(sessionId);
80
+ try {
81
+ await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
82
+ deleteSession(sessionId);
83
+ } catch {
84
+ deleteSession(sessionId);
85
+ }
86
+
87
+ // If this was the last session, create a new one
88
+ if (isLastSession) {
89
+ try {
90
+ const response = await apiFetch('/api/session', { method: 'POST' });
91
+ if (response.ok) {
92
+ const data = await response.json();
93
+ createSession(data.session_id);
94
+ setPlan([]);
95
+ clearPanel();
96
+ }
97
+ } catch (error) {
98
+ console.error('Failed to create new session after deleting last one:', error);
99
+ }
100
+ }
101
+
102
+ setIsDeleting(false);
103
+ setConfirmDeleteId(null);
104
+ }, [deleteSession, confirmDeleteId, isDeleting, sessions, createSession, setPlan, clearPanel]);
105
+
106
  const handleSelect = useCallback(
107
  (sessionId: string) => {
108
  switchSession(sessionId);
 
219
  px: 1.5,
220
  py: 0.875,
221
  mx: 0.75,
222
+ mb: 0.2,
223
  borderRadius: '10px',
224
  cursor: 'pointer',
225
  transition: 'background-color 0.12s ease',
 
295
  <IconButton
296
  className="delete-btn"
297
  size="small"
298
+ onClick={(e) => handleDeleteClick(session.id, e)}
299
  sx={{
300
  color: 'var(--muted-text)',
301
  width: 26,
 
367
  </Box>
368
 
369
  </Box>
370
+ {/* Delete confirmation dialog */}
371
+ <Dialog
372
+ open={!!confirmDeleteId}
373
+ onClose={() => !isDeleting && setConfirmDeleteId(null)}
374
+ slotProps={{
375
+ backdrop: { sx: { backgroundColor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' } },
376
+ }}
377
+ PaperProps={{
378
+ sx: {
379
+ bgcolor: 'var(--panel)',
380
+ border: '1px solid var(--border)',
381
+ borderRadius: 'var(--radius-md)',
382
+ boxShadow: 'var(--shadow-1)',
383
+ maxWidth: 340,
384
+ mx: 2,
385
+ },
386
+ }}
387
+ >
388
+ <DialogTitle
389
+ sx={{
390
+ color: 'var(--text)',
391
+ fontWeight: 700,
392
+ fontSize: '0.95rem',
393
+ pb: 0,
394
+ pt: 2.5,
395
+ px: 3,
396
+ }}
397
+ >
398
+ Delete conversation?
399
+ </DialogTitle>
400
+ <DialogContent sx={{ px: 3, pt: 1 }}>
401
+ <DialogContentText
402
+ sx={{
403
+ color: 'var(--muted-text)',
404
+ fontSize: '0.82rem',
405
+ lineHeight: 1.6,
406
+ }}
407
+ >
408
+ This will permanently remove this conversation and its history.
409
+ </DialogContentText>
410
+ </DialogContent>
411
+ <DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
412
+ <Button
413
+ onClick={() => setConfirmDeleteId(null)}
414
+ size="small"
415
+ disabled={isDeleting}
416
+ sx={{
417
+ color: 'var(--muted-text)',
418
+ fontSize: '0.82rem',
419
+ px: 2,
420
+ '&:hover': { bgcolor: 'var(--hover-bg)' },
421
+ }}
422
+ >
423
+ Cancel
424
+ </Button>
425
+ <Button
426
+ onClick={handleDeleteConfirm}
427
+ variant="contained"
428
+ size="small"
429
+ disabled={isDeleting}
430
+ startIcon={isDeleting ? <CircularProgress size={16} sx={{ color: '#fff' }} /> : undefined}
431
+ sx={{
432
+ fontSize: '0.82rem',
433
+ px: 2.5,
434
+ bgcolor: 'var(--accent-red)',
435
+ color: '#fff',
436
+ boxShadow: 'none',
437
+ '&:hover': {
438
+ bgcolor: 'var(--accent-red)',
439
+ filter: 'brightness(1.15)',
440
+ boxShadow: 'none',
441
+ },
442
+ '&.Mui-disabled': {
443
+ bgcolor: 'var(--accent-red)',
444
+ color: '#fff',
445
+ opacity: 0.7,
446
+ },
447
+ }}
448
+ >
449
+ {isDeleting ? 'Deleting...' : 'Delete'}
450
+ </Button>
451
+ </DialogActions>
452
+ </Dialog>
453
  </Box>
454
  );
455
  }
frontend/src/components/WelcomeScreen/WelcomeScreen.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useEffect, useRef } from 'react';
2
  import {
3
  Box,
4
  Typography,
@@ -6,53 +6,236 @@ import {
6
  CircularProgress,
7
  Alert,
8
  } from '@mui/material';
 
9
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
10
  import GroupAddIcon from '@mui/icons-material/GroupAdd';
 
 
11
  import { useSessionStore } from '@/store/sessionStore';
12
  import { useAgentStore } from '@/store/agentStore';
13
  import { apiFetch } from '@/utils/api';
14
  import { isInIframe, triggerLogin } from '@/hooks/useAuth';
 
15
 
16
- /** HF brand orange */
17
  const HF_ORANGE = '#FF9D00';
 
 
18
 
19
- const ORG_JOIN_URL = 'https://huggingface.co/organizations/ml-agent-explorers/share/GzPMJUivoFPlfkvFtIqEouZKSytatKQSZT';
20
- const ORG_JOINED_KEY = 'hf-agent-org-joined';
 
21
 
22
- function hasJoinedOrg(): boolean {
23
- try { return localStorage.getItem(ORG_JOINED_KEY) === '1'; } catch { return false; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
- function markOrgJoined(): void {
27
- try { localStorage.setItem(ORG_JOINED_KEY, '1'); } catch { /* ignore */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
 
 
 
 
 
30
  export default function WelcomeScreen() {
31
  const { createSession } = useSessionStore();
32
  const { setPlan, clearPanel, user } = useAgentStore();
33
  const [isCreating, setIsCreating] = useState(false);
34
  const [error, setError] = useState<string | null>(null);
35
- const [orgJoined, setOrgJoined] = useState(hasJoinedOrg);
36
- const joinLinkOpened = useRef(false);
37
 
38
  const inIframe = isInIframe();
39
- const isAuthenticated = user?.authenticated;
40
  const isDevUser = user?.username === 'dev';
41
 
42
- // Auto-advance when user returns from the join link
 
 
 
 
 
 
43
  useEffect(() => {
 
44
  const handleVisibility = () => {
45
  if (document.visibilityState !== 'visible' || !joinLinkOpened.current) return;
46
  joinLinkOpened.current = false;
47
- markOrgJoined();
48
- setOrgJoined(true);
49
  };
50
-
51
  document.addEventListener('visibilitychange', handleVisibility);
52
  return () => document.removeEventListener('visibilitychange', handleVisibility);
53
- }, []);
 
 
54
 
55
- const tryCreateSession = useCallback(async () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  setIsCreating(true);
57
  setError(null);
58
 
@@ -80,72 +263,21 @@ export default function WelcomeScreen() {
80
  } finally {
81
  setIsCreating(false);
82
  }
83
- }, [createSession, setPlan, clearPanel]);
84
 
85
- const handleStart = useCallback(async () => {
86
- if (isCreating) return;
87
-
88
- if (!isAuthenticated && !isDevUser) {
89
- if (inIframe) return;
90
- triggerLogin();
91
- return;
92
- }
93
-
94
- await tryCreateSession();
95
- }, [isCreating, isAuthenticated, isDevUser, inIframe, tryCreateSession]);
96
-
97
- // Build the direct Space URL for the "open in new tab" link
98
- const spaceHost = typeof window !== 'undefined'
99
- ? window.location.hostname.includes('.hf.space')
100
- ? window.location.origin
101
- : `https://smolagents-ml-agent.hf.space`
102
- : '';
103
-
104
- // Shared button style
105
- const primaryBtnSx = {
106
- px: 5,
107
- py: 1.5,
108
- fontSize: '1rem',
109
- fontWeight: 700,
110
- textTransform: 'none' as const,
111
- borderRadius: '12px',
112
- bgcolor: HF_ORANGE,
113
- color: '#000',
114
- boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
115
- textDecoration: 'none',
116
- '&:hover': {
117
- bgcolor: '#FFB340',
118
- boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
119
- },
120
- };
121
 
122
- // Description block (reused across screens)
123
- const description = (
124
- <Typography
125
- variant="body1"
126
- sx={{
127
- color: 'var(--muted-text)',
128
- maxWidth: 520,
129
- mb: 5,
130
- lineHeight: 1.8,
131
- fontSize: '0.95rem',
132
- textAlign: 'center',
133
- px: 2,
134
- '& strong': { color: 'var(--text)', fontWeight: 600 },
135
- }}
136
- >
137
- A general-purpose AI agent for <strong>machine learning engineering</strong>.
138
- It browses <strong>Hugging Face documentation</strong>, manages{' '}
139
- <strong>repositories</strong>, launches <strong>training jobs</strong>,
140
- and explores <strong>datasets</strong> β€” all through natural conversation.
141
- </Typography>
142
- );
143
 
144
- // Which screen to show
145
- const needsJoin = inIframe && !orgJoined;
146
- const showOpenAgent = inIframe && orgJoined;
147
- const showSignin = !inIframe && !isAuthenticated && !isDevUser;
148
- const showReady = !inIframe && (isAuthenticated || isDevUser);
 
 
149
 
150
  return (
151
  <Box
@@ -160,12 +292,12 @@ export default function WelcomeScreen() {
160
  py: 8,
161
  }}
162
  >
163
- {/* HF Logo */}
164
  <Box
165
  component="img"
166
  src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
167
  alt="Hugging Face"
168
- sx={{ width: 96, height: 96, mb: 3, display: 'block' }}
169
  />
170
 
171
  {/* Title */}
@@ -174,120 +306,128 @@ export default function WelcomeScreen() {
174
  sx={{
175
  fontWeight: 800,
176
  color: 'var(--text)',
177
- mb: 1.5,
178
  letterSpacing: '-0.02em',
179
- fontSize: { xs: '2rem', md: '2.8rem' },
180
  }}
181
  >
182
  HF Agent
183
  </Typography>
184
 
185
- {/* ── Iframe: join org (first visit only) ──────────────────── */}
186
- {needsJoin && (
187
- <>
188
- <Typography
189
- variant="body1"
190
- sx={{
191
- color: 'var(--muted-text)',
192
- maxWidth: 480,
193
- mb: 4,
194
- lineHeight: 1.8,
195
- fontSize: '0.95rem',
196
- textAlign: 'center',
197
- px: 2,
198
- '& strong': { color: 'var(--text)', fontWeight: 600 },
199
- }}
200
- >
201
- Under the hood, this agent uses GPUs, inference APIs, and other paid Hub goodies β€” but we made them all free for you. Just join <strong>ML Agent Explorers</strong> to get started!
202
- </Typography>
203
-
204
- <Button
205
- variant="contained"
206
- size="large"
207
- component="a"
208
- href={ORG_JOIN_URL}
209
- target="_blank"
210
- rel="noopener noreferrer"
211
- onClick={() => { joinLinkOpened.current = true; }}
212
- startIcon={<GroupAddIcon />}
213
- sx={primaryBtnSx}
214
- >
215
- Join ML Agent Explorers
216
- </Button>
217
- </>
218
- )}
219
-
220
- {/* ── Iframe: already joined β†’ open Space ──────────────────── */}
221
- {showOpenAgent && (
222
- <>
223
- {description}
224
- <Button
225
- variant="contained"
226
- size="large"
227
- component="a"
228
- href={spaceHost}
229
- target="_blank"
230
- rel="noopener noreferrer"
231
- endIcon={<OpenInNewIcon />}
232
- sx={primaryBtnSx}
233
- >
234
- Open HF Agent
235
- </Button>
236
- </>
237
- )}
238
-
239
- {/* ── Direct: not logged in β†’ sign in ──────────────────────── */}
240
- {showSignin && (
241
- <>
242
- {description}
243
- <Button
244
- variant="contained"
245
- size="large"
246
- onClick={() => triggerLogin()}
247
- sx={primaryBtnSx}
248
- >
249
- Sign in with Hugging Face
250
- </Button>
251
 
252
- <Typography
253
- variant="caption"
254
- sx={{
255
- mt: 2.5,
256
- color: 'var(--muted-text)',
257
- fontSize: '0.78rem',
258
- textAlign: 'center',
259
- maxWidth: 360,
260
- lineHeight: 1.6,
261
- }}
262
- >
263
- Make sure to enable access to the <strong>ml-agent-explorers</strong> org when prompted.
264
- </Typography>
265
- </>
266
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
- {/* ── Direct: authenticated β†’ start session ────────────────── */}
269
- {showReady && (
270
- <>
271
- {description}
272
- <Button
273
- variant="contained"
274
- size="large"
275
- onClick={handleStart}
276
- disabled={isCreating}
277
- startIcon={
278
- isCreating ? <CircularProgress size={20} color="inherit" /> : null
279
- }
280
- sx={{
281
- ...primaryBtnSx,
282
- '&.Mui-disabled': {
283
- bgcolor: 'rgba(255, 157, 0, 0.35)',
284
- color: 'rgba(0,0,0,0.45)',
285
- },
286
- }}
287
- >
288
- {isCreating ? 'Initializing...' : 'Start Session'}
289
- </Button>
290
- </>
291
  )}
292
 
293
  {/* Error */}
@@ -311,7 +451,7 @@ export default function WelcomeScreen() {
311
  {/* Footnote */}
312
  <Typography
313
  variant="caption"
314
- sx={{ mt: 5, color: 'var(--muted-text)', opacity: 0.5, fontSize: '0.7rem' }}
315
  >
316
  Conversations are stored locally in your browser.
317
  </Typography>
 
1
+ import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
2
  import {
3
  Box,
4
  Typography,
 
6
  CircularProgress,
7
  Alert,
8
  } from '@mui/material';
9
+ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
10
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
11
  import GroupAddIcon from '@mui/icons-material/GroupAdd';
12
+ import LoginIcon from '@mui/icons-material/Login';
13
+ import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
14
  import { useSessionStore } from '@/store/sessionStore';
15
  import { useAgentStore } from '@/store/agentStore';
16
  import { apiFetch } from '@/utils/api';
17
  import { isInIframe, triggerLogin } from '@/hooks/useAuth';
18
+ import { useOrgMembership } from '@/hooks/useOrgMembership';
19
 
 
20
  const HF_ORANGE = '#FF9D00';
21
+ const ORG_JOIN_URL =
22
+ 'https://huggingface.co/organizations/ml-agent-explorers/share/GzPMJUivoFPlfkvFtIqEouZKSytatKQSZT';
23
 
24
+ // ---------------------------------------------------------------------------
25
+ // ChecklistStep sub-component
26
+ // ---------------------------------------------------------------------------
27
 
28
+ type StepStatus = 'completed' | 'active' | 'locked';
29
+
30
+ interface ChecklistStepProps {
31
+ stepNumber: number;
32
+ title: string;
33
+ description: string;
34
+ status: StepStatus;
35
+ lockedReason?: string;
36
+ actionLabel?: string;
37
+ onAction?: () => void;
38
+ actionIcon?: ReactNode;
39
+ actionHref?: string;
40
+ loading?: boolean;
41
+ isLast?: boolean;
42
+ }
43
+
44
+ function StepIndicator({ status, stepNumber }: { status: StepStatus; stepNumber: number }) {
45
+ if (status === 'completed') {
46
+ return <CheckCircleIcon sx={{ fontSize: 28, color: 'var(--accent-green)' }} />;
47
+ }
48
+ return (
49
+ <Box
50
+ sx={{
51
+ width: 28,
52
+ height: 28,
53
+ borderRadius: '50%',
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ justifyContent: 'center',
57
+ fontSize: '0.8rem',
58
+ fontWeight: 700,
59
+ ...(status === 'active'
60
+ ? { bgcolor: HF_ORANGE, color: '#000' }
61
+ : { bgcolor: 'transparent', border: '2px solid var(--border)', color: 'var(--muted-text)' }),
62
+ }}
63
+ >
64
+ {stepNumber}
65
+ </Box>
66
+ );
67
  }
68
 
69
+ function ChecklistStep({
70
+ stepNumber,
71
+ title,
72
+ description,
73
+ status,
74
+ lockedReason,
75
+ actionLabel,
76
+ onAction,
77
+ actionIcon,
78
+ actionHref,
79
+ loading = false,
80
+ isLast = false,
81
+ }: ChecklistStepProps) {
82
+ const btnSx = {
83
+ px: 3,
84
+ py: 0.75,
85
+ fontSize: '0.85rem',
86
+ fontWeight: 700,
87
+ textTransform: 'none' as const,
88
+ borderRadius: '10px',
89
+ whiteSpace: 'nowrap' as const,
90
+ textDecoration: 'none',
91
+ ...(status === 'active'
92
+ ? {
93
+ bgcolor: HF_ORANGE,
94
+ color: '#000',
95
+ boxShadow: '0 2px 12px rgba(255, 157, 0, 0.25)',
96
+ '&:hover': { bgcolor: '#FFB340', boxShadow: '0 4px 20px rgba(255, 157, 0, 0.4)' },
97
+ }
98
+ : {
99
+ bgcolor: 'rgba(255,255,255,0.04)',
100
+ color: 'var(--muted-text)',
101
+ '&.Mui-disabled': { bgcolor: 'rgba(255,255,255,0.04)', color: 'var(--muted-text)' },
102
+ }),
103
+ };
104
+
105
+ return (
106
+ <Box
107
+ sx={{
108
+ display: 'flex',
109
+ alignItems: 'center',
110
+ gap: 2,
111
+ px: 3,
112
+ py: 2.5,
113
+ borderLeft: '3px solid',
114
+ borderLeftColor:
115
+ status === 'completed'
116
+ ? 'var(--accent-green)'
117
+ : status === 'active'
118
+ ? HF_ORANGE
119
+ : 'transparent',
120
+ ...(!isLast && { borderBottom: '1px solid var(--border)' }),
121
+ opacity: status === 'locked' ? 0.55 : 1,
122
+ transition: 'opacity 0.2s, border-color 0.2s',
123
+ }}
124
+ >
125
+ <StepIndicator status={status} stepNumber={stepNumber} />
126
+
127
+ <Box sx={{ flex: 1, minWidth: 0 }}>
128
+ <Typography
129
+ variant="subtitle2"
130
+ sx={{
131
+ fontWeight: 600,
132
+ fontSize: '0.92rem',
133
+ color: status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
134
+ ...(status === 'completed' && { textDecoration: 'line-through', textDecorationColor: 'var(--muted-text)' }),
135
+ }}
136
+ >
137
+ {title}
138
+ </Typography>
139
+ <Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.8rem', mt: 0.25, lineHeight: 1.5 }}>
140
+ {status === 'locked' && lockedReason ? lockedReason : description}
141
+ </Typography>
142
+ </Box>
143
+
144
+ {status === 'completed' ? (
145
+ <Typography variant="caption" sx={{ color: 'var(--accent-green)', fontWeight: 600, fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
146
+ Done
147
+ </Typography>
148
+ ) : actionLabel ? (
149
+ actionHref ? (
150
+ <Button
151
+ variant="contained"
152
+ size="small"
153
+ component="a"
154
+ href={actionHref}
155
+ target="_blank"
156
+ rel="noopener noreferrer"
157
+ disabled={status === 'locked'}
158
+ startIcon={actionIcon}
159
+ sx={btnSx}
160
+ onClick={onAction}
161
+ >
162
+ {actionLabel}
163
+ </Button>
164
+ ) : (
165
+ <Button
166
+ variant="contained"
167
+ size="small"
168
+ disabled={status === 'locked' || loading}
169
+ startIcon={loading ? <CircularProgress size={16} color="inherit" /> : actionIcon}
170
+ onClick={onAction}
171
+ sx={btnSx}
172
+ >
173
+ {loading ? 'Loading...' : actionLabel}
174
+ </Button>
175
+ )
176
+ ) : null}
177
+ </Box>
178
+ );
179
  }
180
 
181
+ // ---------------------------------------------------------------------------
182
+ // WelcomeScreen
183
+ // ---------------------------------------------------------------------------
184
+
185
  export default function WelcomeScreen() {
186
  const { createSession } = useSessionStore();
187
  const { setPlan, clearPanel, user } = useAgentStore();
188
  const [isCreating, setIsCreating] = useState(false);
189
  const [error, setError] = useState<string | null>(null);
 
 
190
 
191
  const inIframe = isInIframe();
192
+ const isAuthenticated = !!user?.authenticated;
193
  const isDevUser = user?.username === 'dev';
194
 
195
+ // Iframe: localStorage-based org tracking (no auth token available)
196
+ const [iframeOrgJoined, setIframeOrgJoined] = useState(() => {
197
+ try { return localStorage.getItem('hf-agent-org-joined') === '1'; } catch { return false; }
198
+ });
199
+ const joinLinkOpened = useRef(false);
200
+
201
+ // Auto-advance when user returns from org join link (iframe only)
202
  useEffect(() => {
203
+ if (!inIframe) return;
204
  const handleVisibility = () => {
205
  if (document.visibilityState !== 'visible' || !joinLinkOpened.current) return;
206
  joinLinkOpened.current = false;
207
+ try { localStorage.setItem('hf-agent-org-joined', '1'); } catch { /* ignore */ }
208
+ setIframeOrgJoined(true);
209
  };
 
210
  document.addEventListener('visibilitychange', handleVisibility);
211
  return () => document.removeEventListener('visibilitychange', handleVisibility);
212
+ }, [inIframe]);
213
+
214
+ const isOrgMember = inIframe ? iframeOrgJoined : !!user?.orgMember;
215
 
216
+ // Poll for org membership once authenticated (skipped in dev mode and iframe)
217
+ const popupRef = useOrgMembership(isAuthenticated && !isDevUser && !inIframe && !isOrgMember);
218
+
219
+ // ---- Actions ----
220
+
221
+ const handleJoinOrg = useCallback(() => {
222
+ if (inIframe) {
223
+ // Iframe: open link, track via visibilitychange + localStorage
224
+ joinLinkOpened.current = true;
225
+ window.open(ORG_JOIN_URL, '_blank', 'noopener,noreferrer');
226
+ return;
227
+ }
228
+ // Direct: open as popup, auto-close via polling
229
+ const popup = window.open(ORG_JOIN_URL, 'hf-org-join', 'noopener');
230
+ if (popup) {
231
+ popupRef.current = popup;
232
+ } else {
233
+ window.open(ORG_JOIN_URL, '_blank', 'noopener,noreferrer');
234
+ }
235
+ }, [popupRef, inIframe]);
236
+
237
+ const handleStartSession = useCallback(async () => {
238
+ if (isCreating) return;
239
  setIsCreating(true);
240
  setError(null);
241
 
 
263
  } finally {
264
  setIsCreating(false);
265
  }
266
+ }, [isCreating, createSession, setPlan, clearPanel]);
267
 
268
+ // ---- Step status helpers ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
+ const signInStatus: StepStatus = isAuthenticated ? 'completed' : 'active';
271
+ const joinOrgStatus: StepStatus = isOrgMember ? 'completed' : isAuthenticated ? 'active' : 'locked';
272
+ const startStatus: StepStatus = isAuthenticated && isOrgMember ? 'active' : 'locked';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
+ // Space URL for iframe "Open HF Agent" step
275
+ const spaceHost =
276
+ typeof window !== 'undefined'
277
+ ? window.location.hostname.includes('.hf.space')
278
+ ? window.location.origin
279
+ : 'https://smolagents-ml-agent.hf.space'
280
+ : '';
281
 
282
  return (
283
  <Box
 
292
  py: 8,
293
  }}
294
  >
295
+ {/* Logo */}
296
  <Box
297
  component="img"
298
  src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
299
  alt="Hugging Face"
300
+ sx={{ width: 80, height: 80, mb: 2.5, display: 'block' }}
301
  />
302
 
303
  {/* Title */}
 
306
  sx={{
307
  fontWeight: 800,
308
  color: 'var(--text)',
309
+ mb: 1,
310
  letterSpacing: '-0.02em',
311
+ fontSize: { xs: '1.8rem', md: '2.4rem' },
312
  }}
313
  >
314
  HF Agent
315
  </Typography>
316
 
317
+ {/* Description */}
318
+ <Typography
319
+ variant="body1"
320
+ sx={{
321
+ color: 'var(--muted-text)',
322
+ maxWidth: 480,
323
+ mb: 4,
324
+ lineHeight: 1.7,
325
+ fontSize: '0.9rem',
326
+ textAlign: 'center',
327
+ px: 2,
328
+ '& strong': { color: 'var(--text)', fontWeight: 600 },
329
+ }}
330
+ >
331
+ A general-purpose AI agent for <strong>machine learning engineering</strong>.
332
+ It browses <strong>Hugging Face docs</strong>, manages <strong>repos</strong>,
333
+ launches <strong>training jobs</strong>, and explores <strong>datasets</strong>.
334
+ </Typography>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
+ {/* ── Checklist ──────────────────────────────────────────── */}
337
+ <Box
338
+ sx={{
339
+ width: '100%',
340
+ maxWidth: 520,
341
+ bgcolor: 'var(--surface)',
342
+ border: '1px solid var(--border)',
343
+ borderRadius: '12px',
344
+ overflow: 'hidden',
345
+ mx: 2,
346
+ }}
347
+ >
348
+ {isDevUser ? (
349
+ /* Dev mode: single step */
350
+ <ChecklistStep
351
+ stepNumber={1}
352
+ title="Start Session"
353
+ description="Launch an AI agent session for ML engineering."
354
+ status="active"
355
+ actionLabel="Start Session"
356
+ actionIcon={<RocketLaunchIcon sx={{ fontSize: 16 }} />}
357
+ onAction={handleStartSession}
358
+ loading={isCreating}
359
+ isLast
360
+ />
361
+ ) : inIframe ? (
362
+ /* Iframe: 2 steps */
363
+ <>
364
+ <ChecklistStep
365
+ stepNumber={1}
366
+ title="Join ML Agent Explorers"
367
+ description="Get free access to GPUs, inference APIs, and Hub resources."
368
+ status={isOrgMember ? 'completed' : 'active'}
369
+ actionLabel="Join Organization"
370
+ actionIcon={<GroupAddIcon sx={{ fontSize: 16 }} />}
371
+ onAction={handleJoinOrg}
372
+ />
373
+ <ChecklistStep
374
+ stepNumber={2}
375
+ title="Open HF Agent"
376
+ description="Open the agent in a full browser tab to get started."
377
+ status={isOrgMember ? 'active' : 'locked'}
378
+ lockedReason="Join the organization first."
379
+ actionLabel="Open HF Agent"
380
+ actionIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
381
+ actionHref={spaceHost}
382
+ isLast
383
+ />
384
+ </>
385
+ ) : (
386
+ /* Direct access: 3 steps */
387
+ <>
388
+ <ChecklistStep
389
+ stepNumber={1}
390
+ title="Sign in with Hugging Face"
391
+ description="Authenticate to access GPU resources and model APIs."
392
+ status={signInStatus}
393
+ actionLabel="Sign in"
394
+ actionIcon={<LoginIcon sx={{ fontSize: 16 }} />}
395
+ onAction={() => triggerLogin()}
396
+ />
397
+ <ChecklistStep
398
+ stepNumber={2}
399
+ title="Join ML Agent Explorers"
400
+ description="Get free access to GPUs, inference APIs, and Hub resources."
401
+ status={joinOrgStatus}
402
+ lockedReason="Sign in first to continue."
403
+ actionLabel="Join Organization"
404
+ actionIcon={<GroupAddIcon sx={{ fontSize: 16 }} />}
405
+ onAction={handleJoinOrg}
406
+ />
407
+ <ChecklistStep
408
+ stepNumber={3}
409
+ title="Start Session"
410
+ description="Launch an AI agent session for ML engineering."
411
+ status={startStatus}
412
+ lockedReason="Complete the steps above to continue."
413
+ actionLabel="Start Session"
414
+ actionIcon={<RocketLaunchIcon sx={{ fontSize: 16 }} />}
415
+ onAction={handleStartSession}
416
+ loading={isCreating}
417
+ isLast
418
+ />
419
+ </>
420
+ )}
421
+ </Box>
422
 
423
+ {/* Polling hint when waiting for org join */}
424
+ {isAuthenticated && !isOrgMember && !isDevUser && !inIframe && (
425
+ <Typography
426
+ variant="caption"
427
+ sx={{ mt: 2, color: 'var(--muted-text)', fontSize: '0.75rem', textAlign: 'center' }}
428
+ >
429
+ This page updates automatically when you join the organization.
430
+ </Typography>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  )}
432
 
433
  {/* Error */}
 
451
  {/* Footnote */}
452
  <Typography
453
  variant="caption"
454
+ sx={{ mt: 4, color: 'var(--muted-text)', opacity: 0.5, fontSize: '0.7rem' }}
455
  >
456
  Conversations are stored locally in your browser.
457
  </Typography>
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -12,6 +12,7 @@ import { useChat } from '@ai-sdk/react';
12
  import { type UIMessage, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
13
  import { SSEChatTransport, type SideChannelCallbacks } from '@/lib/sse-chat-transport';
14
  import { loadMessages, saveMessages } from '@/lib/chat-message-store';
 
15
  import { llmMessagesToUIMessages } from '@/lib/convert-llm-messages';
16
  import { apiFetch } from '@/utils/api';
17
  import { useAgentStore } from '@/store/agentStore';
@@ -86,14 +87,46 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
86
  }
87
  },
88
  onToolLog: (tool: string, log: string) => {
89
- // Research sub-agent: accumulate steps + update activity status
90
  if (tool === 'research') {
91
  const sessState = useAgentStore.getState().getSessionState(sessionId);
92
- const steps = [...sessState.researchSteps, log];
93
- updateSession(sessionId, {
94
- researchSteps: steps,
95
- activityStatus: { type: 'tool', toolName: 'research', description: log },
96
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  return;
98
  }
99
 
@@ -235,8 +268,11 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
235
  const updates: Partial<import('@/store/agentStore').PerSessionState> = {
236
  activityStatus: { type: 'tool', toolName, description },
237
  };
238
- // Clear research steps when a new research call starts
239
- if (toolName === 'research') updates.researchSteps = [];
 
 
 
240
  updateSession(sessionId, updates);
241
  },
242
  onInterrupted: () => { /* no-op β€” handled by stop() caller */ },
@@ -332,7 +368,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
332
  if (msgsRes.ok) {
333
  const data = await msgsRes.json();
334
  if (cancelled || !Array.isArray(data) || data.length === 0) return;
335
- const uiMsgs = llmMessagesToUIMessages(data, pendingIds);
336
  if (uiMsgs.length > 0) {
337
  chat.setMessages(uiMsgs);
338
  saveMessages(sessionId, uiMsgs);
@@ -344,9 +380,25 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
344
  // results make tools look "done" even when the agent is still
345
  // mid-turn and about to call more tools.
346
  if (backendIsProcessing) {
347
- updateSession(sessionId, { isProcessing: true, activityStatus: { type: 'thinking' } });
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  } else if (pendingIds && pendingIds.size > 0) {
349
  updateSession(sessionId, { activityStatus: { type: 'waiting-approval' } });
 
 
 
350
  }
351
  } catch {
352
  /* backend unreachable -- localStorage fallback is fine */
@@ -453,7 +505,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
453
  // Final hydration to get the complete message state
454
  const result = await hydrateMessages();
455
  if (result) {
456
- const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds);
457
  if (uiMsgs.length > 0) {
458
  chat.setMessages(uiMsgs);
459
  saveMessages(sessionId, uiMsgs);
@@ -467,7 +519,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
467
  stopReconnect();
468
  const result = await hydrateMessages();
469
  if (result) {
470
- const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds);
471
  if (uiMsgs.length > 0) {
472
  chat.setMessages(uiMsgs);
473
  saveMessages(sessionId, uiMsgs);
@@ -491,7 +543,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
491
  if (!result) return;
492
 
493
  const { data, pendingIds, info } = result;
494
- const uiMsgs = llmMessagesToUIMessages(data, pendingIds);
495
  if (uiMsgs.length > 0) {
496
  chat.setMessages(uiMsgs);
497
  saveMessages(sessionId, uiMsgs);
@@ -514,11 +566,14 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
514
  pollTimerRef.current = setInterval(async () => {
515
  const fresh = await hydrateMessages();
516
  if (!fresh) return;
517
- const msgs = llmMessagesToUIMessages(fresh.data, fresh.pendingIds);
518
- if (msgs.length > 0) {
 
 
519
  chat.setMessages(msgs);
520
  saveMessages(sessionId, msgs);
521
- }
 
522
  // If backend stopped processing, clean up
523
  if (fresh.info && !fresh.info.is_processing) {
524
  updateSession(sessionId, { isProcessing: false });
@@ -542,7 +597,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
542
  if (chat.messages.length !== prevLenRef.current) {
543
  prevLenRef.current = chat.messages.length;
544
  saveMessages(sessionId, chat.messages);
545
- }
546
  }, [sessionId, chat.messages]);
547
 
548
  // -- Undo last turn (REST call + client-side message removal) -----------
@@ -598,6 +653,11 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
598
  if (hasApproved) {
599
  updateSession(sessionId, { isProcessing: true });
600
  }
 
 
 
 
 
601
  return true;
602
  },
603
  [sessionId, chat, updateSession, setNeedsAttention],
@@ -612,12 +672,52 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
612
  apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch(() => {});
613
  }, [sessionId, updateSession]);
614
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  return {
616
  messages: chat.messages,
617
  sendMessage: chat.sendMessage,
618
  stop,
619
  status: chat.status,
620
  undoLastTurn,
 
621
  approveTools,
622
  };
623
  }
 
12
  import { type UIMessage, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
13
  import { SSEChatTransport, type SideChannelCallbacks } from '@/lib/sse-chat-transport';
14
  import { loadMessages, saveMessages } from '@/lib/chat-message-store';
15
+ import { saveResearch, loadResearch, clearResearch, RESEARCH_MAX_STEPS } from '@/lib/research-store';
16
  import { llmMessagesToUIMessages } from '@/lib/convert-llm-messages';
17
  import { apiFetch } from '@/utils/api';
18
  import { useAgentStore } from '@/store/agentStore';
 
87
  }
88
  },
89
  onToolLog: (tool: string, log: string) => {
90
+ // Research sub-agent: parse stats vs step logs
91
  if (tool === 'research') {
92
  const sessState = useAgentStore.getState().getSessionState(sessionId);
93
+ const stats = { ...sessState.researchStats };
94
+
95
+ if (log === 'Starting research sub-agent...') {
96
+ const newStats = { toolCount: 0, tokenCount: 0, startedAt: Date.now(), finalElapsed: null };
97
+ updateSession(sessionId, {
98
+ researchSteps: [],
99
+ researchStats: newStats,
100
+ activityStatus: { type: 'tool', toolName: 'research', description: log },
101
+ });
102
+ saveResearch(sessionId, [], newStats);
103
+ } else if (log.startsWith('tokens:')) {
104
+ stats.tokenCount = parseInt(log.slice(7), 10);
105
+ updateSession(sessionId, { researchStats: stats });
106
+ saveResearch(sessionId, sessState.researchSteps, stats);
107
+ } else if (log.startsWith('tools:')) {
108
+ stats.toolCount = parseInt(log.slice(6), 10);
109
+ updateSession(sessionId, { researchStats: stats });
110
+ saveResearch(sessionId, sessState.researchSteps, stats);
111
+ } else if (log === 'Research complete.') {
112
+ const elapsed = stats.startedAt
113
+ ? Math.round((Date.now() - stats.startedAt) / 1000)
114
+ : null;
115
+ const doneStats = { ...stats, startedAt: null, finalElapsed: elapsed };
116
+ updateSession(sessionId, {
117
+ researchStats: doneStats,
118
+ activityStatus: { type: 'tool', toolName: 'research', description: log },
119
+ });
120
+ clearResearch(sessionId);
121
+ } else {
122
+ // Regular tool call step β€” append (trim to max)
123
+ const steps = [...sessState.researchSteps, log].slice(-RESEARCH_MAX_STEPS);
124
+ updateSession(sessionId, {
125
+ researchSteps: steps,
126
+ activityStatus: { type: 'tool', toolName: 'research', description: log },
127
+ });
128
+ saveResearch(sessionId, steps, stats);
129
+ }
130
  return;
131
  }
132
 
 
268
  const updates: Partial<import('@/store/agentStore').PerSessionState> = {
269
  activityStatus: { type: 'tool', toolName, description },
270
  };
271
+ // Clear research steps + stats when a new research call starts
272
+ if (toolName === 'research') {
273
+ updates.researchSteps = [];
274
+ updates.researchStats = { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null };
275
+ }
276
  updateSession(sessionId, updates);
277
  },
278
  onInterrupted: () => { /* no-op β€” handled by stop() caller */ },
 
368
  if (msgsRes.ok) {
369
  const data = await msgsRes.json();
370
  if (cancelled || !Array.isArray(data) || data.length === 0) return;
371
+ const uiMsgs = llmMessagesToUIMessages(data, pendingIds, chatActionsRef.current.messages);
372
  if (uiMsgs.length > 0) {
373
  chat.setMessages(uiMsgs);
374
  saveMessages(sessionId, uiMsgs);
 
380
  // results make tools look "done" even when the agent is still
381
  // mid-turn and about to call more tools.
382
  if (backendIsProcessing) {
383
+ // Restore research sub-agent state alongside isProcessing in one
384
+ // atomic update so the UI never sees isProcessing=false with stale
385
+ // tool states (which would coerce them to 'output-available').
386
+ const savedResearch = loadResearch(sessionId);
387
+ updateSession(sessionId, {
388
+ isProcessing: true,
389
+ activityStatus: savedResearch?.stats.startedAt
390
+ ? { type: 'tool', toolName: 'research', description: 'Resuming research...' }
391
+ : { type: 'thinking' },
392
+ ...(savedResearch && {
393
+ researchSteps: savedResearch.steps,
394
+ researchStats: savedResearch.stats,
395
+ }),
396
+ });
397
  } else if (pendingIds && pendingIds.size > 0) {
398
  updateSession(sessionId, { activityStatus: { type: 'waiting-approval' } });
399
+ clearResearch(sessionId);
400
+ } else {
401
+ clearResearch(sessionId);
402
  }
403
  } catch {
404
  /* backend unreachable -- localStorage fallback is fine */
 
505
  // Final hydration to get the complete message state
506
  const result = await hydrateMessages();
507
  if (result) {
508
+ const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
509
  if (uiMsgs.length > 0) {
510
  chat.setMessages(uiMsgs);
511
  saveMessages(sessionId, uiMsgs);
 
519
  stopReconnect();
520
  const result = await hydrateMessages();
521
  if (result) {
522
+ const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
523
  if (uiMsgs.length > 0) {
524
  chat.setMessages(uiMsgs);
525
  saveMessages(sessionId, uiMsgs);
 
543
  if (!result) return;
544
 
545
  const { data, pendingIds, info } = result;
546
+ const uiMsgs = llmMessagesToUIMessages(data, pendingIds, chatActionsRef.current.messages);
547
  if (uiMsgs.length > 0) {
548
  chat.setMessages(uiMsgs);
549
  saveMessages(sessionId, uiMsgs);
 
566
  pollTimerRef.current = setInterval(async () => {
567
  const fresh = await hydrateMessages();
568
  if (!fresh) return;
569
+ const msgs = llmMessagesToUIMessages(fresh.data, fresh.pendingIds, chatActionsRef.current.messages);
570
+
571
+ const currentCount = chatActionsRef.current.messages.length;
572
+ if (msgs.length > currentCount || currentCount === 0) {
573
  chat.setMessages(msgs);
574
  saveMessages(sessionId, msgs);
575
+ }
576
+
577
  // If backend stopped processing, clean up
578
  if (fresh.info && !fresh.info.is_processing) {
579
  updateSession(sessionId, { isProcessing: false });
 
597
  if (chat.messages.length !== prevLenRef.current) {
598
  prevLenRef.current = chat.messages.length;
599
  saveMessages(sessionId, chat.messages);
600
+ }
601
  }, [sessionId, chat.messages]);
602
 
603
  // -- Undo last turn (REST call + client-side message removal) -----------
 
653
  if (hasApproved) {
654
  updateSession(sessionId, { isProcessing: true });
655
  }
656
+
657
+ // Persist updated tool states so a page refresh during execution
658
+ // won't restore stale approval-requested state from localStorage.
659
+ saveMessages(sessionId, chatActionsRef.current.messages);
660
+
661
  return true;
662
  },
663
  [sessionId, chat, updateSession, setNeedsAttention],
 
672
  apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch(() => {});
673
  }, [sessionId, updateSession]);
674
 
675
+ // -- Edit message + regenerate from that point ----------------------------
676
+ const editAndRegenerate = useCallback(async (messageId: string, newText: string) => {
677
+ try {
678
+ const msgs = chatActionsRef.current.messages;
679
+ const setMsgs = chatActionsRef.current.setMessages;
680
+ if (!setMsgs) return;
681
+
682
+ // Find the target message and compute user message index (0-indexed, skipping system)
683
+ const msgIndex = msgs.findIndex(m => m.id === messageId);
684
+ if (msgIndex < 0) return;
685
+
686
+ let userMsgIndex = 0;
687
+ for (let i = 0; i < msgIndex; i++) {
688
+ if (msgs[i].role === 'user') userMsgIndex++;
689
+ }
690
+
691
+ // 1. Truncate backend history
692
+ const res = await apiFetch(`/api/truncate/${sessionId}`, {
693
+ method: 'POST',
694
+ body: JSON.stringify({ user_message_index: userMsgIndex }),
695
+ headers: { 'Content-Type': 'application/json' },
696
+ });
697
+ if (!res.ok) {
698
+ logger.error('Truncate API returned', res.status);
699
+ return;
700
+ }
701
+
702
+ // 2. Truncate frontend messages
703
+ const truncated = msgs.slice(0, msgIndex);
704
+ setMsgs(truncated);
705
+ saveMessages(sessionId, truncated);
706
+
707
+ // 3. Send the edited message (reuses existing transport + /api/chat)
708
+ chat.sendMessage({ text: newText, metadata: { createdAt: new Date().toISOString() } });
709
+ } catch (e) {
710
+ logger.error('Edit and regenerate failed:', e);
711
+ }
712
+ }, [sessionId, chat]);
713
+
714
  return {
715
  messages: chat.messages,
716
  sendMessage: chat.sendMessage,
717
  stop,
718
  status: chat.status,
719
  undoLastTurn,
720
+ editAndRegenerate,
721
  approveTools,
722
  };
723
  }
frontend/src/hooks/useOrgMembership.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Polls backend for org membership status.
3
+ * When membership is detected, updates the user in the agent store
4
+ * and closes any org-join popup that was opened.
5
+ */
6
+ import { useEffect, useRef } from 'react';
7
+ import { useAgentStore } from '@/store/agentStore';
8
+
9
+ const POLL_INTERVAL_MS = 3000;
10
+
11
+ /**
12
+ * @param enabled Only poll when true (user is authenticated but not yet confirmed as org member)
13
+ * @returns popupRef β€” assign `window.open()` result to `.current` so the hook can auto-close it
14
+ */
15
+ export function useOrgMembership(enabled: boolean) {
16
+ const user = useAgentStore((s) => s.user);
17
+ const setUser = useAgentStore((s) => s.setUser);
18
+ const popupRef = useRef<Window | null>(null);
19
+
20
+ useEffect(() => {
21
+ if (!enabled || user?.orgMember) return;
22
+
23
+ let cancelled = false;
24
+
25
+ const check = async () => {
26
+ try {
27
+ const res = await fetch('/auth/org-membership', { credentials: 'include' });
28
+ if (!res.ok || cancelled) return;
29
+ const data = await res.json();
30
+ if (cancelled) return;
31
+ if (data.is_member && user) {
32
+ setUser({ ...user, orgMember: true });
33
+ try { popupRef.current?.close(); } catch { /* cross-origin or already closed */ }
34
+ popupRef.current = null;
35
+ }
36
+ } catch { /* backend unreachable β€” skip */ }
37
+ };
38
+
39
+ check();
40
+ const id = setInterval(check, POLL_INTERVAL_MS);
41
+ return () => { cancelled = true; clearInterval(id); };
42
+ }, [enabled, user?.orgMember, user, setUser]);
43
+
44
+ return popupRef;
45
+ }
frontend/src/lib/chat-message-store.ts CHANGED
@@ -38,7 +38,8 @@ function writeAll(map: MessagesMap): void {
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 {
 
38
 
39
  export function loadMessages(sessionId: string): UIMessage[] {
40
  const map = readAll();
41
+ const messages = map[sessionId] ?? [];
42
+ return messages;
43
  }
44
 
45
  export function saveMessages(sessionId: string, messages: UIMessage[]): void {
frontend/src/lib/convert-llm-messages.ts CHANGED
@@ -16,19 +16,24 @@ interface LLMMessage {
16
  name?: string | null;
17
  }
18
 
19
- let idCounter = 0;
 
 
20
  function nextId(): string {
21
- return `msg-${Date.now()}-${++idCounter}`;
22
  }
23
 
24
  /**
25
  * @param pendingApprovalIds - Set of tool_call_ids that are waiting for approval.
26
  * When provided, matching tool calls without results will get state
27
  * 'approval-requested' instead of 'input-available'.
 
 
28
  */
29
  export function llmMessagesToUIMessages(
30
  messages: LLMMessage[],
31
  pendingApprovalIds?: Set<string>,
 
32
  ): UIMessage[] {
33
  // Build a map of tool_call_id -> tool result for pairing
34
  const toolResults = new Map<string, { output: string; isError: boolean }>();
@@ -43,13 +48,22 @@ export function llmMessagesToUIMessages(
43
 
44
  const uiMessages: UIMessage[] = [];
45
 
 
 
 
 
 
 
 
46
  for (const msg of messages) {
47
  if (msg.role === 'system') continue;
48
  if (msg.role === 'tool') continue; // handled via tool_calls pairing
49
 
50
  if (msg.role === 'user') {
 
 
51
  uiMessages.push({
52
- id: nextId(),
53
  role: 'user',
54
  parts: [{ type: 'text', text: msg.content || '' }],
55
  });
@@ -109,8 +123,11 @@ export function llmMessagesToUIMessages(
109
  if (prev && prev.role === 'assistant') {
110
  prev.parts.push(...parts);
111
  } else {
 
 
 
112
  uiMessages.push({
113
- id: nextId(),
114
  role: 'assistant',
115
  parts,
116
  });
 
16
  name?: string | null;
17
  }
18
 
19
+ // Generate stable IDs based on message position to prevent duplicate renders
20
+ // when the same message is re-converted multiple times (e.g., during polling)
21
+ let uiMessageCounter = 0;
22
  function nextId(): string {
23
+ return `msg-${++uiMessageCounter}`;
24
  }
25
 
26
  /**
27
  * @param pendingApprovalIds - Set of tool_call_ids that are waiting for approval.
28
  * When provided, matching tool calls without results will get state
29
  * 'approval-requested' instead of 'input-available'.
30
+ * @param existingUIMessages - Current UI messages to preserve IDs when content matches.
31
+ * This prevents React from re-rendering messages with new IDs during polling.
32
  */
33
  export function llmMessagesToUIMessages(
34
  messages: LLMMessage[],
35
  pendingApprovalIds?: Set<string>,
36
+ existingUIMessages?: UIMessage[],
37
  ): UIMessage[] {
38
  // Build a map of tool_call_id -> tool result for pairing
39
  const toolResults = new Map<string, { output: string; isError: boolean }>();
 
48
 
49
  const uiMessages: UIMessage[] = [];
50
 
51
+ // Helper to get existing message ID at a given position if roles match
52
+ const getExistingId = (index: number, role: 'user' | 'assistant'): string | null => {
53
+ if (!existingUIMessages || index >= existingUIMessages.length) return null;
54
+ const existing = existingUIMessages[index];
55
+ return existing.role === role ? existing.id : null;
56
+ };
57
+
58
  for (const msg of messages) {
59
  if (msg.role === 'system') continue;
60
  if (msg.role === 'tool') continue; // handled via tool_calls pairing
61
 
62
  if (msg.role === 'user') {
63
+ // Try to reuse existing ID if the message at this position matches
64
+ const existingId = getExistingId(uiMessages.length, 'user');
65
  uiMessages.push({
66
+ id: existingId || nextId(),
67
  role: 'user',
68
  parts: [{ type: 'text', text: msg.content || '' }],
69
  });
 
123
  if (prev && prev.role === 'assistant') {
124
  prev.parts.push(...parts);
125
  } else {
126
+ // Try to reuse existing ID if the message at this position matches
127
+ const existingId = getExistingId(uiMessages.length, 'assistant');
128
+ const newId = existingId || nextId();
129
  uiMessages.push({
130
+ id: newId,
131
  role: 'assistant',
132
  parts,
133
  });
frontend/src/lib/research-store.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Persist research sub-agent state (steps + stats) per session.
3
+ * Survives page refresh so the rolling display isn't lost mid-research.
4
+ */
5
+ import type { PerSessionState } from '@/store/agentStore';
6
+
7
+ /** Max steps to keep in storage and display. Single source of truth. */
8
+ export const RESEARCH_MAX_STEPS = 40;
9
+
10
+ const STORAGE_KEY = 'hf-agent-research';
11
+
12
+ type ResearchState = {
13
+ steps: string[];
14
+ stats: PerSessionState['researchStats'];
15
+ };
16
+
17
+ type ResearchMap = Record<string, ResearchState>;
18
+
19
+ function readAll(): ResearchMap {
20
+ try {
21
+ const raw = localStorage.getItem(STORAGE_KEY);
22
+ return raw ? JSON.parse(raw) : {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ function writeAll(map: ResearchMap): void {
29
+ try {
30
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
31
+ } catch { /* quota exceeded β€” ignore */ }
32
+ }
33
+
34
+ export function saveResearch(
35
+ sessionId: string,
36
+ steps: string[],
37
+ stats: PerSessionState['researchStats'],
38
+ ): void {
39
+ const map = readAll();
40
+ map[sessionId] = {
41
+ steps: steps.slice(-RESEARCH_MAX_STEPS),
42
+ stats,
43
+ };
44
+ writeAll(map);
45
+ }
46
+
47
+ export function loadResearch(sessionId: string): ResearchState | null {
48
+ const map = readAll();
49
+ return map[sessionId] ?? null;
50
+ }
51
+
52
+ export function clearResearch(sessionId: string): void {
53
+ const map = readAll();
54
+ delete map[sessionId];
55
+ writeAll(map);
56
+ }
frontend/src/lib/sse-chat-transport.ts CHANGED
@@ -277,7 +277,8 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
277
  this.sessionId = sessionId;
278
  this.sideChannel = sideChannel;
279
  // Mark as connected immediately β€” no persistent connection to establish
280
- sideChannel.onConnectionChange(true);
 
281
  }
282
 
283
  updateSideChannel(sideChannel: SideChannelCallbacks): void {
 
277
  this.sessionId = sessionId;
278
  this.sideChannel = sideChannel;
279
  // Mark as connected immediately β€” no persistent connection to establish
280
+ // Defer to avoid setState during render
281
+ queueMicrotask(() => sideChannel.onConnectionChange(true));
282
  }
283
 
284
  updateSideChannel(sideChannel: SideChannelCallbacks): void {
frontend/src/store/agentStore.ts CHANGED
@@ -50,7 +50,8 @@ export type ActivityStatus =
50
  | { type: 'thinking' }
51
  | { type: 'tool'; toolName: string; description?: string }
52
  | { type: 'waiting-approval' }
53
- | { type: 'streaming' };
 
54
 
55
  /** State that is tracked per-session (each session has its own copy). */
56
  export interface PerSessionState {
@@ -62,6 +63,8 @@ export interface PerSessionState {
62
  plan: PlanItem[];
63
  /** Steps completed by the research sub-agent (tool_log events). */
64
  researchSteps: string[];
 
 
65
  }
66
 
67
  const defaultSessionState: PerSessionState = {
@@ -72,6 +75,7 @@ const defaultSessionState: PerSessionState = {
72
  panelEditable: false,
73
  plan: [],
74
  researchSteps: [],
 
75
  };
76
 
77
  interface AgentStore {
@@ -101,6 +105,15 @@ interface AgentStore {
101
  // Job URLs (tool_call_id -> job URL) for HF jobs
102
  jobUrls: Record<string, string>;
103
 
 
 
 
 
 
 
 
 
 
104
  // ── Per-session actions ─────────────────────────────────────────────
105
 
106
  /** Update a session's state. If it's the active session, also update flat state. */
@@ -138,6 +151,15 @@ interface AgentStore {
138
 
139
  setJobUrl: (toolCallId: string, jobUrl: string) => void;
140
  getJobUrl: (toolCallId: string) => string | undefined;
 
 
 
 
 
 
 
 
 
141
  }
142
 
143
  /**
@@ -159,6 +181,44 @@ function syncSnapshot(
159
  };
160
  }
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  export const useAgentStore = create<AgentStore>()((set, get) => ({
163
  sessionStates: {},
164
  activeSessionId: null,
@@ -178,6 +238,9 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
178
 
179
  editedScripts: {},
180
  jobUrls: {},
 
 
 
181
 
182
  // ── Per-session state management ──────────────────────────────────
183
 
@@ -189,7 +252,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
189
  // Apply the processingβ†’idle side effect
190
  const processingCleared = 'isProcessing' in updates && !updates.isProcessing;
191
  if (processingCleared) {
192
- if (updated.activityStatus.type !== 'waiting-approval') {
193
  updated.activityStatus = { type: 'idle' };
194
  }
195
  }
@@ -237,6 +300,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
237
  panelEditable: state.panelEditable,
238
  plan: state.plan,
239
  researchSteps: state.sessionStates[state.activeSessionId]?.researchSteps ?? [],
 
240
  };
241
  }
242
 
@@ -267,7 +331,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
267
 
268
  setProcessing: (isProcessing) => {
269
  const current = get().activityStatus;
270
- const preserveStatus = current.type === 'waiting-approval';
271
  set({ isProcessing, ...(!isProcessing && !preserveStatus ? { activityStatus: { type: 'idle' } } : {}) });
272
  },
273
  setConnected: (isConnected) => set({ isConnected }),
@@ -349,4 +413,38 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
349
  },
350
 
351
  getJobUrl: (toolCallId) => get().jobUrls[toolCallId],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  }));
 
50
  | { type: 'thinking' }
51
  | { type: 'tool'; toolName: string; description?: string }
52
  | { type: 'waiting-approval' }
53
+ | { type: 'streaming' }
54
+ | { type: 'cancelled' };
55
 
56
  /** State that is tracked per-session (each session has its own copy). */
57
  export interface PerSessionState {
 
63
  plan: PlanItem[];
64
  /** Steps completed by the research sub-agent (tool_log events). */
65
  researchSteps: string[];
66
+ /** Live stats from the research sub-agent. */
67
+ researchStats: { toolCount: number; tokenCount: number; startedAt: number | null; finalElapsed: number | null };
68
  }
69
 
70
  const defaultSessionState: PerSessionState = {
 
75
  panelEditable: false,
76
  plan: [],
77
  researchSteps: [],
78
+ researchStats: { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null },
79
  };
80
 
81
  interface AgentStore {
 
105
  // Job URLs (tool_call_id -> job URL) for HF jobs
106
  jobUrls: Record<string, string>;
107
 
108
+ // Job statuses (tool_call_id -> job status) for HF jobs
109
+ jobStatuses: Record<string, string>;
110
+
111
+ // Tool error states (tool_call_id -> true if errored) - persisted across renders
112
+ toolErrors: Record<string, boolean>;
113
+
114
+ // Tool rejected states (tool_call_id -> true if rejected by user) - persisted across renders
115
+ rejectedTools: Record<string, boolean>;
116
+
117
  // ── Per-session actions ─────────────────────────────────────────────
118
 
119
  /** Update a session's state. If it's the active session, also update flat state. */
 
151
 
152
  setJobUrl: (toolCallId: string, jobUrl: string) => void;
153
  getJobUrl: (toolCallId: string) => string | undefined;
154
+
155
+ setJobStatus: (toolCallId: string, status: string) => void;
156
+ getJobStatus: (toolCallId: string) => string | undefined;
157
+
158
+ setToolError: (toolCallId: string, hasError: boolean) => void;
159
+ getToolError: (toolCallId: string) => boolean | undefined;
160
+
161
+ setToolRejected: (toolCallId: string, isRejected: boolean) => void;
162
+ getToolRejected: (toolCallId: string) => boolean | undefined;
163
  }
164
 
165
  /**
 
181
  };
182
  }
183
 
184
+ // Load persisted tool errors from localStorage
185
+ function loadToolErrors(): Record<string, boolean> {
186
+ try {
187
+ const stored = localStorage.getItem('hf-agent-tool-errors');
188
+ return stored ? JSON.parse(stored) : {};
189
+ } catch {
190
+ return {};
191
+ }
192
+ }
193
+
194
+ // Save tool errors to localStorage
195
+ function saveToolErrors(errors: Record<string, boolean>): void {
196
+ try {
197
+ localStorage.setItem('hf-agent-tool-errors', JSON.stringify(errors));
198
+ } catch (e) {
199
+ console.warn('Failed to persist tool errors:', e);
200
+ }
201
+ }
202
+
203
+ // Load persisted rejected tools from localStorage
204
+ function loadRejectedTools(): Record<string, boolean> {
205
+ try {
206
+ const stored = localStorage.getItem('hf-agent-rejected-tools');
207
+ return stored ? JSON.parse(stored) : {};
208
+ } catch {
209
+ return {};
210
+ }
211
+ }
212
+
213
+ // Save rejected tools to localStorage
214
+ function saveRejectedTools(rejected: Record<string, boolean>): void {
215
+ try {
216
+ localStorage.setItem('hf-agent-rejected-tools', JSON.stringify(rejected));
217
+ } catch (e) {
218
+ console.warn('Failed to persist rejected tools:', e);
219
+ }
220
+ }
221
+
222
  export const useAgentStore = create<AgentStore>()((set, get) => ({
223
  sessionStates: {},
224
  activeSessionId: null,
 
238
 
239
  editedScripts: {},
240
  jobUrls: {},
241
+ jobStatuses: {},
242
+ toolErrors: loadToolErrors(),
243
+ rejectedTools: loadRejectedTools(),
244
 
245
  // ── Per-session state management ──────────────────────────────────
246
 
 
252
  // Apply the processingβ†’idle side effect
253
  const processingCleared = 'isProcessing' in updates && !updates.isProcessing;
254
  if (processingCleared) {
255
+ if (updated.activityStatus.type !== 'waiting-approval' && updated.activityStatus.type !== 'cancelled') {
256
  updated.activityStatus = { type: 'idle' };
257
  }
258
  }
 
300
  panelEditable: state.panelEditable,
301
  plan: state.plan,
302
  researchSteps: state.sessionStates[state.activeSessionId]?.researchSteps ?? [],
303
+ researchStats: state.sessionStates[state.activeSessionId]?.researchStats ?? defaultSessionState.researchStats,
304
  };
305
  }
306
 
 
331
 
332
  setProcessing: (isProcessing) => {
333
  const current = get().activityStatus;
334
+ const preserveStatus = current.type === 'waiting-approval' || current.type === 'cancelled';
335
  set({ isProcessing, ...(!isProcessing && !preserveStatus ? { activityStatus: { type: 'idle' } } : {}) });
336
  },
337
  setConnected: (isConnected) => set({ isConnected }),
 
413
  },
414
 
415
  getJobUrl: (toolCallId) => get().jobUrls[toolCallId],
416
+
417
+ // ── Job Statuses ────────────────────────────────────────────────────
418
+
419
+ setJobStatus: (toolCallId, status) => {
420
+ set((state) => ({
421
+ jobStatuses: { ...state.jobStatuses, [toolCallId]: status },
422
+ }));
423
+ },
424
+
425
+ getJobStatus: (toolCallId) => get().jobStatuses[toolCallId],
426
+
427
+ // ── Tool Errors ─────────────────────────────────────────────────────
428
+
429
+ setToolError: (toolCallId, hasError) => {
430
+ set((state) => {
431
+ const updated = { ...state.toolErrors, [toolCallId]: hasError };
432
+ saveToolErrors(updated);
433
+ return { toolErrors: updated };
434
+ });
435
+ },
436
+
437
+ getToolError: (toolCallId) => get().toolErrors[toolCallId],
438
+
439
+ // ── Tool Rejections ──────────────────────────────────────────────────
440
+
441
+ setToolRejected: (toolCallId, isRejected) => {
442
+ set((state) => {
443
+ const updated = { ...state.rejectedTools, [toolCallId]: isRejected };
444
+ saveRejectedTools(updated);
445
+ return { rejectedTools: updated };
446
+ });
447
+ },
448
+
449
+ getToolRejected: (toolCallId) => get().rejectedTools[toolCallId],
450
  }));
frontend/src/types/agent.ts CHANGED
@@ -29,4 +29,5 @@ export interface User {
29
  username?: string;
30
  name?: string;
31
  picture?: string;
 
32
  }
 
29
  username?: string;
30
  name?: string;
31
  picture?: string;
32
+ orgMember?: boolean;
33
  }
uv.lock CHANGED
The diff for this file is too large to render. See raw diff