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:
Christian Bromann 2026-06-11 09:54:35 -07:00 • committed by GitHub
parent 5515fff44c
commit abf354bb05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 4890 additions and 3927 deletions

View file

@ -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`

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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]:

View file

@ -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",

View file

@ -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(

View 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

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -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(

View file

@ -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=["*"],
)

View file

@ -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"
},

View file

@ -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"

View file

@ -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"

View 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

View file

@ -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."
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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"

View 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"

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}
}

View file

@ -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"

View file

@ -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>>

View file

@ -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}

View file

@ -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}

View file

@ -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()

View file

@ -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"

View 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>
);
}

View 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"
/>
);
}
}

View 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>
);
});

View 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>
);
}

View 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>
);
}

View 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>
);
});

View 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>
);
}

View file

@ -0,0 +1,8 @@
export { Messages } from "./Messages";
export { summarizeChangedFiles } from "./summarizeChangedFiles";
export type {
ApprovalCallbacks,
ChangedFileSummaryItem,
MessagesProps,
MessagesScrollControl,
} from "./types";

View 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"}`;
}

View 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));
}

View 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>;
}

View file

@ -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}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}
});

View file

@ -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>
);
});

View file

@ -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>

View file

@ -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}
>

View file

@ -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 (

View file

@ -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";

View 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>
)
}

View 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>
);
});

View 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>
);
}

View file

@ -0,0 +1,3 @@
export { SubagentActivity } from "./SubagentActivity";
export { SubagentCard } from "./SubagentCard";
export { SubagentGroup } from "./SubagentGroup";

View 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 };
}

View file

@ -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;
}

View file

@ -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 }

View 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>
);
}

View file

@ -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(

View file

@ -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;
}

View 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>
);
}

View 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;
}

View 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 });
},
});
}

View file

@ -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" })

View 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;
}

View file

@ -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

View file

@ -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]);
}

View file

@ -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");
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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 *));

View file

@ -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/*"]
}