mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 06:32:12 +00:00
feat(open-swe): stream agent chat via @langchain/react v2 protocol (#1475)
* feat(dashboard): stream agent chat via @langchain/react v2 protocol Replace the bespoke SSE + React Query polling path with LangGraph’s v2 event stream through credentialed dashboard proxies. Run starts go through stream commands; mid-run follow-ups still queue via /messages. * fix import path * fix tests after rebase * format * PR feedback * improved model fallback * fix image handling * embrace sdk * cleanup * cr * more cleanup * fix cors * harden security --------- Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
5515fff44c
commit
abf354bb05
75 changed files with 4890 additions and 3927 deletions
|
|
@ -14,7 +14,7 @@ Open SWE has two runnable pieces:
|
||||||
|
|
||||||
- **Python 3.11 – 3.13** (3.14 is not yet supported due to dependency constraints)
|
- **Python 3.11 – 3.13** (3.14 is not yet supported due to dependency constraints)
|
||||||
- [uv](https://docs.astral.sh/uv/) package manager
|
- [uv](https://docs.astral.sh/uv/) package manager
|
||||||
- [LangGraph CLI](https://langchain-ai.github.io/langgraph/cloud/reference/cli/)
|
- [LangGraph CLI](https://docs.langchain.com/langsmith/cli)
|
||||||
- [ngrok](https://ngrok.com/) (for local development — exposes webhook endpoints to the internet)
|
- [ngrok](https://ngrok.com/) (for local development — exposes webhook endpoints to the internet)
|
||||||
- [Bun](https://bun.sh/) (only if you want to run the dashboard UI locally — see step 8). Node 20+ also works, but `ui/bun.lock` is the canonical lockfile.
|
- [Bun](https://bun.sh/) (only if you want to run the dashboard UI locally — see step 8). Node 20+ also works, but `ui/bun.lock` is the canonical lockfile.
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ Write this down. You'll use it in the callback URL below and again in step 4 whe
|
||||||
|
|
||||||
### 3b. Create the app
|
### 3b. Create the app
|
||||||
|
|
||||||
1. Go to **GitHub Settings → Developer settings → GitHub Apps → New GitHub App**
|
1. Go to **GitHub Settings → Developer settings → [GitHub Apps](https://github.com/settings/apps) → [New GitHub App](https://github.com/settings/apps/new)**
|
||||||
2. Fill in:
|
2. Fill in:
|
||||||
- **App name**: `open-swe` (or your preferred name)
|
- **App name**: `open-swe` (or your preferred name)
|
||||||
- **Homepage URL**: This can be any valid URL — it's only shown on the GitHub Marketplace page (which you won't be using). Use something like `https://github.com/langchain-ai/open-swe`
|
- **Homepage URL**: This can be any valid URL — it's only shown on the GitHub Marketplace page (which you won't be using). Use something like `https://github.com/langchain-ai/open-swe`
|
||||||
|
|
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
"""Convert LangGraph / LangChain message dicts into dashboard UI message payloads."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import uuid
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from ..utils.messages import extract_text_content
|
|
||||||
|
|
||||||
_READ_TOOLS = frozenset({"read_file", "read", "glob", "grep"})
|
|
||||||
_EDIT_TOOLS = frozenset({"write_file", "edit_file", "str_replace", "write", "edit", "patch"})
|
|
||||||
_EXECUTE_TOOLS = frozenset({"execute", "bash", "shell", "run_terminal_cmd"})
|
|
||||||
_SEARCH_TOOLS = frozenset({"glob", "grep", "web_search", "fetch_url", "search"})
|
|
||||||
_INTERNAL_TOOLS = frozenset({"confirming_completion", "no_op"})
|
|
||||||
_DATA_IMAGE_RE = re.compile(r"^data:(image/[^;]+);base64,(.+)$", re.DOTALL)
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
|
||||||
return datetime.now(UTC).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _message_type(message: dict[str, Any]) -> str:
|
|
||||||
raw = message.get("type")
|
|
||||||
if isinstance(raw, str):
|
|
||||||
return raw.lower()
|
|
||||||
role = message.get("role")
|
|
||||||
if role == "user":
|
|
||||||
return "human"
|
|
||||||
if role == "assistant":
|
|
||||||
return "ai"
|
|
||||||
if role == "tool":
|
|
||||||
return "tool"
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_kind(name: str) -> str:
|
|
||||||
lowered = name.lower()
|
|
||||||
if lowered == "slack_thread_reply":
|
|
||||||
return "slack"
|
|
||||||
if lowered == "linear_comment":
|
|
||||||
return "linear"
|
|
||||||
if lowered in _EDIT_TOOLS or any(token in lowered for token in ("edit", "write", "replace")):
|
|
||||||
return "edit"
|
|
||||||
if lowered in _EXECUTE_TOOLS:
|
|
||||||
return "execute"
|
|
||||||
if lowered in _SEARCH_TOOLS:
|
|
||||||
return "search"
|
|
||||||
if lowered in _READ_TOOLS or "read" in lowered:
|
|
||||||
return "read"
|
|
||||||
if lowered == "think":
|
|
||||||
return "think"
|
|
||||||
if lowered in {"fetch", "fetch_url", "http_request"}:
|
|
||||||
return "fetch"
|
|
||||||
return "other"
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_title(name: str, args: dict[str, Any]) -> str:
|
|
||||||
path = args.get("path") or args.get("file_path") or args.get("target_file")
|
|
||||||
if isinstance(path, str) and path.strip():
|
|
||||||
return f"{name} {path.strip()}"
|
|
||||||
command = args.get("command")
|
|
||||||
if isinstance(command, str) and command.strip():
|
|
||||||
first = command.strip().splitlines()[0]
|
|
||||||
return first[:120]
|
|
||||||
return name.replace("_", " ").strip() or "Tool"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_tool_args(raw: Any) -> dict[str, Any]:
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return {"raw": raw}
|
|
||||||
return parsed if isinstance(parsed, dict) else {"raw": raw}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _image_chunks(content: Any) -> list[dict[str, Any]]:
|
|
||||||
if not isinstance(content, list):
|
|
||||||
return []
|
|
||||||
|
|
||||||
chunks: list[dict[str, Any]] = []
|
|
||||||
for item in content:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
item_type = item.get("type")
|
|
||||||
base64_data: str | None = None
|
|
||||||
mime_type: str | None = None
|
|
||||||
if item_type == "image":
|
|
||||||
data = item.get("data") or item.get("base64")
|
|
||||||
mime = item.get("mime_type") or item.get("mimeType")
|
|
||||||
if isinstance(data, str) and isinstance(mime, str):
|
|
||||||
base64_data = data
|
|
||||||
mime_type = mime
|
|
||||||
elif item_type == "image_url":
|
|
||||||
image_url = item.get("image_url")
|
|
||||||
url = image_url.get("url") if isinstance(image_url, dict) else None
|
|
||||||
if isinstance(url, str):
|
|
||||||
match = _DATA_IMAGE_RE.match(url)
|
|
||||||
if match:
|
|
||||||
mime_type, base64_data = match.groups()
|
|
||||||
if base64_data and mime_type:
|
|
||||||
chunk: dict[str, Any] = {
|
|
||||||
"kind": "image",
|
|
||||||
"base64": base64_data,
|
|
||||||
"mimeType": mime_type,
|
|
||||||
}
|
|
||||||
file_name = item.get("fileName") or item.get("file_name")
|
|
||||||
if isinstance(file_name, str) and file_name:
|
|
||||||
chunk["fileName"] = file_name
|
|
||||||
chunks.append(chunk)
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_diff_from_args(name: str, args: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
path = args.get("path") or args.get("file_path") or args.get("target_file")
|
|
||||||
if not isinstance(path, str) or not path.strip():
|
|
||||||
return None
|
|
||||||
old_content = args.get("old_string") or args.get("original_content")
|
|
||||||
new_content = args.get("new_string") or args.get("content") or args.get("new_content")
|
|
||||||
if not isinstance(new_content, str):
|
|
||||||
return None
|
|
||||||
original = old_content if isinstance(old_content, str) else None
|
|
||||||
return {
|
|
||||||
"originalContent": original,
|
|
||||||
"newContent": new_content,
|
|
||||||
"filePath": path.strip(),
|
|
||||||
"isNewFile": original is None,
|
|
||||||
"isBinary": False,
|
|
||||||
"isTruncated": False,
|
|
||||||
"totalLines": max(new_content.count("\n"), 0) + 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_internal_tool(name: str) -> bool:
|
|
||||||
return name in _INTERNAL_TOOLS
|
|
||||||
|
|
||||||
|
|
||||||
def _append_agent_chunks(
|
|
||||||
agent_turn: dict[str, Any] | None,
|
|
||||||
*,
|
|
||||||
msg_id: str,
|
|
||||||
timestamp: str,
|
|
||||||
chunks: list[dict[str, Any]],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if agent_turn is None:
|
|
||||||
return {
|
|
||||||
"id": msg_id,
|
|
||||||
"author": "agent",
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"chunks": list(chunks),
|
|
||||||
}
|
|
||||||
agent_turn["timestamp"] = timestamp
|
|
||||||
agent_turn["chunks"].extend(chunks)
|
|
||||||
return agent_turn
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_text_chunks(chunks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
"""Keep only the latest text chunk when middleware produced a follow-up AI message."""
|
|
||||||
text_indices = [i for i, chunk in enumerate(chunks) if chunk.get("kind") == "text"]
|
|
||||||
if len(text_indices) <= 1:
|
|
||||||
return chunks
|
|
||||||
last_text = text_indices[-1]
|
|
||||||
return [
|
|
||||||
chunk for i, chunk in enumerate(chunks) if chunk.get("kind") != "text" or i == last_text
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def state_messages_to_ui(messages: list[Any]) -> list[dict[str, Any]]:
|
|
||||||
"""Map LangGraph state ``messages`` to the dashboard ``Message`` JSON shape."""
|
|
||||||
pending_tools: dict[str, dict[str, Any]] = {}
|
|
||||||
ui_messages: list[dict[str, Any]] = []
|
|
||||||
agent_turn: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
for index, raw in enumerate(messages):
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
continue
|
|
||||||
msg_type = _message_type(raw)
|
|
||||||
msg_id = raw.get("id") if isinstance(raw.get("id"), str) else f"msg-{index}"
|
|
||||||
timestamp = raw.get("created_at") if isinstance(raw.get("created_at"), str) else _now_iso()
|
|
||||||
|
|
||||||
if msg_type in {"human", "user"}:
|
|
||||||
if agent_turn is not None:
|
|
||||||
agent_turn["chunks"] = _merge_text_chunks(agent_turn["chunks"])
|
|
||||||
ui_messages.append(agent_turn)
|
|
||||||
agent_turn = None
|
|
||||||
content = raw.get("content", "")
|
|
||||||
chunks = _image_chunks(content)
|
|
||||||
text = extract_text_content(content)
|
|
||||||
if text:
|
|
||||||
chunks.append({"kind": "text", "text": text})
|
|
||||||
if not chunks:
|
|
||||||
continue
|
|
||||||
ui_messages.append(
|
|
||||||
{
|
|
||||||
"id": msg_id,
|
|
||||||
"author": "user",
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"chunks": chunks,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if msg_type in {"ai", "assistant"}:
|
|
||||||
chunks: list[dict[str, Any]] = []
|
|
||||||
text = extract_text_content(raw.get("content", ""))
|
|
||||||
if text:
|
|
||||||
chunks.append({"kind": "text", "text": text})
|
|
||||||
|
|
||||||
for tool_call in raw.get("tool_calls") or []:
|
|
||||||
if not isinstance(tool_call, dict):
|
|
||||||
continue
|
|
||||||
name = tool_call.get("name")
|
|
||||||
if not isinstance(name, str) or not name:
|
|
||||||
name = "tool"
|
|
||||||
if _is_internal_tool(name):
|
|
||||||
continue
|
|
||||||
tool_call_id = tool_call.get("id") or tool_call.get("tool_call_id")
|
|
||||||
if not isinstance(tool_call_id, str) or not tool_call_id:
|
|
||||||
tool_call_id = f"tool-{uuid.uuid4().hex[:8]}"
|
|
||||||
args = _parse_tool_args(tool_call.get("args"))
|
|
||||||
chunk: dict[str, Any] = {
|
|
||||||
"kind": "tool-execution",
|
|
||||||
"toolCallId": tool_call_id,
|
|
||||||
"title": _tool_title(name, args),
|
|
||||||
"toolKind": _tool_kind(name),
|
|
||||||
"input": args,
|
|
||||||
"status": "in_progress",
|
|
||||||
}
|
|
||||||
diff_data = _maybe_diff_from_args(name, args)
|
|
||||||
if diff_data:
|
|
||||||
chunk["diffData"] = diff_data
|
|
||||||
chunks.append(chunk)
|
|
||||||
pending_tools[tool_call_id] = chunk
|
|
||||||
|
|
||||||
if chunks:
|
|
||||||
agent_turn = _append_agent_chunks(
|
|
||||||
agent_turn, msg_id=msg_id, timestamp=timestamp, chunks=chunks
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if msg_type == "tool":
|
|
||||||
tool_call_id = raw.get("tool_call_id")
|
|
||||||
if not isinstance(tool_call_id, str):
|
|
||||||
continue
|
|
||||||
name = raw.get("name") if isinstance(raw.get("name"), str) else "tool"
|
|
||||||
if _is_internal_tool(name):
|
|
||||||
pending_tools.pop(tool_call_id, None)
|
|
||||||
continue
|
|
||||||
chunk = pending_tools.get(tool_call_id)
|
|
||||||
output = extract_text_content(raw.get("content", ""))
|
|
||||||
if chunk is not None:
|
|
||||||
chunk["status"] = "error" if raw.get("status") == "error" else "completed"
|
|
||||||
if output:
|
|
||||||
chunk["output"] = output
|
|
||||||
continue
|
|
||||||
|
|
||||||
if agent_turn is None:
|
|
||||||
agent_turn = {
|
|
||||||
"id": msg_id,
|
|
||||||
"author": "agent",
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"chunks": [],
|
|
||||||
}
|
|
||||||
agent_turn["chunks"].append(
|
|
||||||
{
|
|
||||||
"kind": "tool-execution",
|
|
||||||
"toolCallId": tool_call_id,
|
|
||||||
"title": name,
|
|
||||||
"toolKind": _tool_kind(name),
|
|
||||||
"status": "completed",
|
|
||||||
"output": output,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if agent_turn is not None:
|
|
||||||
agent_turn["chunks"] = _merge_text_chunks(agent_turn["chunks"])
|
|
||||||
ui_messages.append(agent_turn)
|
|
||||||
|
|
||||||
return ui_messages
|
|
||||||
|
|
@ -37,12 +37,12 @@ def _secret() -> str:
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def _allowed_redirect_origins() -> set[str]:
|
def allowed_dashboard_origins() -> set[str]:
|
||||||
"""Origins permitted for the post-login redirect.
|
"""Origins permitted for dashboard frontend requests and post-login redirects.
|
||||||
|
|
||||||
Built from DASHBOARD_BASE_URL plus any DASHBOARD_ALLOWED_ORIGINS entries
|
Built from DASHBOARD_BASE_URL plus any DASHBOARD_ALLOWED_ORIGINS entries
|
||||||
so the dashboard itself and its preview deploys can all be redirect
|
so the dashboard itself and its preview deploys are allowed — but nothing
|
||||||
targets — but nothing else.
|
else.
|
||||||
"""
|
"""
|
||||||
origins: set[str] = set()
|
origins: set[str] = set()
|
||||||
base = os.environ.get("DASHBOARD_BASE_URL", "").strip()
|
base = os.environ.get("DASHBOARD_BASE_URL", "").strip()
|
||||||
|
|
@ -57,10 +57,22 @@ def _allowed_redirect_origins() -> set[str]:
|
||||||
|
|
||||||
|
|
||||||
def _origin_of(url: str) -> str:
|
def _origin_of(url: str) -> str:
|
||||||
parsed = urlparse(url)
|
"""Normalize a URL or Origin header value to ``scheme://host[:port]``."""
|
||||||
if not parsed.scheme or not parsed.netloc:
|
trimmed = url.strip().rstrip("/")
|
||||||
|
if not trimmed or trimmed.lower() == "null":
|
||||||
return ""
|
return ""
|
||||||
return f"{parsed.scheme}://{parsed.netloc}"
|
parsed = urlparse(trimmed)
|
||||||
|
if not parsed.scheme or not parsed.hostname:
|
||||||
|
return ""
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
host = parsed.hostname.lower()
|
||||||
|
port = parsed.port
|
||||||
|
if port is None:
|
||||||
|
return f"{scheme}://{host}"
|
||||||
|
default_port = 443 if scheme == "https" else 80 if scheme == "http" else None
|
||||||
|
if default_port is not None and port == default_port:
|
||||||
|
return f"{scheme}://{host}"
|
||||||
|
return f"{scheme}://{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
def sanitize_redirect_to(redirect_to: str | None) -> str:
|
def sanitize_redirect_to(redirect_to: str | None) -> str:
|
||||||
|
|
@ -76,7 +88,7 @@ def sanitize_redirect_to(redirect_to: str | None) -> str:
|
||||||
candidate_origin = _origin_of(redirect_to)
|
candidate_origin = _origin_of(redirect_to)
|
||||||
if not candidate_origin:
|
if not candidate_origin:
|
||||||
return fallback
|
return fallback
|
||||||
if candidate_origin in _allowed_redirect_origins():
|
if candidate_origin in allowed_dashboard_origins():
|
||||||
return redirect_to
|
return redirect_to
|
||||||
logger.warning("Rejected redirect_to=%r — origin not in allowlist", redirect_to)
|
logger.warning("Rejected redirect_to=%r — origin not in allowlist", redirect_to)
|
||||||
return fallback
|
return fallback
|
||||||
|
|
@ -190,6 +202,47 @@ def require_session(request: Request) -> dict[str, Any]:
|
||||||
return decode_session(token)
|
return decode_session(token)
|
||||||
|
|
||||||
|
|
||||||
|
def request_origin(request: Request) -> str | None:
|
||||||
|
"""Return the request's origin (scheme + host + port), if present and valid."""
|
||||||
|
raw_origin = request.headers.get("origin")
|
||||||
|
if raw_origin is not None:
|
||||||
|
if raw_origin.strip().lower() == "null":
|
||||||
|
return None
|
||||||
|
origin = _origin_of(raw_origin)
|
||||||
|
return origin if origin else None
|
||||||
|
referer = request.headers.get("referer")
|
||||||
|
if referer:
|
||||||
|
origin = _origin_of(referer)
|
||||||
|
return origin if origin else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_same_origin(request: Request) -> None:
|
||||||
|
"""Reject cross-site cookie-authenticated mutations (CSRF defense).
|
||||||
|
|
||||||
|
No-op when no dashboard origins are configured (local setups without
|
||||||
|
``DASHBOARD_BASE_URL`` / ``DASHBOARD_ALLOWED_ORIGINS``).
|
||||||
|
"""
|
||||||
|
allowed = allowed_dashboard_origins()
|
||||||
|
if not allowed:
|
||||||
|
return
|
||||||
|
origin = request_origin(request)
|
||||||
|
if not origin or origin not in allowed:
|
||||||
|
logger.warning(
|
||||||
|
"Rejected %s %s — origin %r not in allowlist",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
origin,
|
||||||
|
)
|
||||||
|
raise HTTPException(403, "CSRF check failed")
|
||||||
|
|
||||||
|
|
||||||
|
def require_same_origin_for_mutations(request: Request) -> None:
|
||||||
|
if request.method in {"GET", "HEAD", "OPTIONS"}:
|
||||||
|
return
|
||||||
|
require_same_origin(request)
|
||||||
|
|
||||||
|
|
||||||
def expires_at_from_github_response(data: dict[str, Any], *, field: str) -> str | None:
|
def expires_at_from_github_response(data: dict[str, Any], *, field: str) -> str | None:
|
||||||
"""Convert GitHub ``expires_in`` / ``refresh_token_expires_in`` to an ISO timestamp."""
|
"""Convert GitHub ``expires_in`` / ``refresh_token_expires_in`` to an ISO timestamp."""
|
||||||
raw = data.get(field)
|
raw = data.get(field)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ from .oauth import (
|
||||||
issue_session,
|
issue_session,
|
||||||
issue_state,
|
issue_state,
|
||||||
new_state_nonce,
|
new_state_nonce,
|
||||||
|
require_same_origin_for_mutations,
|
||||||
require_session,
|
require_session,
|
||||||
sanitize_redirect_to,
|
sanitize_redirect_to,
|
||||||
)
|
)
|
||||||
|
|
@ -105,13 +106,16 @@ from .team_settings import (
|
||||||
upsert_team_settings,
|
upsert_team_settings,
|
||||||
)
|
)
|
||||||
from .thread_api import (
|
from .thread_api import (
|
||||||
ThreadCreateBody,
|
|
||||||
ThreadMessageBody,
|
ThreadMessageBody,
|
||||||
cancel_dashboard_thread,
|
cancel_dashboard_thread,
|
||||||
create_dashboard_thread,
|
|
||||||
delete_dashboard_thread,
|
delete_dashboard_thread,
|
||||||
get_dashboard_thread,
|
get_dashboard_thread,
|
||||||
|
get_dashboard_thread_state,
|
||||||
list_dashboard_threads,
|
list_dashboard_threads,
|
||||||
|
proxy_dashboard_thread_commands,
|
||||||
|
proxy_dashboard_thread_history,
|
||||||
|
proxy_dashboard_thread_run_cancel,
|
||||||
|
proxy_dashboard_thread_stream_events,
|
||||||
send_dashboard_message,
|
send_dashboard_message,
|
||||||
stream_dashboard_thread,
|
stream_dashboard_thread,
|
||||||
)
|
)
|
||||||
|
|
@ -124,7 +128,11 @@ from .user_mappings import (
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard/api", tags=["dashboard"])
|
router = APIRouter(
|
||||||
|
prefix="/dashboard/api",
|
||||||
|
tags=["dashboard"],
|
||||||
|
dependencies=[Depends(require_same_origin_for_mutations)],
|
||||||
|
)
|
||||||
_GITHUB_API_TIMEOUT = httpx.Timeout(10.0, connect=3.0)
|
_GITHUB_API_TIMEOUT = httpx.Timeout(10.0, connect=3.0)
|
||||||
_SKIPPABLE_INSTALLATION_REPO_STATUS_CODES = frozenset({403, 404})
|
_SKIPPABLE_INSTALLATION_REPO_STATUS_CODES = frozenset({403, 404})
|
||||||
|
|
||||||
|
|
@ -901,14 +909,6 @@ async def api_list_threads(
|
||||||
return await list_dashboard_threads(session["sub"], email=session.get("email"), include_all=all)
|
return await list_dashboard_threads(session["sub"], email=session.get("email"), include_all=all)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/threads")
|
|
||||||
async def api_create_thread(
|
|
||||||
body: ThreadCreateBody,
|
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return await create_dashboard_thread(session["sub"], body)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/threads/{thread_id}")
|
@router.get("/threads/{thread_id}")
|
||||||
async def api_get_thread(
|
async def api_get_thread(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
|
|
@ -932,6 +932,25 @@ async def api_send_thread_message(
|
||||||
return await send_dashboard_message(thread_id, session["sub"], body, email=session.get("email"))
|
return await send_dashboard_message(thread_id, session["sub"], body, email=session.get("email"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/threads/{thread_id}/runs/{run_id}/cancel")
|
||||||
|
async def api_cancel_thread_run(
|
||||||
|
thread_id: str,
|
||||||
|
run_id: str,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
wait: str = "0",
|
||||||
|
action: str = "interrupt",
|
||||||
|
) -> Response:
|
||||||
|
status_code, content, media_type = await proxy_dashboard_thread_run_cancel(
|
||||||
|
thread_id,
|
||||||
|
run_id,
|
||||||
|
session["sub"],
|
||||||
|
wait=wait,
|
||||||
|
action=action,
|
||||||
|
email=session.get("email"),
|
||||||
|
)
|
||||||
|
return Response(content=content, status_code=status_code, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/threads/{thread_id}/cancel")
|
@router.post("/threads/{thread_id}/cancel")
|
||||||
async def api_cancel_thread(
|
async def api_cancel_thread(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
|
|
@ -949,6 +968,73 @@ async def api_delete_thread(
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/threads/{thread_id}/state")
|
||||||
|
async def api_get_thread_state(
|
||||||
|
thread_id: str,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await get_dashboard_thread_state(thread_id, session["sub"], email=session.get("email"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/threads/{thread_id}/stream/events")
|
||||||
|
async def api_thread_stream_events(
|
||||||
|
thread_id: str,
|
||||||
|
request: Request,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> StreamingResponse:
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
async for chunk in proxy_dashboard_thread_stream_events(
|
||||||
|
thread_id,
|
||||||
|
session["sub"],
|
||||||
|
body,
|
||||||
|
email=session.get("email"),
|
||||||
|
content_type=request.headers.get("content-type", "application/json"),
|
||||||
|
):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/threads/{thread_id}/commands")
|
||||||
|
async def api_thread_commands(
|
||||||
|
thread_id: str,
|
||||||
|
request: Request,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> Response:
|
||||||
|
body = await request.body()
|
||||||
|
status_code, content, media_type = await proxy_dashboard_thread_commands(
|
||||||
|
thread_id,
|
||||||
|
session["sub"],
|
||||||
|
body,
|
||||||
|
email=session.get("email"),
|
||||||
|
content_type=request.headers.get("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
return Response(content=content, status_code=status_code, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/threads/{thread_id}/history")
|
||||||
|
async def api_thread_history(
|
||||||
|
thread_id: str,
|
||||||
|
request: Request,
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> Response:
|
||||||
|
body = await request.body()
|
||||||
|
status_code, content, media_type = await proxy_dashboard_thread_history(
|
||||||
|
thread_id,
|
||||||
|
session["sub"],
|
||||||
|
body,
|
||||||
|
email=session.get("email"),
|
||||||
|
content_type=request.headers.get("content-type", "application/json"),
|
||||||
|
)
|
||||||
|
return Response(content=content, status_code=status_code, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/threads/{thread_id}/stream")
|
@router.get("/threads/{thread_id}/stream")
|
||||||
async def api_stream_thread(
|
async def api_stream_thread(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,23 @@ import binascii
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from langchain_core.messages.content import create_image_block
|
from langchain_core.messages.content import create_image_block
|
||||||
from langgraph_sdk.errors import InternalServerError
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from ..utils.langsmith import get_langsmith_trace_url
|
from ..utils.langsmith import get_langsmith_trace_url
|
||||||
from ..utils.thread_ops import is_thread_active, langgraph_client, queue_message_for_thread
|
from ..utils.thread_ops import (
|
||||||
|
get_thread_active_status,
|
||||||
|
langgraph_client,
|
||||||
|
langgraph_url,
|
||||||
|
queue_message_for_thread,
|
||||||
|
)
|
||||||
from .agent_overrides import normalize_profile_overrides
|
from .agent_overrides import normalize_profile_overrides
|
||||||
from .message_adapter import state_messages_to_ui
|
|
||||||
from .options import SUPPORTED_MODEL_IDS, model_supports_effort, model_supports_images
|
from .options import SUPPORTED_MODEL_IDS, model_supports_effort, model_supports_images
|
||||||
from .profiles import get_profile, get_valid_access_token
|
from .profiles import get_profile, get_valid_access_token
|
||||||
from .team_settings import get_team_default_model
|
from .team_settings import get_team_default_model
|
||||||
|
|
@ -30,10 +33,23 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ASSISTANT_ID = "agent"
|
_ASSISTANT_ID = "agent"
|
||||||
_DASHBOARD_SOURCE = "dashboard"
|
_DASHBOARD_SOURCE = "dashboard"
|
||||||
_DASHBOARD_STREAM_MODES: tuple[str, ...] = ("values", "updates", "messages-tuple")
|
# Modes required for the v2 event-stream protocol (`POST …/stream/events`).
|
||||||
|
# `@langchain/react` subscribes to `messages`, `tools`, `lifecycle`, etc.;
|
||||||
|
# legacy `messages-tuple`-only runs emit almost nothing on those channels.
|
||||||
|
_DASHBOARD_STREAM_MODES: tuple[str, ...] = (
|
||||||
|
"values",
|
||||||
|
"updates",
|
||||||
|
"messages",
|
||||||
|
"messages-tuple",
|
||||||
|
"tools",
|
||||||
|
"checkpoints",
|
||||||
|
"events",
|
||||||
|
)
|
||||||
_SUPPORTED_IMAGE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
|
_SUPPORTED_IMAGE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
|
||||||
_MAX_DASHBOARD_IMAGES = 5
|
_MAX_DASHBOARD_IMAGES = 5
|
||||||
_MAX_DASHBOARD_IMAGE_BYTES = 10 * 1024 * 1024
|
_MAX_DASHBOARD_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
|
_PROXY_REQUEST_TIMEOUT = httpx.Timeout(30.0, connect=5.0)
|
||||||
|
_PROXY_STREAM_TIMEOUT = httpx.Timeout(None)
|
||||||
# Sources whose threads should surface in the Agents UI (besides "dashboard").
|
# Sources whose threads should surface in the Agents UI (besides "dashboard").
|
||||||
_SURFACED_SOURCES: tuple[str, ...] = ("dashboard", "github", "slack", "linear", "schedule")
|
_SURFACED_SOURCES: tuple[str, ...] = ("dashboard", "github", "slack", "linear", "schedule")
|
||||||
|
|
||||||
|
|
@ -43,6 +59,32 @@ def _agent_version_metadata() -> dict[str, str]:
|
||||||
return {"LANGSMITH_AGENT_VERSION": revision} if revision else {}
|
return {"LANGSMITH_AGENT_VERSION": revision} if revision else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_json_content_type(content_type: str) -> None:
|
||||||
|
media_type = content_type.split(";", 1)[0].strip().lower()
|
||||||
|
if media_type != "application/json":
|
||||||
|
raise HTTPException(415, "Content-Type must be application/json")
|
||||||
|
|
||||||
|
|
||||||
|
def _langgraph_proxy_headers(
|
||||||
|
*, content_type: str = "application/json", accept: str | None = None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
headers = {"Content-Type": content_type}
|
||||||
|
if accept:
|
||||||
|
headers["Accept"] = accept
|
||||||
|
api_key = (
|
||||||
|
os.environ.get("LANGSMITH_API_KEY")
|
||||||
|
or os.environ.get("LANGCHAIN_API_KEY")
|
||||||
|
or os.environ.get("LANGSMITH_API_KEY_PROD")
|
||||||
|
)
|
||||||
|
if api_key:
|
||||||
|
headers["X-API-Key"] = api_key
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_is_busy(thread: dict[str, Any]) -> bool:
|
||||||
|
return thread.get("status") == "busy"
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_run_email(login: str, profile: dict[str, Any]) -> str | None:
|
async def _resolve_run_email(login: str, profile: dict[str, Any]) -> str | None:
|
||||||
"""Email used for GitHub/LangSmith auth on a run.
|
"""Email used for GitHub/LangSmith auth on a run.
|
||||||
|
|
||||||
|
|
@ -63,15 +105,6 @@ class DashboardImageBody(BaseModel):
|
||||||
file_name: str | None = Field(default=None, alias="fileName")
|
file_name: str | None = Field(default=None, alias="fileName")
|
||||||
|
|
||||||
|
|
||||||
class ThreadCreateBody(BaseModel):
|
|
||||||
prompt: str = Field(default="", max_length=20_000)
|
|
||||||
images: list[DashboardImageBody] = Field(default_factory=list)
|
|
||||||
repo: str | None = None
|
|
||||||
repo_explicitly_none: bool = False
|
|
||||||
model_id: str | None = None
|
|
||||||
effort: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadMessageBody(BaseModel):
|
class ThreadMessageBody(BaseModel):
|
||||||
content: str = Field(default="", max_length=20_000)
|
content: str = Field(default="", max_length=20_000)
|
||||||
images: list[DashboardImageBody] = Field(default_factory=list)
|
images: list[DashboardImageBody] = Field(default_factory=list)
|
||||||
|
|
@ -250,7 +283,6 @@ def _is_thread_viewed(metadata: dict[str, Any], latest_run_id: str | None) -> bo
|
||||||
def _thread_summary(
|
def _thread_summary(
|
||||||
thread: dict[str, Any],
|
thread: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
messages: list[dict[str, Any]] | None = None,
|
|
||||||
latest_run_status: str | None = None,
|
latest_run_status: str | None = None,
|
||||||
latest_run_id: str | None = None,
|
latest_run_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -305,10 +337,9 @@ def _thread_summary(
|
||||||
"baseRef": metadata.get("base_branch") or "main",
|
"baseRef": metadata.get("base_branch") or "main",
|
||||||
"url": pr_url,
|
"url": pr_url,
|
||||||
}
|
}
|
||||||
if messages is not None:
|
# The transcript hydrates client-side from the SDK (`GET …/state` →
|
||||||
summary["messages"] = messages
|
# `stream.messages`); the summary only carries metadata.
|
||||||
else:
|
summary["messages"] = []
|
||||||
summary["messages"] = []
|
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -435,19 +466,9 @@ async def get_dashboard_thread(
|
||||||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||||
is_owner = _user_owns_thread(metadata, login, email)
|
is_owner = _user_owns_thread(metadata, login, email)
|
||||||
|
|
||||||
messages: list[dict[str, Any]] = []
|
# The transcript is hydrated client-side by the SDK (`StreamProvider` reads
|
||||||
try:
|
# `GET …/state` → `stream.messages`), so the detail endpoint returns
|
||||||
state = await client.threads.get_state(thread_id)
|
# metadata only — no server-side message conversion.
|
||||||
except InternalServerError:
|
|
||||||
logger.warning(
|
|
||||||
"Thread state unavailable for %s (checkpoint replay failed); returning metadata only",
|
|
||||||
thread_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
values = state.get("values") if isinstance(state, dict) else {}
|
|
||||||
raw_messages = values.get("messages") if isinstance(values, dict) else []
|
|
||||||
messages = state_messages_to_ui(raw_messages if isinstance(raw_messages, list) else [])
|
|
||||||
|
|
||||||
thread, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(client, thread)
|
thread, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(client, thread)
|
||||||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata
|
||||||
status = _run_status_to_agent_status(
|
status = _run_status_to_agent_status(
|
||||||
|
|
@ -470,7 +491,6 @@ async def get_dashboard_thread(
|
||||||
|
|
||||||
return _thread_summary(
|
return _thread_summary(
|
||||||
thread,
|
thread,
|
||||||
messages=messages,
|
|
||||||
latest_run_status=latest_run_status,
|
latest_run_status=latest_run_status,
|
||||||
latest_run_id=latest_run_id,
|
latest_run_id=latest_run_id,
|
||||||
)
|
)
|
||||||
|
|
@ -481,7 +501,7 @@ def _resolve_repo_config(repo: str | None) -> dict[str, str]:
|
||||||
return _parse_repo(repo) or {}
|
return _parse_repo(repo) or {}
|
||||||
|
|
||||||
|
|
||||||
async def _start_agent_run(
|
async def _create_dashboard_thread_record(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
*,
|
*,
|
||||||
login: str,
|
login: str,
|
||||||
|
|
@ -493,11 +513,15 @@ async def _start_agent_run(
|
||||||
model_id: str | None = None,
|
model_id: str | None = None,
|
||||||
effort: str | None = None,
|
effort: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Create or update dashboard thread metadata without starting a run."""
|
||||||
profile = await get_profile(login) or {}
|
profile = await get_profile(login) or {}
|
||||||
now_ms = _now_ms()
|
now_ms = _now_ms()
|
||||||
prompt = prompt.strip()
|
prompt = prompt.strip()
|
||||||
resolved_model, resolved_effort = await _resolve_agent_model_choice(profile, model_id, effort)
|
resolved_model, resolved_effort = await _resolve_agent_model_choice(profile, model_id, effort)
|
||||||
content = _user_message_content(prompt, images or [], model_id=resolved_model)
|
# Validate any attached images against the resolved model (raises 422 for
|
||||||
|
# text-only models). The run itself is started client-side via the stream
|
||||||
|
# commands endpoint, so we only need the validation side effect here.
|
||||||
|
_user_message_content(prompt, images or [], model_id=resolved_model)
|
||||||
chosen_model, chosen_effort = _normalize_model_choice(model_id, effort)
|
chosen_model, chosen_effort = _normalize_model_choice(model_id, effort)
|
||||||
metadata_model = chosen_model or profile.get("default_model") or "Default"
|
metadata_model = chosen_model or profile.get("default_model") or "Default"
|
||||||
metadata_effort = chosen_effort or profile.get("reasoning_effort")
|
metadata_effort = chosen_effort or profile.get("reasoning_effort")
|
||||||
|
|
@ -524,55 +548,213 @@ async def _start_agent_run(
|
||||||
client = langgraph_client()
|
client = langgraph_client()
|
||||||
await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
|
await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
|
||||||
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
||||||
await _ensure_dashboard_github_token(login)
|
thread = await client.threads.get(thread_id)
|
||||||
|
return thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_config_from_metadata(metadata: dict[str, Any]) -> dict[str, str]:
|
||||||
|
owner, name, _ = _metadata_repo(metadata)
|
||||||
|
if owner and name:
|
||||||
|
return {"owner": owner, "name": name}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_dashboard_configurable(
|
||||||
|
thread_id: str,
|
||||||
|
login: str,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
*,
|
||||||
|
profile: dict[str, Any] | None = None,
|
||||||
|
overrides: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
profile = profile if profile is not None else await get_profile(login) or {}
|
||||||
|
thread_source = _thread_source(metadata)
|
||||||
configurable: dict[str, Any] = {
|
configurable: dict[str, Any] = {
|
||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
"source": _DASHBOARD_SOURCE,
|
"source": thread_source,
|
||||||
"github_login": login,
|
"github_login": login,
|
||||||
"user_email": await _resolve_run_email(login, profile),
|
"user_email": await _resolve_run_email(login, profile),
|
||||||
}
|
}
|
||||||
if has_repo:
|
repo_config = _repo_config_from_metadata(metadata)
|
||||||
|
if repo_config:
|
||||||
configurable["repo"] = repo_config
|
configurable["repo"] = repo_config
|
||||||
elif repo_explicitly_none:
|
elif metadata.get("repo_explicitly_none") is True:
|
||||||
configurable["repo_explicitly_none"] = True
|
configurable["repo_explicitly_none"] = True
|
||||||
if chosen_model and chosen_effort:
|
source_context = metadata.get("source_context")
|
||||||
configurable["agent_model_id"] = chosen_model
|
if isinstance(source_context, dict):
|
||||||
configurable["agent_effort"] = chosen_effort
|
for key, value in source_context.items():
|
||||||
|
configurable.setdefault(key, value)
|
||||||
|
if overrides:
|
||||||
|
for key, value in overrides.items():
|
||||||
|
if value is not None:
|
||||||
|
configurable[key] = value
|
||||||
|
return configurable
|
||||||
|
|
||||||
run = await client.runs.create(
|
|
||||||
|
def _extract_run_id_from_command_response(payload: Any) -> str | None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
for candidate in (
|
||||||
|
payload.get("run_id"),
|
||||||
|
payload.get("result", {}).get("run_id")
|
||||||
|
if isinstance(payload.get("result"), dict)
|
||||||
|
else None,
|
||||||
|
):
|
||||||
|
if isinstance(candidate, str) and candidate:
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _command_message_content(params: dict[str, Any]) -> Any:
|
||||||
|
"""The most recent user message content from a ``run.start`` command."""
|
||||||
|
run_input = params.get("input")
|
||||||
|
if not isinstance(run_input, dict):
|
||||||
|
return None
|
||||||
|
messages = run_input.get("messages")
|
||||||
|
if not isinstance(messages, list) or not messages:
|
||||||
|
return None
|
||||||
|
last = messages[-1]
|
||||||
|
return last.get("content") if isinstance(last, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _command_prompt_text(content: Any) -> str:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
if isinstance(content, list):
|
||||||
|
texts = [
|
||||||
|
block.get("text", "")
|
||||||
|
for block in content
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text"
|
||||||
|
]
|
||||||
|
return "\n".join(text for text in texts if isinstance(text, str)).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_images_from_content(content: Any) -> list[DashboardImageBody]:
|
||||||
|
"""Reconstruct typed image bodies from a command's message content blocks.
|
||||||
|
|
||||||
|
The client sends image blocks as ``{"type": "image", "base64", "mime_type",
|
||||||
|
"file_name"}`` (see the prompt bar). Rebuilding them lets
|
||||||
|
the shared ``_create_dashboard_thread_record`` validate size/type/model.
|
||||||
|
"""
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return []
|
||||||
|
images: list[DashboardImageBody] = []
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict) or block.get("type") != "image":
|
||||||
|
continue
|
||||||
|
data = block.get("base64")
|
||||||
|
mime = block.get("mime_type") or block.get("mimeType")
|
||||||
|
if not isinstance(data, str) or not isinstance(mime, str):
|
||||||
|
raise HTTPException(422, "invalid image data")
|
||||||
|
file_name = block.get("file_name") or block.get("fileName")
|
||||||
|
images.append(
|
||||||
|
DashboardImageBody(
|
||||||
|
base64=data,
|
||||||
|
mime_type=mime,
|
||||||
|
file_name=file_name if isinstance(file_name, str) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_command_images(content: Any, *, model_id: str | None) -> None:
|
||||||
|
"""Reject images for text-only models / oversize attachments (raises 422)."""
|
||||||
|
images = _dashboard_images_from_content(content)
|
||||||
|
if images:
|
||||||
|
_image_blocks(images, model_id=model_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _enrich_run_start_command(
|
||||||
|
thread_id: str,
|
||||||
|
login: str,
|
||||||
|
command: dict[str, Any],
|
||||||
|
*,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
thread_busy: bool = False,
|
||||||
|
creating: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if command.get("method") != "run.start":
|
||||||
|
return command
|
||||||
|
|
||||||
|
if thread_busy:
|
||||||
|
raise HTTPException(409, "thread is already running; queue message instead")
|
||||||
|
|
||||||
|
client = langgraph_client()
|
||||||
|
params = command.get("params")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
params = {}
|
||||||
|
command["params"] = params
|
||||||
|
|
||||||
|
await _ensure_dashboard_github_token(login)
|
||||||
|
|
||||||
|
client_config = params.get("config")
|
||||||
|
if not isinstance(client_config, dict):
|
||||||
|
client_config = {}
|
||||||
|
client_configurable = client_config.get("configurable")
|
||||||
|
if not isinstance(client_configurable, dict):
|
||||||
|
client_configurable = {}
|
||||||
|
|
||||||
|
chosen_model, chosen_effort = _normalize_model_choice(
|
||||||
|
client_configurable.get("agent_model_id"),
|
||||||
|
client_configurable.get("agent_effort"),
|
||||||
|
)
|
||||||
|
content = _command_message_content(params)
|
||||||
|
overrides: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if creating:
|
||||||
|
# First ``run.start`` for a client-minted thread id: stamp the full
|
||||||
|
# dashboard thread record (owner, title, repo, model) and validate any
|
||||||
|
# attached images against the resolved model before the run is
|
||||||
|
# forwarded to LangGraph. The repo hint rides in the client
|
||||||
|
# configurable; it never reaches the run config (which is rebuilt from
|
||||||
|
# the stamped metadata below).
|
||||||
|
thread = await _create_dashboard_thread_record(
|
||||||
|
thread_id,
|
||||||
|
login=login,
|
||||||
|
repo_config=_parse_repo(client_configurable.get("repo")) or {},
|
||||||
|
repo_explicitly_none=client_configurable.get("repo_explicitly_none") is True,
|
||||||
|
prompt=_command_prompt_text(content),
|
||||||
|
images=_dashboard_images_from_content(content),
|
||||||
|
model_id=client_configurable.get("agent_model_id"),
|
||||||
|
effort=client_configurable.get("agent_effort"),
|
||||||
|
)
|
||||||
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata
|
||||||
|
if chosen_model and chosen_effort:
|
||||||
|
overrides["agent_model_id"] = chosen_model
|
||||||
|
overrides["agent_effort"] = chosen_effort
|
||||||
|
else:
|
||||||
|
_validate_command_images(content, model_id=chosen_model or _metadata_model_id(metadata))
|
||||||
|
if chosen_model and chosen_effort:
|
||||||
|
overrides["agent_model_id"] = chosen_model
|
||||||
|
overrides["agent_effort"] = chosen_effort
|
||||||
|
metadata = {
|
||||||
|
**metadata,
|
||||||
|
"model": chosen_model,
|
||||||
|
"effort": chosen_effort,
|
||||||
|
"updated_at_ms": _now_ms(),
|
||||||
|
}
|
||||||
|
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
||||||
|
|
||||||
|
merged_configurable = await _build_dashboard_configurable(
|
||||||
thread_id,
|
thread_id,
|
||||||
_ASSISTANT_ID,
|
login,
|
||||||
input={"messages": [{"role": "user", "content": content}]},
|
metadata,
|
||||||
config={"configurable": configurable, "metadata": _agent_version_metadata()},
|
overrides=overrides,
|
||||||
if_not_exists="create",
|
|
||||||
stream_mode=list(_DASHBOARD_STREAM_MODES),
|
|
||||||
stream_resumable=True,
|
|
||||||
)
|
|
||||||
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
|
|
||||||
await client.threads.update(
|
|
||||||
thread_id=thread_id,
|
|
||||||
metadata={"latest_run_id": run_id, "latest_run_status": "pending", "updated_at_ms": now_ms},
|
|
||||||
)
|
|
||||||
thread = await client.threads.get(thread_id)
|
|
||||||
return _thread_summary(
|
|
||||||
thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
run_metadata = params.get("metadata")
|
||||||
|
if not isinstance(run_metadata, dict):
|
||||||
|
run_metadata = {}
|
||||||
|
run_metadata = {**run_metadata, **_agent_version_metadata()}
|
||||||
|
|
||||||
async def create_dashboard_thread(login: str, body: ThreadCreateBody) -> dict[str, Any]:
|
params["assistant_id"] = _ASSISTANT_ID
|
||||||
repo_config = _resolve_repo_config(body.repo)
|
params.setdefault("stream_mode", list(_DASHBOARD_STREAM_MODES))
|
||||||
thread_id = str(uuid.uuid4())
|
params.setdefault("stream_resumable", True)
|
||||||
return await _start_agent_run(
|
params["config"] = {**client_config, "configurable": merged_configurable}
|
||||||
thread_id,
|
params["metadata"] = run_metadata
|
||||||
login=login,
|
command["params"] = params
|
||||||
repo_config=repo_config,
|
return command
|
||||||
repo_explicitly_none=body.repo_explicitly_none,
|
|
||||||
prompt=body.prompt,
|
|
||||||
images=body.images,
|
|
||||||
model_id=body.model_id,
|
|
||||||
effort=body.effort,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_dashboard_message(
|
async def send_dashboard_message(
|
||||||
|
|
@ -586,7 +768,6 @@ async def send_dashboard_message(
|
||||||
|
|
||||||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||||
_assert_thread_owner(metadata, login, email)
|
_assert_thread_owner(metadata, login, email)
|
||||||
owner, name, _ = _metadata_repo(metadata)
|
|
||||||
|
|
||||||
prompt = body.content.strip()
|
prompt = body.content.strip()
|
||||||
now_ms = _now_ms()
|
now_ms = _now_ms()
|
||||||
|
|
@ -596,64 +777,26 @@ async def send_dashboard_message(
|
||||||
metadata_update["model"] = chosen_model
|
metadata_update["model"] = chosen_model
|
||||||
metadata_update["effort"] = chosen_effort
|
metadata_update["effort"] = chosen_effort
|
||||||
|
|
||||||
if await is_thread_active(thread_id):
|
active = await get_thread_active_status(thread_id)
|
||||||
active_model = _metadata_model_id(metadata) if body.images else None
|
if active is None:
|
||||||
content = _user_message_content(prompt, body.images, model_id=active_model)
|
raise HTTPException(502, "could not determine whether thread is active")
|
||||||
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
|
if not active:
|
||||||
queue_payload: dict[str, Any] = {"text": prompt, "source": _DASHBOARD_SOURCE}
|
raise HTTPException(
|
||||||
if isinstance(content, list):
|
409,
|
||||||
queue_payload["images"] = [
|
"thread is idle; start a run via the stream commands endpoint",
|
||||||
block
|
|
||||||
for block in content
|
|
||||||
if isinstance(block, dict) and block.get("type") != "text"
|
|
||||||
]
|
|
||||||
queued = await queue_message_for_thread(
|
|
||||||
thread_id,
|
|
||||||
queue_payload,
|
|
||||||
)
|
|
||||||
if not queued:
|
|
||||||
raise HTTPException(502, "failed to queue follow-up message")
|
|
||||||
thread = await client.threads.get(thread_id)
|
|
||||||
return _thread_summary(
|
|
||||||
thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
profile = await get_profile(login) or {}
|
active_model = _metadata_model_id(metadata) if body.images else None
|
||||||
resolved_model, resolved_effort = await _resolve_agent_model_choice(
|
content = _user_message_content(prompt, body.images, model_id=active_model)
|
||||||
profile, body.model_id, body.effort
|
|
||||||
)
|
|
||||||
metadata_update["resolved_model"] = resolved_model
|
|
||||||
metadata_update["resolved_effort"] = resolved_effort
|
|
||||||
content = _user_message_content(prompt, body.images, model_id=resolved_model)
|
|
||||||
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
|
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
|
||||||
|
queue_payload: dict[str, Any] = {"text": prompt, "source": _DASHBOARD_SOURCE}
|
||||||
await _ensure_dashboard_github_token(login)
|
if isinstance(content, list):
|
||||||
configurable: dict[str, Any] = {
|
queue_payload["images"] = [
|
||||||
"thread_id": thread_id,
|
block for block in content if isinstance(block, dict) and block.get("type") != "text"
|
||||||
"source": _DASHBOARD_SOURCE,
|
]
|
||||||
"github_login": login,
|
queued = await queue_message_for_thread(thread_id, queue_payload)
|
||||||
"user_email": await _resolve_run_email(login, profile),
|
if not queued:
|
||||||
}
|
raise HTTPException(502, "failed to queue follow-up message")
|
||||||
if owner and name:
|
|
||||||
configurable["repo"] = {"owner": owner, "name": name}
|
|
||||||
elif metadata.get("repo_explicitly_none") is True:
|
|
||||||
configurable["repo_explicitly_none"] = True
|
|
||||||
if chosen_model and chosen_effort:
|
|
||||||
configurable["agent_model_id"] = chosen_model
|
|
||||||
configurable["agent_effort"] = chosen_effort
|
|
||||||
run = await client.runs.create(
|
|
||||||
thread_id,
|
|
||||||
_ASSISTANT_ID,
|
|
||||||
input={"messages": [{"role": "user", "content": content}]},
|
|
||||||
config={"configurable": configurable, "metadata": _agent_version_metadata()},
|
|
||||||
stream_mode=list(_DASHBOARD_STREAM_MODES),
|
|
||||||
stream_resumable=True,
|
|
||||||
)
|
|
||||||
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
|
|
||||||
await client.threads.update(
|
|
||||||
thread_id=thread_id,
|
|
||||||
metadata={"latest_run_id": run_id, "latest_run_status": "pending", "updated_at_ms": now_ms},
|
|
||||||
)
|
|
||||||
thread = await client.threads.get(thread_id)
|
thread = await client.threads.get(thread_id)
|
||||||
return _thread_summary(
|
return _thread_summary(
|
||||||
thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
||||||
|
|
@ -709,6 +852,211 @@ async def delete_dashboard_thread(thread_id: str, login: str, *, email: str | No
|
||||||
await client.threads.delete(thread_id)
|
await client.threads.delete(thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _authorized_thread_metadata(
|
||||||
|
thread_id: str, login: str, *, email: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
thread = await _authorized_thread(thread_id, login, email=email)
|
||||||
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
async def _authorized_thread(
|
||||||
|
thread_id: str, login: str, *, email: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
thread = await langgraph_client().threads.get(thread_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(404, "thread not found") from exc
|
||||||
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||||
|
_assert_thread_owner(metadata, login, email)
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
async def get_dashboard_thread_state(
|
||||||
|
thread_id: str, login: str, *, email: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
thread = await _authorized_thread(thread_id, login, email=email)
|
||||||
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||||
|
state = await langgraph_client().threads.get_state(thread_id)
|
||||||
|
result = state if isinstance(state, dict) else dict(state)
|
||||||
|
# The SDK's `useStream` opens its live event subscription only when the
|
||||||
|
# hydrated `getState()` looks active (`next` non-empty / absent). When a
|
||||||
|
# run was just started out-of-band (our REST run-create), the latest
|
||||||
|
# checkpoint can still be the previous finished one with `next == []`,
|
||||||
|
# which the SDK reads as idle and never opens the stream. Drop `next`
|
||||||
|
# while a run is pending/running so the SDK treats the thread as active.
|
||||||
|
metadata_run_status = metadata.get("latest_run_status")
|
||||||
|
if _thread_is_busy(thread) or metadata_run_status in {"pending", "running"}:
|
||||||
|
result.pop("next", None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_dashboard_thread_stream_events(
|
||||||
|
thread_id: str,
|
||||||
|
login: str,
|
||||||
|
body: bytes,
|
||||||
|
*,
|
||||||
|
email: str | None = None,
|
||||||
|
content_type: str = "application/json",
|
||||||
|
) -> AsyncIterator[bytes]:
|
||||||
|
_require_json_content_type(content_type)
|
||||||
|
await _authorized_thread_metadata(thread_id, login, email=email)
|
||||||
|
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/stream/events"
|
||||||
|
headers = _langgraph_proxy_headers(content_type=content_type, accept="text/event-stream")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_PROXY_STREAM_TIMEOUT) as client:
|
||||||
|
async with client.stream("POST", url, content=body, headers=headers) as response:
|
||||||
|
if response.status_code >= 400:
|
||||||
|
error_body = await response.aread()
|
||||||
|
payload = {
|
||||||
|
"status": response.status_code,
|
||||||
|
"detail": error_body.decode(errors="replace") or response.reason_phrase,
|
||||||
|
}
|
||||||
|
yield f"event: error\ndata: {json.dumps(payload)}\n\n".encode()
|
||||||
|
return
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
yield chunk
|
||||||
|
except Exception:
|
||||||
|
logger.warning("LangGraph stream/events proxy closed for %s", thread_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_dashboard_thread_commands(
|
||||||
|
thread_id: str,
|
||||||
|
login: str,
|
||||||
|
body: bytes,
|
||||||
|
*,
|
||||||
|
email: str | None = None,
|
||||||
|
content_type: str = "application/json",
|
||||||
|
) -> tuple[int, bytes, str | None]:
|
||||||
|
_require_json_content_type(content_type)
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(400, "command body must be a JSON object") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise HTTPException(400, "command body must be a JSON object")
|
||||||
|
|
||||||
|
# The dashboard mints the thread id client-side and submits straight away,
|
||||||
|
# so the very first ``run.start`` may target a thread that doesn't exist
|
||||||
|
# yet. That command lazily creates + stamps + owns the thread (in
|
||||||
|
# ``_enrich_run_start_command``); any other command against a missing
|
||||||
|
# thread — or a command from a non-owner against an existing thread — is a
|
||||||
|
# 404.
|
||||||
|
method = parsed.get("method")
|
||||||
|
try:
|
||||||
|
thread = await langgraph_client().threads.get(thread_id)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
thread = None
|
||||||
|
|
||||||
|
creating = False
|
||||||
|
if thread is None:
|
||||||
|
if method != "run.start":
|
||||||
|
raise HTTPException(404, "thread not found")
|
||||||
|
creating = True
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
thread_busy = False
|
||||||
|
else:
|
||||||
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||||
|
_assert_thread_owner(metadata, login, email)
|
||||||
|
metadata_run_status = metadata.get("latest_run_status")
|
||||||
|
thread_busy = _thread_is_busy(thread) or metadata_run_status in {"pending", "running"}
|
||||||
|
|
||||||
|
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/commands"
|
||||||
|
headers = _langgraph_proxy_headers(content_type=content_type)
|
||||||
|
|
||||||
|
enriched = await _enrich_run_start_command(
|
||||||
|
thread_id,
|
||||||
|
login,
|
||||||
|
parsed,
|
||||||
|
metadata=metadata,
|
||||||
|
thread_busy=thread_busy,
|
||||||
|
creating=creating,
|
||||||
|
)
|
||||||
|
outgoing = json.dumps(enriched).encode()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
|
||||||
|
response = await client.post(url, content=outgoing, headers=headers)
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.get("method") == "run.start"
|
||||||
|
and response.status_code in {200, 202, 204}
|
||||||
|
and response.content
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
payload = json.loads(response.content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = None
|
||||||
|
run_id = _extract_run_id_from_command_response(payload)
|
||||||
|
if run_id:
|
||||||
|
await langgraph_client().threads.update(
|
||||||
|
thread_id=thread_id,
|
||||||
|
metadata={
|
||||||
|
"latest_run_id": run_id,
|
||||||
|
"latest_run_status": "pending",
|
||||||
|
"updated_at_ms": _now_ms(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
media_type = response.headers.get("content-type")
|
||||||
|
return response.status_code, response.content, media_type
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_dashboard_thread_history(
|
||||||
|
thread_id: str,
|
||||||
|
login: str,
|
||||||
|
body: bytes,
|
||||||
|
*,
|
||||||
|
email: str | None = None,
|
||||||
|
content_type: str = "application/json",
|
||||||
|
) -> tuple[int, bytes, str | None]:
|
||||||
|
_require_json_content_type(content_type)
|
||||||
|
await _authorized_thread_metadata(thread_id, login, email=email)
|
||||||
|
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/history"
|
||||||
|
headers = _langgraph_proxy_headers(content_type=content_type)
|
||||||
|
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
|
||||||
|
response = await client.post(url, content=body or b"{}", headers=headers)
|
||||||
|
media_type = response.headers.get("content-type")
|
||||||
|
return response.status_code, response.content, media_type
|
||||||
|
|
||||||
|
|
||||||
|
async def proxy_dashboard_thread_run_cancel(
|
||||||
|
thread_id: str,
|
||||||
|
run_id: str,
|
||||||
|
login: str,
|
||||||
|
*,
|
||||||
|
wait: str = "0",
|
||||||
|
action: str = "interrupt",
|
||||||
|
email: str | None = None,
|
||||||
|
) -> tuple[int, bytes, str | None]:
|
||||||
|
await _authorized_thread_metadata(thread_id, login, email=email)
|
||||||
|
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/runs/{run_id}/cancel"
|
||||||
|
headers = _langgraph_proxy_headers()
|
||||||
|
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
params={"wait": wait, "action": action},
|
||||||
|
)
|
||||||
|
if response.status_code in {200, 202, 204}:
|
||||||
|
try:
|
||||||
|
await langgraph_client().threads.update(
|
||||||
|
thread_id=thread_id,
|
||||||
|
metadata={
|
||||||
|
"latest_run_status": "interrupted",
|
||||||
|
"updated_at_ms": _now_ms(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"Could not update thread metadata after run cancel for %s",
|
||||||
|
thread_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
media_type = response.headers.get("content-type")
|
||||||
|
return response.status_code, response.content, media_type
|
||||||
|
|
||||||
|
|
||||||
async def stream_dashboard_thread(
|
async def stream_dashboard_thread(
|
||||||
thread_id: str, login: str, *, email: str | None = None, last_event_id: str | None = None
|
thread_id: str, login: str, *, email: str | None = None, last_event_id: str | None = None
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware
|
||||||
from .sanitize_thinking_blocks import SanitizeThinkingBlocksMiddleware
|
from .sanitize_thinking_blocks import SanitizeThinkingBlocksMiddleware
|
||||||
from .sanitize_tool_inputs import SanitizeToolInputsMiddleware
|
from .sanitize_tool_inputs import SanitizeToolInputsMiddleware
|
||||||
from .settle_review_check import settle_review_check_on_exit
|
from .settle_review_check import settle_review_check_on_exit
|
||||||
|
from .tool_artifact import ToolArtifactMiddleware
|
||||||
from .tool_error_handler import ToolErrorMiddleware
|
from .tool_error_handler import ToolErrorMiddleware
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -15,6 +16,7 @@ __all__ = [
|
||||||
"ModelFallbackMiddleware",
|
"ModelFallbackMiddleware",
|
||||||
"SanitizeThinkingBlocksMiddleware",
|
"SanitizeThinkingBlocksMiddleware",
|
||||||
"SanitizeToolInputsMiddleware",
|
"SanitizeToolInputsMiddleware",
|
||||||
|
"ToolArtifactMiddleware",
|
||||||
"ToolErrorMiddleware",
|
"ToolErrorMiddleware",
|
||||||
"SandboxCircuitBreakerMiddleware",
|
"SandboxCircuitBreakerMiddleware",
|
||||||
"SlackAssistantStatusMiddleware",
|
"SlackAssistantStatusMiddleware",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import openai
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||||
from langchain_core.language_models import BaseChatModel
|
from langchain_core.language_models import BaseChatModel
|
||||||
|
from langchain_core.messages import AIMessage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -49,6 +50,46 @@ def _should_fallback(exc: BaseException) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _error_body(exc: BaseException) -> dict[str, Any]:
|
||||||
|
body = getattr(exc, "body", None)
|
||||||
|
return body if isinstance(body, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_str(data: dict[str, Any], *keys: str) -> str | None:
|
||||||
|
current: Any = data
|
||||||
|
for key in keys:
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return None
|
||||||
|
current = current.get(key)
|
||||||
|
return current if isinstance(current, str) and current else None
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_access_error_message(exc: BaseException) -> str | None:
|
||||||
|
if isinstance(exc, anthropic.BadRequestError):
|
||||||
|
body = _error_body(exc)
|
||||||
|
error_code = _nested_str(body, "error", "details", "error_code")
|
||||||
|
if error_code == "model_not_available":
|
||||||
|
provider_message = _nested_str(body, "error", "message") or str(exc)
|
||||||
|
return (
|
||||||
|
"The selected Anthropic model is not available to this workspace. "
|
||||||
|
f"Anthropic returned: {provider_message} "
|
||||||
|
"Choose a different model or update the workspace's Anthropic access and retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(exc, (openai.BadRequestError, openai.NotFoundError)):
|
||||||
|
body = _error_body(exc)
|
||||||
|
error_code = _nested_str(body, "error", "code")
|
||||||
|
if error_code in {"model_not_found", "model_not_available"}:
|
||||||
|
provider_message = _nested_str(body, "error", "message") or str(exc)
|
||||||
|
return (
|
||||||
|
"The selected OpenAI model is not available to this workspace. "
|
||||||
|
f"OpenAI returned: {provider_message} "
|
||||||
|
"Choose a different model or update the workspace's OpenAI access and retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ModelFallbackMiddleware(AgentMiddleware):
|
class ModelFallbackMiddleware(AgentMiddleware):
|
||||||
"""Retry the model call against a fallback provider on transient errors."""
|
"""Retry the model call against a fallback provider on transient errors."""
|
||||||
|
|
||||||
|
|
@ -64,6 +105,10 @@ class ModelFallbackMiddleware(AgentMiddleware):
|
||||||
try:
|
try:
|
||||||
return handler(request)
|
return handler(request)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
access_error_message = _provider_access_error_message(exc)
|
||||||
|
if access_error_message is not None:
|
||||||
|
logger.warning("Model access error surfaced to user: %s", type(exc).__name__)
|
||||||
|
return AIMessage(content=access_error_message)
|
||||||
if not _should_fallback(exc):
|
if not _should_fallback(exc):
|
||||||
raise
|
raise
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -82,6 +127,10 @@ class ModelFallbackMiddleware(AgentMiddleware):
|
||||||
try:
|
try:
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
access_error_message = _provider_access_error_message(exc)
|
||||||
|
if access_error_message is not None:
|
||||||
|
logger.warning("Model access error surfaced to user: %s", type(exc).__name__)
|
||||||
|
return AIMessage(content=access_error_message)
|
||||||
if not _should_fallback(exc):
|
if not _should_fallback(exc):
|
||||||
raise
|
raise
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
|
||||||
244
agent/middleware/tool_artifact.py
Normal file
244
agent/middleware/tool_artifact.py
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
"""Stamp file-edit tool results with a presentation diff (``ToolMessage.artifact``).
|
||||||
|
|
||||||
|
``edit_file`` / ``write_file`` return only a one-line summary, but the dashboard
|
||||||
|
renders a full-file diff per edit. This middleware reads the file's *before*
|
||||||
|
content from the sandbox once, computes the *after* content locally (write → the
|
||||||
|
new content from args; edit → applying the old→new replacement), and stamps the
|
||||||
|
result's ``artifact`` with a ``{"diff": {...}}`` payload.
|
||||||
|
|
||||||
|
``ToolMessage.artifact`` is a standard serialized field, so it survives the
|
||||||
|
checkpoint + ``GET …/state`` hydration: the client renders the same diff live
|
||||||
|
and on reload straight from ``stream.messages`` — no second adapter and no
|
||||||
|
client-side sandbox access (see ``ui/src/lib/agents/streamMessagesToUi.ts``).
|
||||||
|
|
||||||
|
Everything here is best-effort. On any failure — no cached sandbox, a binary or
|
||||||
|
truncated read, a missing ``old_string`` — the tool result is returned untouched
|
||||||
|
and the client falls back to deriving a fragment diff from the tool args.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain.agents.middleware.types import AgentMiddleware, AgentState
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langgraph.config import get_config
|
||||||
|
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from ..utils.sandbox_state import SANDBOX_BACKENDS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_EDIT_FILE = "edit_file"
|
||||||
|
_WRITE_FILE = "write_file"
|
||||||
|
_DIFF_TOOLS = frozenset({_EDIT_FILE, _WRITE_FILE})
|
||||||
|
|
||||||
|
# Cap the before-read: rendering a diff isn't worth pulling a huge file into
|
||||||
|
# memory, and a read at the cap is assumed truncated (skip → args fallback).
|
||||||
|
_MAX_DIFF_LINES = 20_000
|
||||||
|
|
||||||
|
_NOT_FOUND_HINTS = ("not found", "no such file", "does not exist", "file_not_found", "enoent")
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_name(request: ToolCallRequest) -> str | None:
|
||||||
|
tool_call = getattr(request, "tool_call", None)
|
||||||
|
if isinstance(tool_call, Mapping):
|
||||||
|
name = tool_call.get("name")
|
||||||
|
return name if isinstance(name, str) and name else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_args(request: ToolCallRequest) -> dict[str, Any]:
|
||||||
|
tool_call = getattr(request, "tool_call", None)
|
||||||
|
if isinstance(tool_call, Mapping):
|
||||||
|
args = tool_call.get("args")
|
||||||
|
if isinstance(args, Mapping):
|
||||||
|
return dict(args)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_id(request: ToolCallRequest) -> str | None:
|
||||||
|
runtime_config = getattr(getattr(request, "runtime", None), "config", None)
|
||||||
|
config: Mapping[str, Any] | None = (
|
||||||
|
runtime_config if isinstance(runtime_config, Mapping) else None
|
||||||
|
)
|
||||||
|
if config is None:
|
||||||
|
try:
|
||||||
|
maybe_config = get_config()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
config = maybe_config if isinstance(maybe_config, Mapping) else None
|
||||||
|
if config is None:
|
||||||
|
return None
|
||||||
|
configurable = config.get("configurable", {})
|
||||||
|
if not isinstance(configurable, Mapping):
|
||||||
|
return None
|
||||||
|
thread_id = configurable.get("thread_id")
|
||||||
|
return thread_id if isinstance(thread_id, str) and thread_id else None
|
||||||
|
|
||||||
|
|
||||||
|
def _file_path(args: Mapping[str, Any]) -> str | None:
|
||||||
|
raw = args.get("file_path") or args.get("path") or args.get("target_file")
|
||||||
|
return raw.strip() if isinstance(raw, str) and raw.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_read(result: Any) -> tuple[str | None, str | None]:
|
||||||
|
"""Normalize a backend ``ReadResult`` to ``(content, error_kind)``.
|
||||||
|
|
||||||
|
``error_kind`` is ``None`` on success, ``"not_found"`` when the file is
|
||||||
|
absent (a clean signal it's a new file), or ``"other"`` for anything we
|
||||||
|
can't safely turn into a full-file diff (binary, truncated, unreadable).
|
||||||
|
"""
|
||||||
|
error = getattr(result, "error", None)
|
||||||
|
if error:
|
||||||
|
text = str(error).lower()
|
||||||
|
return None, "not_found" if any(h in text for h in _NOT_FOUND_HINTS) else "other"
|
||||||
|
|
||||||
|
file_data = getattr(result, "file_data", None)
|
||||||
|
if file_data is None:
|
||||||
|
return None, "other"
|
||||||
|
|
||||||
|
if isinstance(file_data, Mapping):
|
||||||
|
encoding = file_data.get("encoding")
|
||||||
|
content = file_data.get("content")
|
||||||
|
else:
|
||||||
|
encoding = getattr(file_data, "encoding", None)
|
||||||
|
content = getattr(file_data, "content", None)
|
||||||
|
|
||||||
|
if encoding is not None and encoding != "utf-8":
|
||||||
|
return None, "other" # base64 / binary
|
||||||
|
if not isinstance(content, str):
|
||||||
|
return None, "other"
|
||||||
|
if content.count("\n") + 1 >= _MAX_DIFF_LINES:
|
||||||
|
return None, "other" # assume truncated at the read cap
|
||||||
|
return content, None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_diff_artifact(
|
||||||
|
tool_name: str,
|
||||||
|
args: Mapping[str, Any],
|
||||||
|
before: str | None,
|
||||||
|
before_kind: str | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Pure: build the ``{"diff": {...}}`` artifact, or ``None`` to skip."""
|
||||||
|
file_path = _file_path(args)
|
||||||
|
if file_path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if tool_name == _WRITE_FILE:
|
||||||
|
new_content = args.get("content")
|
||||||
|
if not isinstance(new_content, str):
|
||||||
|
return None
|
||||||
|
if before_kind is None and before is not None:
|
||||||
|
return _diff(file_path, before, new_content, is_new=False)
|
||||||
|
if before_kind == "not_found":
|
||||||
|
return _diff(file_path, None, new_content, is_new=True)
|
||||||
|
return None # unknown prior state — let the client derive a fragment
|
||||||
|
|
||||||
|
if tool_name == _EDIT_FILE:
|
||||||
|
old_string = args.get("old_string")
|
||||||
|
new_string = args.get("new_string")
|
||||||
|
if not isinstance(old_string, str) or not isinstance(new_string, str):
|
||||||
|
return None
|
||||||
|
# A full-file diff needs the real file. Requiring the edited span to be
|
||||||
|
# present confirms the read is the genuine content (and not a binary,
|
||||||
|
# truncated, or empty-placeholder read) before we trust it.
|
||||||
|
if before is None or old_string not in before:
|
||||||
|
return None
|
||||||
|
if args.get("replace_all"):
|
||||||
|
new_content = before.replace(old_string, new_string)
|
||||||
|
else:
|
||||||
|
new_content = before.replace(old_string, new_string, 1)
|
||||||
|
return _diff(file_path, before, new_content, is_new=False)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _diff(
|
||||||
|
file_path: str, original: str | None, new_content: str, *, is_new: bool
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"diff": {
|
||||||
|
"filePath": file_path,
|
||||||
|
"originalContent": original,
|
||||||
|
"newContent": new_content,
|
||||||
|
"isNewFile": is_new,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp(result: ToolMessage | Command, artifact: dict[str, Any] | None) -> None:
|
||||||
|
if artifact is None or not isinstance(result, ToolMessage) or result.status == "error":
|
||||||
|
return
|
||||||
|
existing = result.artifact if isinstance(result.artifact, Mapping) else None
|
||||||
|
result.artifact = {**existing, **artifact} if existing else artifact
|
||||||
|
|
||||||
|
|
||||||
|
class ToolArtifactMiddleware(AgentMiddleware):
|
||||||
|
"""Attach a full-file diff to ``edit_file`` / ``write_file`` results.
|
||||||
|
|
||||||
|
Runs inside ``ToolErrorMiddleware`` (added right after it in the stack) so
|
||||||
|
error normalization still brackets the real tool call. Reads the file's
|
||||||
|
pre-edit content once per edit; on any failure it no-ops.
|
||||||
|
"""
|
||||||
|
|
||||||
|
state_schema = AgentState
|
||||||
|
|
||||||
|
def _backend(self, request: ToolCallRequest) -> Any | None:
|
||||||
|
thread_id = _thread_id(request)
|
||||||
|
if not thread_id:
|
||||||
|
return None
|
||||||
|
return SANDBOX_BACKENDS.get(thread_id)
|
||||||
|
|
||||||
|
def _read_before_sync(self, request: ToolCallRequest) -> tuple[str | None, str | None]:
|
||||||
|
backend = self._backend(request)
|
||||||
|
file_path = _file_path(_tool_args(request))
|
||||||
|
if backend is None or file_path is None:
|
||||||
|
return None, "other"
|
||||||
|
try:
|
||||||
|
result = backend.read(file_path, offset=0, limit=_MAX_DIFF_LINES)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("tool_artifact: read failed for %s", file_path, exc_info=True)
|
||||||
|
return None, "other"
|
||||||
|
return _classify_read(result)
|
||||||
|
|
||||||
|
async def _read_before_async(self, request: ToolCallRequest) -> tuple[str | None, str | None]:
|
||||||
|
backend = self._backend(request)
|
||||||
|
file_path = _file_path(_tool_args(request))
|
||||||
|
if backend is None or file_path is None:
|
||||||
|
return None, "other"
|
||||||
|
try:
|
||||||
|
result = await backend.aread(file_path, offset=0, limit=_MAX_DIFF_LINES)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("tool_artifact: aread failed for %s", file_path, exc_info=True)
|
||||||
|
return None, "other"
|
||||||
|
return _classify_read(result)
|
||||||
|
|
||||||
|
def wrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||||
|
) -> ToolMessage | Command:
|
||||||
|
name = _tool_name(request)
|
||||||
|
if name not in _DIFF_TOOLS:
|
||||||
|
return handler(request)
|
||||||
|
before, kind = self._read_before_sync(request)
|
||||||
|
result = handler(request)
|
||||||
|
_stamp(result, _build_diff_artifact(name, _tool_args(request), before, kind))
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def awrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||||
|
) -> ToolMessage | Command:
|
||||||
|
name = _tool_name(request)
|
||||||
|
if name not in _DIFF_TOOLS:
|
||||||
|
return await handler(request)
|
||||||
|
before, kind = await self._read_before_async(request)
|
||||||
|
result = await handler(request)
|
||||||
|
_stamp(result, _build_diff_artifact(name, _tool_args(request), before, kind))
|
||||||
|
return result
|
||||||
|
|
@ -51,6 +51,7 @@ from .middleware import (
|
||||||
SanitizeThinkingBlocksMiddleware,
|
SanitizeThinkingBlocksMiddleware,
|
||||||
SanitizeToolInputsMiddleware,
|
SanitizeToolInputsMiddleware,
|
||||||
SlackAssistantStatusMiddleware,
|
SlackAssistantStatusMiddleware,
|
||||||
|
ToolArtifactMiddleware,
|
||||||
ToolErrorMiddleware,
|
ToolErrorMiddleware,
|
||||||
check_message_queue_before_model,
|
check_message_queue_before_model,
|
||||||
ensure_no_empty_msg,
|
ensure_no_empty_msg,
|
||||||
|
|
@ -660,6 +661,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
||||||
SanitizeToolInputsMiddleware(),
|
SanitizeToolInputsMiddleware(),
|
||||||
ModelCallLimitMiddleware(run_limit=MODEL_CALL_RECURSION_LIMIT, exit_behavior="end"),
|
ModelCallLimitMiddleware(run_limit=MODEL_CALL_RECURSION_LIMIT, exit_behavior="end"),
|
||||||
ToolErrorMiddleware(),
|
ToolErrorMiddleware(),
|
||||||
|
ToolArtifactMiddleware(),
|
||||||
check_message_queue_before_model,
|
check_message_queue_before_model,
|
||||||
SlackAssistantStatusMiddleware(),
|
SlackAssistantStatusMiddleware(),
|
||||||
ensure_no_empty_msg,
|
ensure_no_empty_msg,
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
"""Helpers for normalizing message content across model providers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from langchain_core.messages import ContentBlock
|
|
||||||
|
|
||||||
|
|
||||||
def extract_text_content(content: str | list[ContentBlock]) -> str:
|
|
||||||
"""Extract human-readable text from model message content.
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
- Plain strings
|
|
||||||
- OpenAI-style content blocks (list of {"type": "text", "text": ...})
|
|
||||||
- Dict wrappers with nested "content" or "text"
|
|
||||||
"""
|
|
||||||
|
|
||||||
if isinstance(content, str):
|
|
||||||
return content.strip()
|
|
||||||
|
|
||||||
if not isinstance(content, list):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
text = ""
|
|
||||||
for item in content:
|
|
||||||
if isinstance(item, dict) and "text" in item:
|
|
||||||
text += item["text"]
|
|
||||||
|
|
||||||
return text.strip()
|
|
||||||
|
|
@ -8,10 +8,13 @@ OPENAI_RESPONSES_WS_BASE_URL = "wss://api.openai.com/v1"
|
||||||
# primary provider a fair chance before the fallback middleware kicks in.
|
# primary provider a fair chance before the fallback middleware kicks in.
|
||||||
DEFAULT_MAX_RETRIES = 6
|
DEFAULT_MAX_RETRIES = 6
|
||||||
|
|
||||||
DEFAULT_LLM_REASONING: "OpenAIReasoning" = {"effort": "medium"}
|
|
||||||
|
|
||||||
OpenAIReasoningEffort = Literal["none", "low", "medium", "high", "xhigh"]
|
OpenAIReasoningEffort = Literal["none", "low", "medium", "high", "xhigh"]
|
||||||
|
# OpenAI's Responses API only returns human-readable reasoning text when a
|
||||||
|
# summary is requested; without it, reasoning happens silently (billed in
|
||||||
|
# output tokens) and the reasoning content block arrives empty.
|
||||||
|
OpenAIReasoningSummary = Literal["auto", "concise", "detailed"]
|
||||||
AnthropicThinkingType = Literal["adaptive"]
|
AnthropicThinkingType = Literal["adaptive"]
|
||||||
|
AnthropicThinkingDisplay = Literal["summarized", "omitted"]
|
||||||
AnthropicEffort = Literal["low", "medium", "high", "xhigh", "max"]
|
AnthropicEffort = Literal["low", "medium", "high", "xhigh", "max"]
|
||||||
GoogleThinkingLevel = Literal["minimal", "low", "medium", "high"]
|
GoogleThinkingLevel = Literal["minimal", "low", "medium", "high"]
|
||||||
FireworksReasoningEffort = Literal["none", "low", "medium", "high", "xhigh", "max"]
|
FireworksReasoningEffort = Literal["none", "low", "medium", "high", "xhigh", "max"]
|
||||||
|
|
@ -19,10 +22,15 @@ FireworksReasoningEffort = Literal["none", "low", "medium", "high", "xhigh", "ma
|
||||||
|
|
||||||
class OpenAIReasoning(TypedDict, total=False):
|
class OpenAIReasoning(TypedDict, total=False):
|
||||||
effort: OpenAIReasoningEffort
|
effort: OpenAIReasoningEffort
|
||||||
|
summary: OpenAIReasoningSummary
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LLM_REASONING: "OpenAIReasoning" = {"effort": "medium", "summary": "auto"}
|
||||||
|
|
||||||
|
|
||||||
class AnthropicThinking(TypedDict, total=False):
|
class AnthropicThinking(TypedDict, total=False):
|
||||||
type: AnthropicThinkingType
|
type: AnthropicThinkingType
|
||||||
|
display: AnthropicThinkingDisplay
|
||||||
|
|
||||||
|
|
||||||
class ModelKwargs(TypedDict, total=False):
|
class ModelKwargs(TypedDict, total=False):
|
||||||
|
|
@ -74,24 +82,33 @@ def openai_reasoning_for(
|
||||||
*,
|
*,
|
||||||
default_effort: OpenAIReasoningEffort | None = None,
|
default_effort: OpenAIReasoningEffort | None = None,
|
||||||
) -> OpenAIReasoning | None:
|
) -> OpenAIReasoning | None:
|
||||||
"""Return an OpenAI reasoning kwarg from a profile effort string."""
|
"""Return an OpenAI reasoning kwarg from a profile effort string.
|
||||||
|
|
||||||
|
Requests ``summary: "auto"`` for every reasoning effort so the Responses
|
||||||
|
API emits visible reasoning text. ``effort: "none"`` disables reasoning
|
||||||
|
entirely, so no summary is attached.
|
||||||
|
"""
|
||||||
effort = profile_effort or default_effort or DEFAULT_LLM_REASONING.get("effort")
|
effort = profile_effort or default_effort or DEFAULT_LLM_REASONING.get("effort")
|
||||||
if effort == "none":
|
if effort == "none":
|
||||||
return {"effort": "none"}
|
return {"effort": "none"}
|
||||||
if effort == "low":
|
if effort == "low":
|
||||||
return {"effort": "low"}
|
return {"effort": "low", "summary": "auto"}
|
||||||
if effort == "medium":
|
if effort == "medium":
|
||||||
return {"effort": "medium"}
|
return {"effort": "medium", "summary": "auto"}
|
||||||
if effort == "high":
|
if effort == "high":
|
||||||
return {"effort": "high"}
|
return {"effort": "high", "summary": "auto"}
|
||||||
if effort == "xhigh":
|
if effort == "xhigh":
|
||||||
return {"effort": "xhigh"}
|
return {"effort": "xhigh", "summary": "auto"}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def anthropic_thinking_for(profile_effort: str | None) -> AnthropicThinking | None:
|
def anthropic_thinking_for(profile_effort: str | None) -> AnthropicThinking | None:
|
||||||
if profile_effort in _ANTHROPIC_EFFORTS:
|
if profile_effort in _ANTHROPIC_EFFORTS:
|
||||||
return {"type": "adaptive"}
|
# `display: "summarized"` makes Opus 4.7+ return the (summarized) reasoning
|
||||||
|
# text in the response. The adaptive default is "omitted", which streams a
|
||||||
|
# reasoning block carrying only a signature and no visible thinking — so the
|
||||||
|
# dashboard never has any text to render.
|
||||||
|
return {"type": "adaptive", "display": "summarized"}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,21 @@ def langgraph_client():
|
||||||
return get_client(url=langgraph_url())
|
return get_client(url=langgraph_url())
|
||||||
|
|
||||||
|
|
||||||
async def is_thread_active(thread_id: str) -> bool:
|
async def get_thread_active_status(thread_id: str) -> bool | None:
|
||||||
"""Return whether the thread currently has a running run."""
|
"""Return whether the thread is active, or None when status cannot be determined."""
|
||||||
try:
|
try:
|
||||||
thread = await langgraph_client().threads.get(thread_id)
|
thread = await langgraph_client().threads.get(thread_id)
|
||||||
status = thread.get("status", "idle") if isinstance(thread, dict) else "idle"
|
status = thread.get("status", "idle") if isinstance(thread, dict) else "idle"
|
||||||
logger.info("Thread %s status check: status=%s", thread_id, status)
|
logger.info("Thread %s status check: status=%s", thread_id, status)
|
||||||
return status == "busy"
|
return status == "busy"
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning(
|
logger.warning("Failed to get thread status for %s: %s", thread_id, exc)
|
||||||
"Failed to get thread status for %s: %s — assuming not active",
|
return None
|
||||||
thread_id,
|
|
||||||
exc,
|
|
||||||
)
|
async def is_thread_active(thread_id: str) -> bool:
|
||||||
return False
|
"""Return whether the thread currently has a running run."""
|
||||||
|
return await get_thread_active_status(thread_id) is True
|
||||||
|
|
||||||
|
|
||||||
async def queue_message_for_thread(
|
async def queue_message_for_thread(
|
||||||
|
|
|
||||||
|
|
@ -124,11 +124,15 @@ DASHBOARD_ALLOWED_ORIGINS: list[str] = [
|
||||||
o.strip() for o in os.environ.get("DASHBOARD_ALLOWED_ORIGINS", "").split(",") if o.strip()
|
o.strip() for o in os.environ.get("DASHBOARD_ALLOWED_ORIGINS", "").split(",") if o.strip()
|
||||||
]
|
]
|
||||||
if DASHBOARD_ALLOWED_ORIGINS:
|
if DASHBOARD_ALLOWED_ORIGINS:
|
||||||
|
if "*" in DASHBOARD_ALLOWED_ORIGINS:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DASHBOARD_ALLOWED_ORIGINS must not include '*' when allow_credentials=True"
|
||||||
|
)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=DASHBOARD_ALLOWED_ORIGINS,
|
allow_origins=DASHBOARD_ALLOWED_ORIGINS,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://langgra.ph/schema.json",
|
"$schema": "https://langgra.ph/schema.json",
|
||||||
"python_version": "3.12",
|
"python_version": "3.12",
|
||||||
|
"api_version": "0.10.0rc3",
|
||||||
"graphs": {
|
"graphs": {
|
||||||
"agent": "agent.server:get_agent",
|
"agent": "agent.server:get_agent",
|
||||||
"reviewer": "agent.reviewer:get_reviewer_agent",
|
"reviewer": "agent.reviewer:get_reviewer_agent",
|
||||||
"analyzer": "agent.analyzer:get_analyzer",
|
"analyzer": "agent.analyzer:get_analyzer",
|
||||||
"scheduler": "agent.scheduler:get_scheduler"
|
"scheduler": "agent.scheduler:get_scheduler"
|
||||||
},
|
},
|
||||||
"dependencies": ["."],
|
"dependencies": [
|
||||||
|
"."
|
||||||
|
],
|
||||||
"http": {
|
"http": {
|
||||||
"app": "agent.webapp:app"
|
"app": "agent.webapp:app"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -77,12 +77,12 @@ async def test_agent_uses_profile_subagent_model_override() -> None:
|
||||||
|
|
||||||
main_call = make_model.call_args_list[0]
|
main_call = make_model.call_args_list[0]
|
||||||
assert main_call.args == ("anthropic:claude-opus-4-8",)
|
assert main_call.args == ("anthropic:claude-opus-4-8",)
|
||||||
assert main_call.kwargs["thinking"] == {"type": "adaptive"}
|
assert main_call.kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||||
assert main_call.kwargs["effort"] == "high"
|
assert main_call.kwargs["effort"] == "high"
|
||||||
|
|
||||||
subagent_call = make_model.call_args_list[1]
|
subagent_call = make_model.call_args_list[1]
|
||||||
assert subagent_call.args == ("openai:gpt-5.5",)
|
assert subagent_call.args == ("openai:gpt-5.5",)
|
||||||
assert subagent_call.kwargs["reasoning"] == {"effort": "xhigh"}
|
assert subagent_call.kwargs["reasoning"] == {"effort": "xhigh", "summary": "auto"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -145,5 +145,8 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
|
||||||
assert subagents[0]["model"] is subagent_model
|
assert subagents[0]["model"] is subagent_model
|
||||||
assert make_model.call_args_list[0].args == ("anthropic:claude-opus-4-8",)
|
assert make_model.call_args_list[0].args == ("anthropic:claude-opus-4-8",)
|
||||||
assert make_model.call_args_list[1].args == ("anthropic:claude-opus-4-8",)
|
assert make_model.call_args_list[1].args == ("anthropic:claude-opus-4-8",)
|
||||||
assert make_model.call_args_list[1].kwargs["thinking"] == {"type": "adaptive"}
|
assert make_model.call_args_list[1].kwargs["thinking"] == {
|
||||||
|
"type": "adaptive",
|
||||||
|
"display": "summarized",
|
||||||
|
}
|
||||||
assert make_model.call_args_list[1].kwargs["effort"] == "high"
|
assert make_model.call_args_list[1].kwargs["effort"] == "high"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from agent.utils.model import anthropic_effort_for, anthropic_thinking_for
|
||||||
|
|
||||||
|
|
||||||
def test_anthropic_uses_adaptive_thinking_and_effort() -> None:
|
def test_anthropic_uses_adaptive_thinking_and_effort() -> None:
|
||||||
assert anthropic_thinking_for("high") == {"type": "adaptive"}
|
assert anthropic_thinking_for("high") == {"type": "adaptive", "display": "summarized"}
|
||||||
assert anthropic_effort_for("high") == "high"
|
assert anthropic_effort_for("high") == "high"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
197
tests/test_dashboard_csrf.py
Normal file
197
tests/test_dashboard_csrf.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""CSRF defenses for cookie-authenticated dashboard mutations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from agent.dashboard import oauth, routes, thread_api
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
*,
|
||||||
|
method: str = "POST",
|
||||||
|
path: str = "/dashboard/api/threads/tid/commands",
|
||||||
|
origin: str | None = None,
|
||||||
|
referer: str | None = None,
|
||||||
|
) -> Request:
|
||||||
|
headers: list[tuple[bytes, bytes]] = []
|
||||||
|
if origin is not None:
|
||||||
|
headers.append((b"origin", origin.encode()))
|
||||||
|
if referer is not None:
|
||||||
|
headers.append((b"referer", referer.encode()))
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": method,
|
||||||
|
"path": path,
|
||||||
|
"headers": headers,
|
||||||
|
}
|
||||||
|
return Request(scope)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dashboard_client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "http://testserver")
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes.router)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_noop_when_unconfigured(monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
|
||||||
|
monkeypatch.delenv("DASHBOARD_ALLOWED_ORIGINS", raising=False)
|
||||||
|
|
||||||
|
oauth.require_same_origin(_request(origin="https://evil.example"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_allows_configured_origin(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||||
|
monkeypatch.setenv("DASHBOARD_ALLOWED_ORIGINS", "https://preview.example")
|
||||||
|
|
||||||
|
oauth.require_same_origin(_request(origin="https://dashboard.example"))
|
||||||
|
oauth.require_same_origin(_request(origin="https://preview.example"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_normalizes_case_and_trailing_slash(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "HTTPS://Dashboard.Example/")
|
||||||
|
monkeypatch.setenv("DASHBOARD_ALLOWED_ORIGINS", "https://Preview.Example/")
|
||||||
|
|
||||||
|
oauth.require_same_origin(_request(origin="https://dashboard.example"))
|
||||||
|
oauth.require_same_origin(_request(origin="https://preview.example"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_accepts_referer(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||||
|
|
||||||
|
oauth.require_same_origin(_request(referer="https://dashboard.example/agents/thread-id"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_rejects_missing_origin_and_referer(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
oauth.require_same_origin(_request())
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_rejects_null_origin(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
oauth.require_same_origin(_request(origin="null"))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_rejects_unknown_origin(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
oauth.require_same_origin(_request(origin="https://evil.example"))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_rejects_prefix_bypass(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
oauth.require_same_origin(_request(origin="https://dashboard.example.evil"))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_does_not_fallback_when_origin_invalid(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
oauth.require_same_origin(
|
||||||
|
_request(origin="null", referer="https://dashboard.example/agents")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_for_mutations_skips_get(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||||
|
|
||||||
|
oauth.require_same_origin_for_mutations(
|
||||||
|
_request(method="GET", path="/dashboard/api/me", origin="https://evil.example")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_same_origin_for_mutations_enforces_post(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
oauth.require_same_origin_for_mutations(_request(origin="https://evil.example"))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_rejects_cross_site_text_plain_post(dashboard_client: TestClient) -> None:
|
||||||
|
response = dashboard_client.post(
|
||||||
|
"/dashboard/api/auth/logout",
|
||||||
|
headers={"Origin": "https://evil.example", "Content-Type": "text/plain"},
|
||||||
|
content='{"method": "run.start"}',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"] == "CSRF check failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_rejects_post_without_origin(dashboard_client: TestClient) -> None:
|
||||||
|
response = dashboard_client.post(
|
||||||
|
"/dashboard/api/auth/logout",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_allows_allowed_origin_post(dashboard_client: TestClient) -> None:
|
||||||
|
response = dashboard_client.post(
|
||||||
|
"/dashboard/api/auth/logout",
|
||||||
|
headers={"Origin": "http://testserver", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
async def test_proxy_commands_rejects_non_json_content_type() -> None:
|
||||||
|
for content_type in ("text/plain", "application/x-www-form-urlencoded", "multipart/form-data"):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await thread_api.proxy_dashboard_thread_commands(
|
||||||
|
"tid",
|
||||||
|
"octocat",
|
||||||
|
b'{"method": "run.start"}',
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
async def test_proxy_commands_accepts_json_content_type_with_charset() -> None:
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await thread_api.proxy_dashboard_thread_commands(
|
||||||
|
"tid",
|
||||||
|
"octocat",
|
||||||
|
b"not-json",
|
||||||
|
content_type="application/json; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
"""Tests for LangGraph message → dashboard UI adapter."""
|
|
||||||
|
|
||||||
from agent.dashboard.message_adapter import state_messages_to_ui
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_messages_to_ui_maps_user_and_tool_calls() -> None:
|
|
||||||
messages = [
|
|
||||||
{"type": "human", "id": "u1", "content": "Fix the bug"},
|
|
||||||
{
|
|
||||||
"type": "ai",
|
|
||||||
"id": "a1",
|
|
||||||
"content": "I'll read the file first.",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call-1",
|
|
||||||
"name": "read_file",
|
|
||||||
"args": {"path": "app.py"},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tool",
|
|
||||||
"tool_call_id": "call-1",
|
|
||||||
"name": "read_file",
|
|
||||||
"content": "print('hi')",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
ui = state_messages_to_ui(messages)
|
|
||||||
|
|
||||||
assert len(ui) == 2
|
|
||||||
assert ui[0]["author"] == "user"
|
|
||||||
assert ui[0]["chunks"][0]["text"] == "Fix the bug"
|
|
||||||
assert ui[1]["author"] == "agent"
|
|
||||||
tool_chunk = ui[1]["chunks"][-1]
|
|
||||||
assert tool_chunk["kind"] == "tool-execution"
|
|
||||||
assert tool_chunk["toolCallId"] == "call-1"
|
|
||||||
assert tool_chunk["status"] == "completed"
|
|
||||||
assert tool_chunk["output"] == "print('hi')"
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_messages_to_ui_maps_user_images() -> None:
|
|
||||||
messages = [
|
|
||||||
{
|
|
||||||
"type": "human",
|
|
||||||
"id": "u1",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "image",
|
|
||||||
"source_type": "base64",
|
|
||||||
"data": "aW1hZ2U=",
|
|
||||||
"mime_type": "image/png",
|
|
||||||
},
|
|
||||||
{"type": "text", "text": "What changed?"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
ui = state_messages_to_ui(messages)
|
|
||||||
|
|
||||||
assert ui == [
|
|
||||||
{
|
|
||||||
"id": "u1",
|
|
||||||
"author": "user",
|
|
||||||
"timestamp": ui[0]["timestamp"],
|
|
||||||
"chunks": [
|
|
||||||
{"kind": "image", "base64": "aW1hZ2U=", "mimeType": "image/png"},
|
|
||||||
{"kind": "text", "text": "What changed?"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_messages_to_ui_tags_slack_and_linear_replies() -> None:
|
|
||||||
messages = [
|
|
||||||
{"type": "human", "id": "u1", "content": "ping"},
|
|
||||||
{
|
|
||||||
"type": "ai",
|
|
||||||
"id": "a1",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call-slack",
|
|
||||||
"name": "slack_thread_reply",
|
|
||||||
"args": {"message": "Done! Opened a PR."},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "call-linear",
|
|
||||||
"name": "linear_comment",
|
|
||||||
"args": {"comment_body": "Done! Opened a PR.", "ticket_id": "abc"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
ui = state_messages_to_ui(messages)
|
|
||||||
|
|
||||||
chunks = ui[-1]["chunks"]
|
|
||||||
slack_chunk = next(c for c in chunks if c["toolCallId"] == "call-slack")
|
|
||||||
linear_chunk = next(c for c in chunks if c["toolCallId"] == "call-linear")
|
|
||||||
assert slack_chunk["toolKind"] == "slack"
|
|
||||||
assert slack_chunk["input"]["message"] == "Done! Opened a PR."
|
|
||||||
assert linear_chunk["toolKind"] == "linear"
|
|
||||||
assert linear_chunk["input"]["comment_body"] == "Done! Opened a PR."
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_messages_to_ui_merges_agent_turn_and_hides_internal_tools() -> None:
|
|
||||||
messages = [
|
|
||||||
{"type": "human", "id": "u1", "content": "hello"},
|
|
||||||
{
|
|
||||||
"type": "ai",
|
|
||||||
"id": "a1",
|
|
||||||
"content": "Hi! What would you like me to work on?",
|
|
||||||
"tool_calls": [{"id": "call-cc", "name": "confirming_completion", "args": {}}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tool",
|
|
||||||
"tool_call_id": "call-cc",
|
|
||||||
"name": "confirming_completion",
|
|
||||||
"content": "Confirming task completion.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "ai",
|
|
||||||
"id": "a2",
|
|
||||||
"content": "Hi! What would you like me to work on? Let me know the task.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
ui = state_messages_to_ui(messages)
|
|
||||||
|
|
||||||
assert len(ui) == 2
|
|
||||||
assert ui[0]["author"] == "user"
|
|
||||||
assert len(ui[1]["chunks"]) == 1
|
|
||||||
assert (
|
|
||||||
ui[1]["chunks"][0]["text"] == "Hi! What would you like me to work on? Let me know the task."
|
|
||||||
)
|
|
||||||
|
|
@ -112,11 +112,15 @@ def dashboard_run_client(monkeypatch: pytest.MonkeyPatch) -> _FakeLangGraphClien
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
def test_start_agent_run_omits_repo_less_marker_when_repo_unset(
|
def test_create_thread_record_omits_repo_less_marker_when_repo_unset(
|
||||||
dashboard_run_client: _FakeLangGraphClient,
|
dashboard_run_client: _FakeLangGraphClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Runs now start client-side via the stream commands endpoint, so the run
|
||||||
|
# configurable is assembled from thread metadata by
|
||||||
|
# ``_build_dashboard_configurable``. The thread record must not persist a
|
||||||
|
# repo-less marker when the repo is simply unset (not explicitly cleared).
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
thread_api._start_agent_run(
|
thread_api._create_dashboard_thread_record(
|
||||||
"thread-id",
|
"thread-id",
|
||||||
login="octo",
|
login="octo",
|
||||||
repo_config={},
|
repo_config={},
|
||||||
|
|
@ -124,44 +128,36 @@ def test_start_agent_run_omits_repo_less_marker_when_repo_unset(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
configurable = dashboard_run_client.runs.configurable
|
configurable = asyncio.run(
|
||||||
assert configurable is not None
|
thread_api._build_dashboard_configurable("thread-id", "octo", {"source": "dashboard"})
|
||||||
|
)
|
||||||
assert "repo_explicitly_none" not in configurable
|
assert "repo_explicitly_none" not in configurable
|
||||||
assert "repo" not in configurable
|
assert "repo" not in configurable
|
||||||
|
|
||||||
|
|
||||||
def test_start_agent_run_marks_repo_less_config_when_explicit(
|
def test_build_configurable_marks_repo_less_config_when_explicit(
|
||||||
dashboard_run_client: _FakeLangGraphClient,
|
dashboard_run_client: _FakeLangGraphClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
asyncio.run(
|
configurable = asyncio.run(
|
||||||
thread_api._start_agent_run(
|
thread_api._build_dashboard_configurable(
|
||||||
"thread-id",
|
"thread-id",
|
||||||
login="octo",
|
"octo",
|
||||||
repo_config={},
|
{"source": "dashboard", "repo_explicitly_none": True},
|
||||||
repo_explicitly_none=True,
|
|
||||||
prompt="do work",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
configurable = dashboard_run_client.runs.configurable
|
|
||||||
assert configurable is not None
|
|
||||||
assert configurable["repo_explicitly_none"] is True
|
assert configurable["repo_explicitly_none"] is True
|
||||||
assert "repo" not in configurable
|
assert "repo" not in configurable
|
||||||
|
|
||||||
|
|
||||||
def test_start_agent_run_omits_repo_less_marker_when_repo_configured(
|
def test_build_configurable_includes_repo_when_configured(
|
||||||
dashboard_run_client: _FakeLangGraphClient,
|
dashboard_run_client: _FakeLangGraphClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
asyncio.run(
|
configurable = asyncio.run(
|
||||||
thread_api._start_agent_run(
|
thread_api._build_dashboard_configurable(
|
||||||
"thread-id",
|
"thread-id",
|
||||||
login="octo",
|
"octo",
|
||||||
repo_config={"owner": "octo", "name": "repo"},
|
{"source": "dashboard", "repo_owner": "octo", "repo_name": "repo"},
|
||||||
prompt="do work",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
configurable = dashboard_run_client.runs.configurable
|
|
||||||
assert configurable is not None
|
|
||||||
assert configurable["repo"] == {"owner": "octo", "name": "repo"}
|
assert configurable["repo"] == {"owner": "octo", "name": "repo"}
|
||||||
assert "repo_explicitly_none" not in configurable
|
assert "repo_explicitly_none" not in configurable
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,15 @@ def test_user_message_content_allows_images_for_vision_model() -> None:
|
||||||
assert any(block.get("type") != "text" for block in content)
|
assert any(block.get("type") != "text" for block in content)
|
||||||
|
|
||||||
|
|
||||||
|
def test_langgraph_proxy_headers_include_api_key(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||||
|
|
||||||
|
headers = thread_api._langgraph_proxy_headers(accept="text/event-stream")
|
||||||
|
|
||||||
|
assert headers["X-API-Key"] == "ls-key"
|
||||||
|
assert headers["Accept"] == "text/event-stream"
|
||||||
|
|
||||||
|
|
||||||
async def test_resolve_agent_model_choice_applies_profile_before_team_default(monkeypatch) -> None:
|
async def test_resolve_agent_model_choice_applies_profile_before_team_default(monkeypatch) -> None:
|
||||||
async def fake_team_default(role: str) -> tuple[str, str]:
|
async def fake_team_default(role: str) -> tuple[str, str]:
|
||||||
assert role == "agent"
|
assert role == "agent"
|
||||||
|
|
@ -70,23 +79,326 @@ async def test_resolve_agent_model_choice_applies_request_before_profile(monkeyp
|
||||||
assert (model_id, effort) == ("anthropic:claude-opus-4-8", "high")
|
assert (model_id, effort) == ("anthropic:claude-opus-4-8", "high")
|
||||||
|
|
||||||
|
|
||||||
async def test_create_dashboard_thread_rejects_images_for_resolved_text_only_model(
|
def _new_thread_client(created: dict[str, object]) -> object:
|
||||||
monkeypatch,
|
class FakeThreads:
|
||||||
) -> None:
|
async def create(
|
||||||
async def fake_profile(login: str) -> dict[str, str]:
|
self, *, thread_id: str, metadata: dict[str, object], if_exists: str
|
||||||
assert login == "octocat"
|
) -> None:
|
||||||
return {"default_model": _TEXT_ONLY_MODEL, "reasoning_effort": "high"}
|
created["thread_id"] = thread_id
|
||||||
|
created["metadata"] = dict(metadata)
|
||||||
|
|
||||||
|
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||||
|
created.setdefault("metadata", {})
|
||||||
|
assert isinstance(created["metadata"], dict)
|
||||||
|
created["metadata"].update(metadata)
|
||||||
|
|
||||||
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
return {"thread_id": thread_id, "metadata": created.get("metadata", {})}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
threads = FakeThreads()
|
||||||
|
|
||||||
|
return FakeClient()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_new_thread_deps(monkeypatch, *, profile: dict[str, object]) -> None:
|
||||||
|
async def fake_profile(login: str) -> dict[str, object]:
|
||||||
|
return dict(profile)
|
||||||
|
|
||||||
async def fake_team_default(role: str) -> tuple[str, str]:
|
async def fake_team_default(role: str) -> tuple[str, str]:
|
||||||
assert role == "agent"
|
assert role == "agent"
|
||||||
return _VISION_MODEL, "medium"
|
return _VISION_MODEL, "medium"
|
||||||
|
|
||||||
|
async def fake_ensure_token(login: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_resolve_email(login: str, prof: dict[str, object]) -> str:
|
||||||
|
return f"{login}@example.com"
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "get_profile", fake_profile)
|
monkeypatch.setattr(thread_api, "get_profile", fake_profile)
|
||||||
monkeypatch.setattr(thread_api, "get_team_default_model", fake_team_default)
|
monkeypatch.setattr(thread_api, "get_team_default_model", fake_team_default)
|
||||||
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
|
||||||
|
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enrich_run_start_command_creates_and_stamps_new_thread(monkeypatch) -> None:
|
||||||
|
created: dict[str, object] = {}
|
||||||
|
_patch_new_thread_deps(monkeypatch, profile={})
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: _new_thread_client(created))
|
||||||
|
|
||||||
|
command = {
|
||||||
|
"method": "run.start",
|
||||||
|
"params": {
|
||||||
|
"input": {"messages": [{"type": "human", "content": "Fix the flaky test"}]},
|
||||||
|
"config": {
|
||||||
|
"configurable": {
|
||||||
|
"repo": "octo/repo",
|
||||||
|
"agent_model_id": _VISION_MODEL,
|
||||||
|
"agent_effort": "medium",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched = await thread_api._enrich_run_start_command(
|
||||||
|
"new-tid",
|
||||||
|
"octocat",
|
||||||
|
command,
|
||||||
|
metadata={},
|
||||||
|
creating=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
stamped = created["metadata"]
|
||||||
|
assert isinstance(stamped, dict)
|
||||||
|
assert stamped["source"] == "dashboard"
|
||||||
|
assert stamped["github_login"] == "octocat"
|
||||||
|
assert stamped["title"] == "Fix the flaky test"
|
||||||
|
assert stamped["repo_owner"] == "octo"
|
||||||
|
assert stamped["repo_name"] == "repo"
|
||||||
|
|
||||||
|
configurable = enriched["params"]["config"]["configurable"]
|
||||||
|
assert configurable["github_login"] == "octocat"
|
||||||
|
assert configurable["source"] == "dashboard"
|
||||||
|
assert configurable["repo"] == {"owner": "octo", "name": "repo"}
|
||||||
|
assert configurable["agent_model_id"] == _VISION_MODEL
|
||||||
|
assert configurable["agent_effort"] == "medium"
|
||||||
|
# Dashboard-only creation hints must not leak into the run config.
|
||||||
|
assert "repo_explicitly_none" not in configurable
|
||||||
|
assert enriched["params"]["assistant_id"] == "agent"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enrich_run_start_command_rejects_images_for_resolved_text_only_model(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
created: dict[str, object] = {}
|
||||||
|
_patch_new_thread_deps(
|
||||||
|
monkeypatch,
|
||||||
|
profile={"default_model": _TEXT_ONLY_MODEL, "reasoning_effort": "high"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: _new_thread_client(created))
|
||||||
|
|
||||||
|
image = _image()
|
||||||
|
command = {
|
||||||
|
"method": "run.start",
|
||||||
|
"params": {
|
||||||
|
"input": {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"type": "human",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"base64": image.base64,
|
||||||
|
"mime_type": image.mime_type,
|
||||||
|
},
|
||||||
|
{"type": "text", "text": "see attached"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {"configurable": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
body = thread_api.ThreadCreateBody(prompt="see attached", images=[_image()])
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
await thread_api.create_dashboard_thread("octocat", body)
|
await thread_api._enrich_run_start_command(
|
||||||
|
"new-tid",
|
||||||
|
"octocat",
|
||||||
|
command,
|
||||||
|
metadata={},
|
||||||
|
creating=True,
|
||||||
|
)
|
||||||
|
|
||||||
assert exc_info.value.status_code == 422
|
assert exc_info.value.status_code == 422
|
||||||
assert "does not support image input" in exc_info.value.detail
|
assert "does not support image input" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
async def test_proxy_commands_lazily_creates_missing_thread_only_for_run_start(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
class MissingThreads:
|
||||||
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
raise RuntimeError("thread not found")
|
||||||
|
|
||||||
|
class MissingClient:
|
||||||
|
threads = MissingThreads()
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: MissingClient())
|
||||||
|
|
||||||
|
# A non-run.start command against a thread that doesn't exist yet is a 404.
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await thread_api.proxy_dashboard_thread_commands(
|
||||||
|
"ghost", "octocat", b'{"method": "run.cancel"}'
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_proxy_commands_run_start_by_non_owner_is_rejected(monkeypatch) -> None:
|
||||||
|
class OwnedThreads:
|
||||||
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"metadata": {"source": "dashboard", "github_login": "owner"},
|
||||||
|
}
|
||||||
|
|
||||||
|
class OwnedClient:
|
||||||
|
threads = OwnedThreads()
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: OwnedClient())
|
||||||
|
|
||||||
|
# An existing thread owned by someone else is never lazily re-created — a
|
||||||
|
# run.start from a non-owner is a 404, not a takeover.
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await thread_api.proxy_dashboard_thread_commands(
|
||||||
|
"tid", "intruder", b'{"method": "run.start"}'
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enrich_run_start_command_allowlists_client_configurable(monkeypatch) -> None:
|
||||||
|
updates: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
class FakeThreads:
|
||||||
|
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||||
|
assert thread_id == "tid"
|
||||||
|
updates.append(metadata)
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
threads = FakeThreads()
|
||||||
|
|
||||||
|
async def fake_get_profile(login: str) -> dict[str, object]:
|
||||||
|
assert login == "octocat"
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def fake_ensure_token(login: str) -> None:
|
||||||
|
assert login == "octocat"
|
||||||
|
|
||||||
|
async def fake_resolve_email(login: str, profile: dict[str, object]) -> str:
|
||||||
|
assert login == "octocat"
|
||||||
|
return "octocat@example.com"
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||||
|
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
|
||||||
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
|
||||||
|
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
|
||||||
|
|
||||||
|
command = {
|
||||||
|
"method": "run.start",
|
||||||
|
"params": {
|
||||||
|
"config": {
|
||||||
|
"configurable": {
|
||||||
|
"github_login": "attacker",
|
||||||
|
"user_email": "attacker@example.com",
|
||||||
|
"source": "github",
|
||||||
|
"repo": {"owner": "evil", "name": "repo"},
|
||||||
|
"agent_model_id": _VISION_MODEL,
|
||||||
|
"agent_effort": "medium",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched = await thread_api._enrich_run_start_command(
|
||||||
|
"tid",
|
||||||
|
"octocat",
|
||||||
|
command,
|
||||||
|
metadata={
|
||||||
|
"source": "dashboard",
|
||||||
|
"github_login": "octocat",
|
||||||
|
"repo_owner": "octo",
|
||||||
|
"repo_name": "repo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
configurable = enriched["params"]["config"]["configurable"]
|
||||||
|
assert configurable["github_login"] == "octocat"
|
||||||
|
assert configurable["user_email"] == "octocat@example.com"
|
||||||
|
assert configurable["source"] == "dashboard"
|
||||||
|
assert configurable["repo"] == {"owner": "octo", "name": "repo"}
|
||||||
|
assert configurable["agent_model_id"] == _VISION_MODEL
|
||||||
|
assert configurable["agent_effort"] == "medium"
|
||||||
|
assert updates[-1]["model"] == _VISION_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
async def test_proxy_commands_rejects_non_object_body(monkeypatch) -> None:
|
||||||
|
class FakeThreads:
|
||||||
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
assert thread_id == "tid"
|
||||||
|
return {
|
||||||
|
"thread_id": "tid",
|
||||||
|
"metadata": {"source": "dashboard", "github_login": "octocat"},
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
threads = FakeThreads()
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await thread_api.proxy_dashboard_thread_commands("tid", "octocat", b"[]")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_proxy_endpoints_enforce_thread_ownership(monkeypatch) -> None:
|
||||||
|
class FakeThreads:
|
||||||
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
assert thread_id == "tid"
|
||||||
|
return {
|
||||||
|
"thread_id": "tid",
|
||||||
|
"metadata": {"source": "dashboard", "github_login": "owner"},
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
threads = FakeThreads()
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await thread_api.get_dashboard_thread_state("tid", "intruder")
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await thread_api.proxy_dashboard_thread_commands("tid", "intruder", b"{}")
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await thread_api.proxy_dashboard_thread_history("tid", "intruder", b"{}")
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await thread_api.proxy_dashboard_thread_run_cancel("tid", "run-1", "intruder")
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await anext(thread_api.proxy_dashboard_thread_stream_events("tid", "intruder", b"{}"))
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_dashboard_message_returns_502_when_activity_unknown(monkeypatch) -> None:
|
||||||
|
class FakeThreads:
|
||||||
|
async def get(self, thread_id: str) -> dict[str, object]:
|
||||||
|
assert thread_id == "tid"
|
||||||
|
return {
|
||||||
|
"thread_id": "tid",
|
||||||
|
"metadata": {"source": "dashboard", "github_login": "octocat"},
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
threads = FakeThreads()
|
||||||
|
|
||||||
|
async def unknown_activity(thread_id: str) -> None:
|
||||||
|
assert thread_id == "tid"
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||||
|
monkeypatch.setattr(thread_api, "get_thread_active_status", unknown_activity)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await thread_api.send_dashboard_message(
|
||||||
|
"tid",
|
||||||
|
"octocat",
|
||||||
|
thread_api.ThreadMessageBody(content="hello"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 502
|
||||||
|
|
|
||||||
|
|
@ -73,23 +73,20 @@ async def test_dashboard_followup_on_slack_thread_uses_dashboard_source(
|
||||||
client = _FakeClient(metadata)
|
client = _FakeClient(metadata)
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
monkeypatch.setattr(thread_api, "is_thread_active", _inactive_thread)
|
monkeypatch.setattr(thread_api, "get_thread_active_status", _inactive_thread)
|
||||||
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
||||||
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
||||||
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
||||||
|
|
||||||
await thread_api.send_dashboard_message(
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
"thread-1",
|
await thread_api.send_dashboard_message(
|
||||||
"octocat",
|
"thread-1",
|
||||||
thread_api.ThreadMessageBody(content="continue in web"),
|
"octocat",
|
||||||
email="octocat@example.com",
|
thread_api.ThreadMessageBody(content="continue in web"),
|
||||||
)
|
email="octocat@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
|
assert exc_info.value.status_code == 409
|
||||||
assert client.threads.updates[0]["source"] == "dashboard"
|
|
||||||
assert run_config["source"] == "dashboard"
|
|
||||||
assert "slack_thread" not in run_config
|
|
||||||
assert run_config["repo"] == {"owner": "octo", "name": "repo"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -105,7 +102,7 @@ async def test_dashboard_followup_sends_image_content_blocks(
|
||||||
client = _FakeClient(metadata)
|
client = _FakeClient(metadata)
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
monkeypatch.setattr(thread_api, "is_thread_active", _inactive_thread)
|
monkeypatch.setattr(thread_api, "get_thread_active_status", _inactive_thread)
|
||||||
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
||||||
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
||||||
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
||||||
|
|
@ -115,26 +112,23 @@ async def test_dashboard_followup_sends_image_content_blocks(
|
||||||
lambda *, base64, mime_type: {"type": "image", "data": base64, "mime_type": mime_type},
|
lambda *, base64, mime_type: {"type": "image", "data": base64, "mime_type": mime_type},
|
||||||
)
|
)
|
||||||
|
|
||||||
await thread_api.send_dashboard_message(
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
"thread-1",
|
await thread_api.send_dashboard_message(
|
||||||
"octocat",
|
"thread-1",
|
||||||
thread_api.ThreadMessageBody(
|
"octocat",
|
||||||
content="describe this",
|
thread_api.ThreadMessageBody(
|
||||||
images=[
|
content="describe this",
|
||||||
thread_api.DashboardImageBody(
|
images=[
|
||||||
base64="aW1hZ2U=",
|
thread_api.DashboardImageBody(
|
||||||
mimeType="image/png",
|
base64="aW1hZ2U=",
|
||||||
fileName="screenshot.png",
|
mimeType="image/png",
|
||||||
)
|
fileName="screenshot.png",
|
||||||
],
|
)
|
||||||
),
|
],
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
|
||||||
content = client.runs.created[0]["kwargs"]["input"]["messages"][0]["content"]
|
assert exc_info.value.status_code == 409
|
||||||
assert content == [
|
|
||||||
{"type": "image", "data": "aW1hZ2U=", "mime_type": "image/png"},
|
|
||||||
{"type": "text", "text": "describe this"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -154,7 +148,7 @@ async def test_dashboard_followup_on_busy_thread_queues_dashboard_handoff(
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
monkeypatch.setattr(thread_api, "is_thread_active", _active_thread)
|
monkeypatch.setattr(thread_api, "get_thread_active_status", _active_thread)
|
||||||
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue_message_for_thread)
|
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue_message_for_thread)
|
||||||
|
|
||||||
await thread_api.send_dashboard_message(
|
await thread_api.send_dashboard_message(
|
||||||
|
|
@ -185,7 +179,7 @@ async def test_dashboard_followup_on_busy_thread_queues_images(
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
monkeypatch.setattr(thread_api, "is_thread_active", _active_thread)
|
monkeypatch.setattr(thread_api, "get_thread_active_status", _active_thread)
|
||||||
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue_message_for_thread)
|
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue_message_for_thread)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
thread_api,
|
thread_api,
|
||||||
|
|
@ -228,7 +222,7 @@ async def test_dashboard_followup_on_busy_text_only_thread_rejects_images(
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
monkeypatch.setattr(thread_api, "is_thread_active", _active_thread)
|
monkeypatch.setattr(thread_api, "get_thread_active_status", _active_thread)
|
||||||
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue_message_for_thread)
|
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue_message_for_thread)
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
|
@ -260,7 +254,7 @@ async def test_dashboard_followup_on_busy_unknown_model_rejects_images(
|
||||||
client = _FakeClient(metadata)
|
client = _FakeClient(metadata)
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
monkeypatch.setattr(thread_api, "is_thread_active", _active_thread)
|
monkeypatch.setattr(thread_api, "get_thread_active_status", _active_thread)
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
await thread_api.send_dashboard_message(
|
await thread_api.send_dashboard_message(
|
||||||
|
|
@ -289,20 +283,19 @@ async def test_dashboard_followup_preserves_explicit_repo_less_thread(
|
||||||
client = _FakeClient(metadata)
|
client = _FakeClient(metadata)
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
monkeypatch.setattr(thread_api, "is_thread_active", _inactive_thread)
|
monkeypatch.setattr(thread_api, "get_thread_active_status", _inactive_thread)
|
||||||
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
||||||
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
||||||
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
||||||
|
|
||||||
await thread_api.send_dashboard_message(
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
"thread-1",
|
await thread_api.send_dashboard_message(
|
||||||
"octocat",
|
"thread-1",
|
||||||
thread_api.ThreadMessageBody(content="continue in web"),
|
"octocat",
|
||||||
)
|
thread_api.ThreadMessageBody(content="continue in web"),
|
||||||
|
)
|
||||||
|
|
||||||
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
|
assert exc_info.value.status_code == 409
|
||||||
assert run_config["repo_explicitly_none"] is True
|
|
||||||
assert "repo" not in run_config
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -316,17 +309,16 @@ async def test_dashboard_followup_without_repo_metadata_allows_team_default(
|
||||||
client = _FakeClient(metadata)
|
client = _FakeClient(metadata)
|
||||||
|
|
||||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||||
monkeypatch.setattr(thread_api, "is_thread_active", _inactive_thread)
|
monkeypatch.setattr(thread_api, "get_thread_active_status", _inactive_thread)
|
||||||
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
|
||||||
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
|
||||||
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
||||||
|
|
||||||
await thread_api.send_dashboard_message(
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
"thread-1",
|
await thread_api.send_dashboard_message(
|
||||||
"octocat",
|
"thread-1",
|
||||||
thread_api.ThreadMessageBody(content="continue in web"),
|
"octocat",
|
||||||
)
|
thread_api.ThreadMessageBody(content="continue in web"),
|
||||||
|
)
|
||||||
|
|
||||||
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
|
assert exc_info.value.status_code == 409
|
||||||
assert "repo_explicitly_none" not in run_config
|
|
||||||
assert "repo" not in run_config
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,21 @@ def _openai_5xx() -> openai.APIStatusError:
|
||||||
return openai.APIStatusError("unavailable", response=response, body=response.json())
|
return openai.APIStatusError("unavailable", response=response, body=response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def _anthropic_model_not_available_error() -> anthropic.BadRequestError:
|
||||||
|
body = {
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"message": "In order to access this model, your organization or workspace must have data retention enabled.",
|
||||||
|
"details": {"error_code": "model_not_available"},
|
||||||
|
},
|
||||||
|
"request_id": "req_test",
|
||||||
|
}
|
||||||
|
request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
|
||||||
|
response = httpx.Response(400, request=request, json=body)
|
||||||
|
return anthropic.BadRequestError("model unavailable", response=response, body=body)
|
||||||
|
|
||||||
|
|
||||||
def _make_request() -> MagicMock:
|
def _make_request() -> MagicMock:
|
||||||
request = MagicMock()
|
request = MagicMock()
|
||||||
request.override = MagicMock(return_value=MagicMock(name="overridden_request"))
|
request.override = MagicMock(return_value=MagicMock(name="overridden_request"))
|
||||||
|
|
@ -99,6 +114,19 @@ class TestModelFallbackMiddleware:
|
||||||
|
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_surfaces_model_unavailable_error(self) -> None:
|
||||||
|
middleware = ModelFallbackMiddleware(MagicMock())
|
||||||
|
|
||||||
|
async def handler(_req: object) -> object:
|
||||||
|
raise _anthropic_model_not_available_error()
|
||||||
|
|
||||||
|
result = await middleware.awrap_model_call(_make_request(), handler)
|
||||||
|
|
||||||
|
assert isinstance(result, AIMessage)
|
||||||
|
assert "selected Anthropic model is not available" in result.text
|
||||||
|
assert "data retention enabled" in result.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_does_not_double_fall_back(self) -> None:
|
async def test_async_does_not_double_fall_back(self) -> None:
|
||||||
"""If the fallback also fails transiently, the error propagates."""
|
"""If the fallback also fails transiently, the error propagates."""
|
||||||
|
|
|
||||||
|
|
@ -279,11 +279,11 @@ async def test_reviewer_applies_eval_model_and_effort_overrides() -> None:
|
||||||
|
|
||||||
main_model_call = make_model.call_args_list[0]
|
main_model_call = make_model.call_args_list[0]
|
||||||
assert main_model_call.args == ("anthropic:claude-opus-4-8",)
|
assert main_model_call.args == ("anthropic:claude-opus-4-8",)
|
||||||
assert main_model_call.kwargs["thinking"] == {"type": "adaptive"}
|
assert main_model_call.kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||||
assert main_model_call.kwargs["effort"] == "high"
|
assert main_model_call.kwargs["effort"] == "high"
|
||||||
subagent_model_call = make_model.call_args_list[1]
|
subagent_model_call = make_model.call_args_list[1]
|
||||||
assert subagent_model_call.args == ("openai:gpt-5.5",)
|
assert subagent_model_call.args == ("openai:gpt-5.5",)
|
||||||
assert subagent_model_call.kwargs["reasoning"] == {"effort": "low"}
|
assert subagent_model_call.kwargs["reasoning"] == {"effort": "low", "summary": "auto"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -327,11 +327,11 @@ async def test_reviewer_subagent_inherits_eval_model_without_explicit_override()
|
||||||
|
|
||||||
main_model_call = make_model.call_args_list[0]
|
main_model_call = make_model.call_args_list[0]
|
||||||
assert main_model_call.args == ("anthropic:claude-opus-4-8",)
|
assert main_model_call.args == ("anthropic:claude-opus-4-8",)
|
||||||
assert main_model_call.kwargs["thinking"] == {"type": "adaptive"}
|
assert main_model_call.kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||||
assert main_model_call.kwargs["effort"] == "high"
|
assert main_model_call.kwargs["effort"] == "high"
|
||||||
subagent_model_call = make_model.call_args_list[1]
|
subagent_model_call = make_model.call_args_list[1]
|
||||||
assert subagent_model_call.args == ("anthropic:claude-opus-4-8",)
|
assert subagent_model_call.args == ("anthropic:claude-opus-4-8",)
|
||||||
assert subagent_model_call.kwargs["thinking"] == {"type": "adaptive"}
|
assert subagent_model_call.kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||||
assert subagent_model_call.kwargs["effort"] == "high"
|
assert subagent_model_call.kwargs["effort"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
229
tests/test_tool_artifact_middleware.py
Normal file
229
tests/test_tool_artifact_middleware.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
"""Tests for ToolArtifactMiddleware diff stamping on edit_file/write_file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
|
||||||
|
from agent.middleware.tool_artifact import ToolArtifactMiddleware
|
||||||
|
from agent.utils import sandbox_state
|
||||||
|
|
||||||
|
|
||||||
|
class FakeReadResult:
|
||||||
|
def __init__(
|
||||||
|
self, *, content: str | None = None, encoding: str = "utf-8", error: str | None = None
|
||||||
|
) -> None:
|
||||||
|
self.error = error
|
||||||
|
self.file_data = None if content is None else {"content": content, "encoding": encoding}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBackend:
|
||||||
|
def __init__(self, results: dict[str, Any]) -> None:
|
||||||
|
self.results = results
|
||||||
|
self.reads: list[str] = []
|
||||||
|
|
||||||
|
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> Any:
|
||||||
|
self.reads.append(file_path)
|
||||||
|
result = self.results.get(file_path)
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
if result is None:
|
||||||
|
return FakeReadResult(error="File not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> Any:
|
||||||
|
return self.read(file_path, offset, limit)
|
||||||
|
|
||||||
|
|
||||||
|
def _request(name: str, args: dict[str, Any], thread_id: str = "t1") -> Any:
|
||||||
|
return SimpleNamespace(
|
||||||
|
tool_call={"name": name, "args": args, "id": "call-1"},
|
||||||
|
runtime=SimpleNamespace(config={"configurable": {"thread_id": thread_id}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(name: str) -> ToolMessage:
|
||||||
|
return ToolMessage(content="ok", tool_call_id="call-1", status="success", name=name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def register_backend():
|
||||||
|
registered: list[str] = []
|
||||||
|
|
||||||
|
def _register(thread_id: str, backend: Any) -> Any:
|
||||||
|
sandbox_state.SANDBOX_BACKENDS[thread_id] = backend
|
||||||
|
registered.append(thread_id)
|
||||||
|
return backend
|
||||||
|
|
||||||
|
yield _register
|
||||||
|
for thread_id in registered:
|
||||||
|
sandbox_state.SANDBOX_BACKENDS.pop(thread_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_file_stamps_full_file_diff(register_backend) -> None:
|
||||||
|
backend = FakeBackend({"/repo/a.py": FakeReadResult(content="line1\nOLD\nline3\n")})
|
||||||
|
register_backend("t1", backend)
|
||||||
|
request = _request(
|
||||||
|
"edit_file", {"file_path": "/repo/a.py", "old_string": "OLD", "new_string": "NEW"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
return _ok("edit_file")
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact == {
|
||||||
|
"diff": {
|
||||||
|
"filePath": "/repo/a.py",
|
||||||
|
"originalContent": "line1\nOLD\nline3\n",
|
||||||
|
"newContent": "line1\nNEW\nline3\n",
|
||||||
|
"isNewFile": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert backend.reads == ["/repo/a.py"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_file_replace_all(register_backend) -> None:
|
||||||
|
backend = FakeBackend({"/repo/a.py": FakeReadResult(content="x x x")})
|
||||||
|
register_backend("t1", backend)
|
||||||
|
request = _request(
|
||||||
|
"edit_file",
|
||||||
|
{"file_path": "/repo/a.py", "old_string": "x", "new_string": "y", "replace_all": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
return _ok("edit_file")
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact["diff"]["newContent"] == "y y y"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_file_missing_old_string_skips(register_backend) -> None:
|
||||||
|
backend = FakeBackend({"/repo/a.py": FakeReadResult(content="nothing here")})
|
||||||
|
register_backend("t1", backend)
|
||||||
|
request = _request(
|
||||||
|
"edit_file", {"file_path": "/repo/a.py", "old_string": "OLD", "new_string": "NEW"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
return _ok("edit_file")
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_file_new_file(register_backend) -> None:
|
||||||
|
backend = FakeBackend({"/repo/new.py": FakeReadResult(error="File not found")})
|
||||||
|
register_backend("t1", backend)
|
||||||
|
request = _request("write_file", {"file_path": "/repo/new.py", "content": "hello\n"})
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
return _ok("write_file")
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact == {
|
||||||
|
"diff": {
|
||||||
|
"filePath": "/repo/new.py",
|
||||||
|
"originalContent": None,
|
||||||
|
"newContent": "hello\n",
|
||||||
|
"isNewFile": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_file_overwrite(register_backend) -> None:
|
||||||
|
backend = FakeBackend({"/repo/x.py": FakeReadResult(content="old content\n")})
|
||||||
|
register_backend("t1", backend)
|
||||||
|
request = _request("write_file", {"file_path": "/repo/x.py", "content": "new content\n"})
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
return _ok("write_file")
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact["diff"]["originalContent"] == "old content\n"
|
||||||
|
assert result.artifact["diff"]["newContent"] == "new content\n"
|
||||||
|
assert result.artifact["diff"]["isNewFile"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_edit_tool_is_untouched(register_backend) -> None:
|
||||||
|
backend = FakeBackend({})
|
||||||
|
register_backend("t1", backend)
|
||||||
|
request = _request("read_file", {"file_path": "/repo/a.py"})
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
return _ok("read_file")
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact is None
|
||||||
|
assert backend.reads == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_error_result_is_not_stamped(register_backend) -> None:
|
||||||
|
backend = FakeBackend({"/repo/a.py": FakeReadResult(content="OLD")})
|
||||||
|
register_backend("t1", backend)
|
||||||
|
request = _request(
|
||||||
|
"edit_file", {"file_path": "/repo/a.py", "old_string": "OLD", "new_string": "NEW"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
return ToolMessage(content="boom", tool_call_id="call-1", status="error", name="edit_file")
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_backend_is_graceful() -> None:
|
||||||
|
request = _request(
|
||||||
|
"edit_file",
|
||||||
|
{"file_path": "/repo/a.py", "old_string": "OLD", "new_string": "NEW"},
|
||||||
|
thread_id="absent-thread",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
return _ok("edit_file")
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_binary_read_skips(register_backend) -> None:
|
||||||
|
backend = FakeBackend({"/repo/img.png": FakeReadResult(content="QkFTRTY0", encoding="base64")})
|
||||||
|
register_backend("t1", backend)
|
||||||
|
request = _request(
|
||||||
|
"edit_file", {"file_path": "/repo/img.png", "old_string": "x", "new_string": "y"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
return _ok("edit_file")
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_existing_artifact_is_merged(register_backend) -> None:
|
||||||
|
backend = FakeBackend({"/repo/a.py": FakeReadResult(content="OLD\n")})
|
||||||
|
register_backend("t1", backend)
|
||||||
|
request = _request(
|
||||||
|
"edit_file", {"file_path": "/repo/a.py", "old_string": "OLD", "new_string": "NEW"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handler(_req: Any) -> ToolMessage:
|
||||||
|
message = _ok("edit_file")
|
||||||
|
message.artifact = {"existing": "kept"}
|
||||||
|
return message
|
||||||
|
|
||||||
|
result = await ToolArtifactMiddleware().awrap_tool_call(request, handler)
|
||||||
|
|
||||||
|
assert result.artifact["existing"] == "kept"
|
||||||
|
assert result.artifact["diff"]["newContent"] == "NEW\n"
|
||||||
1728
ui/bun.lock
1728
ui/bun.lock
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@langchain/core": "^1.1.48",
|
||||||
|
"@langchain/langgraph-sdk": "^1.9.21",
|
||||||
|
"@langchain/react": "^1.0.22",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@pierre/diffs": "^1.2.1",
|
"@pierre/diffs": "^1.2.1",
|
||||||
|
|
@ -29,18 +32,15 @@
|
||||||
"@tanstack/router-plugin": "^1.166.13",
|
"@tanstack/router-plugin": "^1.166.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"diff": "^9.0.0",
|
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"nitro": "latest",
|
"nitro": "latest",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"remark-breaks": "^4.0.0",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"shiki": "^4.1.0",
|
"shiki": "^4.1.0",
|
||||||
|
"streamdown": "^2.5.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
import type { GitStatus, GitStatusEntry } from "@pierre/trees"
|
import type { GitStatus, GitStatusEntry } from "@pierre/trees"
|
||||||
|
|
||||||
import type { AgentThread } from "@/lib/agents/types"
|
import type { AgentThread } from "@/lib/agents/types"
|
||||||
import type { ChangedFileSummaryItem } from "@/components/agents/ported"
|
import type { ChangedFileSummaryItem } from "@/components/agents/messages"
|
||||||
import { useDiffOptions } from "@/components/agents/utils/diffUtils"
|
import { useDiffOptions } from "@/components/agents/utils/diffUtils"
|
||||||
import { summarizeChangedFiles } from "@/components/agents/ported"
|
import { summarizeChangedFiles } from "@/components/agents/ported"
|
||||||
import { Z } from "@/components/agents/z-index"
|
import { Z } from "@/components/agents/z-index"
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ import { SiLinear } from "react-icons/si"
|
||||||
import type { ComponentType, SVGProps } from "react"
|
import type { ComponentType, SVGProps } from "react"
|
||||||
|
|
||||||
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||||
import { formatRelativeTime } from "@/lib/agents/api"
|
import { cn, formatRelativeTime } from "@/lib/utils"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
|
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,24 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||||
|
|
||||||
import type { PendingPrompt } from "@/lib/agents/pendingPrompts"
|
import type { AgentThread, Message } from "@/lib/agents/types"
|
||||||
import type { AgentThread, ImageChunk, Message } from "@/lib/agents/types"
|
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||||
import type { ModelSelection } from "@/lib/agents/useModelOptions"
|
|
||||||
import { AgentGitPanel } from "@/components/agents/AgentGitPanel"
|
import { AgentGitPanel } from "@/components/agents/AgentGitPanel"
|
||||||
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
|
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
|
||||||
import { MessageView } from "@/components/agents/ported"
|
import { Messages } from "@/components/agents/messages"
|
||||||
import {
|
import { streamMessagesToUi } from "@/lib/agents/streamMessagesToUi"
|
||||||
agentThreadKeys,
|
import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessage"
|
||||||
useCancelAgentThread,
|
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||||
useSendAgentMessage,
|
|
||||||
} from "@/lib/agents/queries"
|
|
||||||
import {
|
|
||||||
addPendingPrompt,
|
|
||||||
dropPendingPrompts,
|
|
||||||
getPendingPrompts,
|
|
||||||
} from "@/lib/agents/pendingPrompts"
|
|
||||||
import { useAgentThreadStream } from "@/lib/agents/useThreadStream"
|
|
||||||
import { useModelOptions } from "@/lib/agents/useModelOptions"
|
|
||||||
|
|
||||||
interface AgentThreadViewProps {
|
interface AgentThreadViewProps {
|
||||||
thread: AgentThread
|
thread: AgentThread
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageText(message: Message): string {
|
// The stream lives at the `/agents` layout (one persistent provider that
|
||||||
return message.chunks
|
// survives the home → thread navigation), so this view only consumes it.
|
||||||
.filter((chunk) => chunk.kind === "text")
|
|
||||||
.map((chunk) => chunk.text)
|
|
||||||
.join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageImageKey(message: Message): string {
|
|
||||||
return message.chunks
|
|
||||||
.filter((chunk) => chunk.kind === "image")
|
|
||||||
.map((chunk) => `${chunk.mimeType}:${chunk.base64}`)
|
|
||||||
.join("\u0000")
|
|
||||||
}
|
|
||||||
|
|
||||||
function pendingImageKey(entry: PendingPrompt): string {
|
|
||||||
return (entry.images ?? [])
|
|
||||||
.map((image) => `${image.mimeType}:${image.base64}`)
|
|
||||||
.join("\u0000")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPendingPromptConfirmed(
|
|
||||||
entry: PendingPrompt,
|
|
||||||
messages: Array<Message>
|
|
||||||
): boolean {
|
|
||||||
return messages.slice(entry.insertAt).some((message) => {
|
|
||||||
if (message.author !== "user") return false
|
|
||||||
return (
|
|
||||||
messageText(message) === entry.prompt &&
|
|
||||||
messageImageKey(message) === pendingImageKey(entry)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
const queryClient = useQueryClient()
|
const sendMessage = useSubmitAgentMessage(thread.id)
|
||||||
const sendMessage = useSendAgentMessage(thread.id)
|
const stream = useAgentThreadStream()
|
||||||
const cancelThread = useCancelAgentThread(thread.id)
|
|
||||||
useAgentThreadStream(thread.id, thread.status === "running")
|
|
||||||
const [pendingPrompts, setPendingPrompts] = useState<Array<PendingPrompt>>(
|
|
||||||
() => getPendingPrompts(thread.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
const { models, defaultSelection } = useModelOptions()
|
const { models, defaultSelection } = useModelOptions()
|
||||||
const threadSelection = useMemo<ModelSelection | null>(() => {
|
const threadSelection = useMemo<ModelSelection | null>(() => {
|
||||||
|
|
@ -78,119 +32,58 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
||||||
const activeSelection = selection ?? threadSelection ?? defaultSelection
|
const activeSelection = selection ?? threadSelection ?? defaultSelection
|
||||||
|
|
||||||
useEffect(() => {
|
const baseMessages = useMemo<Array<Message>>(() => {
|
||||||
setPendingPrompts((prev) => {
|
const live = streamMessagesToUi(stream.messages, stream.toolCalls, stream.subagents)
|
||||||
if (prev.length === 0) return prev
|
if (live.length > 0) return live
|
||||||
const next = dropPendingPrompts(thread.id, (entry) =>
|
// Optimistic transcript seeded by `AgentsHome` on thread creation (the
|
||||||
isPendingPromptConfirmed(entry, thread.messages)
|
// only case where a fetched thread carries messages — `getThread` returns
|
||||||
)
|
// none). Bridges the brief gap before the SDK's optimistic `submit` echo
|
||||||
return next.length === prev.length ? prev : next
|
// lands in `stream.messages`.
|
||||||
})
|
if (thread.messages.length > 0) return thread.messages
|
||||||
}, [thread.id, thread.messages])
|
return live
|
||||||
|
}, [
|
||||||
|
stream.messages,
|
||||||
|
stream.toolCalls,
|
||||||
|
stream.subagents,
|
||||||
|
thread.messages,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
const hasMessages = baseMessages.length > 0
|
||||||
queryClient.setQueryData<Array<AgentThread> | undefined>(
|
const isStreaming = thread.status === "running" || stream.isLoading
|
||||||
agentThreadKeys.all,
|
const isThinking = stream.isLoading
|
||||||
(threads) =>
|
const settingUpSandbox = isThinking && baseMessages.length === 0
|
||||||
threads?.map((item) =>
|
// The transcript hydrates from the SDK (`GET …/state` → `stream.messages`).
|
||||||
item.id === thread.id
|
// Show a loading state during that one-time fetch instead of the empty state.
|
||||||
? { ...item, ...thread, messages: item.messages }
|
const isHydrating = stream.isThreadLoading && !hasMessages
|
||||||
: item
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}, [queryClient, thread])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(content: string, images: Array<ImageChunk>) => {
|
|
||||||
const entry: PendingPrompt = {
|
|
||||||
prompt: content,
|
|
||||||
insertAt: thread.messages.length + pendingPrompts.length,
|
|
||||||
images,
|
|
||||||
}
|
|
||||||
addPendingPrompt(thread.id, entry.prompt, entry.insertAt, entry.images)
|
|
||||||
setPendingPrompts((prev) => [...prev, entry])
|
|
||||||
sendMessage.mutate(
|
|
||||||
{
|
|
||||||
content,
|
|
||||||
images,
|
|
||||||
model_id: activeSelection?.modelId ?? null,
|
|
||||||
effort: activeSelection?.effort ?? null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onError: () => {
|
|
||||||
setPendingPrompts(
|
|
||||||
dropPendingPrompts(
|
|
||||||
thread.id,
|
|
||||||
(p) =>
|
|
||||||
p.insertAt === entry.insertAt &&
|
|
||||||
p.prompt === entry.prompt &&
|
|
||||||
pendingImageKey(p) === pendingImageKey(entry)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[
|
|
||||||
sendMessage,
|
|
||||||
thread.id,
|
|
||||||
thread.messages.length,
|
|
||||||
pendingPrompts.length,
|
|
||||||
activeSelection,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
cancelThread.mutate()
|
|
||||||
}, [cancelThread])
|
|
||||||
|
|
||||||
const displayMessages = useMemo<Array<Message>>(() => {
|
|
||||||
if (pendingPrompts.length === 0) return thread.messages
|
|
||||||
const baseTimestamp = new Date().toISOString()
|
|
||||||
const result = thread.messages.slice()
|
|
||||||
pendingPrompts.forEach((entry, i) => {
|
|
||||||
const chunks: Message["chunks"] = [...(entry.images ?? [])]
|
|
||||||
if (entry.prompt) chunks.push({ kind: "text", text: entry.prompt })
|
|
||||||
const synth: Message = {
|
|
||||||
id: `pending-user-${i}`,
|
|
||||||
author: "user",
|
|
||||||
timestamp: baseTimestamp,
|
|
||||||
chunks,
|
|
||||||
}
|
|
||||||
const at = Math.min(Math.max(entry.insertAt, 0), result.length)
|
|
||||||
result.splice(at, 0, synth)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}, [thread.messages, pendingPrompts])
|
|
||||||
|
|
||||||
const hasMessages = displayMessages.length > 0
|
|
||||||
const hasActiveRun = thread.status === "running"
|
|
||||||
const isStreaming = hasActiveRun || pendingPrompts.length > 0
|
|
||||||
const settingUpSandbox =
|
|
||||||
hasActiveRun && thread.messages.length === 0 && pendingPrompts.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
{hasMessages ? (
|
{hasMessages ? (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<MessageView
|
<Messages
|
||||||
messages={displayMessages}
|
messages={baseMessages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
streamIsLoading={stream.isLoading}
|
||||||
|
isThinking={isThinking}
|
||||||
settingUpSandbox={settingUpSandbox}
|
settingUpSandbox={settingUpSandbox}
|
||||||
contentWidthClass="max-w-3xl"
|
contentWidthClass="max-w-3xl"
|
||||||
/>
|
/>
|
||||||
<div className="shrink-0 px-4 pb-4">
|
<div className="shrink-0 px-4 pb-4">
|
||||||
<div className="mx-auto w-full max-w-3xl min-w-0">
|
<div className="mx-auto w-full min-w-0 max-w-3xl">
|
||||||
<AgentPromptBar
|
<AgentPromptBar
|
||||||
placeholder="Add a follow up"
|
placeholder="Add a follow up"
|
||||||
compact
|
compact
|
||||||
busy={isStreaming}
|
busy={isStreaming}
|
||||||
disabled={sendMessage.isPending}
|
disabled={sendMessage.isPending}
|
||||||
canCancel={hasActiveRun}
|
onSubmit={(content, images) =>
|
||||||
cancelling={cancelThread.isPending}
|
sendMessage.mutate({
|
||||||
onCancel={handleCancel}
|
content,
|
||||||
onSubmit={handleSubmit}
|
images,
|
||||||
|
model_id: activeSelection?.modelId ?? null,
|
||||||
|
effort: activeSelection?.effort ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
models={models}
|
models={models}
|
||||||
selection={activeSelection}
|
selection={activeSelection}
|
||||||
onSelectionChange={setSelection}
|
onSelectionChange={setSelection}
|
||||||
|
|
@ -198,6 +91,10 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : isHydrating ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
||||||
|
<p className="text-sm text-[var(--ui-text-dim)]">Loading conversation…</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
||||||
<p className="text-sm text-[var(--ui-text-dim)]">
|
<p className="text-sm text-[var(--ui-text-dim)]">
|
||||||
|
|
@ -209,10 +106,14 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||||
compact
|
compact
|
||||||
busy={isStreaming}
|
busy={isStreaming}
|
||||||
disabled={sendMessage.isPending}
|
disabled={sendMessage.isPending}
|
||||||
canCancel={hasActiveRun}
|
onSubmit={(content, images) =>
|
||||||
cancelling={cancelThread.isPending}
|
sendMessage.mutate({
|
||||||
onCancel={handleCancel}
|
content,
|
||||||
onSubmit={handleSubmit}
|
images,
|
||||||
|
model_id: activeSelection?.modelId ?? null,
|
||||||
|
effort: activeSelection?.effort ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
models={models}
|
models={models}
|
||||||
selection={activeSelection}
|
selection={activeSelection}
|
||||||
onSelectionChange={setSelection}
|
onSelectionChange={setSelection}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,41 @@
|
||||||
import { useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
|
|
||||||
import type { ModelSelection } from "@/lib/agents/useModelOptions"
|
import type { AgentThread, ImageChunk } from "@/lib/agents/types"
|
||||||
|
import type { CreateAgentThreadVariables } from "@/lib/agents/queries"
|
||||||
|
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||||
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
|
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
|
||||||
import { SlackConnectDialog } from "@/components/agents/SlackConnectDialog"
|
import { SlackConnectDialog } from "@/components/agents/SlackConnectDialog"
|
||||||
import { Logo } from "@/components/agents/ported/Logo"
|
import { Logo } from "@/components/agents/ported/Logo"
|
||||||
import { useCreateAgentThread } from "@/lib/agents/queries"
|
import { agentThreadKeys, optimisticThread } from "@/lib/agents/queries"
|
||||||
import { useModelOptions } from "@/lib/agents/useModelOptions"
|
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||||
import { useProfile, useRepos } from "@/lib/profile"
|
import { useProfile, useRepos } from "@/lib/profile"
|
||||||
|
|
||||||
|
function promptContent(text: string, images: Array<ImageChunk>) {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
const imageBlocks = images.map((image) => ({
|
||||||
|
type: "image",
|
||||||
|
base64: image.base64,
|
||||||
|
mime_type: image.mimeType,
|
||||||
|
...(image.fileName ? { file_name: image.fileName } : {}),
|
||||||
|
}))
|
||||||
|
return [...imageBlocks, ...(trimmed ? [{ type: "text", text: trimmed }] : [])]
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentsHome() {
|
export function AgentsHome() {
|
||||||
const createThread = useCreateAgentThread()
|
// Submit straight through the layout's persistent stream. The SDK mints the
|
||||||
|
// thread id (no client-minted id, no `getState` 404), fires the first
|
||||||
|
// `run.start` — which lazily creates + stamps + owns the thread server-side
|
||||||
|
// — and keeps streaming after we navigate to the minted thread below.
|
||||||
|
const stream = useAgentThreadStream()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { models, defaultSelection } = useModelOptions()
|
const { models, defaultSelection } = useModelOptions()
|
||||||
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
||||||
const activeSelection = selection ?? defaultSelection
|
const activeSelection = selection ?? defaultSelection
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const reposQuery = useRepos()
|
const reposQuery = useRepos()
|
||||||
const profileQuery = useProfile()
|
const profileQuery = useProfile()
|
||||||
|
|
@ -25,6 +48,51 @@ export function AgentsHome() {
|
||||||
? (profileQuery.data?.default_repo ?? null)
|
? (profileQuery.data?.default_repo ?? null)
|
||||||
: repoOverride
|
: repoOverride
|
||||||
|
|
||||||
|
// Holds the just-submitted prompt until the SDK mints the thread id; the
|
||||||
|
// effect then seeds the optimistic summary and navigates exactly once.
|
||||||
|
const draftRef = useRef<CreateAgentThreadVariables | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = stream.threadId
|
||||||
|
const draft = draftRef.current
|
||||||
|
if (!id || !draft) return
|
||||||
|
draftRef.current = null
|
||||||
|
const thread = optimisticThread(id, draft)
|
||||||
|
queryClient.setQueryData(agentThreadKeys.detail(id), thread)
|
||||||
|
// Surface the thread in the sidebar immediately; the list's running
|
||||||
|
// refetch reconciles to server truth once the run.start stamps it.
|
||||||
|
queryClient.setQueryData<Array<AgentThread>>(agentThreadKeys.all, (prev) => [
|
||||||
|
thread,
|
||||||
|
...(prev?.filter((existing) => existing.id !== id) ?? []),
|
||||||
|
])
|
||||||
|
void navigate({ to: "/agents/$threadId", params: { threadId: id } })
|
||||||
|
}, [stream.threadId, queryClient, navigate])
|
||||||
|
|
||||||
|
const handleSubmit = (prompt: string, images: Array<ImageChunk>) => {
|
||||||
|
draftRef.current = {
|
||||||
|
prompt,
|
||||||
|
images,
|
||||||
|
repo,
|
||||||
|
repo_explicitly_none: repoOverride === null,
|
||||||
|
model_id: activeSelection?.modelId ?? null,
|
||||||
|
effort: activeSelection?.effort ?? null,
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
|
||||||
|
const configurable: Record<string, unknown> = {}
|
||||||
|
if (activeSelection?.modelId && activeSelection.effort) {
|
||||||
|
configurable.agent_model_id = activeSelection.modelId
|
||||||
|
configurable.agent_effort = activeSelection.effort
|
||||||
|
}
|
||||||
|
if (repo) configurable.repo = repo
|
||||||
|
if (repoOverride === null) configurable.repo_explicitly_none = true
|
||||||
|
|
||||||
|
void stream.submit(
|
||||||
|
{ messages: [{ type: "human", content: promptContent(prompt, images) }] },
|
||||||
|
{ config: { configurable } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8">
|
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8">
|
||||||
<SlackConnectDialog />
|
<SlackConnectDialog />
|
||||||
|
|
@ -32,17 +100,8 @@ export function AgentsHome() {
|
||||||
<div className="flex w-full flex-col items-center gap-6">
|
<div className="flex w-full flex-col items-center gap-6">
|
||||||
<Logo />
|
<Logo />
|
||||||
<AgentPromptBar
|
<AgentPromptBar
|
||||||
onSubmit={(prompt, images) =>
|
onSubmit={handleSubmit}
|
||||||
createThread.mutate({
|
disabled={submitting}
|
||||||
prompt,
|
|
||||||
images,
|
|
||||||
repo,
|
|
||||||
repo_explicitly_none: repoOverride === null,
|
|
||||||
model_id: activeSelection?.modelId ?? null,
|
|
||||||
effort: activeSelection?.effort ?? null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={createThread.isPending}
|
|
||||||
models={models}
|
models={models}
|
||||||
selection={activeSelection}
|
selection={activeSelection}
|
||||||
onSelectionChange={setSelection}
|
onSelectionChange={setSelection}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { groupThreads } from "@/lib/agents/api"
|
||||||
import {
|
import {
|
||||||
useAgentThreads,
|
useAgentThreads,
|
||||||
useDeleteAgentThread,
|
useDeleteAgentThread,
|
||||||
usePrefetchAgentThreadDetails,
|
useSeedAgentThreadDetails,
|
||||||
} from "@/lib/agents/queries"
|
} from "@/lib/agents/queries"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ const NAV = [
|
||||||
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||||
const threadsQuery = useAgentThreads()
|
const threadsQuery = useAgentThreads()
|
||||||
const threads = threadsQuery.data ?? []
|
const threads = threadsQuery.data ?? []
|
||||||
usePrefetchAgentThreadDetails(threads, activeThreadId)
|
useSeedAgentThreadDetails(threads, activeThreadId)
|
||||||
const groups = groupThreads(threads)
|
const groups = groupThreads(threads)
|
||||||
const layout = useSidebarLayout()
|
const layout = useSidebarLayout()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
|
|
||||||
import type { ModelOption } from "@/lib/api"
|
import type { ModelOption } from "@/lib/api"
|
||||||
import type { AgentSchedule } from "@/lib/agents/types"
|
import type { AgentSchedule } from "@/lib/agents/types"
|
||||||
import type { ModelSelection } from "@/lib/agents/useModelOptions"
|
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||||
import { RepoSelector } from "@/components/agents/RepoSelector"
|
import { RepoSelector } from "@/components/agents/RepoSelector"
|
||||||
import { ScheduleTriggerPicker } from "@/components/agents/ScheduleTriggerPicker"
|
import { ScheduleTriggerPicker } from "@/components/agents/ScheduleTriggerPicker"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
import {
|
import {
|
||||||
formatModelSelection,
|
formatModelSelection,
|
||||||
useModelOptions,
|
useModelOptions,
|
||||||
} from "@/lib/agents/useModelOptions"
|
} from "@/lib/agents/provider/useModelOptions"
|
||||||
import { useRepos } from "@/lib/profile"
|
import { useRepos } from "@/lib/profile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
|
||||||
230
ui/src/components/agents/messages/AgentMessage.tsx
Normal file
230
ui/src/components/agents/messages/AgentMessage.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { ReplyCard } from "@/components/agents/ported/ReplyCard";
|
||||||
|
import { ShellCommand } from "@/components/agents/ported/ShellCommand";
|
||||||
|
import { ToolExecution } from "@/components/agents/ported/ToolExecution";
|
||||||
|
import type { Message } from "@/lib/agents/types";
|
||||||
|
|
||||||
|
import { ChunkRenderer } from "./ChunkRenderer";
|
||||||
|
import { ReasoningBlock } from "./ReasoningBlock";
|
||||||
|
import { buildRenderItems, summarizeExploration, type RenderItem } from "./renderItems";
|
||||||
|
import { SubagentGroup } from "@/components/agents/subagents";
|
||||||
|
import { summarizeChangedFiles } from "./summarizeChangedFiles";
|
||||||
|
import { TurnChangedFilesCard } from "./TurnChangedFilesCard";
|
||||||
|
import type { ApprovalCallbacks, ChangedFileSummaryItem } from "./types";
|
||||||
|
|
||||||
|
export function AgentMessage({
|
||||||
|
message,
|
||||||
|
isStreaming,
|
||||||
|
isMarkdownLive,
|
||||||
|
projectPath,
|
||||||
|
...callbacks
|
||||||
|
}: {
|
||||||
|
message: Message;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isMarkdownLive?: boolean;
|
||||||
|
projectPath?: string;
|
||||||
|
} & ApprovalCallbacks) {
|
||||||
|
const renderItems = useMemo(
|
||||||
|
() => buildRenderItems(message.chunks, message.id),
|
||||||
|
[message.chunks, message.id],
|
||||||
|
);
|
||||||
|
const changedFiles = useMemo(() => summarizeChangedFiles(message.chunks), [message.chunks]);
|
||||||
|
const changedFilesTotals = useMemo(() => {
|
||||||
|
let additions = 0;
|
||||||
|
let deletions = 0;
|
||||||
|
for (const item of changedFiles) {
|
||||||
|
additions += item.additions;
|
||||||
|
deletions += item.deletions;
|
||||||
|
}
|
||||||
|
return { additions, deletions };
|
||||||
|
}, [changedFiles]);
|
||||||
|
const changedFilesByPath = useMemo(() => {
|
||||||
|
const byPath = new Map<string, ChangedFileSummaryItem>();
|
||||||
|
for (const file of changedFiles) {
|
||||||
|
byPath.set(file.filePath, file);
|
||||||
|
}
|
||||||
|
return byPath;
|
||||||
|
}, [changedFiles]);
|
||||||
|
|
||||||
|
const exploredGroupIds = useMemo(
|
||||||
|
() =>
|
||||||
|
renderItems
|
||||||
|
.filter((item): item is Extract<RenderItem, { type: "explored-group" }> => item.type === "explored-group")
|
||||||
|
.map((item) => item.id),
|
||||||
|
[renderItems],
|
||||||
|
);
|
||||||
|
const hasExploredGroups = exploredGroupIds.length > 0;
|
||||||
|
const [expandedExploredGroups, setExpandedExploredGroups] = useState<Record<string, boolean>>({});
|
||||||
|
const wasExplorationLiveRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isLive = !!isStreaming;
|
||||||
|
|
||||||
|
if (!hasExploredGroups) {
|
||||||
|
setExpandedExploredGroups({});
|
||||||
|
wasExplorationLiveRef.current = isLive;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLive) {
|
||||||
|
const next: Record<string, boolean> = {};
|
||||||
|
for (const id of exploredGroupIds) {
|
||||||
|
next[id] = true;
|
||||||
|
}
|
||||||
|
setExpandedExploredGroups(next);
|
||||||
|
wasExplorationLiveRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldAutoCollapse = wasExplorationLiveRef.current;
|
||||||
|
setExpandedExploredGroups((prev) => {
|
||||||
|
const next: Record<string, boolean> = {};
|
||||||
|
for (const id of exploredGroupIds) {
|
||||||
|
next[id] = shouldAutoCollapse ? false : (prev[id] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevKeys = Object.keys(prev);
|
||||||
|
const nextKeys = Object.keys(next);
|
||||||
|
if (prevKeys.length !== nextKeys.length) return next;
|
||||||
|
for (const key of nextKeys) {
|
||||||
|
if (prev[key] !== next[key]) return next;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
wasExplorationLiveRef.current = false;
|
||||||
|
}, [hasExploredGroups, isStreaming, exploredGroupIds, message.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-2 min-w-0 space-y-2">
|
||||||
|
{renderItems.map((item, index) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case "reasoning-item": {
|
||||||
|
const reasoningChunk = item.chunk.kind === "reasoning" ? item.chunk : null;
|
||||||
|
const isLastItem = index === renderItems.length - 1;
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="flex-1 min-w-0">
|
||||||
|
<ReasoningBlock
|
||||||
|
text={reasoningChunk?.text ?? ""}
|
||||||
|
isLive={!!isStreaming && isLastItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "explored-group": {
|
||||||
|
const summary = summarizeExploration(item.chunks);
|
||||||
|
const isExpanded = expandedExploredGroups[item.id] ?? false;
|
||||||
|
return (
|
||||||
|
<div key={item.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedExploredGroups((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.id]: !(prev[item.id] ?? false),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full flex items-center justify-between py-1 text-left hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<span className="text-[color:var(--ui-text-muted)] text-[12px]">{summary}</span>
|
||||||
|
<span className="text-[color:var(--ui-text-dim)] text-xs">{isExpanded ? "Hide" : "Show"}</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pt-1 pb-1 space-y-0.5">
|
||||||
|
{item.chunks.map((chunk, chunkIndex) => (
|
||||||
|
<div key={chunk.toolCallId || `explored-chunk-${item.id}-${chunkIndex}`} className="flex-1 min-w-0 text-gray-500">
|
||||||
|
<ToolExecution
|
||||||
|
chunk={chunk}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onOpenDiff={callbacks.onOpenDiff}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "subagent-group":
|
||||||
|
return <SubagentGroup key={item.key} chunks={item.chunks} />;
|
||||||
|
|
||||||
|
case "edit-item": {
|
||||||
|
const fullFileDiff = item.chunk.diffData
|
||||||
|
? changedFilesByPath.get(item.chunk.diffData.filePath)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.key}>
|
||||||
|
<ToolExecution
|
||||||
|
chunk={item.chunk}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onApprove={callbacks.onApprove}
|
||||||
|
onReject={callbacks.onReject}
|
||||||
|
onAutoApprove={callbacks.onAutoApprove}
|
||||||
|
onOpenDiff={callbacks.onOpenDiff}
|
||||||
|
resolvedDiffData={fullFileDiff ? {
|
||||||
|
originalContent: fullFileDiff.originalContent,
|
||||||
|
modifiedContent: fullFileDiff.modifiedContent,
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "shell-item":
|
||||||
|
return (
|
||||||
|
<div key={item.key}>
|
||||||
|
<ShellCommand
|
||||||
|
chunk={item.chunk}
|
||||||
|
projectPath={projectPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "reply-item":
|
||||||
|
return (
|
||||||
|
<div key={item.key}>
|
||||||
|
<ReplyCard chunk={item.chunk} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "tool-item":
|
||||||
|
return (
|
||||||
|
<div key={item.key}>
|
||||||
|
<ToolExecution
|
||||||
|
chunk={item.chunk}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onApprove={callbacks.onApprove}
|
||||||
|
onReject={callbacks.onReject}
|
||||||
|
onAutoApprove={callbacks.onAutoApprove}
|
||||||
|
onOpenDiff={callbacks.onOpenDiff}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "text-chunk":
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="flex-1 min-w-0">
|
||||||
|
<ChunkRenderer
|
||||||
|
chunk={item.chunk}
|
||||||
|
projectPath={projectPath}
|
||||||
|
isMarkdownLive={isMarkdownLive}
|
||||||
|
{...callbacks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
{changedFiles.length > 0 && !isStreaming && (
|
||||||
|
<TurnChangedFilesCard
|
||||||
|
files={changedFiles}
|
||||||
|
totals={changedFilesTotals}
|
||||||
|
projectPath={projectPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
ui/src/components/agents/messages/ChunkRenderer.tsx
Normal file
53
ui/src/components/agents/messages/ChunkRenderer.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { CodeBlock } from "@/components/agents/ported/CodeBlock";
|
||||||
|
import { Markdown } from "@/components/agents/ported/Markdown";
|
||||||
|
import { ToolExecution } from "@/components/agents/ported/ToolExecution";
|
||||||
|
import type { Chunk } from "@/lib/agents/types";
|
||||||
|
|
||||||
|
import type { ApprovalCallbacks } from "./types";
|
||||||
|
|
||||||
|
export function ChunkRenderer({
|
||||||
|
chunk,
|
||||||
|
projectPath,
|
||||||
|
isMarkdownLive,
|
||||||
|
...callbacks
|
||||||
|
}: { chunk: Chunk; projectPath?: string; isMarkdownLive?: boolean } & ApprovalCallbacks) {
|
||||||
|
switch (chunk.kind) {
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<div className="text-[color:var(--ui-text)]">
|
||||||
|
<Markdown content={chunk.text} isLive={isMarkdownLive} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "code":
|
||||||
|
return <CodeBlock text={chunk.text} language={chunk.language} />;
|
||||||
|
case "error":
|
||||||
|
return <span className="text-red-400">{chunk.text}</span>;
|
||||||
|
case "list":
|
||||||
|
return (
|
||||||
|
<div className="text-gray-300 ml-2">
|
||||||
|
{chunk.lines.map((line, i) => (
|
||||||
|
<div key={i}>- {line}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "tool-execution":
|
||||||
|
return (
|
||||||
|
<ToolExecution
|
||||||
|
chunk={chunk}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onApprove={callbacks.onApprove}
|
||||||
|
onReject={callbacks.onReject}
|
||||||
|
onAutoApprove={callbacks.onAutoApprove}
|
||||||
|
onOpenDiff={callbacks.onOpenDiff}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={`data:${chunk.mimeType};base64,${chunk.base64}`}
|
||||||
|
alt={chunk.fileName || "image"}
|
||||||
|
className="max-w-48 max-h-48 rounded border border-gray-600"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
ui/src/components/agents/messages/Messages.tsx
Normal file
237
ui/src/components/agents/messages/Messages.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
// @ts-nocheck — ported from open-swe-app (Electron); strict checks applied when wiring cloud APIs.
|
||||||
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
import { useLiveMarkdownMessageId } from "@/lib/agents/provider/useLiveMarkdownMessageId";
|
||||||
|
|
||||||
|
import { AgentMessage } from "./AgentMessage";
|
||||||
|
import { ThinkingSpinner } from "./ThinkingSpinner";
|
||||||
|
import { UserMessage } from "./UserMessage";
|
||||||
|
import type { MessagesProps } from "./types";
|
||||||
|
|
||||||
|
const BOTTOM_LOCK_THRESHOLD_PX = 24;
|
||||||
|
|
||||||
|
export const Messages = memo(function Messages({
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
streamIsLoading,
|
||||||
|
isThinking,
|
||||||
|
settingUpSandbox,
|
||||||
|
project,
|
||||||
|
contentWidthClass = "max-w-[42rem]",
|
||||||
|
contentPaddingClass = "px-6",
|
||||||
|
bottomInset = 0,
|
||||||
|
scrollButtonSlot = "internal",
|
||||||
|
onShowScrollToBottomChange,
|
||||||
|
scrollControlRef,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
onAutoApprove,
|
||||||
|
onOpenDiff,
|
||||||
|
}: MessagesProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const autoScrollEnabledRef = useRef(true);
|
||||||
|
const lastManualScrollTopRef = useRef(0);
|
||||||
|
const previousScrollTopRef = useRef(0);
|
||||||
|
const pendingScrollFrameRef = useRef<number | null>(null);
|
||||||
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
|
|
||||||
|
const clearScheduledScroll = useCallback(() => {
|
||||||
|
if (pendingScrollFrameRef.current === null) return;
|
||||||
|
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
||||||
|
pendingScrollFrameRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isNearBottom = useCallback((el: HTMLDivElement) => {
|
||||||
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
return distanceFromBottom <= BOTTOM_LOCK_THRESHOLD_PX;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncScrollButtonVisibility = useCallback((el: HTMLDivElement) => {
|
||||||
|
setShowScrollToBottom(!isNearBottom(el));
|
||||||
|
}, [isNearBottom]);
|
||||||
|
|
||||||
|
const scrollToBottomNow = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
const currentTop = el.scrollTop;
|
||||||
|
lastManualScrollTopRef.current = currentTop;
|
||||||
|
previousScrollTopRef.current = currentTop;
|
||||||
|
syncScrollButtonVisibility(el);
|
||||||
|
}, [syncScrollButtonVisibility]);
|
||||||
|
|
||||||
|
const scheduleScrollToBottom = useCallback(() => {
|
||||||
|
if (!autoScrollEnabledRef.current) return;
|
||||||
|
|
||||||
|
clearScheduledScroll();
|
||||||
|
pendingScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
pendingScrollFrameRef.current = null;
|
||||||
|
if (!autoScrollEnabledRef.current) return;
|
||||||
|
scrollToBottomNow();
|
||||||
|
});
|
||||||
|
}, [clearScheduledScroll, scrollToBottomNow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const currentTop = el.scrollTop;
|
||||||
|
const scrolledUp = currentTop < previousScrollTopRef.current - 1;
|
||||||
|
const nearBottom = isNearBottom(el);
|
||||||
|
|
||||||
|
if (scrolledUp) {
|
||||||
|
autoScrollEnabledRef.current = false;
|
||||||
|
clearScheduledScroll();
|
||||||
|
} else if (nearBottom) {
|
||||||
|
autoScrollEnabledRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncScrollButtonVisibility(el);
|
||||||
|
lastManualScrollTopRef.current = currentTop;
|
||||||
|
previousScrollTopRef.current = currentTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollToBottomNow();
|
||||||
|
autoScrollEnabledRef.current = true;
|
||||||
|
|
||||||
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("scroll", handleScroll);
|
||||||
|
clearScheduledScroll();
|
||||||
|
};
|
||||||
|
}, [clearScheduledScroll, isNearBottom, scrollToBottomNow, syncScrollButtonVisibility]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (autoScrollEnabledRef.current) {
|
||||||
|
scheduleScrollToBottom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||||
|
const targetTop = Math.min(lastManualScrollTopRef.current, maxTop);
|
||||||
|
const jumpDistance = Math.abs(el.scrollTop - targetTop);
|
||||||
|
|
||||||
|
if (jumpDistance > el.clientHeight * 0.5) {
|
||||||
|
el.scrollTop = targetTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousScrollTopRef.current = el.scrollTop;
|
||||||
|
syncScrollButtonVisibility(el);
|
||||||
|
}, [messages, isStreaming, scheduleScrollToBottom, syncScrollButtonVisibility]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scroller = scrollRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!scroller || !content || typeof ResizeObserver === "undefined") return;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (autoScrollEnabledRef.current) {
|
||||||
|
scheduleScrollToBottom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||||
|
if (lastManualScrollTopRef.current > maxTop) {
|
||||||
|
scroller.scrollTop = maxTop;
|
||||||
|
lastManualScrollTopRef.current = maxTop;
|
||||||
|
previousScrollTopRef.current = maxTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncScrollButtonVisibility(scroller);
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(scroller);
|
||||||
|
resizeObserver.observe(content);
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [scheduleScrollToBottom, syncScrollButtonVisibility]);
|
||||||
|
|
||||||
|
const visibleMessages = useMemo(() => messages.filter((message) => !message.hidden), [messages]);
|
||||||
|
const liveMarkdownMessageId = useLiveMarkdownMessageId(
|
||||||
|
visibleMessages,
|
||||||
|
streamIsLoading,
|
||||||
|
isStreaming,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScrollToBottom = useCallback(() => {
|
||||||
|
autoScrollEnabledRef.current = true;
|
||||||
|
clearScheduledScroll();
|
||||||
|
scrollToBottomNow();
|
||||||
|
}, [clearScheduledScroll, scrollToBottomNow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollControlRef) return;
|
||||||
|
scrollControlRef.current = { scrollToBottom: handleScrollToBottom };
|
||||||
|
return () => {
|
||||||
|
scrollControlRef.current = null;
|
||||||
|
};
|
||||||
|
}, [handleScrollToBottom, scrollControlRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onShowScrollToBottomChange?.(showScrollToBottom);
|
||||||
|
}, [onShowScrollToBottomChange, showScrollToBottom]);
|
||||||
|
|
||||||
|
const projectPath = project?.path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1 min-h-0 min-w-0">
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="h-full min-h-0 min-w-0 overflow-y-auto overflow-x-hidden py-5 text-[13px] leading-6 font-sans antialiased"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className={`w-full ${contentWidthClass} mx-auto min-w-0 ${contentPaddingClass}`}
|
||||||
|
style={bottomInset > 0 ? { paddingBottom: bottomInset } : undefined}
|
||||||
|
>
|
||||||
|
{visibleMessages.map((message, index) => {
|
||||||
|
const isLastMessage = index === visibleMessages.length - 1;
|
||||||
|
const messageIsStreaming = isStreaming && isLastMessage;
|
||||||
|
const messageIsMarkdownLive = message.id === liveMarkdownMessageId;
|
||||||
|
|
||||||
|
if (message.author === "user") {
|
||||||
|
return <UserMessage key={message.id} message={message} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentMessage
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
isStreaming={messageIsStreaming}
|
||||||
|
isMarkdownLive={messageIsMarkdownLive}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onReject={onReject}
|
||||||
|
onAutoApprove={onAutoApprove}
|
||||||
|
onOpenDiff={onOpenDiff}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ThinkingSpinner
|
||||||
|
isActive={isThinking ?? streamIsLoading ?? isStreaming}
|
||||||
|
settingUpSandbox={settingUpSandbox}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scrollButtonSlot === "internal" && showScrollToBottom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleScrollToBottom}
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
className="absolute left-1/2 z-30 inline-flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full bg-[var(--ui-panel-2)] text-[color:var(--ui-text-muted)] shadow-md transition-colors hover:bg-[var(--ui-panel)] hover:text-[color:var(--ui-text)]"
|
||||||
|
style={{ bottom: bottomInset > 0 ? bottomInset + 8 : 16 }}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
74
ui/src/components/agents/messages/ReasoningBlock.tsx
Normal file
74
ui/src/components/agents/messages/ReasoningBlock.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const secs = Math.max(1, Math.ceil(ms / 1000));
|
||||||
|
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningLabel(elapsedMs: number | null): string {
|
||||||
|
if (elapsedMs === null) return "Thought";
|
||||||
|
if (elapsedMs < 1000) return "Thought briefly";
|
||||||
|
return `Thought for ${formatElapsed(elapsedMs)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a model's reasoning ("thinking") tokens. While the reasoning is live
|
||||||
|
* it streams in muted gray text under a shimmering "Thinking…" header; once the
|
||||||
|
* reasoning ends it auto-collapses into a "Thought for …" toggle the user can
|
||||||
|
* expand on demand.
|
||||||
|
*/
|
||||||
|
export function ReasoningBlock({ text, isLive }: { text: string; isLive: boolean }) {
|
||||||
|
const [userExpanded, setUserExpanded] = useState(false);
|
||||||
|
const [elapsedMs, setElapsedMs] = useState<number | null>(null);
|
||||||
|
const startedAtRef = useRef<number | null>(null);
|
||||||
|
const wasLiveRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLive) {
|
||||||
|
if (startedAtRef.current === null) startedAtRef.current = Date.now();
|
||||||
|
wasLiveRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wasLiveRef.current && startedAtRef.current !== null) {
|
||||||
|
setElapsedMs(Date.now() - startedAtRef.current);
|
||||||
|
wasLiveRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isLive]);
|
||||||
|
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed && !isLive) return null;
|
||||||
|
|
||||||
|
const expanded = isLive || userExpanded;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isLive) setUserExpanded((value) => !value);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-left transition-opacity hover:opacity-90 disabled:cursor-default"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
disabled={isLive}
|
||||||
|
>
|
||||||
|
{isLive ? (
|
||||||
|
<span className="shimmer-text text-xs">Thinking...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-3 w-3 text-[color:var(--ui-text-dim)] shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[color:var(--ui-text-dim)]">{reasoningLabel(elapsedMs)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && trimmed && (
|
||||||
|
<div className="mt-1 ml-1 border-l-2 border-[var(--ui-border)] pl-3 text-[12px] leading-5 whitespace-pre-wrap break-words text-[color:var(--ui-text-dim)]">
|
||||||
|
{trimmed}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
ui/src/components/agents/messages/ThinkingSpinner.tsx
Normal file
98
ui/src/components/agents/messages/ThinkingSpinner.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const BUSY_TEXTS: { present: string; past: string }[] = [
|
||||||
|
{ present: "vibing...", past: "Vibed" },
|
||||||
|
{ present: "noodling...", past: "Noodled" },
|
||||||
|
{ present: "pondering...", past: "Pondered" },
|
||||||
|
{ present: "thinking really hard...", past: "Thought really hard" },
|
||||||
|
{ present: "spinning up...", past: "Spun up" },
|
||||||
|
{ present: "connecting the dots...", past: "Connected the dots" },
|
||||||
|
{ present: "brewing ideas...", past: "Brewed ideas" },
|
||||||
|
{ present: "cooking...", past: "Cooked" },
|
||||||
|
{ present: "crunching...", past: "Crunched" },
|
||||||
|
{ present: "scheming...", past: "Schemed" },
|
||||||
|
{ present: "processing...", past: "Processed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const secs = Math.max(1, Math.ceil(ms / 1000));
|
||||||
|
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THINKING_SETTLE_MS = 300;
|
||||||
|
|
||||||
|
export function ThinkingSpinner({
|
||||||
|
isActive,
|
||||||
|
settingUpSandbox = false,
|
||||||
|
}: {
|
||||||
|
isActive: boolean;
|
||||||
|
settingUpSandbox?: boolean;
|
||||||
|
}) {
|
||||||
|
const [textIdx, setTextIdx] = useState(0);
|
||||||
|
const [done, setDone] = useState<{ past: string; elapsed: string } | null>(null);
|
||||||
|
const [settledActive, setSettledActive] = useState(isActive);
|
||||||
|
const startTimeRef = useRef(0);
|
||||||
|
const sessionActiveRef = useRef(false);
|
||||||
|
const textIdxRef = useRef(textIdx);
|
||||||
|
const settingUpSandboxRef = useRef(settingUpSandbox);
|
||||||
|
textIdxRef.current = textIdx;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingUpSandboxRef.current = settingUpSandbox;
|
||||||
|
}, [settingUpSandbox]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) {
|
||||||
|
setSettledActive(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = window.setTimeout(() => setSettledActive(false), THINKING_SETTLE_MS);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settledActive) {
|
||||||
|
if (!sessionActiveRef.current) {
|
||||||
|
sessionActiveRef.current = true;
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
setTextIdx(Math.floor(Math.random() * BUSY_TEXTS.length));
|
||||||
|
setDone(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sessionActiveRef.current) return;
|
||||||
|
sessionActiveRef.current = false;
|
||||||
|
setDone({
|
||||||
|
past: settingUpSandboxRef.current
|
||||||
|
? "Set up sandbox"
|
||||||
|
: BUSY_TEXTS[textIdxRef.current]?.past ?? "",
|
||||||
|
elapsed: formatElapsed(Date.now() - startTimeRef.current),
|
||||||
|
});
|
||||||
|
}, [settledActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settledActive || settingUpSandbox) return;
|
||||||
|
const BUSY_TEXT_ROTATE_INTERVAL_MS = 12000;
|
||||||
|
const id = setInterval(() => setTextIdx((i) => (i + 1) % BUSY_TEXTS.length), BUSY_TEXT_ROTATE_INTERVAL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [settledActive, settingUpSandbox]);
|
||||||
|
|
||||||
|
const showActive = isActive || settledActive;
|
||||||
|
if (!showActive && !done) return null;
|
||||||
|
|
||||||
|
if (done && !showActive) {
|
||||||
|
return (
|
||||||
|
<div className="my-2 flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[color:var(--ui-text-dim)]">{done.past} for {done.elapsed}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-2 flex items-center gap-2">
|
||||||
|
<span className="shimmer-text text-xs">
|
||||||
|
{settingUpSandbox ? "Setting up sandbox..." : BUSY_TEXTS[textIdx]?.present ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
ui/src/components/agents/messages/TurnChangedFilesCard.tsx
Normal file
129
ui/src/components/agents/messages/TurnChangedFilesCard.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||||
|
|
||||||
|
import { useDiffOptions, warmDiffHighlighter } from "@/components/agents/utils/diffUtils";
|
||||||
|
|
||||||
|
import type { ChangedFileSummaryItem } from "./types";
|
||||||
|
|
||||||
|
function stripProjectPathForDisplay(path: string, projectPath?: string): string {
|
||||||
|
if (!projectPath) return path;
|
||||||
|
const normalizedPath = path.replace(/\\/g, "/");
|
||||||
|
const normalizedProjectPath = projectPath.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
if (!normalizedPath.startsWith(`${normalizedProjectPath}/`)) return path;
|
||||||
|
return normalizedPath.slice(normalizedProjectPath.length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangedFileRow = memo(function ChangedFileRow({
|
||||||
|
file,
|
||||||
|
projectPath,
|
||||||
|
open,
|
||||||
|
diffReady,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
file: ChangedFileSummaryItem;
|
||||||
|
projectPath?: string;
|
||||||
|
open: boolean;
|
||||||
|
diffReady: boolean;
|
||||||
|
onToggle: (filePath: string) => void;
|
||||||
|
}) {
|
||||||
|
const diffOptions = useDiffOptions();
|
||||||
|
const displayPath = useMemo(
|
||||||
|
() => stripProjectPathForDisplay(file.filePath, projectPath),
|
||||||
|
[file.filePath, projectPath],
|
||||||
|
);
|
||||||
|
const oldFile = useMemo(
|
||||||
|
() => ({ name: displayPath, contents: file.originalContent }),
|
||||||
|
[displayPath, file.originalContent],
|
||||||
|
);
|
||||||
|
const newFile = useMemo(
|
||||||
|
() => ({ name: displayPath, contents: file.modifiedContent }),
|
||||||
|
[displayPath, file.modifiedContent],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b last:border-b-0 border-[var(--ui-border)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(file.filePath)}
|
||||||
|
className="w-full px-3 py-2 text-left hover:bg-[var(--ui-panel-2)] transition-colors flex items-center justify-between gap-3"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="text-[13px] text-[color:var(--ui-accent)] truncate min-w-0">{displayPath}</span>
|
||||||
|
<span className="shrink-0 flex items-center gap-2">
|
||||||
|
<span className="text-xs flex items-center gap-2">
|
||||||
|
<span className="text-green-400">+{file.additions}</span>
|
||||||
|
<span className="text-red-400">-{file.deletions}</span>
|
||||||
|
</span>
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5 text-[color:var(--ui-text-dim)] shrink-0" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-[color:var(--ui-text-dim)] shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-2 pb-2">
|
||||||
|
<div className="rounded-lg bg-[var(--ui-panel)] overflow-hidden border border-[var(--ui-border-subtle)]">
|
||||||
|
{diffReady ? (
|
||||||
|
<div className="max-h-[250px] overflow-auto">
|
||||||
|
<MultiFileDiff oldFile={oldFile} newFile={newFile} options={diffOptions} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-4 text-xs text-[color:var(--ui-text-dim)]">Loading diff…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TurnChangedFilesCard = memo(function TurnChangedFilesCard({
|
||||||
|
files,
|
||||||
|
totals,
|
||||||
|
projectPath,
|
||||||
|
}: {
|
||||||
|
files: ChangedFileSummaryItem[];
|
||||||
|
totals: { additions: number; deletions: number };
|
||||||
|
projectPath?: string;
|
||||||
|
}) {
|
||||||
|
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
||||||
|
const [diffReady, setDiffReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
warmDiffHighlighter().finally(() => {
|
||||||
|
if (active) setDiffReady(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFile = useCallback((filePath: string) => {
|
||||||
|
setExpandedByPath((prev) => ({ ...prev, [filePath]: !prev[filePath] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl bg-[var(--ui-accent-bubble)] overflow-hidden">
|
||||||
|
<div className="px-3 py-2 text-xs text-[color:var(--ui-text-muted)] border-b border-[var(--ui-border)] flex items-center gap-2">
|
||||||
|
<span>{files.length} file{files.length === 1 ? "" : "s"} changed</span>
|
||||||
|
<span className="text-green-400">+{totals.additions}</span>
|
||||||
|
<span className="text-red-400">-{totals.deletions}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{files.map((file) => (
|
||||||
|
<ChangedFileRow
|
||||||
|
key={file.filePath}
|
||||||
|
file={file}
|
||||||
|
projectPath={projectPath}
|
||||||
|
open={!!expandedByPath[file.filePath]}
|
||||||
|
diffReady={diffReady}
|
||||||
|
onToggle={toggleFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
66
ui/src/components/agents/messages/UserMessage.tsx
Normal file
66
ui/src/components/agents/messages/UserMessage.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { Message } from "@/lib/agents/types";
|
||||||
|
|
||||||
|
export function UserMessage({ message }: { message: Message }) {
|
||||||
|
const text = message.chunks
|
||||||
|
.filter((c) => c.kind === "text")
|
||||||
|
.map((c) => (c as { kind: "text"; text: string }).text)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const images = message.chunks.filter((c) => c.kind === "image");
|
||||||
|
const textRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scrolledFromTop, setScrolledFromTop] = useState(false);
|
||||||
|
const [scrolledFromBottom, setScrolledFromBottom] = useState(false);
|
||||||
|
|
||||||
|
const updateScrollIndicators = useCallback(() => {
|
||||||
|
const el = textRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setScrolledFromTop(el.scrollTop > 0);
|
||||||
|
setScrolledFromBottom(el.scrollTop < el.scrollHeight - el.clientHeight - 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
updateScrollIndicators();
|
||||||
|
}, [text, updateScrollIndicators]);
|
||||||
|
|
||||||
|
const textEdgeShadows = [
|
||||||
|
scrolledFromTop ? "inset 0 12px 10px -10px rgba(42, 63, 95, 0.95)" : "",
|
||||||
|
scrolledFromBottom ? "inset 0 -12px 10px -10px rgba(42, 63, 95, 0.95)" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end my-4">
|
||||||
|
<div className="max-w-[78%]">
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="flex gap-2 mb-2 flex-wrap justify-end">
|
||||||
|
{images.map((img, i) => (
|
||||||
|
img.kind === "image" && (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={`data:${img.mimeType};base64,${img.base64}`}
|
||||||
|
alt={img.fileName || "image"}
|
||||||
|
className="max-w-48 max-h-48 rounded border border-gray-600"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{text && (
|
||||||
|
<div className="inline-block max-w-full rounded-2xl bg-[var(--ui-accent-bubble)] overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={textRef}
|
||||||
|
onScroll={updateScrollIndicators}
|
||||||
|
className="max-h-[250px] overflow-auto px-3 py-1.5 text-[color:var(--ui-text)] text-[13px] whitespace-pre-wrap break-words"
|
||||||
|
style={{ boxShadow: textEdgeShadows || "none" }}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
ui/src/components/agents/messages/index.ts
Normal file
8
ui/src/components/agents/messages/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export { Messages } from "./Messages";
|
||||||
|
export { summarizeChangedFiles } from "./summarizeChangedFiles";
|
||||||
|
export type {
|
||||||
|
ApprovalCallbacks,
|
||||||
|
ChangedFileSummaryItem,
|
||||||
|
MessagesProps,
|
||||||
|
MessagesScrollControl,
|
||||||
|
} from "./types";
|
||||||
171
ui/src/components/agents/messages/renderItems.ts
Normal file
171
ui/src/components/agents/messages/renderItems.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import type { Chunk, ToolExecutionChunk } from "@/lib/agents/types";
|
||||||
|
|
||||||
|
export type RenderItem =
|
||||||
|
| { type: "text-chunk"; key: string; chunk: Chunk }
|
||||||
|
| { type: "reasoning-item"; key: string; chunk: Chunk }
|
||||||
|
| { type: "explored-group"; key: string; id: string; chunks: ToolExecutionChunk[] }
|
||||||
|
/**
|
||||||
|
* One or more `task` (subagent) tool calls collapsed into a single group so
|
||||||
|
* they can be rendered side by side as a card grid (see subagents/SubagentGroup).
|
||||||
|
*/
|
||||||
|
| { type: "subagent-group"; key: string; id: string; chunks: ToolExecutionChunk[] }
|
||||||
|
| { type: "edit-item"; key: string; chunk: ToolExecutionChunk }
|
||||||
|
| { type: "shell-item"; key: string; chunk: ToolExecutionChunk }
|
||||||
|
| { type: "reply-item"; key: string; chunk: ToolExecutionChunk }
|
||||||
|
| { type: "tool-item"; key: string; chunk: ToolExecutionChunk };
|
||||||
|
|
||||||
|
function getChunkRenderKey(chunk: Chunk, sourceIndex: number): string {
|
||||||
|
switch (chunk.kind) {
|
||||||
|
case "tool-execution":
|
||||||
|
return `tool-${chunk.toolCallId}`;
|
||||||
|
case "text":
|
||||||
|
return `text-${sourceIndex}`;
|
||||||
|
case "reasoning":
|
||||||
|
return `reasoning-${sourceIndex}`;
|
||||||
|
case "code":
|
||||||
|
return `code-${sourceIndex}`;
|
||||||
|
case "error":
|
||||||
|
return `error-${sourceIndex}`;
|
||||||
|
case "list":
|
||||||
|
return `list-${sourceIndex}`;
|
||||||
|
case "image":
|
||||||
|
return `image-${sourceIndex}`;
|
||||||
|
default:
|
||||||
|
return `chunk-${sourceIndex}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditTool(chunk: ToolExecutionChunk): boolean {
|
||||||
|
const kind = chunk.toolKind;
|
||||||
|
if (kind === "edit" || kind === "delete" || kind === "move") return true;
|
||||||
|
if (chunk.diffs?.length) return true;
|
||||||
|
if (chunk.diffData) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExplorationTool(chunk: ToolExecutionChunk): boolean {
|
||||||
|
if (chunk.diffs?.length) return false;
|
||||||
|
if (chunk.diffData) return false;
|
||||||
|
const kind = chunk.toolKind;
|
||||||
|
return kind === "read" || kind === "search";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShellTool(chunk: ToolExecutionChunk): boolean {
|
||||||
|
return chunk.toolKind === "execute";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReplyTool(chunk: ToolExecutionChunk): boolean {
|
||||||
|
return chunk.toolKind === "slack" || chunk.toolKind === "linear";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a tool chunk represents a spawned subagent. Subagents are launched
|
||||||
|
* via deepagents' `task` tool, which the transcript builder
|
||||||
|
* (`streamMessagesToUi.ts`) tags as `toolKind: "task"`.
|
||||||
|
* These are grouped and rendered as cards instead of a plain tool line.
|
||||||
|
*/
|
||||||
|
function isSubagentTool(chunk: ToolExecutionChunk): boolean {
|
||||||
|
return chunk.toolKind === "task";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRenderItems(chunks: Chunk[], messageId?: string): RenderItem[] {
|
||||||
|
const items: RenderItem[] = [];
|
||||||
|
let exploredBuffer: ToolExecutionChunk[] = [];
|
||||||
|
let exploredStartIndex = -1;
|
||||||
|
let subagentBuffer: ToolExecutionChunk[] = [];
|
||||||
|
let subagentStartIndex = -1;
|
||||||
|
|
||||||
|
const flushExplored = () => {
|
||||||
|
if (exploredBuffer.length === 0) return;
|
||||||
|
const firstId = exploredBuffer[0]?.toolCallId;
|
||||||
|
const id = `explored-${firstId || exploredStartIndex}`;
|
||||||
|
items.push({
|
||||||
|
type: "explored-group",
|
||||||
|
key: id,
|
||||||
|
id,
|
||||||
|
chunks: [...exploredBuffer],
|
||||||
|
});
|
||||||
|
exploredBuffer = [];
|
||||||
|
exploredStartIndex = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushSubagents = () => {
|
||||||
|
if (subagentBuffer.length === 0) return;
|
||||||
|
const firstId = subagentBuffer[0]?.toolCallId;
|
||||||
|
const id = `subagents-${firstId || subagentStartIndex}`;
|
||||||
|
items.push({
|
||||||
|
type: "subagent-group",
|
||||||
|
key: id,
|
||||||
|
id,
|
||||||
|
chunks: [...subagentBuffer],
|
||||||
|
});
|
||||||
|
subagentBuffer = [];
|
||||||
|
subagentStartIndex = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushGroups = () => {
|
||||||
|
flushExplored();
|
||||||
|
flushSubagents();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
|
const chunk = chunks[i];
|
||||||
|
if (!chunk) continue;
|
||||||
|
|
||||||
|
if (chunk.kind === "tool-execution") {
|
||||||
|
if (isSubagentTool(chunk)) {
|
||||||
|
flushExplored();
|
||||||
|
if (subagentBuffer.length === 0) subagentStartIndex = i;
|
||||||
|
subagentBuffer.push(chunk);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExplorationTool(chunk)) {
|
||||||
|
flushSubagents();
|
||||||
|
if (exploredBuffer.length === 0) exploredStartIndex = i;
|
||||||
|
exploredBuffer.push(chunk);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushGroups();
|
||||||
|
|
||||||
|
if (isEditTool(chunk)) {
|
||||||
|
items.push({ type: "edit-item", key: `tool-${chunk.toolCallId}`, chunk });
|
||||||
|
} else if (isShellTool(chunk)) {
|
||||||
|
items.push({ type: "shell-item", key: `tool-${chunk.toolCallId}`, chunk });
|
||||||
|
} else if (isReplyTool(chunk)) {
|
||||||
|
items.push({ type: "reply-item", key: `tool-${chunk.toolCallId}`, chunk });
|
||||||
|
} else {
|
||||||
|
items.push({ type: "tool-item", key: `tool-${chunk.toolCallId}`, chunk });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.kind === "text" && !chunk.text.trim()) continue;
|
||||||
|
|
||||||
|
if (chunk.kind === "reasoning") {
|
||||||
|
flushGroups();
|
||||||
|
items.push({
|
||||||
|
type: "reasoning-item",
|
||||||
|
key: messageId ? `${messageId}-${getChunkRenderKey(chunk, i)}` : getChunkRenderKey(chunk, i),
|
||||||
|
chunk,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushGroups();
|
||||||
|
items.push({
|
||||||
|
type: "text-chunk",
|
||||||
|
key: messageId ? `${messageId}-${getChunkRenderKey(chunk, i)}` : getChunkRenderKey(chunk, i),
|
||||||
|
chunk,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
flushGroups();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeExploration(chunks: ToolExecutionChunk[]): string {
|
||||||
|
const count = chunks.length;
|
||||||
|
return `Explored ${count} file${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
51
ui/src/components/agents/messages/summarizeChangedFiles.ts
Normal file
51
ui/src/components/agents/messages/summarizeChangedFiles.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { countLineChanges } from "@/components/agents/utils/diffStats";
|
||||||
|
import type { Chunk, DiffData } from "@/lib/agents/types";
|
||||||
|
|
||||||
|
import type { ChangedFileSummaryItem } from "./types";
|
||||||
|
|
||||||
|
export function summarizeChangedFiles(chunks: Chunk[]): ChangedFileSummaryItem[] {
|
||||||
|
const byFile = new Map<string, { filePath: string; originalContent: string | null; modifiedContent: string }>();
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (chunk.kind !== "tool-execution") continue;
|
||||||
|
if (chunk.status !== "completed") continue;
|
||||||
|
const diffEntries = chunk.diffs?.length ? chunk.diffs : (chunk.diffData ? [chunk.diffData] : []);
|
||||||
|
if (diffEntries.length === 0) continue;
|
||||||
|
|
||||||
|
for (const diffData of diffEntries as DiffData[]) {
|
||||||
|
const existing = byFile.get(diffData.filePath);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
byFile.set(diffData.filePath, {
|
||||||
|
filePath: diffData.filePath,
|
||||||
|
originalContent: diffData.originalContent,
|
||||||
|
modifiedContent: diffData.newContent,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
byFile.set(diffData.filePath, {
|
||||||
|
filePath: existing.filePath,
|
||||||
|
originalContent: existing.originalContent,
|
||||||
|
modifiedContent: diffData.newContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byFile.values()]
|
||||||
|
.map((file) => {
|
||||||
|
const { additions, deletions } = countLineChanges(
|
||||||
|
file.originalContent,
|
||||||
|
file.modifiedContent,
|
||||||
|
file.filePath,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
filePath: file.filePath,
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
originalContent: file.originalContent ?? "",
|
||||||
|
modifiedContent: file.modifiedContent,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
||||||
|
}
|
||||||
40
ui/src/components/agents/messages/types.ts
Normal file
40
ui/src/components/agents/messages/types.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Message, Project } from "@/lib/agents/types";
|
||||||
|
|
||||||
|
export interface ChangedFileSummaryItem {
|
||||||
|
filePath: string;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
originalContent: string;
|
||||||
|
modifiedContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalCallbacks {
|
||||||
|
onApprove?: (approvalRequestId: string) => void;
|
||||||
|
onReject?: (approvalRequestId: string) => void;
|
||||||
|
onAutoApprove?: (approvalRequestId: string) => void;
|
||||||
|
onOpenDiff?: (diffData: { filePath: string; originalContent: string; modifiedContent: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessagesScrollControl = {
|
||||||
|
scrollToBottom: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MessagesProps extends ApprovalCallbacks {
|
||||||
|
messages: Message[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
/** Live run signal from `useStream().isLoading` — drives Streamdown token animation. */
|
||||||
|
streamIsLoading?: boolean;
|
||||||
|
/** When set, drives the thinking spinner (stream + pending). Falls back to streamIsLoading/isStreaming. */
|
||||||
|
isThinking?: boolean;
|
||||||
|
settingUpSandbox?: boolean;
|
||||||
|
project?: Project | null;
|
||||||
|
contentWidthClass?: string;
|
||||||
|
/** Horizontal padding on centered content (scroll track stays edge-to-edge). */
|
||||||
|
contentPaddingClass?: string;
|
||||||
|
/** Extra scroll padding so content can scroll under a bottom overlay (e.g. floating prompt). */
|
||||||
|
bottomInset?: number;
|
||||||
|
/** When "external", parent renders the scroll button (e.g. above a floating prompt). */
|
||||||
|
scrollButtonSlot?: "internal" | "external";
|
||||||
|
onShowScrollToBottomChange?: (show: boolean) => void;
|
||||||
|
scrollControlRef?: React.MutableRefObject<MessagesScrollControl | null>;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useRef, useEffect, useState, forwardRef } from "react";
|
import { useCallback, useRef, useEffect, useState, forwardRef } from "react";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { MessageView, summarizeChangedFiles } from "./MessageView";
|
import { Messages, summarizeChangedFiles } from "@/components/agents/messages";
|
||||||
import { PromptBar } from "./PromptBar";
|
import { PromptBar } from "./PromptBar";
|
||||||
import { TodoList } from "./TodoList";
|
import { TodoList } from "./TodoList";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
|
@ -408,7 +408,7 @@ export function ChatView({ tabId }: ChatViewProps) {
|
||||||
onClick={handleContainerClick}
|
onClick={handleContainerClick}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
>
|
>
|
||||||
<MessageView
|
<Messages
|
||||||
messages={session!.messages}
|
messages={session!.messages}
|
||||||
isStreaming={session!.isStreaming}
|
isStreaming={session!.isStreaming}
|
||||||
contentWidthClass={MESSAGE_CONTENT_WIDTH}
|
contentWidthClass={MESSAGE_CONTENT_WIDTH}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import {
|
import { ArrowUp, ChevronDown, ImagePlus, LoaderCircle, X } from "lucide-react"
|
||||||
ArrowUp,
|
import { StopIcon } from "@phosphor-icons/react"
|
||||||
ChevronDown,
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
ImagePlus,
|
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||||
LoaderCircle,
|
|
||||||
Square,
|
|
||||||
X,
|
|
||||||
} from "lucide-react"
|
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -18,12 +14,89 @@ import {
|
||||||
|
|
||||||
import type { ModelOption } from "@/lib/api"
|
import type { ModelOption } from "@/lib/api"
|
||||||
import type { ImageChunk } from "@/lib/agents/types"
|
import type { ImageChunk } from "@/lib/agents/types"
|
||||||
import type { ModelSelection } from "@/lib/agents/useModelOptions"
|
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||||
import { RepoSelector } from "@/components/agents/RepoSelector"
|
import { RepoSelector } from "@/components/agents/RepoSelector"
|
||||||
import { formatModelSelection } from "@/lib/agents/useModelOptions"
|
import { useIsInAgentThreadStream } from "@/lib/agents/provider/useIsInAgentThreadStream"
|
||||||
|
import { agentThreadKeys } from "@/lib/agents/queries"
|
||||||
|
import { formatModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||||
|
import { IconButton } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const PROMPT_TEXTAREA_MAX_HEIGHT = 200
|
const PROMPT_TEXTAREA_MAX_HEIGHT = 200
|
||||||
|
|
||||||
|
interface SubmitButtonProps {
|
||||||
|
canSubmit: boolean
|
||||||
|
disabled: boolean
|
||||||
|
onSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlainSubmitButton({ canSubmit, disabled, onSubmit }: SubmitButtonProps) {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
aria-label="Send message"
|
||||||
|
className="shrink-0 rounded-full bg-[var(--ui-accent)] text-white hover:bg-[var(--ui-accent)] hover:opacity-90 disabled:cursor-default disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{disabled ? (
|
||||||
|
<LoaderCircle className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowUp className="size-3.5" strokeWidth={2.5} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubmitButton(props: SubmitButtonProps) {
|
||||||
|
const inAgentThreadStream = useIsInAgentThreadStream()
|
||||||
|
|
||||||
|
if (inAgentThreadStream) return <StreamSubmitButton {...props} />
|
||||||
|
|
||||||
|
return <PlainSubmitButton {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamSubmitButton(props: SubmitButtonProps) {
|
||||||
|
const stream = useAgentThreadStream()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [stopping, setStopping] = useState(false)
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
if (stopping) return
|
||||||
|
setStopping(true)
|
||||||
|
try {
|
||||||
|
await stream.stop()
|
||||||
|
const threadId = stream.threadId
|
||||||
|
if (threadId) {
|
||||||
|
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
||||||
|
prev ? { ...prev, status: "interrupted" as const } : prev
|
||||||
|
)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setStopping(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stream.isLoading) return <PlainSubmitButton {...props} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleStop()}
|
||||||
|
disabled={stopping}
|
||||||
|
aria-label="Stop run"
|
||||||
|
title="Stop run"
|
||||||
|
className="shrink-0 rounded-full bg-[var(--ui-accent)] text-white hover:bg-[var(--ui-accent)] hover:opacity-90 disabled:cursor-default disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{stopping ? (
|
||||||
|
<LoaderCircle className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<StopIcon className="size-3.5" weight="fill" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
const MAX_IMAGE_COUNT = 5
|
const MAX_IMAGE_COUNT = 5
|
||||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
const SUPPORTED_IMAGE_TYPES = new Set([
|
const SUPPORTED_IMAGE_TYPES = new Set([
|
||||||
|
|
@ -38,10 +111,6 @@ export interface CloudPromptBarProps {
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
/** When true, a stop button is shown next to send to cancel the active run. */
|
|
||||||
canCancel?: boolean
|
|
||||||
cancelling?: boolean
|
|
||||||
onCancel?: () => void
|
|
||||||
onSubmit?: (value: string, images: Array<ImageChunk>) => void
|
onSubmit?: (value: string, images: Array<ImageChunk>) => void
|
||||||
models?: Array<ModelOption>
|
models?: Array<ModelOption>
|
||||||
selection?: ModelSelection | null
|
selection?: ModelSelection | null
|
||||||
|
|
@ -65,11 +134,11 @@ function fileToImageChunk(file: File): Promise<ImageChunk | null> {
|
||||||
resolve(
|
resolve(
|
||||||
base64
|
base64
|
||||||
? {
|
? {
|
||||||
kind: "image",
|
kind: "image",
|
||||||
base64,
|
base64,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -84,9 +153,6 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
||||||
compact = false,
|
compact = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
busy = false,
|
busy = false,
|
||||||
canCancel = false,
|
|
||||||
cancelling = false,
|
|
||||||
onCancel,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
models = [],
|
models = [],
|
||||||
selection = null,
|
selection = null,
|
||||||
|
|
@ -360,40 +426,11 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
||||||
<ImagePlus className="size-4" />
|
<ImagePlus className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{canCancel && !canSubmit ? (
|
<SubmitButton
|
||||||
<button
|
canSubmit={canSubmit}
|
||||||
type="button"
|
disabled={disabled}
|
||||||
onClick={onCancel}
|
onSubmit={handleSubmit}
|
||||||
disabled={cancelling}
|
/>
|
||||||
aria-label="Cancel run"
|
|
||||||
title="Cancel run"
|
|
||||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--ui-accent)] text-white transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{cancelling ? (
|
|
||||||
<LoaderCircle className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Square
|
|
||||||
className="size-3"
|
|
||||||
fill="currentColor"
|
|
||||||
strokeWidth={0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
aria-label="Send message"
|
|
||||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--ui-accent)] text-white transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{disabled ? (
|
|
||||||
<LoaderCircle className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ArrowUp className="size-3.5" strokeWidth={2.5} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,233 +1,21 @@
|
||||||
// @ts-nocheck — ported from open-swe-app (Electron).
|
import { useMemo } from "react";
|
||||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||||
import { diffLines } from 'diff';
|
import { diffOptions } from "@/components/agents/utils/diffUtils";
|
||||||
import { getSingletonHighlighter, type ThemedToken } from 'shiki';
|
import { countLineChanges } from "@/components/agents/utils/diffStats";
|
||||||
import type { DiffData } from '@/lib/agents/types';
|
import type { DiffData } from "@/lib/agents/types";
|
||||||
|
|
||||||
interface DiffViewProps {
|
interface DiffViewProps {
|
||||||
diffData: DiffData;
|
diffData: DiffData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map file extensions to shiki language IDs
|
|
||||||
function getLanguageFromPath(filePath: string): string {
|
|
||||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
|
||||||
py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
|
|
||||||
kt: 'kotlin', swift: 'swift', c: 'c', cpp: 'cpp', cs: 'csharp',
|
|
||||||
html: 'html', css: 'css', scss: 'scss', json: 'json',
|
|
||||||
yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown', mdx: 'mdx',
|
|
||||||
sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'fish', sql: 'sql',
|
|
||||||
xml: 'xml', php: 'php', r: 'r', lua: 'lua', ex: 'elixir',
|
|
||||||
exs: 'elixir', elm: 'elm', clj: 'clojure', hs: 'haskell',
|
|
||||||
scala: 'scala', vue: 'vue', svelte: 'svelte', graphql: 'graphql',
|
|
||||||
tf: 'hcl', hcl: 'hcl', ini: 'ini',
|
|
||||||
};
|
|
||||||
const filename = filePath.split('/').pop()?.toLowerCase() ?? '';
|
|
||||||
if (filename === 'dockerfile') return 'dockerfile';
|
|
||||||
if (filename === 'makefile') return 'makefile';
|
|
||||||
return map[ext] ?? 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlighted line cache: maps "lang::lineText" -> ThemedToken[]
|
|
||||||
type TokenCache = Map<string, ThemedToken[]>;
|
|
||||||
|
|
||||||
// Renders a line of text as highlighted token spans, falling back to plain text
|
|
||||||
function HighlightedLine({ tokens, fallback, isAdd, isRemove }: {
|
|
||||||
tokens: ThemedToken[] | null;
|
|
||||||
fallback: string;
|
|
||||||
isAdd: boolean;
|
|
||||||
isRemove: boolean;
|
|
||||||
}) {
|
|
||||||
if (!tokens) {
|
|
||||||
return (
|
|
||||||
<span className={isAdd ? 'text-[#a5d6a7]' : isRemove ? 'text-[#ffb4ab]' : 'text-gray-400'}>
|
|
||||||
{fallback}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{tokens.map((token, i) => (
|
|
||||||
<span key={i} style={{ color: token.color }}>
|
|
||||||
{token.content}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTEXT_LINES = 3;
|
|
||||||
const MAX_COLLAPSED_LINES = 20;
|
|
||||||
|
|
||||||
type DiffLineData = {
|
|
||||||
type: 'context' | 'remove' | 'add' | 'separator';
|
|
||||||
text: string;
|
|
||||||
oldLineNum?: number;
|
|
||||||
newLineNum?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function toLineArray(text: string): string[] {
|
|
||||||
if (text.length === 0) return [];
|
|
||||||
const lines = text.split('\n');
|
|
||||||
if (text.endsWith('\n')) lines.pop();
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeDiffLines(
|
|
||||||
originalContent: string | null,
|
|
||||||
newContent: string
|
|
||||||
): DiffLineData[] {
|
|
||||||
const result: DiffLineData[] = [];
|
|
||||||
const parts = diffLines(originalContent ?? '', newContent, {
|
|
||||||
ignoreWhitespace: false,
|
|
||||||
newlineIsToken: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let oldLineNum = 1;
|
|
||||||
let newLineNum = 1;
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
const lines = toLineArray(part.value);
|
|
||||||
|
|
||||||
if (part.added) {
|
|
||||||
for (const line of lines) {
|
|
||||||
result.push({
|
|
||||||
type: 'add',
|
|
||||||
text: line,
|
|
||||||
newLineNum,
|
|
||||||
});
|
|
||||||
newLineNum += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.removed) {
|
|
||||||
for (const line of lines) {
|
|
||||||
result.push({
|
|
||||||
type: 'remove',
|
|
||||||
text: line,
|
|
||||||
oldLineNum,
|
|
||||||
});
|
|
||||||
oldLineNum += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
result.push({
|
|
||||||
type: 'context',
|
|
||||||
text: line,
|
|
||||||
oldLineNum,
|
|
||||||
newLineNum,
|
|
||||||
});
|
|
||||||
oldLineNum += 1;
|
|
||||||
newLineNum += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterToHunks(lines: DiffLineData[], contextLines: number = CONTEXT_LINES): DiffLineData[] {
|
|
||||||
const changeIndices: number[] = [];
|
|
||||||
lines.forEach((line, idx) => {
|
|
||||||
if (line.type === 'add' || line.type === 'remove') {
|
|
||||||
changeIndices.push(idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (changeIndices.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const includeSet = new Set<number>();
|
|
||||||
for (const idx of changeIndices) {
|
|
||||||
for (let i = Math.max(0, idx - contextLines); i <= Math.min(lines.length - 1, idx + contextLines); i++) {
|
|
||||||
includeSet.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: DiffLineData[] = [];
|
|
||||||
let lastIncluded = -2;
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (includeSet.has(i)) {
|
|
||||||
if (lastIncluded >= 0 && i - lastIncluded > 1) {
|
|
||||||
result.push({ type: 'separator', text: '···' });
|
|
||||||
}
|
|
||||||
result.push(lines[i]);
|
|
||||||
lastIncluded = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DiffView({ diffData }: DiffViewProps) {
|
export function DiffView({ diffData }: DiffViewProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const { originalContent, newContent, filePath, isBinary } = diffData;
|
||||||
const [tokenCache, setTokenCache] = useState<TokenCache | null>(null);
|
const displayPath = filePath.split("/").pop() || filePath;
|
||||||
const highlightingRef = useRef(false);
|
const stats = useMemo(
|
||||||
|
() => countLineChanges(originalContent, newContent, filePath),
|
||||||
const { originalContent, newContent, filePath, isNewFile, isBinary } = diffData;
|
[filePath, newContent, originalContent],
|
||||||
const language = getLanguageFromPath(filePath);
|
|
||||||
|
|
||||||
const allDiffLines = useMemo(
|
|
||||||
() => computeDiffLines(originalContent, newContent),
|
|
||||||
[originalContent, newContent]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const hunkLines = useMemo(
|
|
||||||
() => filterToHunks(allDiffLines),
|
|
||||||
[allDiffLines]
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
let additions = 0;
|
|
||||||
let deletions = 0;
|
|
||||||
for (const line of allDiffLines) {
|
|
||||||
if (line.type === 'add') additions++;
|
|
||||||
else if (line.type === 'remove') deletions++;
|
|
||||||
}
|
|
||||||
return { additions, deletions };
|
|
||||||
}, [allDiffLines]);
|
|
||||||
|
|
||||||
// Tokenize all visible lines for syntax highlighting
|
|
||||||
useEffect(() => {
|
|
||||||
if (language === 'text' || isBinary) return;
|
|
||||||
if (highlightingRef.current) return;
|
|
||||||
highlightingRef.current = true;
|
|
||||||
|
|
||||||
const linesToHighlight = hunkLines.filter(l => l.type !== 'separator');
|
|
||||||
|
|
||||||
getSingletonHighlighter({
|
|
||||||
themes: ['github-dark'],
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
langs: [language as any],
|
|
||||||
}).then(highlighter => {
|
|
||||||
const cache: TokenCache = new Map();
|
|
||||||
for (const line of linesToHighlight) {
|
|
||||||
const cacheKey = `${language}::${line.text}`;
|
|
||||||
if (cache.has(cacheKey)) continue;
|
|
||||||
try {
|
|
||||||
const result = highlighter.codeToTokens(line.text, {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
lang: language as any,
|
|
||||||
theme: 'github-dark',
|
|
||||||
});
|
|
||||||
cache.set(cacheKey, result.tokens[0] ?? []);
|
|
||||||
} catch {
|
|
||||||
// Skip lines that fail tokenization
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTokenCache(cache);
|
|
||||||
highlightingRef.current = false;
|
|
||||||
}).catch((err: unknown) => {
|
|
||||||
console.warn('[diff-view] Syntax highlighting failed:', err);
|
|
||||||
highlightingRef.current = false;
|
|
||||||
});
|
|
||||||
}, [hunkLines, language, isBinary]);
|
|
||||||
|
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 text-gray-500 text-xs font-mono">
|
<div className="mt-2 text-gray-500 text-xs font-mono">
|
||||||
|
|
@ -236,11 +24,7 @@ export function DiffView({ diffData }: DiffViewProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayLines = expanded ? hunkLines : hunkLines.slice(0, MAX_COLLAPSED_LINES);
|
if (stats.additions === 0 && stats.deletions === 0) {
|
||||||
const hasMoreLines = hunkLines.length > MAX_COLLAPSED_LINES;
|
|
||||||
const hiddenCount = hunkLines.length - MAX_COLLAPSED_LINES;
|
|
||||||
|
|
||||||
if (hunkLines.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 text-gray-500 text-xs font-mono">
|
<div className="mt-2 text-gray-500 text-xs font-mono">
|
||||||
No changes
|
No changes
|
||||||
|
|
@ -251,66 +35,18 @@ export function DiffView({ diffData }: DiffViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 font-mono text-xs">
|
<div className="mt-2 font-mono text-xs">
|
||||||
<div className="flex items-center gap-2 text-gray-500 mb-1">
|
<div className="flex items-center gap-2 text-gray-500 mb-1">
|
||||||
<span className="text-gray-400">{filePath.split('/').pop()}</span>
|
<span className="text-gray-400">{displayPath}</span>
|
||||||
{isNewFile && <span>(new)</span>}
|
{diffData.isNewFile && <span>(new)</span>}
|
||||||
<span className="text-green-400">+{stats.additions}</span>
|
<span className="text-green-400">+{stats.additions}</span>
|
||||||
<span className="text-red-400">-{stats.deletions}</span>
|
<span className="text-red-400">-{stats.deletions}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="max-h-60 overflow-auto rounded-lg border border-[var(--ui-border-subtle)] bg-[var(--ui-panel)]">
|
||||||
<div className="max-h-60 overflow-auto border-l border-gray-700 pl-2">
|
<MultiFileDiff
|
||||||
{displayLines.map((line, idx) => {
|
oldFile={{ name: displayPath, contents: originalContent ?? "" }}
|
||||||
if (line.type === 'separator') {
|
newFile={{ name: displayPath, contents: newContent }}
|
||||||
return (
|
options={diffOptions}
|
||||||
<div key={idx} className="text-gray-600 py-0.5">
|
/>
|
||||||
{line.text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAdd = line.type === 'add';
|
|
||||||
const isRemove = line.type === 'remove';
|
|
||||||
const cacheKey = `${language}::${line.text}`;
|
|
||||||
const tokens = tokenCache?.get(cacheKey) ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={`whitespace-pre ${
|
|
||||||
isAdd ? 'bg-[#12261a]' :
|
|
||||||
isRemove ? 'bg-[#2d1a1f]' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-gray-600 w-8 inline-block text-right pr-2">
|
|
||||||
{line.oldLineNum || line.newLineNum || ''}
|
|
||||||
</span>
|
|
||||||
<span className={`w-4 inline-block ${
|
|
||||||
isAdd ? 'text-[#3fb950]' :
|
|
||||||
isRemove ? 'text-[#f85149]' : 'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{isAdd ? '+' : isRemove ? '-' : ' '}
|
|
||||||
</span>
|
|
||||||
<HighlightedLine tokens={tokens} fallback={line.text} isAdd={isAdd} isRemove={isRemove} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasMoreLines && !expanded && (
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(true)}
|
|
||||||
className="mt-1 text-xs text-[#87CEEB] hover:text-[#a8d8ea]"
|
|
||||||
>
|
|
||||||
+{hiddenCount} more lines
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{expanded && hasMoreLines && (
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
className="mt-1 text-xs text-[#87CEEB] hover:text-[#a8d8ea]"
|
|
||||||
>
|
|
||||||
Show less
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,104 @@
|
||||||
import type { ReactNode } from 'react';
|
import { memo, type ReactNode } from "react";
|
||||||
import ReactMarkdown from 'react-markdown';
|
import { Streamdown } from "streamdown";
|
||||||
import remarkGfm from 'remark-gfm';
|
import "streamdown/styles.css";
|
||||||
import { CodeBlock } from './CodeBlock';
|
|
||||||
import { MarkdownTable } from './MarkdownTable';
|
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
/** When true, keep Streamdown in streaming mode for the duration of the run. */
|
||||||
|
isLive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractText(children: ReactNode): string {
|
/**
|
||||||
return String(children);
|
* Must stay referentially and structurally stable while a message is streaming.
|
||||||
}
|
* Streamdown keys its animate plugin on JSON.stringify(animated); changing
|
||||||
|
* stagger/duration recreates the plugin, resets prevContentLength, and
|
||||||
|
* re-animates text that was already on screen.
|
||||||
|
*/
|
||||||
|
const STREAMDOWN_ANIMATED = {
|
||||||
|
sep: "word",
|
||||||
|
animation: "slideUp",
|
||||||
|
duration: 60,
|
||||||
|
stagger: 10,
|
||||||
|
easing: "ease-out",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function Markdown({ content }: MarkdownProps) {
|
const STREAMDOWN_COMPONENTS = {
|
||||||
|
h1: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<div className="text-[color:var(--ui-accent)] text-[20px] font-semibold mt-4 mb-2 tracking-tight">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
h2: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<div className="text-[color:var(--ui-accent)] text-[17px] font-semibold mt-3 mb-2 tracking-tight">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
h3: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<div className="text-[color:var(--ui-accent)] text-[15px] font-semibold mt-3 mb-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
p: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<p className="my-1.5 text-[color:var(--ui-text)] break-words [overflow-wrap:anywhere]">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
a: ({ href, children }: { href?: string; children?: ReactNode }) => (
|
||||||
|
<a
|
||||||
|
className="text-[color:var(--ui-accent)] underline decoration-[color:var(--ui-accent)]/50 break-words [overflow-wrap:anywhere]"
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
ul: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<div className="my-1.5 ml-4 min-w-0">{children}</div>
|
||||||
|
),
|
||||||
|
ol: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<div className="my-1.5 ml-4 min-w-0">{children}</div>
|
||||||
|
),
|
||||||
|
li: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<div className="text-[color:var(--ui-text)] break-words [overflow-wrap:anywhere] [&>p]:inline [&>p]:my-0">
|
||||||
|
- {children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
blockquote: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<div className="my-2 border-l-2 border-[var(--ui-border)] pl-3 text-[color:var(--ui-text-muted)] break-words [overflow-wrap:anywhere]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
hr: () => <hr className="border-[var(--ui-border-subtle)] my-3" />,
|
||||||
|
code: ({ className, children }: { className?: string; children?: ReactNode }) => {
|
||||||
|
const text = String(children);
|
||||||
|
const match = /language-([^\s]+)/.exec(className || "");
|
||||||
|
const isBlock = match || text.includes("\n");
|
||||||
|
if (isBlock) return <code className={className}>{children}</code>;
|
||||||
|
return (
|
||||||
|
<code className="rounded-md bg-[var(--ui-panel-2)] px-1.5 py-0.5 font-mono text-[0.85em] text-[color:var(--ui-accent)] whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||||
|
{text}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHIKI_THEME: ["github-light", "github-dark"] = ["github-light", "github-dark"];
|
||||||
|
|
||||||
|
export const Markdown = memo(function Markdown({ content, isLive = false }: MarkdownProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 max-w-full text-[13px] leading-6 break-words [overflow-wrap:anywhere]">
|
<div className="min-w-0 max-w-full text-[13px] leading-6 break-words [overflow-wrap:anywhere] [&_.streamdown]:text-[color:var(--ui-text)]">
|
||||||
<ReactMarkdown
|
<Streamdown
|
||||||
remarkPlugins={[remarkGfm]}
|
mode={isLive ? "streaming" : "static"}
|
||||||
components={{
|
parseIncompleteMarkdown={isLive}
|
||||||
h1: ({ children }) => <div className="text-[color:var(--ui-accent)] text-[20px] font-semibold mt-4 mb-2 tracking-tight">{children}</div>,
|
isAnimating={isLive}
|
||||||
h2: ({ children }) => <div className="text-[color:var(--ui-accent)] text-[17px] font-semibold mt-3 mb-2 tracking-tight">{children}</div>,
|
animated={isLive ? STREAMDOWN_ANIMATED : false}
|
||||||
h3: ({ children }) => <div className="text-[color:var(--ui-accent)] text-[15px] font-semibold mt-3 mb-1">{children}</div>,
|
shikiTheme={SHIKI_THEME}
|
||||||
p: ({ children }) => <p className="my-1.5 text-[color:var(--ui-text)] break-words [overflow-wrap:anywhere]">{children}</p>,
|
className="streamdown-agent min-w-0 max-w-full"
|
||||||
a: ({ href, children }) => (
|
components={STREAMDOWN_COMPONENTS}
|
||||||
<a className="text-[color:var(--ui-accent)] underline decoration-[color:var(--ui-accent)]/50 break-words [overflow-wrap:anywhere]" href={href} target="_blank" rel="noreferrer">
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
ul: ({ children }) => <div className="my-1.5 ml-4 min-w-0">{children}</div>,
|
|
||||||
ol: ({ children }) => <div className="my-1.5 ml-4 min-w-0">{children}</div>,
|
|
||||||
li: ({ children }) => <div className="text-[color:var(--ui-text)] break-words [overflow-wrap:anywhere] [&>p]:inline [&>p]:my-0">- {children}</div>,
|
|
||||||
blockquote: ({ children }) => (
|
|
||||||
<div className="my-2 border-l-2 border-[var(--ui-border)] pl-3 text-[color:var(--ui-text-muted)] break-words [overflow-wrap:anywhere]">{children}</div>
|
|
||||||
),
|
|
||||||
hr: () => <hr className="border-[var(--ui-border-subtle)] my-3" />,
|
|
||||||
table: ({ children }) => <MarkdownTable>{children}</MarkdownTable>,
|
|
||||||
thead: ({ children }) => <thead className="border-b border-[var(--ui-border)]">{children}</thead>,
|
|
||||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
|
||||||
tr: ({ children }) => <tr>{children}</tr>,
|
|
||||||
th: ({ children }) => <th className="px-3 py-1 text-left text-[color:var(--ui-accent)] break-words [overflow-wrap:anywhere]">{children}</th>,
|
|
||||||
td: ({ children }) => <td className="px-3 py-1 text-[color:var(--ui-text-muted)] break-words [overflow-wrap:anywhere]">{children}</td>,
|
|
||||||
code: ({ className, children }) => {
|
|
||||||
const text = extractText(children);
|
|
||||||
const match = /language-([^\s]+)/.exec(className || '');
|
|
||||||
const isBlock = match || text.includes('\n');
|
|
||||||
if (isBlock) {
|
|
||||||
return <CodeBlock text={text.replace(/\n$/, '')} language={match?.[1]} />;
|
|
||||||
}
|
|
||||||
return <code className="rounded-md bg-[var(--ui-panel-2)] px-1.5 py-0.5 font-mono text-[0.85em] text-[color:var(--ui-accent)] whitespace-pre-wrap break-words [overflow-wrap:anywhere]">{text}</code>;
|
|
||||||
},
|
|
||||||
pre: ({ children }) => <>{children}</>,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</Streamdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,926 +0,0 @@
|
||||||
// @ts-nocheck — ported from open-swe-app (Electron); strict checks applied when wiring cloud APIs.
|
|
||||||
import { useRef, useEffect, useLayoutEffect, useCallback, memo, useMemo, useState } from "react";
|
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
||||||
import { diffLines } from "diff";
|
|
||||||
import { MultiFileDiff } from "@pierre/diffs/react";
|
|
||||||
import { useDiffOptions } from "@/components/agents/utils/diffUtils";
|
|
||||||
import { CodeBlock } from "./CodeBlock";
|
|
||||||
import { Markdown } from "./Markdown";
|
|
||||||
import { ToolExecution } from "./ToolExecution";
|
|
||||||
import { ShellCommand } from "./ShellCommand";
|
|
||||||
import { ReplyCard } from "./ReplyCard";
|
|
||||||
import type {
|
|
||||||
Chunk,
|
|
||||||
Message,
|
|
||||||
ToolExecutionChunk,
|
|
||||||
Project,
|
|
||||||
DiffData,
|
|
||||||
} from "@/lib/agents/types";
|
|
||||||
|
|
||||||
type RenderItem =
|
|
||||||
| { type: "text-chunk"; key: string; chunk: Chunk }
|
|
||||||
| { type: "explored-group"; key: string; id: string; chunks: ToolExecutionChunk[] }
|
|
||||||
| { type: "edit-item"; key: string; chunk: ToolExecutionChunk }
|
|
||||||
| { type: "shell-item"; key: string; chunk: ToolExecutionChunk }
|
|
||||||
| { type: "reply-item"; key: string; chunk: ToolExecutionChunk }
|
|
||||||
| { type: "tool-item"; key: string; chunk: ToolExecutionChunk };
|
|
||||||
|
|
||||||
function getChunkRenderKey(chunk: Chunk, sourceIndex: number): string {
|
|
||||||
switch (chunk.kind) {
|
|
||||||
case "tool-execution":
|
|
||||||
return `tool-${chunk.toolCallId}`;
|
|
||||||
case "text":
|
|
||||||
return `text-${sourceIndex}`;
|
|
||||||
case "code":
|
|
||||||
return `code-${sourceIndex}`;
|
|
||||||
case "error":
|
|
||||||
return `error-${sourceIndex}`;
|
|
||||||
case "list":
|
|
||||||
return `list-${sourceIndex}`;
|
|
||||||
case "image":
|
|
||||||
return `image-${sourceIndex}`;
|
|
||||||
default:
|
|
||||||
return `chunk-${sourceIndex}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditTool(chunk: ToolExecutionChunk): boolean {
|
|
||||||
const kind = chunk.toolKind;
|
|
||||||
if (kind === "edit" || kind === "delete" || kind === "move") return true;
|
|
||||||
if (chunk.diffs?.length) return true;
|
|
||||||
if (chunk.diffData) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExplorationTool(chunk: ToolExecutionChunk): boolean {
|
|
||||||
if (chunk.diffs?.length) return false;
|
|
||||||
if (chunk.diffData) return false;
|
|
||||||
const kind = chunk.toolKind;
|
|
||||||
return kind === "read" || kind === "search";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isShellTool(chunk: ToolExecutionChunk): boolean {
|
|
||||||
return chunk.toolKind === "execute";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReplyTool(chunk: ToolExecutionChunk): boolean {
|
|
||||||
return chunk.toolKind === "slack" || chunk.toolKind === "linear";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRenderItems(chunks: Chunk[]): RenderItem[] {
|
|
||||||
const items: RenderItem[] = [];
|
|
||||||
let exploredBuffer: ToolExecutionChunk[] = [];
|
|
||||||
let exploredStartIndex = -1;
|
|
||||||
|
|
||||||
const flushExplored = () => {
|
|
||||||
if (exploredBuffer.length === 0) return;
|
|
||||||
const firstId = exploredBuffer[0]?.toolCallId;
|
|
||||||
const id = `explored-${firstId || exploredStartIndex}`;
|
|
||||||
items.push({
|
|
||||||
type: "explored-group",
|
|
||||||
key: id,
|
|
||||||
id,
|
|
||||||
chunks: [...exploredBuffer],
|
|
||||||
});
|
|
||||||
exploredBuffer = [];
|
|
||||||
exploredStartIndex = -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length; i += 1) {
|
|
||||||
const chunk = chunks[i];
|
|
||||||
|
|
||||||
if (chunk.kind === "tool-execution") {
|
|
||||||
if (isExplorationTool(chunk)) {
|
|
||||||
if (exploredBuffer.length === 0) exploredStartIndex = i;
|
|
||||||
exploredBuffer.push(chunk);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
flushExplored();
|
|
||||||
|
|
||||||
if (isEditTool(chunk)) {
|
|
||||||
items.push({ type: "edit-item", key: `tool-${chunk.toolCallId}`, chunk });
|
|
||||||
} else if (isShellTool(chunk)) {
|
|
||||||
items.push({ type: "shell-item", key: `tool-${chunk.toolCallId}`, chunk });
|
|
||||||
} else if (isReplyTool(chunk)) {
|
|
||||||
items.push({ type: "reply-item", key: `tool-${chunk.toolCallId}`, chunk });
|
|
||||||
} else {
|
|
||||||
items.push({ type: "tool-item", key: `tool-${chunk.toolCallId}`, chunk });
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk.kind === "text" && !chunk.text.trim()) continue;
|
|
||||||
|
|
||||||
flushExplored();
|
|
||||||
items.push({
|
|
||||||
type: "text-chunk",
|
|
||||||
key: getChunkRenderKey(chunk, i),
|
|
||||||
chunk,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
flushExplored();
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeExploration(chunks: ToolExecutionChunk[]): string {
|
|
||||||
const count = chunks.length;
|
|
||||||
return `Explored ${count} file${count === 1 ? "" : "s"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChangedFileSummaryItem {
|
|
||||||
filePath: string;
|
|
||||||
additions: number;
|
|
||||||
deletions: number;
|
|
||||||
originalContent: string;
|
|
||||||
modifiedContent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countLines(text: string): number {
|
|
||||||
if (text.length === 0) return 0;
|
|
||||||
const segments = text.split("\n");
|
|
||||||
return text.endsWith("\n") ? segments.length - 1 : segments.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countLineChanges(originalContent: string | null, newContent: string): { additions: number; deletions: number } {
|
|
||||||
const before = originalContent ?? "";
|
|
||||||
const parts = diffLines(before, newContent, {
|
|
||||||
ignoreWhitespace: false,
|
|
||||||
newlineIsToken: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let additions = 0;
|
|
||||||
let deletions = 0;
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
const lineCount = countLines(part.value);
|
|
||||||
if (part.added) additions += lineCount;
|
|
||||||
else if (part.removed) deletions += lineCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { additions, deletions };
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripProjectPathForDisplay(path: string, projectPath?: string): string {
|
|
||||||
if (!projectPath) return path;
|
|
||||||
const normalizedPath = path.replace(/\\/g, "/");
|
|
||||||
const normalizedProjectPath = projectPath.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
||||||
if (!normalizedPath.startsWith(`${normalizedProjectPath}/`)) return path;
|
|
||||||
return normalizedPath.slice(normalizedProjectPath.length + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TurnChangedFilesCard = memo(function TurnChangedFilesCard({
|
|
||||||
files,
|
|
||||||
totals,
|
|
||||||
projectPath,
|
|
||||||
}: {
|
|
||||||
files: ChangedFileSummaryItem[];
|
|
||||||
totals: { additions: number; deletions: number };
|
|
||||||
projectPath?: string;
|
|
||||||
}) {
|
|
||||||
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
|
||||||
const diffOptions = useDiffOptions();
|
|
||||||
|
|
||||||
const toggleFile = useCallback((filePath: string) => {
|
|
||||||
setExpandedByPath((prev) => ({ ...prev, [filePath]: !prev[filePath] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3 rounded-xl border border-[var(--ui-border-subtle)] bg-[var(--ui-code-bubble)] overflow-hidden">
|
|
||||||
<div className="px-3 py-2 text-xs text-[color:var(--ui-text-muted)] border-b border-[var(--ui-border)] flex items-center gap-2">
|
|
||||||
<span>{files.length} file{files.length === 1 ? "" : "s"} changed</span>
|
|
||||||
<span className="text-green-400">+{totals.additions}</span>
|
|
||||||
<span className="text-red-400">-{totals.deletions}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{files.map((file) => {
|
|
||||||
const displayPath = stripProjectPathForDisplay(file.filePath, projectPath);
|
|
||||||
const open = !!expandedByPath[file.filePath];
|
|
||||||
const oldFile = { name: displayPath, contents: file.originalContent };
|
|
||||||
const newFile = { name: displayPath, contents: file.modifiedContent };
|
|
||||||
return (
|
|
||||||
<div key={file.filePath} className="border-b last:border-b-0 border-[var(--ui-border)]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleFile(file.filePath)}
|
|
||||||
className="w-full px-3 py-2 text-left hover:bg-[var(--ui-panel-2)] transition-colors flex items-center justify-between gap-3"
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
<span className="text-[13px] text-[color:var(--ui-accent)] truncate min-w-0">{displayPath}</span>
|
|
||||||
<span className="shrink-0 flex items-center gap-2">
|
|
||||||
<span className="text-xs flex items-center gap-2">
|
|
||||||
<span className="text-green-400">+{file.additions}</span>
|
|
||||||
<span className="text-red-400">-{file.deletions}</span>
|
|
||||||
</span>
|
|
||||||
{open ? (
|
|
||||||
<ChevronUp className="h-3.5 w-3.5 text-[color:var(--ui-text-dim)] shrink-0" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-[color:var(--ui-text-dim)] shrink-0" aria-hidden />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="px-2 pb-2">
|
|
||||||
<div className="rounded-lg bg-[var(--ui-panel)] overflow-hidden border border-[var(--ui-border-subtle)]">
|
|
||||||
<div className="max-h-[250px] overflow-auto">
|
|
||||||
<MultiFileDiff
|
|
||||||
oldFile={oldFile}
|
|
||||||
newFile={newFile}
|
|
||||||
options={diffOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export function summarizeChangedFiles(chunks: Chunk[]): ChangedFileSummaryItem[] {
|
|
||||||
const byFile = new Map<string, { filePath: string; originalContent: string | null; modifiedContent: string }>();
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
if (chunk.kind !== "tool-execution") continue;
|
|
||||||
if (chunk.status !== "completed") continue;
|
|
||||||
const diffEntries = chunk.diffs?.length ? chunk.diffs : (chunk.diffData ? [chunk.diffData] : []);
|
|
||||||
if (diffEntries.length === 0) continue;
|
|
||||||
|
|
||||||
for (const diffData of diffEntries as DiffData[]) {
|
|
||||||
const existing = byFile.get(diffData.filePath);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
byFile.set(diffData.filePath, {
|
|
||||||
filePath: diffData.filePath,
|
|
||||||
originalContent: diffData.originalContent,
|
|
||||||
modifiedContent: diffData.newContent,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
byFile.set(diffData.filePath, {
|
|
||||||
filePath: existing.filePath,
|
|
||||||
originalContent: existing.originalContent,
|
|
||||||
modifiedContent: diffData.newContent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...byFile.values()]
|
|
||||||
.map((file) => {
|
|
||||||
const { additions, deletions } = countLineChanges(file.originalContent, file.modifiedContent);
|
|
||||||
return {
|
|
||||||
filePath: file.filePath,
|
|
||||||
additions,
|
|
||||||
deletions,
|
|
||||||
originalContent: file.originalContent ?? "",
|
|
||||||
modifiedContent: file.modifiedContent,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApprovalCallbacks {
|
|
||||||
onApprove?: (approvalRequestId: string) => void;
|
|
||||||
onReject?: (approvalRequestId: string) => void;
|
|
||||||
onAutoApprove?: (approvalRequestId: string) => void;
|
|
||||||
onOpenDiff?: (diffData: { filePath: string; originalContent: string; modifiedContent: string }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MessageViewScrollControl = {
|
|
||||||
scrollToBottom: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MessageViewProps extends ApprovalCallbacks {
|
|
||||||
messages: Message[];
|
|
||||||
isStreaming: boolean;
|
|
||||||
settingUpSandbox?: boolean;
|
|
||||||
project?: Project | null;
|
|
||||||
contentWidthClass?: string;
|
|
||||||
/** Horizontal padding on centered content (scroll track stays edge-to-edge). */
|
|
||||||
contentPaddingClass?: string;
|
|
||||||
/** Extra scroll padding so content can scroll under a bottom overlay (e.g. floating prompt). */
|
|
||||||
bottomInset?: number;
|
|
||||||
/** When "external", parent renders the scroll button (e.g. above a floating prompt). */
|
|
||||||
scrollButtonSlot?: "internal" | "external";
|
|
||||||
onShowScrollToBottomChange?: (show: boolean) => void;
|
|
||||||
scrollControlRef?: React.MutableRefObject<MessageViewScrollControl | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BUSY_TEXTS: { present: string; past: string }[] = [
|
|
||||||
{ present: "vibing...", past: "Vibed" },
|
|
||||||
{ present: "noodling...", past: "Noodled" },
|
|
||||||
{ present: "pondering...", past: "Pondered" },
|
|
||||||
{ present: "thinking really hard...", past: "Thought really hard" },
|
|
||||||
{ present: "spinning up...", past: "Spun up" },
|
|
||||||
{ present: "connecting the dots...", past: "Connected the dots" },
|
|
||||||
{ present: "brewing ideas...", past: "Brewed ideas" },
|
|
||||||
{ present: "cooking...", past: "Cooked" },
|
|
||||||
{ present: "crunching...", past: "Crunched" },
|
|
||||||
{ present: "scheming...", past: "Schemed" },
|
|
||||||
{ present: "processing...", past: "Processed" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatElapsed(ms: number): string {
|
|
||||||
const secs = Math.round(ms / 1000);
|
|
||||||
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChunkRenderer({
|
|
||||||
chunk,
|
|
||||||
projectPath,
|
|
||||||
...callbacks
|
|
||||||
}: { chunk: Chunk; projectPath?: string } & ApprovalCallbacks) {
|
|
||||||
switch (chunk.kind) {
|
|
||||||
case "text":
|
|
||||||
return (
|
|
||||||
<div className="text-[color:var(--ui-text)]">
|
|
||||||
<Markdown content={chunk.text} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "code":
|
|
||||||
return <CodeBlock text={chunk.text} language={chunk.language} />;
|
|
||||||
case "error":
|
|
||||||
return <span className="text-red-400">{chunk.text}</span>;
|
|
||||||
case "list":
|
|
||||||
return (
|
|
||||||
<div className="text-gray-300 ml-2">
|
|
||||||
{chunk.lines.map((line, i) => (
|
|
||||||
<div key={i}>- {line}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "tool-execution":
|
|
||||||
return (
|
|
||||||
<ToolExecution
|
|
||||||
chunk={chunk}
|
|
||||||
projectPath={projectPath}
|
|
||||||
onApprove={callbacks.onApprove}
|
|
||||||
onReject={callbacks.onReject}
|
|
||||||
onAutoApprove={callbacks.onAutoApprove}
|
|
||||||
onOpenDiff={callbacks.onOpenDiff}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "image":
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={`data:${chunk.mimeType};base64,${chunk.base64}`}
|
|
||||||
alt={chunk.fileName || "image"}
|
|
||||||
className="max-w-48 max-h-48 rounded border border-gray-600"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserMessage({ message }: { message: Message }) {
|
|
||||||
const text = message.chunks
|
|
||||||
.filter((c) => c.kind === "text")
|
|
||||||
.map((c) => (c as { kind: "text"; text: string }).text)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const images = message.chunks.filter((c) => c.kind === "image");
|
|
||||||
const textRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [scrolledFromTop, setScrolledFromTop] = useState(false);
|
|
||||||
const [scrolledFromBottom, setScrolledFromBottom] = useState(false);
|
|
||||||
|
|
||||||
const updateScrollIndicators = useCallback(() => {
|
|
||||||
const el = textRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
setScrolledFromTop(el.scrollTop > 0);
|
|
||||||
setScrolledFromBottom(el.scrollTop < el.scrollHeight - el.clientHeight - 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
updateScrollIndicators();
|
|
||||||
}, [text, updateScrollIndicators]);
|
|
||||||
|
|
||||||
const textEdgeShadows = [
|
|
||||||
scrolledFromTop ? "inset 0 12px 10px -10px rgba(42, 63, 95, 0.95)" : "",
|
|
||||||
scrolledFromBottom ? "inset 0 -12px 10px -10px rgba(42, 63, 95, 0.95)" : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end my-4">
|
|
||||||
<div className="max-w-[78%]">
|
|
||||||
{images.length > 0 && (
|
|
||||||
<div className="flex gap-2 mb-2 flex-wrap justify-end">
|
|
||||||
{images.map((img, i) => (
|
|
||||||
img.kind === "image" && (
|
|
||||||
<img
|
|
||||||
key={i}
|
|
||||||
src={`data:${img.mimeType};base64,${img.base64}`}
|
|
||||||
alt={img.fileName || "image"}
|
|
||||||
className="max-w-48 max-h-48 rounded border border-gray-600"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{text && (
|
|
||||||
<div className="inline-block max-w-full rounded-2xl bg-[var(--ui-accent-bubble)] overflow-hidden">
|
|
||||||
<div
|
|
||||||
ref={textRef}
|
|
||||||
onScroll={updateScrollIndicators}
|
|
||||||
className="max-h-[250px] overflow-auto px-3 py-1.5 text-[color:var(--ui-text)] text-[13px] whitespace-pre-wrap break-words"
|
|
||||||
style={{ boxShadow: textEdgeShadows || "none" }}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentMessage({
|
|
||||||
message,
|
|
||||||
isStreaming,
|
|
||||||
projectPath,
|
|
||||||
...callbacks
|
|
||||||
}: {
|
|
||||||
message: Message;
|
|
||||||
isStreaming?: boolean;
|
|
||||||
projectPath?: string;
|
|
||||||
} & ApprovalCallbacks) {
|
|
||||||
const renderItems = useMemo(() => buildRenderItems(message.chunks), [message.chunks]);
|
|
||||||
const changedFiles = useMemo(() => summarizeChangedFiles(message.chunks), [message.chunks]);
|
|
||||||
const changedFilesTotals = useMemo(() => {
|
|
||||||
let additions = 0;
|
|
||||||
let deletions = 0;
|
|
||||||
for (const item of changedFiles) {
|
|
||||||
additions += item.additions;
|
|
||||||
deletions += item.deletions;
|
|
||||||
}
|
|
||||||
return { additions, deletions };
|
|
||||||
}, [changedFiles]);
|
|
||||||
const changedFilesByPath = useMemo(() => {
|
|
||||||
const byPath = new Map<string, ChangedFileSummaryItem>();
|
|
||||||
for (const file of changedFiles) {
|
|
||||||
byPath.set(file.filePath, file);
|
|
||||||
}
|
|
||||||
return byPath;
|
|
||||||
}, [changedFiles]);
|
|
||||||
|
|
||||||
const exploredGroupIds = useMemo(
|
|
||||||
() =>
|
|
||||||
renderItems
|
|
||||||
.filter((item): item is Extract<RenderItem, { type: "explored-group" }> => item.type === "explored-group")
|
|
||||||
.map((item) => item.id),
|
|
||||||
[renderItems],
|
|
||||||
);
|
|
||||||
const hasExploredGroups = exploredGroupIds.length > 0;
|
|
||||||
const [expandedExploredGroups, setExpandedExploredGroups] = useState<Record<string, boolean>>({});
|
|
||||||
const wasExplorationLiveRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isLive = !!isStreaming;
|
|
||||||
|
|
||||||
if (!hasExploredGroups) {
|
|
||||||
setExpandedExploredGroups({});
|
|
||||||
wasExplorationLiveRef.current = isLive;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLive) {
|
|
||||||
const next: Record<string, boolean> = {};
|
|
||||||
for (const id of exploredGroupIds) {
|
|
||||||
next[id] = true;
|
|
||||||
}
|
|
||||||
setExpandedExploredGroups(next);
|
|
||||||
wasExplorationLiveRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldAutoCollapse = wasExplorationLiveRef.current;
|
|
||||||
setExpandedExploredGroups((prev) => {
|
|
||||||
const next: Record<string, boolean> = {};
|
|
||||||
for (const id of exploredGroupIds) {
|
|
||||||
next[id] = shouldAutoCollapse ? false : (prev[id] ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevKeys = Object.keys(prev);
|
|
||||||
const nextKeys = Object.keys(next);
|
|
||||||
if (prevKeys.length !== nextKeys.length) return next;
|
|
||||||
for (const key of nextKeys) {
|
|
||||||
if (prev[key] !== next[key]) return next;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
wasExplorationLiveRef.current = false;
|
|
||||||
}, [hasExploredGroups, isStreaming, exploredGroupIds, message.id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-2 min-w-0 space-y-2">
|
|
||||||
{renderItems.map((item) => {
|
|
||||||
switch (item.type) {
|
|
||||||
case "explored-group": {
|
|
||||||
const summary = summarizeExploration(item.chunks);
|
|
||||||
const isExpanded = expandedExploredGroups[item.id] ?? false;
|
|
||||||
return (
|
|
||||||
<div key={item.key}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedExploredGroups((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.id]: !(prev[item.id] ?? false),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="w-full flex items-center justify-between py-1 text-left hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
<span className="text-[color:var(--ui-text-muted)] text-[12px]">{summary}</span>
|
|
||||||
<span className="text-[color:var(--ui-text-dim)] text-xs">{isExpanded ? "Hide" : "Show"}</span>
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="pt-1 pb-1 space-y-0.5">
|
|
||||||
{item.chunks.map((chunk, chunkIndex) => (
|
|
||||||
<div key={chunk.toolCallId || `explored-chunk-${item.id}-${chunkIndex}`} className="flex-1 min-w-0 text-gray-500">
|
|
||||||
<ToolExecution
|
|
||||||
chunk={chunk}
|
|
||||||
projectPath={projectPath}
|
|
||||||
onOpenDiff={callbacks.onOpenDiff}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case "edit-item": {
|
|
||||||
const fullFileDiff = item.chunk.diffData
|
|
||||||
? changedFilesByPath.get(item.chunk.diffData.filePath)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.key}>
|
|
||||||
<ToolExecution
|
|
||||||
chunk={item.chunk}
|
|
||||||
projectPath={projectPath}
|
|
||||||
onApprove={callbacks.onApprove}
|
|
||||||
onReject={callbacks.onReject}
|
|
||||||
onAutoApprove={callbacks.onAutoApprove}
|
|
||||||
onOpenDiff={callbacks.onOpenDiff}
|
|
||||||
resolvedDiffData={fullFileDiff ? {
|
|
||||||
originalContent: fullFileDiff.originalContent,
|
|
||||||
modifiedContent: fullFileDiff.modifiedContent,
|
|
||||||
} : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case "shell-item":
|
|
||||||
return (
|
|
||||||
<div key={item.key}>
|
|
||||||
<ShellCommand
|
|
||||||
chunk={item.chunk}
|
|
||||||
projectPath={projectPath}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "reply-item":
|
|
||||||
return (
|
|
||||||
<div key={item.key}>
|
|
||||||
<ReplyCard chunk={item.chunk} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "tool-item":
|
|
||||||
return (
|
|
||||||
<div key={item.key}>
|
|
||||||
<ToolExecution
|
|
||||||
chunk={item.chunk}
|
|
||||||
projectPath={projectPath}
|
|
||||||
onApprove={callbacks.onApprove}
|
|
||||||
onReject={callbacks.onReject}
|
|
||||||
onAutoApprove={callbacks.onAutoApprove}
|
|
||||||
onOpenDiff={callbacks.onOpenDiff}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "text-chunk":
|
|
||||||
return (
|
|
||||||
<div key={item.key} className="flex-1 min-w-0">
|
|
||||||
<ChunkRenderer
|
|
||||||
chunk={item.chunk}
|
|
||||||
projectPath={projectPath}
|
|
||||||
{...callbacks}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
|
|
||||||
{changedFiles.length > 0 && !isStreaming && (
|
|
||||||
<TurnChangedFilesCard
|
|
||||||
files={changedFiles}
|
|
||||||
totals={changedFilesTotals}
|
|
||||||
projectPath={projectPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MessageBubble = memo(function MessageBubble({
|
|
||||||
message,
|
|
||||||
isStreaming,
|
|
||||||
projectPath,
|
|
||||||
...callbacks
|
|
||||||
}: {
|
|
||||||
message: Message;
|
|
||||||
isStreaming?: boolean;
|
|
||||||
projectPath?: string;
|
|
||||||
} & ApprovalCallbacks) {
|
|
||||||
if (message.author === "user") {
|
|
||||||
return <UserMessage message={message} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<AgentMessage
|
|
||||||
message={message}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
projectPath={projectPath}
|
|
||||||
{...callbacks}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function ThinkingSpinner({
|
|
||||||
isStreaming,
|
|
||||||
settingUpSandbox,
|
|
||||||
}: {
|
|
||||||
isStreaming: boolean;
|
|
||||||
settingUpSandbox: boolean;
|
|
||||||
}) {
|
|
||||||
const [textIdx, setTextIdx] = useState(0);
|
|
||||||
const [done, setDone] = useState<{ past: string; elapsed: string } | null>(null);
|
|
||||||
const startTimeRef = useRef(0);
|
|
||||||
const textIdxRef = useRef(textIdx);
|
|
||||||
const wasStreamingRef = useRef(false);
|
|
||||||
const settingUpSandboxRef = useRef(settingUpSandbox);
|
|
||||||
textIdxRef.current = textIdx;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStreaming) {
|
|
||||||
wasStreamingRef.current = true;
|
|
||||||
startTimeRef.current = Date.now();
|
|
||||||
setTextIdx(Math.floor(Math.random() * BUSY_TEXTS.length));
|
|
||||||
setDone(null);
|
|
||||||
} else if (wasStreamingRef.current) {
|
|
||||||
setDone({
|
|
||||||
past: settingUpSandboxRef.current
|
|
||||||
? "Set up sandbox"
|
|
||||||
: BUSY_TEXTS[textIdxRef.current].past,
|
|
||||||
elapsed: formatElapsed(Date.now() - startTimeRef.current),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isStreaming]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
settingUpSandboxRef.current = settingUpSandbox;
|
|
||||||
}, [settingUpSandbox]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isStreaming || settingUpSandbox) return;
|
|
||||||
const BUSY_TEXT_ROTATE_INTERVAL_MS = 12000;
|
|
||||||
const id = setInterval(() => setTextIdx((i) => (i + 1) % BUSY_TEXTS.length), BUSY_TEXT_ROTATE_INTERVAL_MS);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [isStreaming, settingUpSandbox]);
|
|
||||||
|
|
||||||
if (!isStreaming && !done) return null;
|
|
||||||
|
|
||||||
if (done) {
|
|
||||||
return (
|
|
||||||
<div className="my-2 flex items-center gap-2">
|
|
||||||
<span className="font-sans text-xs text-[color:var(--ui-text-dim)] select-none">*</span>
|
|
||||||
<span className="text-xs text-[color:var(--ui-text-dim)]">{done.past} for {done.elapsed}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-2 flex items-center gap-2">
|
|
||||||
<span className="shimmer-text text-xs">
|
|
||||||
{settingUpSandbox ? "Setting up sandbox..." : BUSY_TEXTS[textIdx].present}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const BOTTOM_LOCK_THRESHOLD_PX = 24;
|
|
||||||
|
|
||||||
export const MessageView = memo(function MessageView({
|
|
||||||
messages,
|
|
||||||
isStreaming,
|
|
||||||
settingUpSandbox = false,
|
|
||||||
project,
|
|
||||||
contentWidthClass = "max-w-[42rem]",
|
|
||||||
contentPaddingClass = "px-6",
|
|
||||||
bottomInset = 0,
|
|
||||||
scrollButtonSlot = "internal",
|
|
||||||
onShowScrollToBottomChange,
|
|
||||||
scrollControlRef,
|
|
||||||
onApprove,
|
|
||||||
onReject,
|
|
||||||
onAutoApprove,
|
|
||||||
onOpenDiff,
|
|
||||||
}: MessageViewProps) {
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const autoScrollEnabledRef = useRef(true);
|
|
||||||
const lastManualScrollTopRef = useRef(0);
|
|
||||||
const previousScrollTopRef = useRef(0);
|
|
||||||
const pendingScrollFrameRef = useRef<number | null>(null);
|
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
|
||||||
|
|
||||||
const clearScheduledScroll = useCallback(() => {
|
|
||||||
if (pendingScrollFrameRef.current === null) return;
|
|
||||||
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
|
||||||
pendingScrollFrameRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isNearBottom = useCallback((el: HTMLDivElement) => {
|
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
||||||
return distanceFromBottom <= BOTTOM_LOCK_THRESHOLD_PX;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const syncScrollButtonVisibility = useCallback((el: HTMLDivElement) => {
|
|
||||||
setShowScrollToBottom(!isNearBottom(el));
|
|
||||||
}, [isNearBottom]);
|
|
||||||
|
|
||||||
const scrollToBottomNow = useCallback(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
const currentTop = el.scrollTop;
|
|
||||||
lastManualScrollTopRef.current = currentTop;
|
|
||||||
previousScrollTopRef.current = currentTop;
|
|
||||||
syncScrollButtonVisibility(el);
|
|
||||||
}, [syncScrollButtonVisibility]);
|
|
||||||
|
|
||||||
const scheduleScrollToBottom = useCallback(() => {
|
|
||||||
if (!autoScrollEnabledRef.current) return;
|
|
||||||
|
|
||||||
clearScheduledScroll();
|
|
||||||
pendingScrollFrameRef.current = window.requestAnimationFrame(() => {
|
|
||||||
pendingScrollFrameRef.current = null;
|
|
||||||
if (!autoScrollEnabledRef.current) return;
|
|
||||||
scrollToBottomNow();
|
|
||||||
});
|
|
||||||
}, [clearScheduledScroll, scrollToBottomNow]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
const currentTop = el.scrollTop;
|
|
||||||
const scrolledUp = currentTop < previousScrollTopRef.current - 1;
|
|
||||||
const nearBottom = isNearBottom(el);
|
|
||||||
|
|
||||||
if (scrolledUp) {
|
|
||||||
autoScrollEnabledRef.current = false;
|
|
||||||
clearScheduledScroll();
|
|
||||||
} else if (nearBottom) {
|
|
||||||
autoScrollEnabledRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncScrollButtonVisibility(el);
|
|
||||||
lastManualScrollTopRef.current = currentTop;
|
|
||||||
previousScrollTopRef.current = currentTop;
|
|
||||||
};
|
|
||||||
|
|
||||||
scrollToBottomNow();
|
|
||||||
autoScrollEnabledRef.current = true;
|
|
||||||
|
|
||||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
el.removeEventListener("scroll", handleScroll);
|
|
||||||
clearScheduledScroll();
|
|
||||||
};
|
|
||||||
}, [clearScheduledScroll, isNearBottom, scrollToBottomNow, syncScrollButtonVisibility]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
if (autoScrollEnabledRef.current) {
|
|
||||||
scheduleScrollToBottom();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
||||||
const targetTop = Math.min(lastManualScrollTopRef.current, maxTop);
|
|
||||||
const jumpDistance = Math.abs(el.scrollTop - targetTop);
|
|
||||||
|
|
||||||
if (jumpDistance > el.clientHeight * 0.5) {
|
|
||||||
el.scrollTop = targetTop;
|
|
||||||
}
|
|
||||||
|
|
||||||
previousScrollTopRef.current = el.scrollTop;
|
|
||||||
syncScrollButtonVisibility(el);
|
|
||||||
}, [messages, isStreaming, scheduleScrollToBottom, syncScrollButtonVisibility]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const scroller = scrollRef.current;
|
|
||||||
const content = contentRef.current;
|
|
||||||
if (!scroller || !content || typeof ResizeObserver === "undefined") return;
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (autoScrollEnabledRef.current) {
|
|
||||||
scheduleScrollToBottom();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
|
||||||
if (lastManualScrollTopRef.current > maxTop) {
|
|
||||||
scroller.scrollTop = maxTop;
|
|
||||||
lastManualScrollTopRef.current = maxTop;
|
|
||||||
previousScrollTopRef.current = maxTop;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncScrollButtonVisibility(scroller);
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserver.observe(scroller);
|
|
||||||
resizeObserver.observe(content);
|
|
||||||
|
|
||||||
return () => resizeObserver.disconnect();
|
|
||||||
}, [scheduleScrollToBottom, syncScrollButtonVisibility]);
|
|
||||||
|
|
||||||
const visibleMessages = useMemo(() => messages.filter((message) => !message.hidden), [messages]);
|
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
|
||||||
autoScrollEnabledRef.current = true;
|
|
||||||
clearScheduledScroll();
|
|
||||||
scrollToBottomNow();
|
|
||||||
}, [clearScheduledScroll, scrollToBottomNow]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scrollControlRef) return;
|
|
||||||
scrollControlRef.current = { scrollToBottom: handleScrollToBottom };
|
|
||||||
return () => {
|
|
||||||
scrollControlRef.current = null;
|
|
||||||
};
|
|
||||||
}, [handleScrollToBottom, scrollControlRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onShowScrollToBottomChange?.(showScrollToBottom);
|
|
||||||
}, [onShowScrollToBottomChange, showScrollToBottom]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex-1 min-h-0 min-w-0">
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className="h-full min-h-0 min-w-0 overflow-y-auto overflow-x-hidden py-5 text-[13px] leading-6 font-sans antialiased"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
className={`w-full ${contentWidthClass} mx-auto min-w-0 ${contentPaddingClass}`}
|
|
||||||
style={bottomInset > 0 ? { paddingBottom: bottomInset } : undefined}
|
|
||||||
>
|
|
||||||
{visibleMessages.map((message, index) => (
|
|
||||||
<MessageBubble
|
|
||||||
key={message.id}
|
|
||||||
message={message}
|
|
||||||
isStreaming={isStreaming && index === visibleMessages.length - 1}
|
|
||||||
projectPath={project?.path}
|
|
||||||
onApprove={onApprove}
|
|
||||||
onReject={onReject}
|
|
||||||
onAutoApprove={onAutoApprove}
|
|
||||||
onOpenDiff={onOpenDiff}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<ThinkingSpinner
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
settingUpSandbox={settingUpSandbox}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{scrollButtonSlot === "internal" && showScrollToBottom && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleScrollToBottom}
|
|
||||||
aria-label="Scroll to bottom"
|
|
||||||
className="absolute left-1/2 z-30 inline-flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full bg-[var(--ui-panel-2)] text-[color:var(--ui-text-muted)] shadow-md transition-colors hover:bg-[var(--ui-panel)] hover:text-[color:var(--ui-text)]"
|
|
||||||
style={{ bottom: bottomInset > 0 ? bottomInset + 8 : 16 }}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -66,17 +66,33 @@ export const ShellCommand = memo(function ShellCommand({
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="rounded-xl border border-[var(--ui-border-subtle)] bg-[var(--ui-code-bubble)] mt-1 overflow-hidden max-h-[250px] flex flex-col">
|
<div className="rounded-xl border border-[var(--ui-border-subtle)] bg-[var(--ui-code-bubble)] mt-1 overflow-hidden max-h-[250px] flex flex-col">
|
||||||
<div className="px-3 pt-2 pb-1 font-mono text-xs shrink-0">
|
<div className="px-3 pt-2 pb-1 font-mono text-xs shrink-0">
|
||||||
<div className="text-[color:var(--ui-accent-2)] mb-2">bash</div>
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
<div className="text-[color:var(--ui-text)] font-semibold whitespace-pre overflow-x-auto">
|
<span className="text-[color:var(--ui-accent-2)]">bash</span>
|
||||||
<span className="text-[color:var(--ui-text-dim)]">$ </span>
|
{chunk.status === "in_progress" && (
|
||||||
{command}
|
<span className="text-yellow-400 shrink-0">Running...</span>
|
||||||
|
)}
|
||||||
|
{chunk.status === "completed" && (
|
||||||
|
<span className="text-[color:var(--ui-text-muted)] shrink-0">✓ Success</span>
|
||||||
|
)}
|
||||||
|
{chunk.status === "error" && (
|
||||||
|
<span className="text-red-400 shrink-0">✗ Failed</span>
|
||||||
|
)}
|
||||||
|
{chunk.status === "pending" && (
|
||||||
|
<span className="text-yellow-400 shrink-0">Waiting for approval...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[120px] overflow-y-auto">
|
||||||
|
<div className="text-[color:var(--ui-text)] font-semibold whitespace-pre overflow-x-auto">
|
||||||
|
<span className="text-[color:var(--ui-text-dim)]">$ </span>
|
||||||
|
{command}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{output && (
|
{output && (
|
||||||
<div
|
<div
|
||||||
ref={outputRef}
|
ref={outputRef}
|
||||||
onScroll={handleOutputScroll}
|
onScroll={handleOutputScroll}
|
||||||
className="min-h-0 flex-1 overflow-auto px-3 pb-1"
|
className="min-h-0 flex-1 overflow-auto px-3 pb-2"
|
||||||
style={{ boxShadow: outputEdgeShadows || "none" }}
|
style={{ boxShadow: outputEdgeShadows || "none" }}
|
||||||
>
|
>
|
||||||
<pre className="mt-1 text-[color:var(--ui-text-muted)] whitespace-pre font-mono text-xs w-max min-w-full">
|
<pre className="mt-1 text-[color:var(--ui-text-muted)] whitespace-pre font-mono text-xs w-max min-w-full">
|
||||||
|
|
@ -84,20 +100,6 @@ export const ShellCommand = memo(function ShellCommand({
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="px-3 py-1.5 flex justify-end shrink-0">
|
|
||||||
{chunk.status === "in_progress" && (
|
|
||||||
<span className="text-yellow-400 text-xs">Running...</span>
|
|
||||||
)}
|
|
||||||
{chunk.status === "completed" && (
|
|
||||||
<span className="text-[color:var(--ui-text-muted)] text-xs">✓ Success</span>
|
|
||||||
)}
|
|
||||||
{chunk.status === "error" && (
|
|
||||||
<span className="text-red-400 text-xs">✗ Failed</span>
|
|
||||||
)}
|
|
||||||
{chunk.status === "pending" && (
|
|
||||||
<span className="text-yellow-400 text-xs">Waiting for approval...</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// Ported desktop git panel — calls host `window.git`, not used by the web dashboard
|
||||||
|
// (see AgentGitPanel). Excluded from tsconfig until integrated; see ui/tsconfig.json.
|
||||||
import { useState, useCallback, useRef, memo, useMemo, useEffect } from "react";
|
import { useState, useCallback, useRef, memo, useMemo, useEffect } from "react";
|
||||||
import { diffLines } from "diff";
|
|
||||||
import { MultiFileDiff } from "@pierre/diffs/react";
|
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||||
import type { FileContents } from "@pierre/diffs/react";
|
import type { FileContents } from "@pierre/diffs/react";
|
||||||
import type { GitStatusEntry, GitFileStatus } from "@/lib/agents/types";
|
import type { GitStatusEntry, GitFileStatus } from "@/lib/agents/types";
|
||||||
|
import { countLineChanges } from "@/components/agents/utils/diffStats";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -111,16 +113,10 @@ function isConflict(status: GitFileStatus): boolean {
|
||||||
|
|
||||||
function countChanges(
|
function countChanges(
|
||||||
oldContent: string,
|
oldContent: string,
|
||||||
newContent: string
|
newContent: string,
|
||||||
|
filePath: string,
|
||||||
): { additions: number; deletions: number } {
|
): { additions: number; deletions: number } {
|
||||||
const parts = diffLines(oldContent || "", newContent || "");
|
return countLineChanges(oldContent, newContent, filePath);
|
||||||
let additions = 0;
|
|
||||||
let deletions = 0;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.added) additions += part.count ?? 0;
|
|
||||||
else if (part.removed) deletions += part.count ?? 0;
|
|
||||||
}
|
|
||||||
return { additions, deletions };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripProjectPath(path: string, projectPath?: string): string {
|
function stripProjectPath(path: string, projectPath?: string): string {
|
||||||
|
|
@ -133,38 +129,6 @@ function stripProjectPath(path: string, projectPath?: string): string {
|
||||||
return normalizedPath.slice(normalizedProjectPath.length + 1);
|
return normalizedPath.slice(normalizedProjectPath.length + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useInterval(callback: () => void, delayMs: number) {
|
|
||||||
const savedCallback = useRef(callback);
|
|
||||||
savedCallback.current = callback;
|
|
||||||
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const start = useCallback(() => {
|
|
||||||
if (intervalRef.current) return;
|
|
||||||
intervalRef.current = setInterval(() => savedCallback.current(), delayMs);
|
|
||||||
}, [delayMs]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-start/cleanup
|
|
||||||
useState(() => {
|
|
||||||
intervalRef.current = setInterval(() => savedCallback.current(), delayMs);
|
|
||||||
});
|
|
||||||
// cleanup on unmount (using ref trick to avoid useEffect)
|
|
||||||
const cleanupRef = useRef(false);
|
|
||||||
if (!cleanupRef.current) {
|
|
||||||
cleanupRef.current = true;
|
|
||||||
// We still need useEffect for cleanup unfortunately
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start, stop };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SourceControlPanel = memo(function SourceControlPanel({
|
export const SourceControlPanel = memo(function SourceControlPanel({
|
||||||
projectPath,
|
projectPath,
|
||||||
mainProjectPath,
|
mainProjectPath,
|
||||||
|
|
@ -223,7 +187,8 @@ export const SourceControlPanel = memo(function SourceControlPanel({
|
||||||
if (diff) {
|
if (diff) {
|
||||||
const { additions, deletions } = countChanges(
|
const { additions, deletions } = countChanges(
|
||||||
diff.original,
|
diff.original,
|
||||||
diff.modified
|
diff.modified,
|
||||||
|
entry.path,
|
||||||
);
|
);
|
||||||
diffs.push({
|
diffs.push({
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
|
|
@ -508,12 +473,12 @@ export const SourceControlPanel = memo(function SourceControlPanel({
|
||||||
const branchName = syncStatus?.branchName ?? "";
|
const branchName = syncStatus?.branchName ?? "";
|
||||||
const aheadBehind = syncStatus
|
const aheadBehind = syncStatus
|
||||||
? (() => {
|
? (() => {
|
||||||
const { ahead, behind } = syncStatus;
|
const { ahead, behind } = syncStatus;
|
||||||
if (ahead > 0 && behind > 0) return `${ahead}↑ ${behind}↓`;
|
if (ahead > 0 && behind > 0) return `${ahead}↑ ${behind}↓`;
|
||||||
if (ahead > 0) return `${ahead}↑`;
|
if (ahead > 0) return `${ahead}↑`;
|
||||||
if (behind > 0) return `${behind}↓`;
|
if (behind > 0) return `${behind}↓`;
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -783,11 +748,10 @@ export const SourceControlPanel = memo(function SourceControlPanel({
|
||||||
{/* Status toast */}
|
{/* Status toast */}
|
||||||
{statusMsg && (
|
{statusMsg && (
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-14 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-md text-xs z-30 shadow-lg ${
|
className={`absolute bottom-14 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-md text-xs z-30 shadow-lg ${statusMsg.error
|
||||||
statusMsg.error
|
? "bg-red-500/20 text-red-300 border border-red-500/30"
|
||||||
? "bg-red-500/20 text-red-300 border border-red-500/30"
|
: "bg-green-500/20 text-green-300 border border-green-500/30"
|
||||||
: "bg-green-500/20 text-green-300 border border-green-500/30"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{statusMsg.text}
|
{statusMsg.text}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -966,11 +930,10 @@ const DiffCard = memo(function DiffCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
className={`rounded-lg bg-[var(--ui-accent-bubble)] overflow-hidden border ${
|
className={`rounded-lg bg-[var(--ui-accent-bubble)] overflow-hidden border ${selected
|
||||||
selected
|
? "border-[color:var(--ui-accent)]"
|
||||||
? "border-[color:var(--ui-accent)]"
|
: "border-[var(--ui-border-subtle)]"
|
||||||
: "border-[var(--ui-border-subtle)]"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1137,11 +1100,10 @@ const TreeNode = memo(function TreeNode({
|
||||||
}
|
}
|
||||||
onSelect(node.path);
|
onSelect(node.path);
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center gap-2 pr-3 py-1.5 text-xs text-left transition-colors ${
|
className={`flex w-full items-center gap-2 pr-3 py-1.5 text-xs text-left transition-colors ${isSelected
|
||||||
isSelected
|
? "bg-[var(--ui-panel-2)] text-[color:var(--ui-text)]"
|
||||||
? "bg-[var(--ui-panel-2)] text-[color:var(--ui-text)]"
|
: "text-[color:var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[color:var(--ui-text)]"
|
||||||
: "text-[color:var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[color:var(--ui-text)]"
|
}`}
|
||||||
}`}
|
|
||||||
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||||||
title={node.path}
|
title={node.path}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { memo, useState, useCallback, useMemo, useRef, useLayoutEffect } from "react";
|
import { memo, useState, useCallback, useMemo, useRef, useLayoutEffect } from "react";
|
||||||
import { diffLines } from "diff";
|
|
||||||
import { MultiFileDiff } from "@pierre/diffs/react";
|
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||||
import type { ToolExecutionChunk, AcpToolKind } from "@/lib/agents/types";
|
import type { ToolExecutionChunk, AcpToolKind } from "@/lib/agents/types";
|
||||||
import { DiffView } from "./DiffView";
|
import { DiffView } from "./DiffView";
|
||||||
import { useDiffOptions } from "@/components/agents/utils/diffUtils";
|
import { useDiffOptions } from "@/components/agents/utils/diffUtils";
|
||||||
|
import { countLineChanges } from "@/components/agents/utils/diffStats";
|
||||||
|
|
||||||
interface ToolExecutionProps {
|
interface ToolExecutionProps {
|
||||||
chunk: ToolExecutionChunk;
|
chunk: ToolExecutionChunk;
|
||||||
|
|
@ -27,31 +27,6 @@ function getFileName(path: string): string {
|
||||||
return parts[parts.length - 1] || path;
|
return parts[parts.length - 1] || path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countLines(text: string): number {
|
|
||||||
if (text.length === 0) return 0;
|
|
||||||
const segments = text.split("\n");
|
|
||||||
return text.endsWith("\n") ? segments.length - 1 : segments.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countLineChanges(originalContent: string | null | undefined, newContent: string): { additions: number; deletions: number } {
|
|
||||||
const before = originalContent ?? "";
|
|
||||||
const parts = diffLines(before, newContent, {
|
|
||||||
ignoreWhitespace: false,
|
|
||||||
newlineIsToken: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let additions = 0;
|
|
||||||
let deletions = 0;
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
const lineCount = countLines(part.value);
|
|
||||||
if (part.added) additions += lineCount;
|
|
||||||
else if (part.removed) deletions += lineCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { additions, deletions };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatToolDisplay(
|
function formatToolDisplay(
|
||||||
title: string,
|
title: string,
|
||||||
toolKind: AcpToolKind,
|
toolKind: AcpToolKind,
|
||||||
|
|
@ -227,7 +202,7 @@ export const ToolExecution = memo(function ToolExecution({
|
||||||
const isCompletedEditOp = isEditOp && diffData && (status === "completed" || status === "error");
|
const isCompletedEditOp = isEditOp && diffData && (status === "completed" || status === "error");
|
||||||
const editedFilePath = diffData ? stripProjectPath(diffData.filePath, projectPath) : "";
|
const editedFilePath = diffData ? stripProjectPath(diffData.filePath, projectPath) : "";
|
||||||
const editedFileName = editedFilePath ? getFileName(editedFilePath) : "";
|
const editedFileName = editedFilePath ? getFileName(editedFilePath) : "";
|
||||||
const diffStats = diffData ? countLineChanges(diffData.originalContent, diffData.newContent) : null;
|
const diffStats = diffData ? countLineChanges(diffData.originalContent, diffData.newContent, diffData.filePath) : null;
|
||||||
|
|
||||||
if (isCompletedEditOp && diffStats && diffData) {
|
if (isCompletedEditOp && diffStats && diffData) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,10 @@ export { Logo } from "./Logo";
|
||||||
export { Markdown } from "./Markdown";
|
export { Markdown } from "./Markdown";
|
||||||
export { MarkdownTable } from "./MarkdownTable";
|
export { MarkdownTable } from "./MarkdownTable";
|
||||||
export {
|
export {
|
||||||
MessageView,
|
Messages,
|
||||||
summarizeChangedFiles,
|
summarizeChangedFiles,
|
||||||
type ChangedFileSummaryItem,
|
type MessagesScrollControl,
|
||||||
type MessageViewScrollControl,
|
} from "@/components/agents/messages";
|
||||||
} from "./MessageView";
|
|
||||||
export { PanelResizeHandle } from "./PanelResizeHandle";
|
export { PanelResizeHandle } from "./PanelResizeHandle";
|
||||||
export { ShellCommand } from "./ShellCommand";
|
export { ShellCommand } from "./ShellCommand";
|
||||||
export { TodoList } from "./TodoList";
|
export { TodoList } from "./TodoList";
|
||||||
|
|
|
||||||
50
ui/src/components/agents/subagents/SubagentActivity.tsx
Normal file
50
ui/src/components/agents/subagents/SubagentActivity.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { useStreamContext as useAgentThreadStream, useToolCalls } from "@langchain/react"
|
||||||
|
import { Check, Loader2, X } from "lucide-react"
|
||||||
|
|
||||||
|
function humanizeToolName(name: string): string {
|
||||||
|
return name.replace(/_/g, " ").trim() || "tool"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live nested activity for a single subagent, read straight from the SDK's
|
||||||
|
* scoped `tools` projection (`useToolCalls(stream, { namespace })`). The
|
||||||
|
* namespace comes from `stream.subagents` (attached to the `task` chunk by
|
||||||
|
* `streamMessagesToUi`), so this subscribes to exactly the subagent that the
|
||||||
|
* parent card represents.
|
||||||
|
*
|
||||||
|
* Mounting opens a ref-counted subscription scoped to `namespace`; unmounting
|
||||||
|
* closes it. Only mounted from {@link SubagentCard} when
|
||||||
|
* `useIsInAgentThreadStream()` is true, so the `useStreamContext` read is
|
||||||
|
* always inside a `StreamProvider`.
|
||||||
|
*/
|
||||||
|
export function SubagentActivity({ namespace }: { namespace: Array<string> }) {
|
||||||
|
const stream = useAgentThreadStream()
|
||||||
|
const toolCalls = useToolCalls(stream, { namespace })
|
||||||
|
|
||||||
|
if (toolCalls.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex flex-col gap-1 border-t border-[var(--ui-border)] pt-1.5">
|
||||||
|
{toolCalls.map((toolCall, index) => {
|
||||||
|
const id = toolCall.id || toolCall.callId || `sub-tool-${index}`
|
||||||
|
return (
|
||||||
|
<div key={id} className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{toolCall.status === "finished" ? (
|
||||||
|
<Check className="h-3 w-3 shrink-0 text-[color:var(--ui-accent)]" aria-hidden />
|
||||||
|
) : toolCall.status === "error" ? (
|
||||||
|
<X className="h-3 w-3 shrink-0 text-red-400" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Loader2
|
||||||
|
className="h-3 w-3 shrink-0 animate-spin text-[color:var(--ui-text-dim)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate text-[10px] text-[color:var(--ui-text-dim)]">
|
||||||
|
{humanizeToolName(toolCall.name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
ui/src/components/agents/subagents/SubagentCard.tsx
Normal file
54
ui/src/components/agents/subagents/SubagentCard.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Bot, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { useIsInAgentThreadStream } from "@/lib/agents/provider/useIsInAgentThreadStream";
|
||||||
|
import type { ToolExecutionChunk } from "@/lib/agents/types";
|
||||||
|
|
||||||
|
import { SubagentActivity } from "./SubagentActivity";
|
||||||
|
|
||||||
|
/** Coerce an unknown tool-argument value to a trimmed string, or `""`. */
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single subagent spawned via the `task` tool. Shows the subagent type and
|
||||||
|
* the task input (its `description`) as a compact rectangle.
|
||||||
|
*/
|
||||||
|
export const SubagentCard = memo(function SubagentCard({ chunk }: { chunk: ToolExecutionChunk }) {
|
||||||
|
const inLiveStream = useIsInAgentThreadStream();
|
||||||
|
const input = chunk.input ?? {};
|
||||||
|
const subagentType = asString(input.subagent_type) || "subagent";
|
||||||
|
const description = asString(input.description);
|
||||||
|
const isRunning = chunk.status === "in_progress" || chunk.status === "pending";
|
||||||
|
const isError = chunk.status === "error";
|
||||||
|
const namespace = chunk.subagentNamespace;
|
||||||
|
const activity =
|
||||||
|
inLiveStream && namespace && namespace.length > 0 ? (
|
||||||
|
<SubagentActivity namespace={namespace} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5 overflow-hidden rounded-lg border border-[var(--ui-border)] bg-[var(--ui-accent-bubble)] p-2.5">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{isRunning ? (
|
||||||
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-[color:var(--ui-accent)]" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Bot
|
||||||
|
className={`h-3 w-3 shrink-0 ${isError ? "text-red-400" : "text-[color:var(--ui-accent)]"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate text-[11px] font-medium text-[color:var(--ui-text-muted)]">
|
||||||
|
{subagentType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="line-clamp-5 whitespace-pre-wrap break-words text-[11px] leading-4 text-[color:var(--ui-text-dim)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{activity}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
26
ui/src/components/agents/subagents/SubagentGroup.tsx
Normal file
26
ui/src/components/agents/subagents/SubagentGroup.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { ToolExecutionChunk } from "@/lib/agents/types";
|
||||||
|
|
||||||
|
import { SubagentCard } from "./SubagentCard";
|
||||||
|
|
||||||
|
/** Maximum number of subagent cards rendered per row. */
|
||||||
|
const MAX_SUBAGENT_COLUMNS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the subagents from a `subagent-group` render item as a responsive
|
||||||
|
* card grid. The column count follows the number of cards up to
|
||||||
|
* {@link MAX_SUBAGENT_COLUMNS}, so 1–4 subagents fill the row evenly and 5+
|
||||||
|
* wrap onto additional rows.
|
||||||
|
*/
|
||||||
|
export function SubagentGroup({ chunks }: { chunks: ToolExecutionChunk[] }) {
|
||||||
|
const columns = Math.min(Math.max(chunks.length, 1), MAX_SUBAGENT_COLUMNS);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid gap-2"
|
||||||
|
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||||
|
>
|
||||||
|
{chunks.map((chunk) => (
|
||||||
|
<SubagentCard key={chunk.toolCallId} chunk={chunk} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
ui/src/components/agents/subagents/index.ts
Normal file
3
ui/src/components/agents/subagents/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { SubagentActivity } from "./SubagentActivity";
|
||||||
|
export { SubagentCard } from "./SubagentCard";
|
||||||
|
export { SubagentGroup } from "./SubagentGroup";
|
||||||
20
ui/src/components/agents/utils/diffStats.ts
Normal file
20
ui/src/components/agents/utils/diffStats.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { parseDiffFromFile } from "@pierre/diffs";
|
||||||
|
|
||||||
|
export function countLineChanges(
|
||||||
|
originalContent: string | null | undefined,
|
||||||
|
newContent: string,
|
||||||
|
filePath = "file",
|
||||||
|
): { additions: number; deletions: number } {
|
||||||
|
const meta = parseDiffFromFile(
|
||||||
|
{ name: filePath, contents: originalContent ?? "" },
|
||||||
|
{ name: filePath, contents: newContent },
|
||||||
|
);
|
||||||
|
|
||||||
|
let additions = 0;
|
||||||
|
let deletions = 0;
|
||||||
|
for (const hunk of meta.hunks) {
|
||||||
|
additions += hunk.additionLines;
|
||||||
|
deletions += hunk.deletionLines;
|
||||||
|
}
|
||||||
|
return { additions, deletions };
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { preloadHighlighter } from "@pierre/diffs";
|
||||||
import { useResolvedTheme } from "@/lib/theme";
|
import { useResolvedTheme } from "@/lib/theme";
|
||||||
|
|
||||||
export const DIFF_UNSAFE_CSS = `
|
export const DIFF_UNSAFE_CSS = `
|
||||||
|
|
@ -73,3 +74,29 @@ export function useDiffOptions() {
|
||||||
[resolvedTheme]
|
[resolvedTheme]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let highlighterWarmup: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pierre's <MultiFileDiff> renders an empty <diffs-container> on its first mount
|
||||||
|
* when the shared Shiki highlighter (specifically its themes) hasn't loaded yet:
|
||||||
|
* the cold-start render bails before painting and relies on an async repaint that
|
||||||
|
* can be dropped — most reliably under React StrictMode's mount/unmount/mount,
|
||||||
|
* which leaves a stale empty <pre> behind so the remounted instance no-ops.
|
||||||
|
*
|
||||||
|
* Warming the themes up-front makes that first render synchronous and non-empty.
|
||||||
|
* Idempotent and client-only (preloadHighlighter creates a Shiki instance).
|
||||||
|
*/
|
||||||
|
export function warmDiffHighlighter(): Promise<void> {
|
||||||
|
if (typeof window === "undefined") return Promise.resolve();
|
||||||
|
if (highlighterWarmup == null) {
|
||||||
|
highlighterWarmup = preloadHighlighter({
|
||||||
|
themes: [diffOptions.theme.light, diffOptions.theme.dark],
|
||||||
|
langs: ["text"],
|
||||||
|
}).catch((error) => {
|
||||||
|
highlighterWarmup = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return highlighterWarmup;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,4 +54,20 @@ function Button({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
function IconButton({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, IconButton, buttonVariants }
|
||||||
|
|
|
||||||
99
ui/src/lib/agents/AgentThreadStreamProvider.tsx
Normal file
99
ui/src/lib/agents/AgentThreadStreamProvider.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
import { StreamProvider } from "@langchain/react";
|
||||||
|
import { overrideFetchImplementation } from "@langchain/langgraph-sdk";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { agentsApi } from "./api";
|
||||||
|
import { agentThreadKeys } from "./queries";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const AGENT_ASSISTANT_ID = "agent";
|
||||||
|
|
||||||
|
const dashboardFetch: typeof fetch = (input, init) =>
|
||||||
|
fetch(input, { ...init, credentials: "include" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We use the SDK's built-in `sse` transport (via {@link StreamProvider}'s
|
||||||
|
* `apiUrl` + `fetch`), so commands, the event stream, and `getState`
|
||||||
|
* hydration all flow through {@link dashboardFetch}. But subagent/subgraph
|
||||||
|
* discovery on hydrate (`POST /threads/:id/history`) and `getState` itself
|
||||||
|
* are issued by the SDK's internal `Client` rather than the transport's
|
||||||
|
* `fetch`. Without this, the `Client` falls back to a bare `fetch` that
|
||||||
|
* omits the dashboard session cookie cross-origin, so the proxy rejects the
|
||||||
|
* read with `401 "not authenticated"`. Override the SDK's global fetch so
|
||||||
|
* every `Client` read carries the same credentials as the transport.
|
||||||
|
*/
|
||||||
|
overrideFetchImplementation(dashboardFetch);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SDK transport builds request URLs as `new URL(apiUrl + path)`, so
|
||||||
|
* `apiUrl` must be absolute — a relative base (e.g. "/dashboard/api")
|
||||||
|
* makes the SDK fall back to the LangGraph default host
|
||||||
|
* (`http://localhost:8123`) and drop the proxy prefix. Promote a
|
||||||
|
* same-origin base to an absolute URL using the current origin.
|
||||||
|
*/
|
||||||
|
function toAbsoluteApiUrl(url: string): string {
|
||||||
|
if (/^https?:\/\//.test(url)) return url;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return `${window.location.origin}${url.startsWith("/") ? "" : "/"}${url}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentStreamApiUrl = toAbsoluteApiUrl(agentsApi.langGraphApiUrl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One persistent stream for the whole `/agents` subtree, mounted by the
|
||||||
|
* layout so it survives the home → thread navigation. The built-in `sse`
|
||||||
|
* transport (default `apiUrl` branch) is reused across thread switches —
|
||||||
|
* changing `threadId` re-hydrates the same controller instead of tearing
|
||||||
|
* down a per-thread transport — which is what lets a home-page
|
||||||
|
* `stream.submit` keep streaming after we navigate to the minted thread.
|
||||||
|
*/
|
||||||
|
export function AgentThreadStreamProvider({
|
||||||
|
threadId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
/**
|
||||||
|
* The active thread, or `null` on routes without one (the Agents home,
|
||||||
|
* automations). A `null` id leaves the SDK in its lazy-create mode: the
|
||||||
|
* first `stream.submit` mints the thread id, fires `onThreadId`, and skips
|
||||||
|
* the `getState` hydrate — so a fresh thread needs no client-minted id and
|
||||||
|
* no `getState` 404 round-trip.
|
||||||
|
*/
|
||||||
|
threadId: string | null;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// The SDK captures the lifecycle callbacks once at controller creation, so
|
||||||
|
// they must be stable. Read the live thread id from a ref instead of
|
||||||
|
// closing over the (changing) prop.
|
||||||
|
const threadIdRef = useRef<string | null>(threadId);
|
||||||
|
threadIdRef.current = threadId;
|
||||||
|
|
||||||
|
const onCreated = useCallback(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const onCompleted = useCallback(() => {
|
||||||
|
const id = threadIdRef.current;
|
||||||
|
if (id) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.detail(id) });
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StreamProvider
|
||||||
|
apiUrl={agentStreamApiUrl}
|
||||||
|
assistantId={AGENT_ASSISTANT_ID}
|
||||||
|
fetch={dashboardFetch}
|
||||||
|
threadId={threadId ?? undefined}
|
||||||
|
onCreated={onCreated}
|
||||||
|
onCompleted={onCompleted}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</StreamProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,14 @@ import type { AgentSchedule, AgentThread, ImageChunk, Message } from "./types"
|
||||||
|
|
||||||
export type { AgentSchedule, AgentThread, Message }
|
export type { AgentSchedule, AgentThread, Message }
|
||||||
|
|
||||||
export interface ThreadCreateRequest {
|
export class AgentsApiError extends Error {
|
||||||
prompt: string
|
constructor(
|
||||||
images?: Array<ImageChunk>
|
public readonly status: number,
|
||||||
repo?: string | null
|
message: string
|
||||||
repo_explicitly_none?: boolean
|
) {
|
||||||
model_id?: string | null
|
super(message)
|
||||||
effort?: string | null
|
this.name = "AgentsApiError"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadMessageRequest {
|
export interface ThreadMessageRequest {
|
||||||
|
|
@ -42,6 +43,8 @@ const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
|
||||||
""
|
""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const agentsLangGraphApiUrl = `${API_BASE}/dashboard/api`
|
||||||
|
|
||||||
async function agentsRequest<T>(
|
async function agentsRequest<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {}
|
init: RequestInit = {}
|
||||||
|
|
@ -67,13 +70,14 @@ async function agentsRequest<T>(
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
throw new Error(message)
|
throw new AgentsApiError(res.status, message)
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T
|
if (res.status === 204) return undefined as T
|
||||||
return (await res.json()) as T
|
return (await res.json()) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agentsApi = {
|
export const agentsApi = {
|
||||||
|
langGraphApiUrl: agentsLangGraphApiUrl,
|
||||||
listThreads: () => agentsRequest<Array<AgentThread>>("/threads"),
|
listThreads: () => agentsRequest<Array<AgentThread>>("/threads"),
|
||||||
listSchedules: () => agentsRequest<Array<AgentSchedule>>("/schedules"),
|
listSchedules: () => agentsRequest<Array<AgentSchedule>>("/schedules"),
|
||||||
createSchedule: (body: ScheduleCreateRequest) =>
|
createSchedule: (body: ScheduleCreateRequest) =>
|
||||||
|
|
@ -99,12 +103,7 @@ export const agentsApi = {
|
||||||
options?.markViewed === false ? "?mark_viewed=false" : ""
|
options?.markViewed === false ? "?mark_viewed=false" : ""
|
||||||
}`
|
}`
|
||||||
),
|
),
|
||||||
createThread: (body: ThreadCreateRequest) =>
|
queueMessage: (threadId: string, body: ThreadMessageRequest) =>
|
||||||
agentsRequest<AgentThread>("/threads", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
sendMessage: (threadId: string, body: ThreadMessageRequest) =>
|
|
||||||
agentsRequest<AgentThread>(
|
agentsRequest<AgentThread>(
|
||||||
`/threads/${encodeURIComponent(threadId)}/messages`,
|
`/threads/${encodeURIComponent(threadId)}/messages`,
|
||||||
{
|
{
|
||||||
|
|
@ -127,18 +126,6 @@ export const agentsApi = {
|
||||||
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
|
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRelativeTime(ts: number): string {
|
|
||||||
const diff = Date.now() - ts
|
|
||||||
const mins = Math.floor(diff / 60000)
|
|
||||||
if (mins < 60) return `${mins}m`
|
|
||||||
const hours = Math.floor(mins / 60)
|
|
||||||
if (hours < 24) return `${hours}h`
|
|
||||||
const days = Math.floor(hours / 24)
|
|
||||||
if (days < 7) return `${days}d`
|
|
||||||
const weeks = Math.floor(days / 7)
|
|
||||||
return `${weeks}w`
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ThreadGroup = "today" | "last7" | "last30" | "older"
|
export type ThreadGroup = "today" | "last7" | "last30" | "older"
|
||||||
|
|
||||||
export function groupThreads(
|
export function groupThreads(
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import type { ImageChunk } from "./types";
|
|
||||||
|
|
||||||
const STORAGE_KEY = (threadId: string) => `open-swe:pending-prompts:${threadId}`;
|
|
||||||
|
|
||||||
export interface PendingPrompt {
|
|
||||||
prompt: string;
|
|
||||||
insertAt: number;
|
|
||||||
images?: Array<ImageChunk>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImageChunk(value: unknown): value is ImageChunk {
|
|
||||||
const image = value as { base64?: unknown; kind?: unknown; mimeType?: unknown };
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
image.kind === "image" &&
|
|
||||||
typeof image.base64 === "string" &&
|
|
||||||
typeof image.mimeType === "string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPendingPrompt(value: unknown): value is PendingPrompt {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
typeof (value as PendingPrompt).prompt === "string" &&
|
|
||||||
typeof (value as PendingPrompt).insertAt === "number" &&
|
|
||||||
((value as PendingPrompt).images === undefined ||
|
|
||||||
(Array.isArray((value as PendingPrompt).images) &&
|
|
||||||
(value as PendingPrompt).images!.every(isImageChunk)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeRead(threadId: string): Array<PendingPrompt> {
|
|
||||||
if (typeof window === "undefined") return [];
|
|
||||||
try {
|
|
||||||
const raw = window.sessionStorage.getItem(STORAGE_KEY(threadId));
|
|
||||||
if (!raw) return [];
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed.filter(isPendingPrompt) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeWrite(threadId: string, prompts: Array<PendingPrompt>): void {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
if (prompts.length === 0) {
|
|
||||||
window.sessionStorage.removeItem(STORAGE_KEY(threadId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
window.sessionStorage.setItem(STORAGE_KEY(threadId), JSON.stringify(prompts));
|
|
||||||
} catch {
|
|
||||||
window.sessionStorage.removeItem(STORAGE_KEY(threadId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPendingPrompts(threadId: string): Array<PendingPrompt> {
|
|
||||||
return safeRead(threadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addPendingPrompt(
|
|
||||||
threadId: string,
|
|
||||||
prompt: string,
|
|
||||||
insertAt: number,
|
|
||||||
images?: Array<ImageChunk>,
|
|
||||||
): void {
|
|
||||||
safeWrite(threadId, [...safeRead(threadId), { prompt, insertAt, images }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dropPendingPrompts(
|
|
||||||
threadId: string,
|
|
||||||
predicate: (entry: PendingPrompt) => boolean,
|
|
||||||
): Array<PendingPrompt> {
|
|
||||||
const next = safeRead(threadId).filter((p) => !predicate(p));
|
|
||||||
safeWrite(threadId, next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
28
ui/src/lib/agents/provider/useIsInAgentThreadStream.tsx
Normal file
28
ui/src/lib/agents/provider/useIsInAgentThreadStream.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight marker for whether the current subtree is rendering inside an
|
||||||
|
* *active* thread (the `/agents/$threadId` route), as opposed to elsewhere
|
||||||
|
* under the shared `/agents` stream provider (e.g. `AgentsHome`, automations).
|
||||||
|
*
|
||||||
|
* The `AgentThreadStreamProvider` now spans the whole `/agents` layout, so
|
||||||
|
* `useStreamContext()` is callable everywhere underneath it — but shared UI
|
||||||
|
* such as `CloudPromptBar` must still distinguish "in a live thread" (show the
|
||||||
|
* stop button, mount nested subagent activity) from the home prompt. The
|
||||||
|
* boundary is wrapped only around the thread view, so this stays `false` on
|
||||||
|
* the home page where there is no thread to act on.
|
||||||
|
*/
|
||||||
|
const AgentThreadStreamBoundaryContext = createContext(false);
|
||||||
|
|
||||||
|
export function useIsInAgentThreadStream(): boolean {
|
||||||
|
return useContext(AgentThreadStreamBoundaryContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentThreadStreamBoundary({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<AgentThreadStreamBoundaryContext.Provider value={true}>
|
||||||
|
{children}
|
||||||
|
</AgentThreadStreamBoundaryContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
ui/src/lib/agents/provider/useLiveMarkdownMessageId.ts
Normal file
57
ui/src/lib/agents/provider/useLiveMarkdownMessageId.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { Message } from "@/lib/agents/types";
|
||||||
|
|
||||||
|
/** How long run-idle must persist before clearing the live markdown target. */
|
||||||
|
const LIVE_MARKDOWN_CLEAR_MS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latches the agent message id that should stay in Streamdown "live" mode.
|
||||||
|
* `stream.isLoading` flickers on lifecycle events between graph steps; this
|
||||||
|
* keeps markdown streaming until the run has been idle for a beat.
|
||||||
|
*/
|
||||||
|
export function useLiveMarkdownMessageId(
|
||||||
|
visibleMessages: Message[],
|
||||||
|
streamIsLoading: boolean | undefined,
|
||||||
|
isStreaming: boolean,
|
||||||
|
): string | null {
|
||||||
|
const [liveMessageId, setLiveMessageId] = useState<string | null>(null);
|
||||||
|
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
const runActive = Boolean(streamIsLoading || isStreaming);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (runActive) {
|
||||||
|
if (clearTimerRef.current) {
|
||||||
|
clearTimeout(clearTimerRef.current);
|
||||||
|
clearTimerRef.current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMessage = visibleMessages[visibleMessages.length - 1];
|
||||||
|
// A new user prompt at the tail means the prior agent turn is done — keep it
|
||||||
|
// static so Streamdown does not re-animate it while waiting for the reply.
|
||||||
|
if (!lastMessage || lastMessage.author === "user") {
|
||||||
|
setLiveMessageId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMessage.author === "agent") {
|
||||||
|
setLiveMessageId((prev) => (prev === lastMessage.id ? prev : lastMessage.id));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimerRef.current = setTimeout(() => {
|
||||||
|
clearTimerRef.current = undefined;
|
||||||
|
setLiveMessageId(null);
|
||||||
|
}, LIVE_MARKDOWN_CLEAR_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (clearTimerRef.current) {
|
||||||
|
clearTimeout(clearTimerRef.current);
|
||||||
|
clearTimerRef.current = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [runActive, visibleMessages]);
|
||||||
|
|
||||||
|
return liveMessageId;
|
||||||
|
}
|
||||||
88
ui/src/lib/agents/provider/useSubmitAgentMessage.ts
Normal file
88
ui/src/lib/agents/provider/useSubmitAgentMessage.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useStreamContext as useAgentThreadStream } from "@langchain/react";
|
||||||
|
|
||||||
|
import type { SendAgentMessageVariables } from "@/lib/agents/queries";
|
||||||
|
import { AgentsApiError, agentsApi } from "@/lib/agents/api";
|
||||||
|
import { agentThreadKeys } from "@/lib/agents/queries";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct the message content for the LangGraph run.
|
||||||
|
*
|
||||||
|
* @param vars - The variables for the message.
|
||||||
|
* @returns The message content.
|
||||||
|
*/
|
||||||
|
function messageContent(vars: SendAgentMessageVariables) {
|
||||||
|
const text = vars.content.trim();
|
||||||
|
const imageBlocks = vars.images?.map((image) => ({
|
||||||
|
type: "image",
|
||||||
|
base64: image.base64,
|
||||||
|
mime_type: image.mimeType,
|
||||||
|
...(image.fileName ? { file_name: image.fileName } : {}),
|
||||||
|
})) ?? [];
|
||||||
|
return [...imageBlocks, ...(text ? [{ type: "text", text }] : [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-initiated sends from the prompt bar. Prefer this over calling `stream.submit`
|
||||||
|
* directly so cache updates and the busy-thread queue path stay consistent.
|
||||||
|
*
|
||||||
|
* When the thread is idle, submits a new run via the stream commands endpoint.
|
||||||
|
* When a run is already in flight (`stream.isLoading`), posts to the dashboard
|
||||||
|
* `/messages` endpoint instead of using LangGraph `multitaskStrategy: "enqueue"`.
|
||||||
|
* That endpoint writes to the thread store; `check_message_queue_before_model`
|
||||||
|
* injects the message into the *current* run before the next model call — the
|
||||||
|
* same mid-run follow-up path used by Slack, Linear, and GitHub webhooks.
|
||||||
|
*
|
||||||
|
* @param threadId - The ID of the thread to submit the message to.
|
||||||
|
* @returns The mutation object.
|
||||||
|
*/
|
||||||
|
export function useSubmitAgentMessage(threadId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const stream = useAgentThreadStream();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (vars: SendAgentMessageVariables) => {
|
||||||
|
const queue = () =>
|
||||||
|
agentsApi.queueMessage(threadId, {
|
||||||
|
content: vars.content,
|
||||||
|
images: vars.images,
|
||||||
|
model_id: vars.model_id,
|
||||||
|
effort: vars.effort,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stream.isLoading) {
|
||||||
|
await queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queue();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof AgentsApiError) || error.status !== 409) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = (!vars.model_id || !vars.effort)
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
configurable: {
|
||||||
|
agent_model_id: vars.model_id,
|
||||||
|
agent_effort: vars.effort,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await stream.submit(
|
||||||
|
{ messages: [{ type: "human", content: messageContent(vars) }] },
|
||||||
|
{ config },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
||||||
|
prev ? { ...prev, status: "running" as const } : prev,
|
||||||
|
);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,8 @@ import { useNavigate } from "@tanstack/react-router"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
|
||||||
import { agentsApi } from "./api"
|
import { agentsApi } from "./api"
|
||||||
import { addPendingPrompt } from "./pendingPrompts"
|
|
||||||
import type { ScheduleUpdateRequest } from "./api"
|
import type { ScheduleUpdateRequest } from "./api"
|
||||||
import type { ImageChunk } from "./types"
|
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
||||||
|
|
||||||
export const agentThreadKeys = {
|
export const agentThreadKeys = {
|
||||||
all: ["agent-threads"] as const,
|
all: ["agent-threads"] as const,
|
||||||
|
|
@ -16,26 +15,23 @@ export const agentScheduleKeys = {
|
||||||
all: ["agent-schedules"] as const,
|
all: ["agent-schedules"] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREFETCH_THREAD_DETAIL_LIMIT = 12
|
// The list endpoint (`GET /threads`) and the detail endpoint
|
||||||
|
// (`GET /threads/{id}`) return the same per-thread summary, so warming the
|
||||||
export function usePrefetchAgentThreadDetails(
|
// detail cache from the already-fetched list avoids a fan-out of one request
|
||||||
threads: Array<{ id: string }>,
|
// per sidebar thread. Navigation stays instant; the real (mark-viewed) fetch
|
||||||
|
// fires only when a thread is actually opened. The active thread is skipped so
|
||||||
|
// its live detail query stays the source of truth.
|
||||||
|
export function useSeedAgentThreadDetails(
|
||||||
|
threads: Array<AgentThread>,
|
||||||
activeThreadId?: string
|
activeThreadId?: string
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const threadIds = threads
|
for (const thread of threads) {
|
||||||
.map((thread) => thread.id)
|
if (thread.id === activeThreadId) continue
|
||||||
.filter((threadId) => threadId !== activeThreadId)
|
queryClient.setQueryData(agentThreadKeys.detail(thread.id), thread)
|
||||||
.slice(0, PREFETCH_THREAD_DETAIL_LIMIT)
|
}
|
||||||
|
|
||||||
threadIds.forEach((threadId) => {
|
|
||||||
void queryClient.prefetchQuery({
|
|
||||||
queryKey: agentThreadKeys.detail(threadId),
|
|
||||||
queryFn: () => agentsApi.getThread(threadId, { markViewed: false }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [activeThreadId, queryClient, threads])
|
}, [activeThreadId, queryClient, threads])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,11 +50,6 @@ export function useAgentThread(threadId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: agentThreadKeys.detail(threadId),
|
queryKey: agentThreadKeys.detail(threadId),
|
||||||
queryFn: () => agentsApi.getThread(threadId),
|
queryFn: () => agentsApi.getThread(threadId),
|
||||||
refetchOnMount: "always",
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
const status = query.state.data?.status
|
|
||||||
return status === "running" ? 2000 : false
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,27 +94,57 @@ export function useDeleteAgentSchedule() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateAgentThread() {
|
export interface CreateAgentThreadVariables {
|
||||||
const queryClient = useQueryClient()
|
prompt: string
|
||||||
const navigate = useNavigate()
|
images?: Array<ImageChunk>
|
||||||
|
repo?: string | null
|
||||||
|
repo_explicitly_none?: boolean
|
||||||
|
model_id?: string | null
|
||||||
|
effort?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
return useMutation({
|
/**
|
||||||
mutationFn: agentsApi.createThread,
|
* Build the placeholder thread shown the instant a run is started from the
|
||||||
onSuccess: (thread, variables) => {
|
* home page — before the server has stamped the thread record. Seeded into
|
||||||
addPendingPrompt(
|
* the detail + list caches by `AgentsHome` so the `$threadId` route renders
|
||||||
thread.id,
|
* immediately (the 30s `staleTime` keeps it from refetching into a 404), then
|
||||||
variables.prompt,
|
* reconciled to server truth by the list's running refetch + the stream's
|
||||||
thread.messages.length,
|
* `onCreated` / `onCompleted` invalidations.
|
||||||
variables.images
|
*/
|
||||||
)
|
export function optimisticThread(
|
||||||
queryClient.setQueryData(agentThreadKeys.detail(thread.id), {
|
threadId: string,
|
||||||
...thread,
|
vars: CreateAgentThreadVariables
|
||||||
status: thread.status === "idle" ? "running" : thread.status,
|
): AgentThread {
|
||||||
})
|
const now = Date.now()
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
const text = vars.prompt.trim()
|
||||||
navigate({ to: "/agents/$threadId", params: { threadId: thread.id } })
|
const repoFullName = vars.repo ?? ""
|
||||||
},
|
const chunks: Array<Chunk> = [
|
||||||
})
|
...(vars.images ?? []),
|
||||||
|
...(text ? [{ kind: "text", text } satisfies Chunk] : []),
|
||||||
|
]
|
||||||
|
const message: Message = {
|
||||||
|
id: `optimistic-user-${threadId}`,
|
||||||
|
author: "user",
|
||||||
|
timestamp: new Date(now).toISOString(),
|
||||||
|
chunks,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: threadId,
|
||||||
|
title: text.slice(0, 80) || "New agent",
|
||||||
|
repo: repoFullName.split("/")[1] ?? "",
|
||||||
|
repoFullName,
|
||||||
|
branch: "main",
|
||||||
|
model: vars.model_id ?? "Default",
|
||||||
|
effort: vars.effort ?? null,
|
||||||
|
source: "dashboard",
|
||||||
|
status: "running",
|
||||||
|
viewed: true,
|
||||||
|
viewedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
traceUrl: null,
|
||||||
|
messages: message.chunks.length > 0 ? [message] : [],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendAgentMessageVariables {
|
export interface SendAgentMessageVariables {
|
||||||
|
|
@ -133,52 +154,14 @@ export interface SendAgentMessageVariables {
|
||||||
effort?: string | null
|
effort?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSendAgentMessage(threadId: string) {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (vars: SendAgentMessageVariables) =>
|
|
||||||
agentsApi.sendMessage(threadId, {
|
|
||||||
content: vars.content,
|
|
||||||
images: vars.images,
|
|
||||||
model_id: vars.model_id,
|
|
||||||
effort: vars.effort,
|
|
||||||
}),
|
|
||||||
onSuccess: (thread) => {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
agentThreadKeys.detail(threadId),
|
|
||||||
(prev: typeof thread | undefined) => {
|
|
||||||
if (!prev) return thread
|
|
||||||
return {
|
|
||||||
...thread,
|
|
||||||
messages:
|
|
||||||
thread.messages.length > 0 ? thread.messages : prev.messages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCancelAgentThread(threadId: string) {
|
export function useCancelAgentThread(threadId: string) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => agentsApi.cancelThread(threadId),
|
mutationFn: () => agentsApi.cancelThread(threadId),
|
||||||
onSuccess: (thread) => {
|
onSuccess: (thread) => {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread)
|
||||||
agentThreadKeys.detail(threadId),
|
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true })
|
||||||
(prev: typeof thread | undefined) => {
|
|
||||||
if (!prev) return thread
|
|
||||||
return {
|
|
||||||
...thread,
|
|
||||||
messages:
|
|
||||||
thread.messages.length > 0 ? thread.messages : prev.messages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +174,7 @@ export function useDeleteAgentThread() {
|
||||||
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
|
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
|
||||||
onSuccess: (_, threadId) => {
|
onSuccess: (_, threadId) => {
|
||||||
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
|
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true })
|
||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
if (path.includes(`/agents/${threadId}`)) {
|
if (path.includes(`/agents/${threadId}`)) {
|
||||||
navigate({ to: "/agents" })
|
navigate({ to: "/agents" })
|
||||||
|
|
|
||||||
380
ui/src/lib/agents/streamMessagesToUi.ts
Normal file
380
ui/src/lib/agents/streamMessagesToUi.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";
|
||||||
|
import type { BaseMessage, ContentBlock } from "@langchain/core/messages";
|
||||||
|
import type { AssembledToolCall, SubagentDiscoverySnapshot } from "@langchain/react";
|
||||||
|
|
||||||
|
import type { Chunk, DiffData, Message, ToolExecutionChunk } from "./types";
|
||||||
|
|
||||||
|
const READ_TOOLS = new Set(["read_file", "read", "glob", "grep"]);
|
||||||
|
const EDIT_TOOLS = new Set(["write_file", "edit_file", "str_replace", "write", "edit", "patch"]);
|
||||||
|
const EXECUTE_TOOLS = new Set(["execute", "bash", "shell", "run_terminal_cmd"]);
|
||||||
|
const SEARCH_TOOLS = new Set(["glob", "grep", "web_search", "fetch_url", "search"]);
|
||||||
|
const INTERNAL_TOOLS = new Set(["confirming_completion", "no_op"]);
|
||||||
|
|
||||||
|
type ToolKind = ToolExecutionChunk["toolKind"];
|
||||||
|
|
||||||
|
function toolKind(name: string): ToolKind {
|
||||||
|
const lowered = name.toLowerCase();
|
||||||
|
// deepagents' subagent spawner — surfaced as a subagent card in Messages.
|
||||||
|
if (lowered === "task") return "task";
|
||||||
|
if (lowered === "slack_thread_reply") return "slack";
|
||||||
|
if (lowered === "linear_comment") return "linear";
|
||||||
|
if (EDIT_TOOLS.has(lowered) || ["edit", "write", "replace"].some((t) => lowered.includes(t))) {
|
||||||
|
return "edit";
|
||||||
|
}
|
||||||
|
if (EXECUTE_TOOLS.has(lowered)) return "execute";
|
||||||
|
if (SEARCH_TOOLS.has(lowered)) return "search";
|
||||||
|
if (READ_TOOLS.has(lowered) || lowered.includes("read")) return "read";
|
||||||
|
if (lowered === "think") return "think";
|
||||||
|
if (["fetch", "fetch_url", "http_request"].includes(lowered)) return "fetch";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolTitle(name: string, args: Record<string, unknown>): string {
|
||||||
|
const path = args.path ?? args.file_path ?? args.target_file;
|
||||||
|
if (typeof path === "string" && path.trim()) return `${name} ${path.trim()}`;
|
||||||
|
const command = args.command;
|
||||||
|
if (typeof command === "string" && command.trim()) {
|
||||||
|
return command.trim().split("\n")[0]?.slice(0, 120) ?? "";
|
||||||
|
}
|
||||||
|
return name.replace(/_/g, " ").trim() || "Tool";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolArgs(raw: unknown): Record<string, unknown> {
|
||||||
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw as Record<string, unknown>;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: { raw };
|
||||||
|
} catch {
|
||||||
|
return { raw };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeDiffFromArgs(args: Record<string, unknown>): DiffData | null {
|
||||||
|
const path = args.path ?? args.file_path ?? args.target_file;
|
||||||
|
if (typeof path !== "string" || !path.trim()) return null;
|
||||||
|
const oldContent = args.old_string ?? args.original_content;
|
||||||
|
const newContent = args.new_string ?? args.content ?? args.new_content;
|
||||||
|
if (typeof newContent !== "string") return null;
|
||||||
|
const original = typeof oldContent === "string" ? oldContent : null;
|
||||||
|
return {
|
||||||
|
originalContent: original,
|
||||||
|
newContent,
|
||||||
|
filePath: path.trim(),
|
||||||
|
isNewFile: original === null,
|
||||||
|
isBinary: false,
|
||||||
|
isTruncated: false,
|
||||||
|
totalLines: Math.max(newContent.split("\n").length, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTextChunks(chunks: Array<Chunk>): Array<Chunk> {
|
||||||
|
const textIndices = chunks.flatMap((c, i) => (c.kind === "text" ? [i] : []));
|
||||||
|
if (textIndices.length <= 1) return chunks;
|
||||||
|
const lastText = textIndices[textIndices.length - 1];
|
||||||
|
return chunks.filter((c, i) => c.kind !== "text" || i === lastText);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentTurn = { id: string; author: Message["author"]; timestamp: string; chunks: Array<Chunk> };
|
||||||
|
|
||||||
|
function messageTimestamp(raw: BaseMessage): string {
|
||||||
|
const msg = raw as unknown as Record<string, unknown>;
|
||||||
|
const createdAt = msg.created_at;
|
||||||
|
if (typeof createdAt === "string" && createdAt) return createdAt;
|
||||||
|
const responseMetadata = msg.response_metadata;
|
||||||
|
if (responseMetadata && typeof responseMetadata === "object") {
|
||||||
|
const metadataCreatedAt = (responseMetadata as Record<string, unknown>).created_at;
|
||||||
|
if (typeof metadataCreatedAt === "string" && metadataCreatedAt) return metadataCreatedAt;
|
||||||
|
}
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull reasoning ("thinking") text out of a message's standard content blocks.
|
||||||
|
* `@langchain/core` v1 normalizes provider-specific reasoning (Anthropic
|
||||||
|
* `thinking`, OpenAI reasoning, …) into `{ type: "reasoning", reasoning }`
|
||||||
|
* blocks via the `contentBlocks` getter, so we don't have to parse each
|
||||||
|
* provider's raw shape ourselves.
|
||||||
|
*/
|
||||||
|
function reasoningText(raw: BaseMessage): string {
|
||||||
|
let blocks: Array<ContentBlock.Standard>;
|
||||||
|
try {
|
||||||
|
blocks = raw.contentBlocks;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
let text = "";
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type !== "reasoning") continue;
|
||||||
|
// Reasoning blocks can arrive without a summary (e.g. OpenAI reasoning
|
||||||
|
// models emit `{ type: "reasoning", extras: { content: [] } }` with no
|
||||||
|
// `reasoning` field) — skip those so we don't render a "Thought" block
|
||||||
|
// whose body is the literal string "undefined".
|
||||||
|
const reasoning: unknown = block.reasoning;
|
||||||
|
if (typeof reasoning === "string") text += reasoning;
|
||||||
|
}
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageChunks(content: unknown): Array<Chunk> {
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
|
||||||
|
const chunks: Array<Chunk> = [];
|
||||||
|
for (const item of content) {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||||
|
const block = item as Record<string, unknown>;
|
||||||
|
const type = block.type;
|
||||||
|
let base64: string | undefined;
|
||||||
|
let mimeType: string | undefined;
|
||||||
|
|
||||||
|
if (type === "image") {
|
||||||
|
const data = block.data ?? block.base64;
|
||||||
|
const mime = block.mime_type ?? block.mimeType;
|
||||||
|
if (typeof data === "string" && typeof mime === "string") {
|
||||||
|
base64 = data;
|
||||||
|
mimeType = mime;
|
||||||
|
}
|
||||||
|
} else if (type === "image_url") {
|
||||||
|
const imageUrl = block.image_url;
|
||||||
|
const url =
|
||||||
|
imageUrl && typeof imageUrl === "object"
|
||||||
|
? (imageUrl as Record<string, unknown>).url
|
||||||
|
: undefined;
|
||||||
|
if (typeof url === "string") {
|
||||||
|
const match = /^data:(image\/[^;]+);base64,(.+)$/s.exec(url);
|
||||||
|
if (match) {
|
||||||
|
mimeType = match[1];
|
||||||
|
base64 = match[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base64 && mimeType) {
|
||||||
|
const fileName = block.fileName ?? block.file_name;
|
||||||
|
chunks.push({
|
||||||
|
kind: "image",
|
||||||
|
base64,
|
||||||
|
mimeType,
|
||||||
|
...(typeof fileName === "string" && fileName ? { fileName } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the SDK's assembled tool-call lifecycle status onto the UI status.
|
||||||
|
* `stream.toolCalls` exposes a fully-assembled, reactive view of each call
|
||||||
|
* ({@link AssembledToolCall}) so we no longer hand-match AI `tool_calls` to
|
||||||
|
* their `ToolMessage` results to derive status/output.
|
||||||
|
*/
|
||||||
|
function toolStatus(
|
||||||
|
assembled: AssembledToolCall | undefined,
|
||||||
|
toolMessage: ToolMessage | undefined,
|
||||||
|
): ToolExecutionChunk["status"] {
|
||||||
|
if (assembled) {
|
||||||
|
if (assembled.status === "finished") return "completed";
|
||||||
|
if (assembled.status === "error") return "error";
|
||||||
|
return "in_progress";
|
||||||
|
}
|
||||||
|
if (toolMessage) return toolMessage.status === "error" ? "error" : "completed";
|
||||||
|
return "in_progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a {@link SubagentDiscoverySnapshot}'s lifecycle to the UI tool status. */
|
||||||
|
function subagentStatus(
|
||||||
|
snapshot: SubagentDiscoverySnapshot,
|
||||||
|
): ToolExecutionChunk["status"] {
|
||||||
|
if (snapshot.status === "complete") return "completed";
|
||||||
|
if (snapshot.status === "error") return "error";
|
||||||
|
return "in_progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolOutputText(
|
||||||
|
assembled: AssembledToolCall | undefined,
|
||||||
|
toolMessage: ToolMessage | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const value = assembled?.output;
|
||||||
|
if (value != null) {
|
||||||
|
if (typeof value === "string") return value.trim() || undefined;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = toolMessage?.text.trim();
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a server-computed diff off a tool result's persisted `artifact`.
|
||||||
|
*
|
||||||
|
* `ToolMessage.artifact` survives the checkpoint + `/state` hydration (it is
|
||||||
|
* a standard serialized field), so `ToolArtifactMiddleware`
|
||||||
|
* (`agent/middleware/tool_artifact.py`) attaches a real, sandbox-computed
|
||||||
|
* {@link DiffData} here and the client renders it both live and on reload —
|
||||||
|
* without re-deriving it from tool args. Falls back to the args heuristic
|
||||||
|
* ({@link maybeDiffFromArgs}) when no artifact is present.
|
||||||
|
*/
|
||||||
|
function diffFromArtifact(artifact: unknown): DiffData | null {
|
||||||
|
if (!artifact || typeof artifact !== "object") return null;
|
||||||
|
const record = artifact as Record<string, unknown>;
|
||||||
|
const candidate: unknown = record.diff ?? record.diffData ?? artifact;
|
||||||
|
if (candidate === null || typeof candidate !== "object") return null;
|
||||||
|
const diff = candidate as Record<string, unknown>;
|
||||||
|
const { filePath, newContent } = diff;
|
||||||
|
if (typeof filePath !== "string" || typeof newContent !== "string") return null;
|
||||||
|
// The server may send a minimal `{ filePath, originalContent, newContent }`,
|
||||||
|
// so fill the remaining presentation fields with sensible defaults.
|
||||||
|
const originalContent = typeof diff.originalContent === "string" ? diff.originalContent : null;
|
||||||
|
return {
|
||||||
|
originalContent,
|
||||||
|
newContent,
|
||||||
|
filePath: filePath.trim(),
|
||||||
|
isNewFile: typeof diff.isNewFile === "boolean" ? diff.isNewFile : originalContent === null,
|
||||||
|
isBinary: typeof diff.isBinary === "boolean" ? diff.isBinary : false,
|
||||||
|
isTruncated: typeof diff.isTruncated === "boolean" ? diff.isTruncated : false,
|
||||||
|
totalLines:
|
||||||
|
typeof diff.totalLines === "number"
|
||||||
|
? diff.totalLines
|
||||||
|
: Math.max(newContent.split("\n").length, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the SDK's live projections into the dashboard chunk model so the
|
||||||
|
* transcript streams (and hydrates) directly from the SDK instead of a
|
||||||
|
* hand-rolled, server-mirrored adapter.
|
||||||
|
*
|
||||||
|
* - `messages` ({@link BaseMessage}[]) drives ordering, text, and reasoning.
|
||||||
|
* - `toolCalls` ({@link AssembledToolCall}[], i.e. `stream.toolCalls`) drives
|
||||||
|
* each tool call's status and output — no `pendingTools` bookkeeping.
|
||||||
|
* - `toolKind` / `title` stay a pure mapping of name+args (known at call time,
|
||||||
|
* already persisted) so the in-progress card renders instantly.
|
||||||
|
* - `diffData` prefers the persisted `ToolMessage.artifact`, falling back to
|
||||||
|
* the args heuristic.
|
||||||
|
* - `subagents` ({@link SubagentDiscoverySnapshot}[], i.e. `stream.subagents`)
|
||||||
|
* authoritatively identifies `task` calls that spawned a subagent (matched by
|
||||||
|
* `snapshot.id === toolCallId`) and supplies their lifecycle status + the
|
||||||
|
* namespace Messages uses to subscribe to nested activity.
|
||||||
|
*/
|
||||||
|
export function streamMessagesToUi(
|
||||||
|
messages: Array<BaseMessage>,
|
||||||
|
toolCalls: ReadonlyArray<AssembledToolCall> = [],
|
||||||
|
subagents: ReadonlyMap<string, SubagentDiscoverySnapshot> = new Map(),
|
||||||
|
): Array<Message> {
|
||||||
|
const toolCallsById = new Map<string, AssembledToolCall>();
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
const id = toolCall.id || toolCall.callId;
|
||||||
|
if (id) toolCallsById.set(id, toolCall);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The discovery map is keyed by subagent name (one entry per name), but each
|
||||||
|
// snapshot records the `task` tool-call id that spawned it — so re-index by
|
||||||
|
// that id to correlate a snapshot to the exact `task` chunk that created it.
|
||||||
|
const subagentsByCallId = new Map<string, SubagentDiscoverySnapshot>();
|
||||||
|
for (const snapshot of subagents.values()) {
|
||||||
|
if (snapshot.id) subagentsByCallId.set(snapshot.id, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolMessagesById = new Map<string, ToolMessage>();
|
||||||
|
for (const raw of messages) {
|
||||||
|
if (ToolMessage.isInstance(raw) && typeof raw.tool_call_id === "string") {
|
||||||
|
toolMessagesById.set(raw.tool_call_id, raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiMessages: Array<Message> = [];
|
||||||
|
let agentTurn: AgentTurn | null = null;
|
||||||
|
|
||||||
|
const flushAgentTurn = () => {
|
||||||
|
if (!agentTurn) return;
|
||||||
|
uiMessages.push({ ...agentTurn, chunks: mergeTextChunks(agentTurn.chunks) });
|
||||||
|
agentTurn = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendAgentChunks = (msgId: string, timestamp: string, chunks: Array<Chunk>) => {
|
||||||
|
if (!agentTurn) {
|
||||||
|
agentTurn = {
|
||||||
|
id: msgId,
|
||||||
|
author: "agent",
|
||||||
|
timestamp,
|
||||||
|
chunks: [...chunks],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
agentTurn.timestamp = timestamp;
|
||||||
|
agentTurn.chunks.push(...chunks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
messages.forEach((raw, index) => {
|
||||||
|
const msgId = typeof raw.id === "string" && raw.id ? raw.id : `msg-${index}`;
|
||||||
|
const timestamp = messageTimestamp(raw);
|
||||||
|
|
||||||
|
if (HumanMessage.isInstance(raw)) {
|
||||||
|
flushAgentTurn();
|
||||||
|
const content = (raw as unknown as { content?: unknown }).content;
|
||||||
|
const chunks = imageChunks(content);
|
||||||
|
const text = raw.text.trim();
|
||||||
|
if (text) chunks.push({ kind: "text", text });
|
||||||
|
if (!chunks.length) return;
|
||||||
|
uiMessages.push({
|
||||||
|
id: msgId,
|
||||||
|
author: "user",
|
||||||
|
timestamp,
|
||||||
|
chunks,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AIMessage.isInstance(raw)) {
|
||||||
|
const chunks: Array<Chunk> = [];
|
||||||
|
const reasoning = reasoningText(raw);
|
||||||
|
if (reasoning) chunks.push({ kind: "reasoning", text: reasoning });
|
||||||
|
const text = raw.text.trim();
|
||||||
|
if (text) chunks.push({ kind: "text", text });
|
||||||
|
|
||||||
|
for (const toolCall of raw.tool_calls ?? []) {
|
||||||
|
const name = toolCall.name || "tool";
|
||||||
|
if (INTERNAL_TOOLS.has(name)) continue;
|
||||||
|
const toolCallId = toolCall.id || `tool-${index}-${chunks.length}`;
|
||||||
|
const args = parseToolArgs(toolCall.args);
|
||||||
|
const assembled = toolCallsById.get(toolCallId);
|
||||||
|
const toolMessage = toolMessagesById.get(toolCallId);
|
||||||
|
const chunk: ToolExecutionChunk = {
|
||||||
|
kind: "tool-execution",
|
||||||
|
toolCallId,
|
||||||
|
title: toolTitle(name, args),
|
||||||
|
toolKind: toolKind(name),
|
||||||
|
input: args,
|
||||||
|
status: toolStatus(assembled, toolMessage),
|
||||||
|
};
|
||||||
|
const output = toolOutputText(assembled, toolMessage);
|
||||||
|
if (output) chunk.output = output;
|
||||||
|
const diffData = diffFromArtifact(toolMessage?.artifact) ?? maybeDiffFromArgs(args);
|
||||||
|
if (diffData) chunk.diffData = diffData;
|
||||||
|
// When the SDK has discovered the subagent this `task` call spawned, take
|
||||||
|
// its namespace (for scoped nested activity) and authoritative status.
|
||||||
|
const subagent = subagentsByCallId.get(toolCallId);
|
||||||
|
if (subagent) {
|
||||||
|
chunk.subagentNamespace = [...subagent.namespace];
|
||||||
|
chunk.status = subagentStatus(subagent);
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length) appendAgentChunks(msgId, timestamp, chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ToolMessage`s no longer produce their own chunk — their status/output is
|
||||||
|
// attached to the originating tool-call chunk above via `stream.toolCalls`.
|
||||||
|
});
|
||||||
|
|
||||||
|
flushAgentTurn();
|
||||||
|
return uiMessages;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ export type Author = "user" | "agent" | "system" | "tool"
|
||||||
|
|
||||||
export type ChunkKind =
|
export type ChunkKind =
|
||||||
| "text"
|
| "text"
|
||||||
|
| "reasoning"
|
||||||
| "code"
|
| "code"
|
||||||
| "error"
|
| "error"
|
||||||
| "list"
|
| "list"
|
||||||
|
|
@ -41,6 +42,8 @@ export type AcpToolKind =
|
||||||
| "fetch"
|
| "fetch"
|
||||||
| "slack"
|
| "slack"
|
||||||
| "linear"
|
| "linear"
|
||||||
|
/** deepagents `task` tool — spawns a subagent; rendered as a subagent card. */
|
||||||
|
| "task"
|
||||||
| "other"
|
| "other"
|
||||||
|
|
||||||
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error"
|
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error"
|
||||||
|
|
@ -73,6 +76,14 @@ export interface ToolExecutionChunk {
|
||||||
diffData?: DiffData
|
diffData?: DiffData
|
||||||
diffs?: Array<DiffData>
|
diffs?: Array<DiffData>
|
||||||
locations?: Array<AcpToolLocation>
|
locations?: Array<AcpToolLocation>
|
||||||
|
/**
|
||||||
|
* Namespace of the subagent this `task` call spawned, from the SDK's
|
||||||
|
* `stream.subagents` discovery map (correlated by tool-call id). Present only
|
||||||
|
* for `toolKind: "task"` chunks whose subagent the SDK has discovered; lets
|
||||||
|
* the UI open a scoped `useToolCalls(stream, { namespace })` subscription to
|
||||||
|
* show the subagent's nested activity.
|
||||||
|
*/
|
||||||
|
subagentNamespace?: Array<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextChunk {
|
export interface TextChunk {
|
||||||
|
|
@ -80,6 +91,11 @@ export interface TextChunk {
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReasoningChunk {
|
||||||
|
kind: "reasoning"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CodeChunk {
|
export interface CodeChunk {
|
||||||
kind: "code"
|
kind: "code"
|
||||||
text: string
|
text: string
|
||||||
|
|
@ -110,6 +126,7 @@ export interface ImageChunk {
|
||||||
|
|
||||||
export type Chunk =
|
export type Chunk =
|
||||||
| TextChunk
|
| TextChunk
|
||||||
|
| ReasoningChunk
|
||||||
| CodeChunk
|
| CodeChunk
|
||||||
| ErrorChunk
|
| ErrorChunk
|
||||||
| ListChunk
|
| ListChunk
|
||||||
|
|
@ -194,9 +211,21 @@ export type GitFileStatus =
|
||||||
| "index-modified"
|
| "index-modified"
|
||||||
| "index-added"
|
| "index-added"
|
||||||
| "index-deleted"
|
| "index-deleted"
|
||||||
|
| "index-renamed"
|
||||||
|
| "index-copied"
|
||||||
| "modified"
|
| "modified"
|
||||||
| "deleted"
|
| "deleted"
|
||||||
| "untracked"
|
| "untracked"
|
||||||
|
| "ignored"
|
||||||
|
| "type-changed"
|
||||||
|
| "intent-to-add"
|
||||||
|
| "both-modified"
|
||||||
|
| "both-added"
|
||||||
|
| "both-deleted"
|
||||||
|
| "added-by-us"
|
||||||
|
| "added-by-them"
|
||||||
|
| "deleted-by-us"
|
||||||
|
| "deleted-by-them";
|
||||||
|
|
||||||
export interface GitStatusEntry {
|
export interface GitStatusEntry {
|
||||||
path: string
|
path: string
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { agentsApi } from "./api";
|
|
||||||
import { agentThreadKeys } from "./queries";
|
|
||||||
import type { AgentThread, Message, TextChunk } from "./types";
|
|
||||||
|
|
||||||
interface StreamPart {
|
|
||||||
event?: string;
|
|
||||||
data?: unknown;
|
|
||||||
id?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChunkLike {
|
|
||||||
id?: string;
|
|
||||||
type?: string;
|
|
||||||
content?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractText(content: unknown): string {
|
|
||||||
if (typeof content === "string") return content;
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
let out = "";
|
|
||||||
for (const part of content) {
|
|
||||||
if (typeof part === "string") {
|
|
||||||
out += part;
|
|
||||||
} else if (part && typeof part === "object" && "text" in part) {
|
|
||||||
const text = (part as { text?: unknown }).text;
|
|
||||||
if (typeof text === "string") out += text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendTokenChunk(thread: AgentThread, chunk: ChunkLike): AgentThread {
|
|
||||||
const text = extractText(chunk.content);
|
|
||||||
if (!text || !chunk.id) return thread;
|
|
||||||
const id = chunk.id;
|
|
||||||
|
|
||||||
const existing = thread.messages.find((m) => m.id === id);
|
|
||||||
if (existing) {
|
|
||||||
const chunks = existing.chunks.slice();
|
|
||||||
const textIdx = chunks.findIndex((c) => c.kind === "text");
|
|
||||||
if (textIdx >= 0) {
|
|
||||||
const prev = chunks[textIdx] as TextChunk;
|
|
||||||
chunks[textIdx] = { ...prev, text: prev.text + text };
|
|
||||||
} else {
|
|
||||||
chunks.unshift({ kind: "text", text });
|
|
||||||
}
|
|
||||||
const updated: Message = { ...existing, chunks };
|
|
||||||
return {
|
|
||||||
...thread,
|
|
||||||
messages: thread.messages.map((m) => (m.id === id ? updated : m)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMessage: Message = {
|
|
||||||
id,
|
|
||||||
author: "agent",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
chunks: [{ kind: "text", text }],
|
|
||||||
};
|
|
||||||
return { ...thread, messages: [...thread.messages, newMessage] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStreamingChunk(chunk: ChunkLike): boolean {
|
|
||||||
const type = chunk.type;
|
|
||||||
if (typeof type !== "string") return false;
|
|
||||||
return type === "AIMessageChunk" || type.endsWith("MessageChunk");
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMessagesEvent(
|
|
||||||
threadId: string,
|
|
||||||
data: unknown,
|
|
||||||
queryClient: QueryClient,
|
|
||||||
): boolean {
|
|
||||||
if (!Array.isArray(data) || data.length === 0) return false;
|
|
||||||
const first = data[0];
|
|
||||||
if (!first || typeof first !== "object") return false;
|
|
||||||
const chunk = first as ChunkLike;
|
|
||||||
if (!isStreamingChunk(chunk)) return false;
|
|
||||||
|
|
||||||
const key = agentThreadKeys.detail(threadId);
|
|
||||||
void queryClient.cancelQueries({ queryKey: key });
|
|
||||||
queryClient.setQueryData<AgentThread | undefined>(
|
|
||||||
key,
|
|
||||||
(prev) => (prev ? appendTokenChunk(prev, chunk) : prev),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStreamPart(
|
|
||||||
threadId: string,
|
|
||||||
part: StreamPart,
|
|
||||||
queryClient: QueryClient,
|
|
||||||
) {
|
|
||||||
const event = part.event ?? "";
|
|
||||||
if (event.startsWith("messages")) {
|
|
||||||
const handled = applyMessagesEvent(threadId, part.data, queryClient);
|
|
||||||
if (handled) return;
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.detail(threadId) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgentThreadStream(threadId: string, enabled: boolean) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function consume() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(agentsApi.streamUrl(threadId), {
|
|
||||||
credentials: "include",
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: { Accept: "text/event-stream" },
|
|
||||||
});
|
|
||||||
if (!res.ok || !res.body) return;
|
|
||||||
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
while (!cancelled) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
|
|
||||||
let boundary = buffer.indexOf("\n\n");
|
|
||||||
while (boundary !== -1) {
|
|
||||||
const block = buffer.slice(0, boundary);
|
|
||||||
buffer = buffer.slice(boundary + 2);
|
|
||||||
for (const line of block.split("\n")) {
|
|
||||||
if (!line.startsWith("data: ")) continue;
|
|
||||||
const raw = line.slice(6);
|
|
||||||
try {
|
|
||||||
const part = JSON.parse(raw) as StreamPart;
|
|
||||||
handleStreamPart(threadId, part, queryClient);
|
|
||||||
} catch {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: agentThreadKeys.detail(threadId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
boundary = buffer.indexOf("\n\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
console.debug("agent thread stream closed", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void consume();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [enabled, queryClient, threadId]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,54 @@
|
||||||
import { clsx } from "clsx"
|
import { clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import type {ClassValue} from "clsx";
|
import type { ClassValue } from "clsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge class names into a single string.
|
||||||
|
* @param inputs - The class names to merge.
|
||||||
|
* @returns The merged class name string.
|
||||||
|
* @example
|
||||||
|
* cn("text-red-500", "bg-blue-500") // "text-red-500 bg-blue-500"
|
||||||
|
* cn("text-red-500", "bg-blue-500", "text-2xl") // "text-red-500 bg-blue-500 text-2xl"
|
||||||
|
*/
|
||||||
export function cn(...inputs: Array<ClassValue>) {
|
export function cn(...inputs: Array<ClassValue>) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intl.RelativeTimeFormat instance for formatting relative times.
|
||||||
|
*/
|
||||||
|
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a timestamp as a relative time string.
|
||||||
|
* @param ts - The timestamp to format.
|
||||||
|
* @returns The relative time string.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatRelativeTime(Date.now()) // "just now"
|
||||||
|
* formatRelativeTime(Date.now() - 1000) // "1 second ago"
|
||||||
|
* formatRelativeTime(Date.now() - 60 * 1000) // "1 minute ago"
|
||||||
|
* formatRelativeTime(Date.now() - 60 * 60 * 1000) // "1 hour ago"
|
||||||
|
* formatRelativeTime(Date.now() - 24 * 60 * 60 * 1000) // "1 day ago"
|
||||||
|
* formatRelativeTime(Date.now() - 7 * 24 * 60 * 60 * 1000) // "1 week ago"
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(ts: number): string {
|
||||||
|
const diffMs = ts - Date.now(); // negative for past
|
||||||
|
const absMs = Math.abs(diffMs);
|
||||||
|
|
||||||
|
if (absMs < 60_000) return rtf.format(Math.round(diffMs / 1000), "second");
|
||||||
|
if (absMs < 60 * 60_000) return rtf.format(Math.round(diffMs / 60_000), "minute");
|
||||||
|
if (absMs < 24 * 60 * 60_000) {
|
||||||
|
return rtf.format(Math.round(diffMs / (60 * 60_000)), "hour");
|
||||||
|
}
|
||||||
|
if (absMs < 7 * 24 * 60 * 60_000) {
|
||||||
|
return rtf.format(Math.round(diffMs / (24 * 60 * 60_000)), "day");
|
||||||
|
}
|
||||||
|
if (absMs < 30 * 24 * 60 * 60_000) {
|
||||||
|
return rtf.format(Math.round(diffMs / (7 * 24 * 60 * 60_000)), "week");
|
||||||
|
}
|
||||||
|
if (absMs < 365 * 24 * 60 * 60_000) {
|
||||||
|
return rtf.format(Math.round(diffMs / (30 * 24 * 60 * 60_000)), "month");
|
||||||
|
}
|
||||||
|
return rtf.format(Math.round(diffMs / (365 * 24 * 60 * 60_000)), "year");
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import { AgentsShell } from "@/components/agents/AgentsSidebar"
|
import { AgentsShell } from "@/components/agents/AgentsSidebar"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import agentsCss from "@/styles/agents.css?url"
|
import agentsCss from "@/styles/agents.css?url"
|
||||||
|
import { AgentThreadStreamProvider } from "@/lib/agents/AgentThreadStreamProvider"
|
||||||
import { useSession } from "@/lib/session"
|
import { useSession } from "@/lib/session"
|
||||||
|
|
||||||
export const Route = createFileRoute("/agents")({
|
export const Route = createFileRoute("/agents")({
|
||||||
|
|
@ -40,7 +41,9 @@ function AgentsLayout() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentsShell user={session.data} activeThreadId={activeThreadId}>
|
<AgentsShell user={session.data} activeThreadId={activeThreadId}>
|
||||||
<Outlet />
|
<AgentThreadStreamProvider threadId={activeThreadId ?? null}>
|
||||||
|
<Outlet />
|
||||||
|
</AgentThreadStreamProvider>
|
||||||
</AgentsShell>
|
</AgentsShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Navigate, createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
import { AgentThreadView } from "@/components/agents/AgentThreadView"
|
import { AgentThreadView } from "@/components/agents/AgentThreadView"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { AgentThreadStreamBoundary } from "@/lib/agents/provider/useIsInAgentThreadStream"
|
||||||
import { useAgentThread } from "@/lib/agents/queries"
|
import { useAgentThread } from "@/lib/agents/queries"
|
||||||
|
|
||||||
export const Route = createFileRoute("/agents/$threadId")({
|
export const Route = createFileRoute("/agents/$threadId")({
|
||||||
|
|
@ -24,5 +25,9 @@ function AgentThreadPage() {
|
||||||
return <Navigate to="/agents" />
|
return <Navigate to="/agents" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AgentThreadView thread={threadQuery.data} />
|
return (
|
||||||
|
<AgentThreadStreamBoundary>
|
||||||
|
<AgentThreadView thread={threadQuery.data} />
|
||||||
|
</AgentThreadStreamBoundary>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "@fontsource-variable/inter";
|
@import "@fontsource-variable/inter";
|
||||||
@import "./styles/agents.css";
|
@import "./styles/agents.css";
|
||||||
|
@source "../node_modules/streamdown/dist/*.js";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
{
|
{
|
||||||
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
|
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
// Ported from a desktop host app; not typechecked until integrated (see file headers).
|
||||||
"src/components/agents/ported/ChatView.tsx",
|
"src/components/agents/ported/ChatView.tsx",
|
||||||
"src/components/agents/ported/PromptBar.tsx",
|
"src/components/agents/ported/PromptBar.tsx",
|
||||||
"src/components/agents/ported/BranchSelector.tsx",
|
"src/components/agents/ported/BranchSelector.tsx",
|
||||||
"src/components/agents/ported/ContextIndicator.tsx",
|
"src/components/agents/ported/ContextIndicator.tsx",
|
||||||
"src/components/agents/ported/SourceControlPanel.tsx",
|
"src/components/agents/ported/SourceControlPanel.tsx",
|
||||||
|
// Depends on host-provided window.git; live dashboard uses AgentGitPanel instead.
|
||||||
"src/components/agents/ported/SourceControlTile.tsx",
|
"src/components/agents/ported/SourceControlTile.tsx",
|
||||||
"src/components/agents/ported/Footer.tsx",
|
"src/components/agents/ported/Footer.tsx",
|
||||||
"src/components/agents/ported/ThreadPicker.tsx"
|
"src/components/agents/ported/ThreadPicker.tsx"
|
||||||
|
|
@ -33,7 +35,6 @@
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue