mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +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)
|
||||
- [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)
|
||||
- [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
|
||||
|
||||
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:
|
||||
- **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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
def _allowed_redirect_origins() -> set[str]:
|
||||
"""Origins permitted for the post-login redirect.
|
||||
def allowed_dashboard_origins() -> set[str]:
|
||||
"""Origins permitted for dashboard frontend requests and post-login redirects.
|
||||
|
||||
Built from DASHBOARD_BASE_URL plus any DASHBOARD_ALLOWED_ORIGINS entries
|
||||
so the dashboard itself and its preview deploys can all be redirect
|
||||
targets — but nothing else.
|
||||
so the dashboard itself and its preview deploys are allowed — but nothing
|
||||
else.
|
||||
"""
|
||||
origins: set[str] = set()
|
||||
base = os.environ.get("DASHBOARD_BASE_URL", "").strip()
|
||||
|
|
@ -57,10 +57,22 @@ def _allowed_redirect_origins() -> set[str]:
|
|||
|
||||
|
||||
def _origin_of(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
"""Normalize a URL or Origin header value to ``scheme://host[:port]``."""
|
||||
trimmed = url.strip().rstrip("/")
|
||||
if not trimmed or trimmed.lower() == "null":
|
||||
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:
|
||||
|
|
@ -76,7 +88,7 @@ def sanitize_redirect_to(redirect_to: str | None) -> str:
|
|||
candidate_origin = _origin_of(redirect_to)
|
||||
if not candidate_origin:
|
||||
return fallback
|
||||
if candidate_origin in _allowed_redirect_origins():
|
||||
if candidate_origin in allowed_dashboard_origins():
|
||||
return redirect_to
|
||||
logger.warning("Rejected redirect_to=%r — origin not in allowlist", redirect_to)
|
||||
return fallback
|
||||
|
|
@ -190,6 +202,47 @@ def require_session(request: Request) -> dict[str, Any]:
|
|||
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:
|
||||
"""Convert GitHub ``expires_in`` / ``refresh_token_expires_in`` to an ISO timestamp."""
|
||||
raw = data.get(field)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from .oauth import (
|
|||
issue_session,
|
||||
issue_state,
|
||||
new_state_nonce,
|
||||
require_same_origin_for_mutations,
|
||||
require_session,
|
||||
sanitize_redirect_to,
|
||||
)
|
||||
|
|
@ -105,13 +106,16 @@ from .team_settings import (
|
|||
upsert_team_settings,
|
||||
)
|
||||
from .thread_api import (
|
||||
ThreadCreateBody,
|
||||
ThreadMessageBody,
|
||||
cancel_dashboard_thread,
|
||||
create_dashboard_thread,
|
||||
delete_dashboard_thread,
|
||||
get_dashboard_thread,
|
||||
get_dashboard_thread_state,
|
||||
list_dashboard_threads,
|
||||
proxy_dashboard_thread_commands,
|
||||
proxy_dashboard_thread_history,
|
||||
proxy_dashboard_thread_run_cancel,
|
||||
proxy_dashboard_thread_stream_events,
|
||||
send_dashboard_message,
|
||||
stream_dashboard_thread,
|
||||
)
|
||||
|
|
@ -124,7 +128,11 @@ from .user_mappings import (
|
|||
|
||||
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)
|
||||
_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)
|
||||
|
||||
|
||||
@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}")
|
||||
async def api_get_thread(
|
||||
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"))
|
||||
|
||||
|
||||
@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")
|
||||
async def api_cancel_thread(
|
||||
thread_id: str,
|
||||
|
|
@ -949,6 +968,73 @@ async def api_delete_thread(
|
|||
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")
|
||||
async def api_stream_thread(
|
||||
thread_id: str,
|
||||
|
|
|
|||
|
|
@ -7,20 +7,23 @@ import binascii
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from langchain_core.messages.content import create_image_block
|
||||
from langgraph_sdk.errors import InternalServerError
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
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 .message_adapter import state_messages_to_ui
|
||||
from .options import SUPPORTED_MODEL_IDS, model_supports_effort, model_supports_images
|
||||
from .profiles import get_profile, get_valid_access_token
|
||||
from .team_settings import get_team_default_model
|
||||
|
|
@ -30,10 +33,23 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
_ASSISTANT_ID = "agent"
|
||||
_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"})
|
||||
_MAX_DASHBOARD_IMAGES = 5
|
||||
_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").
|
||||
_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 {}
|
||||
|
||||
|
||||
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:
|
||||
"""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")
|
||||
|
||||
|
||||
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):
|
||||
content: str = Field(default="", max_length=20_000)
|
||||
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(
|
||||
thread: dict[str, Any],
|
||||
*,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
latest_run_status: str | None = None,
|
||||
latest_run_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -305,10 +337,9 @@ def _thread_summary(
|
|||
"baseRef": metadata.get("base_branch") or "main",
|
||||
"url": pr_url,
|
||||
}
|
||||
if messages is not None:
|
||||
summary["messages"] = messages
|
||||
else:
|
||||
summary["messages"] = []
|
||||
# The transcript hydrates client-side from the SDK (`GET …/state` →
|
||||
# `stream.messages`); the summary only carries metadata.
|
||||
summary["messages"] = []
|
||||
return summary
|
||||
|
||||
|
||||
|
|
@ -435,19 +466,9 @@ async def get_dashboard_thread(
|
|||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||
is_owner = _user_owns_thread(metadata, login, email)
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
try:
|
||||
state = await client.threads.get_state(thread_id)
|
||||
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 [])
|
||||
|
||||
# The transcript is hydrated client-side by the SDK (`StreamProvider` reads
|
||||
# `GET …/state` → `stream.messages`), so the detail endpoint returns
|
||||
# metadata only — no server-side message conversion.
|
||||
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
|
||||
status = _run_status_to_agent_status(
|
||||
|
|
@ -470,7 +491,6 @@ async def get_dashboard_thread(
|
|||
|
||||
return _thread_summary(
|
||||
thread,
|
||||
messages=messages,
|
||||
latest_run_status=latest_run_status,
|
||||
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 {}
|
||||
|
||||
|
||||
async def _start_agent_run(
|
||||
async def _create_dashboard_thread_record(
|
||||
thread_id: str,
|
||||
*,
|
||||
login: str,
|
||||
|
|
@ -493,11 +513,15 @@ async def _start_agent_run(
|
|||
model_id: str | None = None,
|
||||
effort: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create or update dashboard thread metadata without starting a run."""
|
||||
profile = await get_profile(login) or {}
|
||||
now_ms = _now_ms()
|
||||
prompt = prompt.strip()
|
||||
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)
|
||||
metadata_model = chosen_model or profile.get("default_model") or "Default"
|
||||
metadata_effort = chosen_effort or profile.get("reasoning_effort")
|
||||
|
|
@ -524,55 +548,213 @@ async def _start_agent_run(
|
|||
client = langgraph_client()
|
||||
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 _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] = {
|
||||
"thread_id": thread_id,
|
||||
"source": _DASHBOARD_SOURCE,
|
||||
"source": thread_source,
|
||||
"github_login": login,
|
||||
"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
|
||||
elif repo_explicitly_none:
|
||||
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
|
||||
source_context = metadata.get("source_context")
|
||||
if isinstance(source_context, dict):
|
||||
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,
|
||||
_ASSISTANT_ID,
|
||||
input={"messages": [{"role": "user", "content": content}]},
|
||||
config={"configurable": configurable, "metadata": _agent_version_metadata()},
|
||||
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}
|
||||
login,
|
||||
metadata,
|
||||
overrides=overrides,
|
||||
)
|
||||
|
||||
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]:
|
||||
repo_config = _resolve_repo_config(body.repo)
|
||||
thread_id = str(uuid.uuid4())
|
||||
return await _start_agent_run(
|
||||
thread_id,
|
||||
login=login,
|
||||
repo_config=repo_config,
|
||||
repo_explicitly_none=body.repo_explicitly_none,
|
||||
prompt=body.prompt,
|
||||
images=body.images,
|
||||
model_id=body.model_id,
|
||||
effort=body.effort,
|
||||
)
|
||||
params["assistant_id"] = _ASSISTANT_ID
|
||||
params.setdefault("stream_mode", list(_DASHBOARD_STREAM_MODES))
|
||||
params.setdefault("stream_resumable", True)
|
||||
params["config"] = {**client_config, "configurable": merged_configurable}
|
||||
params["metadata"] = run_metadata
|
||||
command["params"] = params
|
||||
return command
|
||||
|
||||
|
||||
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 {}
|
||||
_assert_thread_owner(metadata, login, email)
|
||||
owner, name, _ = _metadata_repo(metadata)
|
||||
|
||||
prompt = body.content.strip()
|
||||
now_ms = _now_ms()
|
||||
|
|
@ -596,64 +777,26 @@ async def send_dashboard_message(
|
|||
metadata_update["model"] = chosen_model
|
||||
metadata_update["effort"] = chosen_effort
|
||||
|
||||
if await is_thread_active(thread_id):
|
||||
active_model = _metadata_model_id(metadata) if body.images else None
|
||||
content = _user_message_content(prompt, body.images, model_id=active_model)
|
||||
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
|
||||
queue_payload: dict[str, Any] = {"text": prompt, "source": _DASHBOARD_SOURCE}
|
||||
if isinstance(content, list):
|
||||
queue_payload["images"] = [
|
||||
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}
|
||||
active = await get_thread_active_status(thread_id)
|
||||
if active is None:
|
||||
raise HTTPException(502, "could not determine whether thread is active")
|
||||
if not active:
|
||||
raise HTTPException(
|
||||
409,
|
||||
"thread is idle; start a run via the stream commands endpoint",
|
||||
)
|
||||
|
||||
profile = await get_profile(login) or {}
|
||||
resolved_model, resolved_effort = await _resolve_agent_model_choice(
|
||||
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)
|
||||
active_model = _metadata_model_id(metadata) if body.images else None
|
||||
content = _user_message_content(prompt, body.images, model_id=active_model)
|
||||
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
|
||||
|
||||
await _ensure_dashboard_github_token(login)
|
||||
configurable: dict[str, Any] = {
|
||||
"thread_id": thread_id,
|
||||
"source": _DASHBOARD_SOURCE,
|
||||
"github_login": login,
|
||||
"user_email": await _resolve_run_email(login, profile),
|
||||
}
|
||||
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},
|
||||
)
|
||||
queue_payload: dict[str, Any] = {"text": prompt, "source": _DASHBOARD_SOURCE}
|
||||
if isinstance(content, list):
|
||||
queue_payload["images"] = [
|
||||
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}
|
||||
|
|
@ -709,6 +852,211 @@ async def delete_dashboard_thread(thread_id: str, login: str, *, email: str | No
|
|||
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(
|
||||
thread_id: str, login: str, *, email: str | None = None, last_event_id: str | None = None
|
||||
) -> AsyncIterator[str]:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware
|
|||
from .sanitize_thinking_blocks import SanitizeThinkingBlocksMiddleware
|
||||
from .sanitize_tool_inputs import SanitizeToolInputsMiddleware
|
||||
from .settle_review_check import settle_review_check_on_exit
|
||||
from .tool_artifact import ToolArtifactMiddleware
|
||||
from .tool_error_handler import ToolErrorMiddleware
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -15,6 +16,7 @@ __all__ = [
|
|||
"ModelFallbackMiddleware",
|
||||
"SanitizeThinkingBlocksMiddleware",
|
||||
"SanitizeToolInputsMiddleware",
|
||||
"ToolArtifactMiddleware",
|
||||
"ToolErrorMiddleware",
|
||||
"SandboxCircuitBreakerMiddleware",
|
||||
"SlackAssistantStatusMiddleware",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import openai
|
|||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -49,6 +50,46 @@ def _should_fallback(exc: BaseException) -> bool:
|
|||
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):
|
||||
"""Retry the model call against a fallback provider on transient errors."""
|
||||
|
||||
|
|
@ -64,6 +105,10 @@ class ModelFallbackMiddleware(AgentMiddleware):
|
|||
try:
|
||||
return handler(request)
|
||||
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):
|
||||
raise
|
||||
logger.warning(
|
||||
|
|
@ -82,6 +127,10 @@ class ModelFallbackMiddleware(AgentMiddleware):
|
|||
try:
|
||||
return await handler(request)
|
||||
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):
|
||||
raise
|
||||
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,
|
||||
SanitizeToolInputsMiddleware,
|
||||
SlackAssistantStatusMiddleware,
|
||||
ToolArtifactMiddleware,
|
||||
ToolErrorMiddleware,
|
||||
check_message_queue_before_model,
|
||||
ensure_no_empty_msg,
|
||||
|
|
@ -660,6 +661,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
SanitizeToolInputsMiddleware(),
|
||||
ModelCallLimitMiddleware(run_limit=MODEL_CALL_RECURSION_LIMIT, exit_behavior="end"),
|
||||
ToolErrorMiddleware(),
|
||||
ToolArtifactMiddleware(),
|
||||
check_message_queue_before_model,
|
||||
SlackAssistantStatusMiddleware(),
|
||||
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.
|
||||
DEFAULT_MAX_RETRIES = 6
|
||||
|
||||
DEFAULT_LLM_REASONING: "OpenAIReasoning" = {"effort": "medium"}
|
||||
|
||||
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"]
|
||||
AnthropicThinkingDisplay = Literal["summarized", "omitted"]
|
||||
AnthropicEffort = Literal["low", "medium", "high", "xhigh", "max"]
|
||||
GoogleThinkingLevel = Literal["minimal", "low", "medium", "high"]
|
||||
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):
|
||||
effort: OpenAIReasoningEffort
|
||||
summary: OpenAIReasoningSummary
|
||||
|
||||
|
||||
DEFAULT_LLM_REASONING: "OpenAIReasoning" = {"effort": "medium", "summary": "auto"}
|
||||
|
||||
|
||||
class AnthropicThinking(TypedDict, total=False):
|
||||
type: AnthropicThinkingType
|
||||
display: AnthropicThinkingDisplay
|
||||
|
||||
|
||||
class ModelKwargs(TypedDict, total=False):
|
||||
|
|
@ -74,24 +82,33 @@ def openai_reasoning_for(
|
|||
*,
|
||||
default_effort: OpenAIReasoningEffort | None = 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")
|
||||
if effort == "none":
|
||||
return {"effort": "none"}
|
||||
if effort == "low":
|
||||
return {"effort": "low"}
|
||||
return {"effort": "low", "summary": "auto"}
|
||||
if effort == "medium":
|
||||
return {"effort": "medium"}
|
||||
return {"effort": "medium", "summary": "auto"}
|
||||
if effort == "high":
|
||||
return {"effort": "high"}
|
||||
return {"effort": "high", "summary": "auto"}
|
||||
if effort == "xhigh":
|
||||
return {"effort": "xhigh"}
|
||||
return {"effort": "xhigh", "summary": "auto"}
|
||||
return None
|
||||
|
||||
|
||||
def anthropic_thinking_for(profile_effort: str | None) -> AnthropicThinking | None:
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,20 +21,21 @@ def langgraph_client():
|
|||
return get_client(url=langgraph_url())
|
||||
|
||||
|
||||
async def is_thread_active(thread_id: str) -> bool:
|
||||
"""Return whether the thread currently has a running run."""
|
||||
async def get_thread_active_status(thread_id: str) -> bool | None:
|
||||
"""Return whether the thread is active, or None when status cannot be determined."""
|
||||
try:
|
||||
thread = await langgraph_client().threads.get(thread_id)
|
||||
status = thread.get("status", "idle") if isinstance(thread, dict) else "idle"
|
||||
logger.info("Thread %s status check: status=%s", thread_id, status)
|
||||
return status == "busy"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Failed to get thread status for %s: %s — assuming not active",
|
||||
thread_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
logger.warning("Failed to get thread status for %s: %s", thread_id, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def is_thread_active(thread_id: str) -> bool:
|
||||
"""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(
|
||||
|
|
|
|||
|
|
@ -124,11 +124,15 @@ DASHBOARD_ALLOWED_ORIGINS: list[str] = [
|
|||
o.strip() for o in os.environ.get("DASHBOARD_ALLOWED_ORIGINS", "").split(",") if o.strip()
|
||||
]
|
||||
if DASHBOARD_ALLOWED_ORIGINS:
|
||||
if "*" in DASHBOARD_ALLOWED_ORIGINS:
|
||||
raise RuntimeError(
|
||||
"DASHBOARD_ALLOWED_ORIGINS must not include '*' when allow_credentials=True"
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=DASHBOARD_ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
{
|
||||
"$schema": "https://langgra.ph/schema.json",
|
||||
"python_version": "3.12",
|
||||
"api_version": "0.10.0rc3",
|
||||
"graphs": {
|
||||
"agent": "agent.server:get_agent",
|
||||
"reviewer": "agent.reviewer:get_reviewer_agent",
|
||||
"analyzer": "agent.analyzer:get_analyzer",
|
||||
"scheduler": "agent.scheduler:get_scheduler"
|
||||
},
|
||||
"dependencies": ["."],
|
||||
"dependencies": [
|
||||
"."
|
||||
],
|
||||
"http": {
|
||||
"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]
|
||||
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"
|
||||
|
||||
subagent_call = make_model.call_args_list[1]
|
||||
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
|
||||
|
|
@ -145,5 +145,8 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
|
|||
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[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"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from agent.utils.model import anthropic_effort_for, anthropic_thinking_for
|
|||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
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,
|
||||
) -> 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(
|
||||
thread_api._start_agent_run(
|
||||
thread_api._create_dashboard_thread_record(
|
||||
"thread-id",
|
||||
login="octo",
|
||||
repo_config={},
|
||||
|
|
@ -124,44 +128,36 @@ def test_start_agent_run_omits_repo_less_marker_when_repo_unset(
|
|||
)
|
||||
)
|
||||
|
||||
configurable = dashboard_run_client.runs.configurable
|
||||
assert configurable is not None
|
||||
configurable = asyncio.run(
|
||||
thread_api._build_dashboard_configurable("thread-id", "octo", {"source": "dashboard"})
|
||||
)
|
||||
assert "repo_explicitly_none" 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,
|
||||
) -> None:
|
||||
asyncio.run(
|
||||
thread_api._start_agent_run(
|
||||
configurable = asyncio.run(
|
||||
thread_api._build_dashboard_configurable(
|
||||
"thread-id",
|
||||
login="octo",
|
||||
repo_config={},
|
||||
repo_explicitly_none=True,
|
||||
prompt="do work",
|
||||
"octo",
|
||||
{"source": "dashboard", "repo_explicitly_none": True},
|
||||
)
|
||||
)
|
||||
|
||||
configurable = dashboard_run_client.runs.configurable
|
||||
assert configurable is not None
|
||||
assert configurable["repo_explicitly_none"] is True
|
||||
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,
|
||||
) -> None:
|
||||
asyncio.run(
|
||||
thread_api._start_agent_run(
|
||||
configurable = asyncio.run(
|
||||
thread_api._build_dashboard_configurable(
|
||||
"thread-id",
|
||||
login="octo",
|
||||
repo_config={"owner": "octo", "name": "repo"},
|
||||
prompt="do work",
|
||||
"octo",
|
||||
{"source": "dashboard", "repo_owner": "octo", "repo_name": "repo"},
|
||||
)
|
||||
)
|
||||
|
||||
configurable = dashboard_run_client.runs.configurable
|
||||
assert configurable is not None
|
||||
assert configurable["repo"] == {"owner": "octo", "name": "repo"}
|
||||
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)
|
||||
|
||||
|
||||
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 fake_team_default(role: str) -> tuple[str, str]:
|
||||
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")
|
||||
|
||||
|
||||
async def test_create_dashboard_thread_rejects_images_for_resolved_text_only_model(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
async def fake_profile(login: str) -> dict[str, str]:
|
||||
assert login == "octocat"
|
||||
return {"default_model": _TEXT_ONLY_MODEL, "reasoning_effort": "high"}
|
||||
def _new_thread_client(created: dict[str, object]) -> object:
|
||||
class FakeThreads:
|
||||
async def create(
|
||||
self, *, thread_id: str, metadata: dict[str, object], if_exists: str
|
||||
) -> None:
|
||||
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]:
|
||||
assert role == "agent"
|
||||
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_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:
|
||||
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 "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)
|
||||
|
||||
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, "get_profile", _empty_profile)
|
||||
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
||||
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(content="continue in web"),
|
||||
email="octocat@example.com",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(content="continue in web"),
|
||||
email="octocat@example.com",
|
||||
)
|
||||
|
||||
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
|
||||
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"}
|
||||
assert exc_info.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -105,7 +102,7 @@ async def test_dashboard_followup_sends_image_content_blocks(
|
|||
client = _FakeClient(metadata)
|
||||
|
||||
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, "get_profile", _empty_profile)
|
||||
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},
|
||||
)
|
||||
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(
|
||||
content="describe this",
|
||||
images=[
|
||||
thread_api.DashboardImageBody(
|
||||
base64="aW1hZ2U=",
|
||||
mimeType="image/png",
|
||||
fileName="screenshot.png",
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(
|
||||
content="describe this",
|
||||
images=[
|
||||
thread_api.DashboardImageBody(
|
||||
base64="aW1hZ2U=",
|
||||
mimeType="image/png",
|
||||
fileName="screenshot.png",
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
content = client.runs.created[0]["kwargs"]["input"]["messages"][0]["content"]
|
||||
assert content == [
|
||||
{"type": "image", "data": "aW1hZ2U=", "mime_type": "image/png"},
|
||||
{"type": "text", "text": "describe this"},
|
||||
]
|
||||
assert exc_info.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -154,7 +148,7 @@ async def test_dashboard_followup_on_busy_thread_queues_dashboard_handoff(
|
|||
return True
|
||||
|
||||
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)
|
||||
|
||||
await thread_api.send_dashboard_message(
|
||||
|
|
@ -185,7 +179,7 @@ async def test_dashboard_followup_on_busy_thread_queues_images(
|
|||
return True
|
||||
|
||||
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,
|
||||
|
|
@ -228,7 +222,7 @@ async def test_dashboard_followup_on_busy_text_only_thread_rejects_images(
|
|||
return True
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
await thread_api.send_dashboard_message(
|
||||
|
|
@ -289,20 +283,19 @@ async def test_dashboard_followup_preserves_explicit_repo_less_thread(
|
|||
client = _FakeClient(metadata)
|
||||
|
||||
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, "get_profile", _empty_profile)
|
||||
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
||||
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(content="continue in web"),
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(content="continue in web"),
|
||||
)
|
||||
|
||||
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
|
||||
assert run_config["repo_explicitly_none"] is True
|
||||
assert "repo" not in run_config
|
||||
assert exc_info.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -316,17 +309,16 @@ async def test_dashboard_followup_without_repo_metadata_allows_team_default(
|
|||
client = _FakeClient(metadata)
|
||||
|
||||
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, "get_profile", _empty_profile)
|
||||
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
|
||||
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(content="continue in web"),
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(content="continue in web"),
|
||||
)
|
||||
|
||||
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
|
||||
assert "repo_explicitly_none" not in run_config
|
||||
assert "repo" not in run_config
|
||||
assert exc_info.value.status_code == 409
|
||||
|
|
|
|||
|
|
@ -33,6 +33,21 @@ def _openai_5xx() -> openai.APIStatusError:
|
|||
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:
|
||||
request = MagicMock()
|
||||
request.override = MagicMock(return_value=MagicMock(name="overridden_request"))
|
||||
|
|
@ -99,6 +114,19 @@ class TestModelFallbackMiddleware:
|
|||
|
||||
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
|
||||
async def test_async_does_not_double_fall_back(self) -> None:
|
||||
"""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]
|
||||
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"
|
||||
subagent_model_call = make_model.call_args_list[1]
|
||||
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
|
||||
|
|
@ -327,11 +327,11 @@ async def test_reviewer_subagent_inherits_eval_model_without_explicit_override()
|
|||
|
||||
main_model_call = make_model.call_args_list[0]
|
||||
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"
|
||||
subagent_model_call = make_model.call_args_list[1]
|
||||
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"
|
||||
|
||||
|
||||
|
|
|
|||
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": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@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",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@pierre/diffs": "^1.2.1",
|
||||
|
|
@ -29,18 +32,15 @@
|
|||
"@tanstack/router-plugin": "^1.166.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^9.0.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"nitro": "latest",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.7.0",
|
||||
"shiki": "^4.1.0",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
|
@ -66,4 +66,4 @@
|
|||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
import type { GitStatus, GitStatusEntry } from "@pierre/trees"
|
||||
|
||||
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 { summarizeChangedFiles } from "@/components/agents/ported"
|
||||
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 { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||
import { formatRelativeTime } from "@/lib/agents/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn, formatRelativeTime } from "@/lib/utils"
|
||||
|
||||
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,70 +1,24 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||
|
||||
import type { PendingPrompt } from "@/lib/agents/pendingPrompts"
|
||||
import type { AgentThread, ImageChunk, Message } from "@/lib/agents/types"
|
||||
import type { ModelSelection } from "@/lib/agents/useModelOptions"
|
||||
import type { AgentThread, Message } from "@/lib/agents/types"
|
||||
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||
import { AgentGitPanel } from "@/components/agents/AgentGitPanel"
|
||||
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
|
||||
import { MessageView } from "@/components/agents/ported"
|
||||
import {
|
||||
agentThreadKeys,
|
||||
useCancelAgentThread,
|
||||
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"
|
||||
import { Messages } from "@/components/agents/messages"
|
||||
import { streamMessagesToUi } from "@/lib/agents/streamMessagesToUi"
|
||||
import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessage"
|
||||
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||
|
||||
interface AgentThreadViewProps {
|
||||
thread: AgentThread
|
||||
}
|
||||
|
||||
function messageText(message: Message): string {
|
||||
return message.chunks
|
||||
.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)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// The stream lives at the `/agents` layout (one persistent provider that
|
||||
// survives the home → thread navigation), so this view only consumes it.
|
||||
export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const sendMessage = useSendAgentMessage(thread.id)
|
||||
const cancelThread = useCancelAgentThread(thread.id)
|
||||
useAgentThreadStream(thread.id, thread.status === "running")
|
||||
const [pendingPrompts, setPendingPrompts] = useState<Array<PendingPrompt>>(
|
||||
() => getPendingPrompts(thread.id)
|
||||
)
|
||||
const sendMessage = useSubmitAgentMessage(thread.id)
|
||||
const stream = useAgentThreadStream()
|
||||
|
||||
const { models, defaultSelection } = useModelOptions()
|
||||
const threadSelection = useMemo<ModelSelection | null>(() => {
|
||||
|
|
@ -78,119 +32,58 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
||||
const activeSelection = selection ?? threadSelection ?? defaultSelection
|
||||
|
||||
useEffect(() => {
|
||||
setPendingPrompts((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
const next = dropPendingPrompts(thread.id, (entry) =>
|
||||
isPendingPromptConfirmed(entry, thread.messages)
|
||||
)
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
}, [thread.id, thread.messages])
|
||||
const baseMessages = useMemo<Array<Message>>(() => {
|
||||
const live = streamMessagesToUi(stream.messages, stream.toolCalls, stream.subagents)
|
||||
if (live.length > 0) return live
|
||||
// Optimistic transcript seeded by `AgentsHome` on thread creation (the
|
||||
// only case where a fetched thread carries messages — `getThread` returns
|
||||
// none). Bridges the brief gap before the SDK's optimistic `submit` echo
|
||||
// lands in `stream.messages`.
|
||||
if (thread.messages.length > 0) return thread.messages
|
||||
return live
|
||||
}, [
|
||||
stream.messages,
|
||||
stream.toolCalls,
|
||||
stream.subagents,
|
||||
thread.messages,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
queryClient.setQueryData<Array<AgentThread> | undefined>(
|
||||
agentThreadKeys.all,
|
||||
(threads) =>
|
||||
threads?.map((item) =>
|
||||
item.id === thread.id
|
||||
? { ...item, ...thread, messages: item.messages }
|
||||
: 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
|
||||
const hasMessages = baseMessages.length > 0
|
||||
const isStreaming = thread.status === "running" || stream.isLoading
|
||||
const isThinking = stream.isLoading
|
||||
const settingUpSandbox = isThinking && baseMessages.length === 0
|
||||
// The transcript hydrates from the SDK (`GET …/state` → `stream.messages`).
|
||||
// Show a loading state during that one-time fetch instead of the empty state.
|
||||
const isHydrating = stream.isThreadLoading && !hasMessages
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{hasMessages ? (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<MessageView
|
||||
messages={displayMessages}
|
||||
<Messages
|
||||
messages={baseMessages}
|
||||
isStreaming={isStreaming}
|
||||
streamIsLoading={stream.isLoading}
|
||||
isThinking={isThinking}
|
||||
settingUpSandbox={settingUpSandbox}
|
||||
contentWidthClass="max-w-3xl"
|
||||
/>
|
||||
<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
|
||||
placeholder="Add a follow up"
|
||||
compact
|
||||
busy={isStreaming}
|
||||
disabled={sendMessage.isPending}
|
||||
canCancel={hasActiveRun}
|
||||
cancelling={cancelThread.isPending}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={(content, images) =>
|
||||
sendMessage.mutate({
|
||||
content,
|
||||
images,
|
||||
model_id: activeSelection?.modelId ?? null,
|
||||
effort: activeSelection?.effort ?? null,
|
||||
})
|
||||
}
|
||||
models={models}
|
||||
selection={activeSelection}
|
||||
onSelectionChange={setSelection}
|
||||
|
|
@ -198,6 +91,10 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
</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">
|
||||
<p className="text-sm text-[var(--ui-text-dim)]">
|
||||
|
|
@ -209,10 +106,14 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
compact
|
||||
busy={isStreaming}
|
||||
disabled={sendMessage.isPending}
|
||||
canCancel={hasActiveRun}
|
||||
cancelling={cancelThread.isPending}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={(content, images) =>
|
||||
sendMessage.mutate({
|
||||
content,
|
||||
images,
|
||||
model_id: activeSelection?.modelId ?? null,
|
||||
effort: activeSelection?.effort ?? null,
|
||||
})
|
||||
}
|
||||
models={models}
|
||||
selection={activeSelection}
|
||||
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 { SlackConnectDialog } from "@/components/agents/SlackConnectDialog"
|
||||
import { Logo } from "@/components/agents/ported/Logo"
|
||||
import { useCreateAgentThread } from "@/lib/agents/queries"
|
||||
import { useModelOptions } from "@/lib/agents/useModelOptions"
|
||||
import { agentThreadKeys, optimisticThread } from "@/lib/agents/queries"
|
||||
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||
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() {
|
||||
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 [selection, setSelection] = useState<ModelSelection | null>(null)
|
||||
const activeSelection = selection ?? defaultSelection
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const reposQuery = useRepos()
|
||||
const profileQuery = useProfile()
|
||||
|
|
@ -25,6 +48,51 @@ export function AgentsHome() {
|
|||
? (profileQuery.data?.default_repo ?? null)
|
||||
: 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 (
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8">
|
||||
<SlackConnectDialog />
|
||||
|
|
@ -32,17 +100,8 @@ export function AgentsHome() {
|
|||
<div className="flex w-full flex-col items-center gap-6">
|
||||
<Logo />
|
||||
<AgentPromptBar
|
||||
onSubmit={(prompt, images) =>
|
||||
createThread.mutate({
|
||||
prompt,
|
||||
images,
|
||||
repo,
|
||||
repo_explicitly_none: repoOverride === null,
|
||||
model_id: activeSelection?.modelId ?? null,
|
||||
effort: activeSelection?.effort ?? null,
|
||||
})
|
||||
}
|
||||
disabled={createThread.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
disabled={submitting}
|
||||
models={models}
|
||||
selection={activeSelection}
|
||||
onSelectionChange={setSelection}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { groupThreads } from "@/lib/agents/api"
|
|||
import {
|
||||
useAgentThreads,
|
||||
useDeleteAgentThread,
|
||||
usePrefetchAgentThreadDetails,
|
||||
useSeedAgentThreadDetails,
|
||||
} from "@/lib/agents/queries"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ const NAV = [
|
|||
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||
const threadsQuery = useAgentThreads()
|
||||
const threads = threadsQuery.data ?? []
|
||||
usePrefetchAgentThreadDetails(threads, activeThreadId)
|
||||
useSeedAgentThreadDetails(threads, activeThreadId)
|
||||
const groups = groupThreads(threads)
|
||||
const layout = useSidebarLayout()
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
|
||||
import type { ModelOption } from "@/lib/api"
|
||||
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 { ScheduleTriggerPicker } from "@/components/agents/ScheduleTriggerPicker"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
|
@ -23,7 +23,7 @@ import {
|
|||
import {
|
||||
formatModelSelection,
|
||||
useModelOptions,
|
||||
} from "@/lib/agents/useModelOptions"
|
||||
} from "@/lib/agents/provider/useModelOptions"
|
||||
import { useRepos } from "@/lib/profile"
|
||||
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 { useStore } from "../../store";
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { MessageView, summarizeChangedFiles } from "./MessageView";
|
||||
import { Messages, summarizeChangedFiles } from "@/components/agents/messages";
|
||||
import { PromptBar } from "./PromptBar";
|
||||
import { TodoList } from "./TodoList";
|
||||
import { Logo } from "./Logo";
|
||||
|
|
@ -408,7 +408,7 @@ export function ChatView({ tabId }: ChatViewProps) {
|
|||
onClick={handleContainerClick}
|
||||
{...dragProps}
|
||||
>
|
||||
<MessageView
|
||||
<Messages
|
||||
messages={session!.messages}
|
||||
isStreaming={session!.isStreaming}
|
||||
contentWidthClass={MESSAGE_CONTENT_WIDTH}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import {
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ImagePlus,
|
||||
LoaderCircle,
|
||||
Square,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { ArrowUp, ChevronDown, ImagePlus, LoaderCircle, X } from "lucide-react"
|
||||
import { StopIcon } from "@phosphor-icons/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
|
|
@ -18,12 +14,89 @@ import {
|
|||
|
||||
import type { ModelOption } from "@/lib/api"
|
||||
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 { 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"
|
||||
|
||||
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_BYTES = 10 * 1024 * 1024
|
||||
const SUPPORTED_IMAGE_TYPES = new Set([
|
||||
|
|
@ -38,10 +111,6 @@ export interface CloudPromptBarProps {
|
|||
compact?: boolean
|
||||
disabled?: 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
|
||||
models?: Array<ModelOption>
|
||||
selection?: ModelSelection | null
|
||||
|
|
@ -65,11 +134,11 @@ function fileToImageChunk(file: File): Promise<ImageChunk | null> {
|
|||
resolve(
|
||||
base64
|
||||
? {
|
||||
kind: "image",
|
||||
base64,
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
}
|
||||
kind: "image",
|
||||
base64,
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
}
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
|
@ -84,9 +153,6 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
|||
compact = false,
|
||||
disabled = false,
|
||||
busy = false,
|
||||
canCancel = false,
|
||||
cancelling = false,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
models = [],
|
||||
selection = null,
|
||||
|
|
@ -360,40 +426,11 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
|||
<ImagePlus className="size-4" />
|
||||
</button>
|
||||
|
||||
{canCancel && !canSubmit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
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>
|
||||
)}
|
||||
<SubmitButton
|
||||
canSubmit={canSubmit}
|
||||
disabled={disabled}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,233 +1,21 @@
|
|||
// @ts-nocheck — ported from open-swe-app (Electron).
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { diffLines } from 'diff';
|
||||
import { getSingletonHighlighter, type ThemedToken } from 'shiki';
|
||||
import type { DiffData } from '@/lib/agents/types';
|
||||
import { useMemo } from "react";
|
||||
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||
import { diffOptions } from "@/components/agents/utils/diffUtils";
|
||||
import { countLineChanges } from "@/components/agents/utils/diffStats";
|
||||
import type { DiffData } from "@/lib/agents/types";
|
||||
|
||||
interface DiffViewProps {
|
||||
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) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [tokenCache, setTokenCache] = useState<TokenCache | null>(null);
|
||||
const highlightingRef = useRef(false);
|
||||
|
||||
const { originalContent, newContent, filePath, isNewFile, isBinary } = diffData;
|
||||
const language = getLanguageFromPath(filePath);
|
||||
|
||||
const allDiffLines = useMemo(
|
||||
() => computeDiffLines(originalContent, newContent),
|
||||
[originalContent, newContent]
|
||||
const { originalContent, newContent, filePath, isBinary } = diffData;
|
||||
const displayPath = filePath.split("/").pop() || filePath;
|
||||
const stats = useMemo(
|
||||
() => countLineChanges(originalContent, newContent, filePath),
|
||||
[filePath, newContent, originalContent],
|
||||
);
|
||||
|
||||
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) {
|
||||
return (
|
||||
<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);
|
||||
const hasMoreLines = hunkLines.length > MAX_COLLAPSED_LINES;
|
||||
const hiddenCount = hunkLines.length - MAX_COLLAPSED_LINES;
|
||||
|
||||
if (hunkLines.length === 0) {
|
||||
if (stats.additions === 0 && stats.deletions === 0) {
|
||||
return (
|
||||
<div className="mt-2 text-gray-500 text-xs font-mono">
|
||||
No changes
|
||||
|
|
@ -251,66 +35,18 @@ export function DiffView({ diffData }: DiffViewProps) {
|
|||
return (
|
||||
<div className="mt-2 font-mono text-xs">
|
||||
<div className="flex items-center gap-2 text-gray-500 mb-1">
|
||||
<span className="text-gray-400">{filePath.split('/').pop()}</span>
|
||||
{isNewFile && <span>(new)</span>}
|
||||
<span className="text-gray-400">{displayPath}</span>
|
||||
{diffData.isNewFile && <span>(new)</span>}
|
||||
<span className="text-green-400">+{stats.additions}</span>
|
||||
<span className="text-red-400">-{stats.deletions}</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-auto border-l border-gray-700 pl-2">
|
||||
{displayLines.map((line, idx) => {
|
||||
if (line.type === 'separator') {
|
||||
return (
|
||||
<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 className="max-h-60 overflow-auto rounded-lg border border-[var(--ui-border-subtle)] bg-[var(--ui-panel)]">
|
||||
<MultiFileDiff
|
||||
oldFile={{ name: displayPath, contents: originalContent ?? "" }}
|
||||
newFile={{ name: displayPath, contents: newContent }}
|
||||
options={diffOptions}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,104 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { MarkdownTable } from './MarkdownTable';
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import "streamdown/styles.css";
|
||||
|
||||
interface MarkdownProps {
|
||||
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 (
|
||||
<div className="min-w-0 max-w-full text-[13px] leading-6 break-words [overflow-wrap:anywhere]">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <div className="text-[color:var(--ui-accent)] text-[20px] font-semibold mt-4 mb-2 tracking-tight">{children}</div>,
|
||||
h2: ({ children }) => <div className="text-[color:var(--ui-accent)] text-[17px] font-semibold mt-3 mb-2 tracking-tight">{children}</div>,
|
||||
h3: ({ children }) => <div className="text-[color:var(--ui-accent)] text-[15px] font-semibold mt-3 mb-1">{children}</div>,
|
||||
p: ({ children }) => <p className="my-1.5 text-[color:var(--ui-text)] break-words [overflow-wrap:anywhere]">{children}</p>,
|
||||
a: ({ href, children }) => (
|
||||
<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}</>,
|
||||
}}
|
||||
<div className="min-w-0 max-w-full text-[13px] leading-6 break-words [overflow-wrap:anywhere] [&_.streamdown]:text-[color:var(--ui-text)]">
|
||||
<Streamdown
|
||||
mode={isLive ? "streaming" : "static"}
|
||||
parseIncompleteMarkdown={isLive}
|
||||
isAnimating={isLive}
|
||||
animated={isLive ? STREAMDOWN_ANIMATED : false}
|
||||
shikiTheme={SHIKI_THEME}
|
||||
className="streamdown-agent min-w-0 max-w-full"
|
||||
components={STREAMDOWN_COMPONENTS}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</Streamdown>
|
||||
</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 && (
|
||||
<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="text-[color:var(--ui-accent-2)] mb-2">bash</div>
|
||||
<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 className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-[color:var(--ui-accent-2)]">bash</span>
|
||||
{chunk.status === "in_progress" && (
|
||||
<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>
|
||||
{output && (
|
||||
<div
|
||||
ref={outputRef}
|
||||
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" }}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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 { diffLines } from "diff";
|
||||
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||
import type { FileContents } from "@pierre/diffs/react";
|
||||
import type { GitStatusEntry, GitFileStatus } from "@/lib/agents/types";
|
||||
import { countLineChanges } from "@/components/agents/utils/diffStats";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
|
|
@ -111,16 +113,10 @@ function isConflict(status: GitFileStatus): boolean {
|
|||
|
||||
function countChanges(
|
||||
oldContent: string,
|
||||
newContent: string
|
||||
newContent: string,
|
||||
filePath: string,
|
||||
): { additions: number; deletions: number } {
|
||||
const parts = diffLines(oldContent || "", newContent || "");
|
||||
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 };
|
||||
return countLineChanges(oldContent, newContent, filePath);
|
||||
}
|
||||
|
||||
function stripProjectPath(path: string, projectPath?: string): string {
|
||||
|
|
@ -133,38 +129,6 @@ function stripProjectPath(path: string, projectPath?: string): string {
|
|||
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({
|
||||
projectPath,
|
||||
mainProjectPath,
|
||||
|
|
@ -223,7 +187,8 @@ export const SourceControlPanel = memo(function SourceControlPanel({
|
|||
if (diff) {
|
||||
const { additions, deletions } = countChanges(
|
||||
diff.original,
|
||||
diff.modified
|
||||
diff.modified,
|
||||
entry.path,
|
||||
);
|
||||
diffs.push({
|
||||
path: entry.path,
|
||||
|
|
@ -508,12 +473,12 @@ export const SourceControlPanel = memo(function SourceControlPanel({
|
|||
const branchName = syncStatus?.branchName ?? "";
|
||||
const aheadBehind = syncStatus
|
||||
? (() => {
|
||||
const { ahead, behind } = syncStatus;
|
||||
if (ahead > 0 && behind > 0) return `${ahead}↑ ${behind}↓`;
|
||||
if (ahead > 0) return `${ahead}↑`;
|
||||
if (behind > 0) return `${behind}↓`;
|
||||
return null;
|
||||
})()
|
||||
const { ahead, behind } = syncStatus;
|
||||
if (ahead > 0 && behind > 0) return `${ahead}↑ ${behind}↓`;
|
||||
if (ahead > 0) return `${ahead}↑`;
|
||||
if (behind > 0) return `${behind}↓`;
|
||||
return null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
|
@ -783,11 +748,10 @@ export const SourceControlPanel = memo(function SourceControlPanel({
|
|||
{/* Status toast */}
|
||||
{statusMsg && (
|
||||
<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 ${
|
||||
statusMsg.error
|
||||
? "bg-red-500/20 text-red-300 border border-red-500/30"
|
||||
: "bg-green-500/20 text-green-300 border border-green-500/30"
|
||||
}`}
|
||||
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
|
||||
? "bg-red-500/20 text-red-300 border border-red-500/30"
|
||||
: "bg-green-500/20 text-green-300 border border-green-500/30"
|
||||
}`}
|
||||
>
|
||||
{statusMsg.text}
|
||||
</div>
|
||||
|
|
@ -966,11 +930,10 @@ const DiffCard = memo(function DiffCard({
|
|||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`rounded-lg bg-[var(--ui-accent-bubble)] overflow-hidden border ${
|
||||
selected
|
||||
? "border-[color:var(--ui-accent)]"
|
||||
: "border-[var(--ui-border-subtle)]"
|
||||
}`}
|
||||
className={`rounded-lg bg-[var(--ui-accent-bubble)] overflow-hidden border ${selected
|
||||
? "border-[color:var(--ui-accent)]"
|
||||
: "border-[var(--ui-border-subtle)]"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1137,11 +1100,10 @@ const TreeNode = memo(function TreeNode({
|
|||
}
|
||||
onSelect(node.path);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 pr-3 py-1.5 text-xs text-left transition-colors ${
|
||||
isSelected
|
||||
? "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)]"
|
||||
}`}
|
||||
className={`flex w-full items-center gap-2 pr-3 py-1.5 text-xs text-left transition-colors ${isSelected
|
||||
? "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)]"
|
||||
}`}
|
||||
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||||
title={node.path}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { memo, useState, useCallback, useMemo, useRef, useLayoutEffect } from "react";
|
||||
import { diffLines } from "diff";
|
||||
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||
import type { ToolExecutionChunk, AcpToolKind } from "@/lib/agents/types";
|
||||
import { DiffView } from "./DiffView";
|
||||
import { useDiffOptions } from "@/components/agents/utils/diffUtils";
|
||||
import { countLineChanges } from "@/components/agents/utils/diffStats";
|
||||
|
||||
interface ToolExecutionProps {
|
||||
chunk: ToolExecutionChunk;
|
||||
|
|
@ -27,31 +27,6 @@ function getFileName(path: string): string {
|
|||
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(
|
||||
title: string,
|
||||
toolKind: AcpToolKind,
|
||||
|
|
@ -227,7 +202,7 @@ export const ToolExecution = memo(function ToolExecution({
|
|||
const isCompletedEditOp = isEditOp && diffData && (status === "completed" || status === "error");
|
||||
const editedFilePath = diffData ? stripProjectPath(diffData.filePath, projectPath) : "";
|
||||
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) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ export { Logo } from "./Logo";
|
|||
export { Markdown } from "./Markdown";
|
||||
export { MarkdownTable } from "./MarkdownTable";
|
||||
export {
|
||||
MessageView,
|
||||
Messages,
|
||||
summarizeChangedFiles,
|
||||
type ChangedFileSummaryItem,
|
||||
type MessageViewScrollControl,
|
||||
} from "./MessageView";
|
||||
type MessagesScrollControl,
|
||||
} from "@/components/agents/messages";
|
||||
export { PanelResizeHandle } from "./PanelResizeHandle";
|
||||
export { ShellCommand } from "./ShellCommand";
|
||||
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 { preloadHighlighter } from "@pierre/diffs";
|
||||
import { useResolvedTheme } from "@/lib/theme";
|
||||
|
||||
export const DIFF_UNSAFE_CSS = `
|
||||
|
|
@ -73,3 +74,29 @@ export function useDiffOptions() {
|
|||
[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 interface ThreadCreateRequest {
|
||||
prompt: string
|
||||
images?: Array<ImageChunk>
|
||||
repo?: string | null
|
||||
repo_explicitly_none?: boolean
|
||||
model_id?: string | null
|
||||
effort?: string | null
|
||||
export class AgentsApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = "AgentsApiError"
|
||||
}
|
||||
}
|
||||
|
||||
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>(
|
||||
path: string,
|
||||
init: RequestInit = {}
|
||||
|
|
@ -67,13 +70,14 @@ async function agentsRequest<T>(
|
|||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message)
|
||||
throw new AgentsApiError(res.status, message)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export const agentsApi = {
|
||||
langGraphApiUrl: agentsLangGraphApiUrl,
|
||||
listThreads: () => agentsRequest<Array<AgentThread>>("/threads"),
|
||||
listSchedules: () => agentsRequest<Array<AgentSchedule>>("/schedules"),
|
||||
createSchedule: (body: ScheduleCreateRequest) =>
|
||||
|
|
@ -99,12 +103,7 @@ export const agentsApi = {
|
|||
options?.markViewed === false ? "?mark_viewed=false" : ""
|
||||
}`
|
||||
),
|
||||
createThread: (body: ThreadCreateRequest) =>
|
||||
agentsRequest<AgentThread>("/threads", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
sendMessage: (threadId: string, body: ThreadMessageRequest) =>
|
||||
queueMessage: (threadId: string, body: ThreadMessageRequest) =>
|
||||
agentsRequest<AgentThread>(
|
||||
`/threads/${encodeURIComponent(threadId)}/messages`,
|
||||
{
|
||||
|
|
@ -127,18 +126,6 @@ export const agentsApi = {
|
|||
`${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 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 { agentsApi } from "./api"
|
||||
import { addPendingPrompt } from "./pendingPrompts"
|
||||
import type { ScheduleUpdateRequest } from "./api"
|
||||
import type { ImageChunk } from "./types"
|
||||
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
||||
|
||||
export const agentThreadKeys = {
|
||||
all: ["agent-threads"] as const,
|
||||
|
|
@ -16,26 +15,23 @@ export const agentScheduleKeys = {
|
|||
all: ["agent-schedules"] as const,
|
||||
}
|
||||
|
||||
const PREFETCH_THREAD_DETAIL_LIMIT = 12
|
||||
|
||||
export function usePrefetchAgentThreadDetails(
|
||||
threads: Array<{ id: string }>,
|
||||
// The list endpoint (`GET /threads`) and the detail endpoint
|
||||
// (`GET /threads/{id}`) return the same per-thread summary, so warming the
|
||||
// detail cache from the already-fetched list avoids a fan-out of one request
|
||||
// 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
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
const threadIds = threads
|
||||
.map((thread) => thread.id)
|
||||
.filter((threadId) => threadId !== activeThreadId)
|
||||
.slice(0, PREFETCH_THREAD_DETAIL_LIMIT)
|
||||
|
||||
threadIds.forEach((threadId) => {
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: agentThreadKeys.detail(threadId),
|
||||
queryFn: () => agentsApi.getThread(threadId, { markViewed: false }),
|
||||
})
|
||||
})
|
||||
for (const thread of threads) {
|
||||
if (thread.id === activeThreadId) continue
|
||||
queryClient.setQueryData(agentThreadKeys.detail(thread.id), thread)
|
||||
}
|
||||
}, [activeThreadId, queryClient, threads])
|
||||
}
|
||||
|
||||
|
|
@ -54,11 +50,6 @@ export function useAgentThread(threadId: string) {
|
|||
return useQuery({
|
||||
queryKey: agentThreadKeys.detail(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() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
export interface CreateAgentThreadVariables {
|
||||
prompt: string
|
||||
images?: Array<ImageChunk>
|
||||
repo?: string | null
|
||||
repo_explicitly_none?: boolean
|
||||
model_id?: string | null
|
||||
effort?: string | null
|
||||
}
|
||||
|
||||
return useMutation({
|
||||
mutationFn: agentsApi.createThread,
|
||||
onSuccess: (thread, variables) => {
|
||||
addPendingPrompt(
|
||||
thread.id,
|
||||
variables.prompt,
|
||||
thread.messages.length,
|
||||
variables.images
|
||||
)
|
||||
queryClient.setQueryData(agentThreadKeys.detail(thread.id), {
|
||||
...thread,
|
||||
status: thread.status === "idle" ? "running" : thread.status,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||
navigate({ to: "/agents/$threadId", params: { threadId: thread.id } })
|
||||
},
|
||||
})
|
||||
/**
|
||||
* Build the placeholder thread shown the instant a run is started from the
|
||||
* home page — before the server has stamped the thread record. Seeded into
|
||||
* the detail + list caches by `AgentsHome` so the `$threadId` route renders
|
||||
* immediately (the 30s `staleTime` keeps it from refetching into a 404), then
|
||||
* reconciled to server truth by the list's running refetch + the stream's
|
||||
* `onCreated` / `onCompleted` invalidations.
|
||||
*/
|
||||
export function optimisticThread(
|
||||
threadId: string,
|
||||
vars: CreateAgentThreadVariables
|
||||
): AgentThread {
|
||||
const now = Date.now()
|
||||
const text = vars.prompt.trim()
|
||||
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 {
|
||||
|
|
@ -133,52 +154,14 @@ export interface SendAgentMessageVariables {
|
|||
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) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => agentsApi.cancelThread(threadId),
|
||||
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 })
|
||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread)
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -191,7 +174,7 @@ export function useDeleteAgentThread() {
|
|||
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
|
||||
onSuccess: (_, threadId) => {
|
||||
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true })
|
||||
const path = window.location.pathname
|
||||
if (path.includes(`/agents/${threadId}`)) {
|
||||
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 =
|
||||
| "text"
|
||||
| "reasoning"
|
||||
| "code"
|
||||
| "error"
|
||||
| "list"
|
||||
|
|
@ -41,6 +42,8 @@ export type AcpToolKind =
|
|||
| "fetch"
|
||||
| "slack"
|
||||
| "linear"
|
||||
/** deepagents `task` tool — spawns a subagent; rendered as a subagent card. */
|
||||
| "task"
|
||||
| "other"
|
||||
|
||||
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error"
|
||||
|
|
@ -73,6 +76,14 @@ export interface ToolExecutionChunk {
|
|||
diffData?: DiffData
|
||||
diffs?: Array<DiffData>
|
||||
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 {
|
||||
|
|
@ -80,6 +91,11 @@ export interface TextChunk {
|
|||
text: string
|
||||
}
|
||||
|
||||
export interface ReasoningChunk {
|
||||
kind: "reasoning"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface CodeChunk {
|
||||
kind: "code"
|
||||
text: string
|
||||
|
|
@ -110,6 +126,7 @@ export interface ImageChunk {
|
|||
|
||||
export type Chunk =
|
||||
| TextChunk
|
||||
| ReasoningChunk
|
||||
| CodeChunk
|
||||
| ErrorChunk
|
||||
| ListChunk
|
||||
|
|
@ -194,9 +211,21 @@ export type GitFileStatus =
|
|||
| "index-modified"
|
||||
| "index-added"
|
||||
| "index-deleted"
|
||||
| "index-renamed"
|
||||
| "index-copied"
|
||||
| "modified"
|
||||
| "deleted"
|
||||
| "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 {
|
||||
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 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>) {
|
||||
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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import agentsCss from "@/styles/agents.css?url"
|
||||
import { AgentThreadStreamProvider } from "@/lib/agents/AgentThreadStreamProvider"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
export const Route = createFileRoute("/agents")({
|
||||
|
|
@ -40,7 +41,9 @@ function AgentsLayout() {
|
|||
|
||||
return (
|
||||
<AgentsShell user={session.data} activeThreadId={activeThreadId}>
|
||||
<Outlet />
|
||||
<AgentThreadStreamProvider threadId={activeThreadId ?? null}>
|
||||
<Outlet />
|
||||
</AgentThreadStreamProvider>
|
||||
</AgentsShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Navigate, createFileRoute } from "@tanstack/react-router"
|
|||
|
||||
import { AgentThreadView } from "@/components/agents/AgentThreadView"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { AgentThreadStreamBoundary } from "@/lib/agents/provider/useIsInAgentThreadStream"
|
||||
import { useAgentThread } from "@/lib/agents/queries"
|
||||
|
||||
export const Route = createFileRoute("/agents/$threadId")({
|
||||
|
|
@ -24,5 +25,9 @@ function AgentThreadPage() {
|
|||
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 "@fontsource-variable/inter";
|
||||
@import "./styles/agents.css";
|
||||
@source "../node_modules/streamdown/dist/*.js";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
{
|
||||
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
|
||||
"exclude": [
|
||||
// Ported from a desktop host app; not typechecked until integrated (see file headers).
|
||||
"src/components/agents/ported/ChatView.tsx",
|
||||
"src/components/agents/ported/PromptBar.tsx",
|
||||
"src/components/agents/ported/BranchSelector.tsx",
|
||||
"src/components/agents/ported/ContextIndicator.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/Footer.tsx",
|
||||
"src/components/agents/ported/ThreadPicker.tsx"
|
||||
|
|
@ -33,7 +35,6 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"allowJs": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue