feat: add Agents chat UI for cloud threads (#1323)

* feat(ui): add Agents chat UI ported from open-swe-app

Introduce a Cursor-style Agents surface separate from the dashboard, with ported chat/diff components and mock thread data until LangGraph APIs land.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(dashboard): wire Agents UI to LangGraph thread APIs

Add dashboard thread list/detail/run/message/stream endpoints with a LangGraph message adapter, dashboard OAuth auth for runs, and TanStack Query hooks replacing mock data.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): single agent reply per turn in Agents UI

Use UUID thread IDs LangGraph accepts, skip confirming_completion for
dashboard threads, and merge adapter agent messages so duplicate bubbles
do not render.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(ui): polish Agents UI with floating prompt and layout cleanup

Remove no-op chrome (git panel, headers, sidebar search), port CloudPromptBar
from open-swe-app, and refine chat layout so messages scroll behind the input.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent): patch deepagents reducer for None messages on checkpoint replay

LangGraph thread state could 500 when cancelled runs left messages as None.
Apply the reducer guard before graph import, fall back to metadata in the
dashboard API, and adjust Agents prompt bar layout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(ui): unify sidebar user menu and clean up Agents UI navigation

Extract SidebarUserMenu so the dashboard and Agents sidebars render the
same profile button, drop the redundant Agents nav row in favor of the
existing Back to Agents link, add the open-swe logo header to the Agents
sidebar, flatten the New Agent button, and cap the home screen run list
to keep the prompt input in view.

* feat(ui): resizable/collapsible sidebar shared across dashboard and Agents

Add a useSidebarLayout hook + SidebarFrame wrapper so both sidebars
share a persisted width (default 260px, drag to resize, 200-420 range)
and a collapse toggle that hides the panel and surfaces a floating
reopen button. Also adds a DELETE /threads/{id} endpoint and an X-on-
hover thread delete control in the Agents sidebar.

* feat(ui): instant user message and busy indicator on Agents transition

Stash submitted prompts in sessionStorage, pre-populate the new thread
detail cache, and merge pending prompts into the rendered message list
so the Agents page renders the user bubble plus the existing thinking
spinner immediately instead of flashing a skeleton and "Agent is
starting" while the run boots.

* feat(ui): token-stream agent replies in the Agents thread view

Opt the LangGraph runs into messages-tuple streaming and forward those
events through the existing SSE channel. The frontend now applies
AIMessageChunk deltas directly to the cached thread (cancelling any
in-flight refetch first so optimistic tokens are not clobbered) and
keeps positional pending prompts so the user bubble stays in the right
place while the agent streams its reply.

* fix(dashboard): await threads.join_stream before iterating

threads.join_stream is async def returning an AsyncIterator, so it must
be awaited before async for. The SSE endpoint was raising
TypeError: 'async for' requires an object with __aiter__ method, got
coroutine on every connection.

* fix(dashboard): drop messages-tuple stream_mode that broke thinking-mode tool turns

Setting stream_mode=["values","messages-tuple","updates"] on
runs.create forces langchain_anthropic into streaming, and on the
second model call (after tool execution) its serialized thinking
blocks come back malformed, so Anthropic rejects the request with
'messages.1.content.0.thinking.thinking: Field required'. Revert to
the default stream_mode so claude-opus thinking + tool use runs to
completion. The frontend keeps the messages-event handler in place
as a no-op fallback for when streaming is re-enabled.

* feat(agents): per-thread model picker wired through to the run

Add optional model_id/effort to the create-thread and send-message
request bodies, forward them as agent_model_id/agent_effort in the
LangGraph run configurable, and record the resolved choice in thread
metadata so the UI can show the model the run is actually using.
get_agent now picks the per-thread override last (highest priority over
team default + profile override) and falls back gracefully when it is
absent or unsupported.

The frontend prompt bar becomes a controlled component fed by a
shared useModelOptions hook (options + profile -> defaultSelection).
AgentsHome seeds the picker from the user's profile default; the
thread view seeds from the thread's recorded model/effort and lets
each follow-up retarget the run.

* refactor(ui): align Agents prompt bar layout with open-swe-app PromptBar

Drop the absolute-positioned send button, restore the original
px-4 py-3.5 min-h-[106px] flex-col container, and move the model
picker into a mt-auto pt-2 footer row so the placeholder text and
the model selector share the same horizontal padding.

* chore: fix lint/format CI failures

Remove unused imports and reformat two files flagged by ruff.

* fix(tests): stop messages-reducer patch tests from polluting the suite

Restore agent modules after reducer patch tests and import LangSmithSandbox
from agent.server in proxy refresh tests so isinstance checks stay valid.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Johannes du Plessis 2026-05-22 11:15:59 -07:00 • committed by GitHub
parent 0ceca8d0db
commit e87085139b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 8778 additions and 170 deletions

View file

@ -0,0 +1,72 @@
"""Guard deepagents message reducer against None checkpoint state.
DeltaChannel snapshots can persist ``None`` for ``messages`` (e.g. after a
cancelled run). The stock reducer calls ``convert_to_messages(None)``, which
raises when LangGraph replays checkpoint history for ``GET /threads/.../state``.
Must load ``deepagents._messages_reducer`` without importing ``deepagents.graph``
first — ``deepagents.__init__`` eagerly imports ``create_deep_agent``, which
binds the reducer into ``DeltaChannel`` at class definition time.
"""
from __future__ import annotations
import importlib
import importlib.util
import sys
from collections.abc import Sequence
from pathlib import Path
from langchain_core.messages import AnyMessage
_PATCHED_ATTR = "_open_swe_messages_reducer_patched"
def _load_messages_reducer_module():
name = "deepagents._messages_reducer"
existing = sys.modules.get(name)
if existing is not None:
return existing
spec = importlib.util.find_spec("deepagents")
if spec is None or not spec.origin:
raise ImportError("deepagents package not found")
root = Path(spec.origin).parent
reducer_spec = importlib.util.spec_from_file_location(
name,
root / "_messages_reducer.py",
)
if reducer_spec is None or reducer_spec.loader is None:
raise ImportError("deepagents._messages_reducer not found")
mod = importlib.util.module_from_spec(reducer_spec)
sys.modules[name] = mod
reducer_spec.loader.exec_module(mod)
return mod
def _apply() -> None:
mod = _load_messages_reducer_module()
if getattr(mod._messages_delta_reducer, _PATCHED_ATTR, False):
return
orig = mod._messages_delta_reducer
if getattr(orig, _PATCHED_ATTR, False):
return
def _messages_delta_reducer(
state: list[AnyMessage] | None, writes: Sequence[list[AnyMessage]]
) -> list[AnyMessage]:
if state is None:
state = []
return orig(state, writes)
mod._messages_delta_reducer = _messages_delta_reducer
setattr(_messages_delta_reducer, _PATCHED_ATTR, True)
graph_mod = sys.modules.get("deepagents.graph")
if graph_mod is not None:
graph_mod._messages_delta_reducer = mod._messages_delta_reducer
importlib.reload(graph_mod)
_apply()

View file

@ -0,0 +1,238 @@
"""Convert LangGraph / LangChain message dicts into dashboard UI message payloads."""
from __future__ import annotations
import json
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"})
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 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 _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
text = extract_text_content(raw.get("content", ""))
if not text:
continue
ui_messages.append(
{
"id": msg_id,
"author": "user",
"timestamp": timestamp,
"chunks": [{"kind": "text", "text": text}],
}
)
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

@ -9,7 +9,7 @@ from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, Response
from fastapi.responses import RedirectResponse, Response, StreamingResponse
from pydantic import BaseModel
from .admin import is_admin
@ -61,6 +61,17 @@ from .team_settings import (
get_team_settings,
upsert_team_settings,
)
from .thread_api import (
ThreadCreateBody,
ThreadMessageBody,
cancel_dashboard_thread,
create_dashboard_thread,
delete_dashboard_thread,
get_dashboard_thread,
list_dashboard_threads,
send_dashboard_message,
stream_dashboard_thread,
)
logger = logging.getLogger(__name__)
@ -562,3 +573,68 @@ async def api_delete_review_style(
await cancel_review_style_analysis(full_name)
await delete_review_style(full_name)
return Response(status_code=204)
@router.get("/threads")
async def api_list_threads(
session: dict[str, Any] = _SESSION_DEP,
) -> list[dict[str, Any]]:
return await list_dashboard_threads(session["sub"])
@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,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
return await get_dashboard_thread(thread_id, session["sub"])
@router.post("/threads/{thread_id}/messages")
async def api_send_thread_message(
thread_id: str,
body: ThreadMessageBody,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
return await send_dashboard_message(thread_id, session["sub"], body)
@router.post("/threads/{thread_id}/cancel")
async def api_cancel_thread(
thread_id: str,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
return await cancel_dashboard_thread(thread_id, session["sub"])
@router.delete("/threads/{thread_id}")
async def api_delete_thread(
thread_id: str,
session: dict[str, Any] = _SESSION_DEP,
) -> Response:
await delete_dashboard_thread(thread_id, session["sub"])
return Response(status_code=204)
@router.get("/threads/{thread_id}/stream")
async def api_stream_thread(
thread_id: str,
session: dict[str, Any] = _SESSION_DEP,
) -> StreamingResponse:
async def event_generator():
async for chunk in stream_dashboard_thread(thread_id, session["sub"]):
yield chunk
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)

View file

@ -0,0 +1,441 @@
"""Dashboard thread list/detail/run/stream endpoints backed by LangGraph."""
from __future__ import annotations
import json
import logging
import os
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException
from langgraph_sdk.errors import InternalServerError
from pydantic import BaseModel, Field
from ..utils.auth import persist_encrypted_github_token
from ..utils.thread_ops import is_thread_active, langgraph_client, queue_message_for_thread
from .agent_overrides import get_profile_default_repo
from .message_adapter import state_messages_to_ui
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
from .profiles import OAUTH_TOKENS_NAMESPACE, get_profile, get_valid_access_token
from .profiles import _get_value as get_oauth_record
logger = logging.getLogger(__name__)
_ASSISTANT_ID = "agent"
_DASHBOARD_SOURCE = "dashboard"
def _agent_version_metadata() -> dict[str, str]:
revision = os.environ.get("LANGCHAIN_REVISION_ID")
return {"LANGSMITH_AGENT_VERSION": revision} if revision else {}
class ThreadCreateBody(BaseModel):
prompt: str = Field(min_length=1, max_length=20_000)
repo: str | None = None
model_id: str | None = None
effort: str | None = None
class ThreadMessageBody(BaseModel):
content: str = Field(min_length=1, max_length=20_000)
model_id: str | None = None
effort: str | None = None
def _normalize_model_choice(
model_id: str | None, effort: str | None
) -> tuple[str | None, str | None]:
if not isinstance(model_id, str) or model_id not in SUPPORTED_MODEL_IDS:
return None, None
if not isinstance(effort, str) or not model_supports_effort(model_id, effort):
return None, None
return model_id, effort
def _now_ms() -> int:
return int(datetime.now(UTC).timestamp() * 1000)
def _parse_repo(full_name: str | None) -> dict[str, str] | None:
if not isinstance(full_name, str):
return None
parts = full_name.strip().split("/", 1)
if len(parts) != 2:
return None
owner, name = parts[0].strip(), parts[1].strip()
if not owner or not name:
return None
return {"owner": owner, "name": name}
async def _persist_dashboard_github_token(thread_id: str, login: str) -> None:
token = await get_valid_access_token(login)
if not token:
raise HTTPException(401, "github token unavailable, re-login required")
record = await get_oauth_record(OAUTH_TOKENS_NAMESPACE, login)
expires_at = record.get("token_expires_at") if isinstance(record, dict) else None
await persist_encrypted_github_token(
thread_id,
token,
expires_at=expires_at if isinstance(expires_at, str) else None,
)
def _thread_owner_login(metadata: dict[str, Any]) -> str | None:
login = metadata.get("github_login")
return login.strip() if isinstance(login, str) and login.strip() else None
def _assert_thread_owner(metadata: dict[str, Any], login: str) -> None:
owner = _thread_owner_login(metadata)
if owner != login:
raise HTTPException(404, "thread not found")
if metadata.get("source") != _DASHBOARD_SOURCE:
raise HTTPException(404, "thread not found")
def _metadata_repo(metadata: dict[str, Any]) -> tuple[str, str, str]:
owner = metadata.get("repo_owner")
name = metadata.get("repo_name")
if isinstance(owner, str) and isinstance(name, str) and owner and name:
return owner, name, f"{owner}/{name}"
repo = metadata.get("repo")
if isinstance(repo, dict):
o = repo.get("owner")
n = repo.get("name")
if isinstance(o, str) and isinstance(n, str) and o and n:
return o, n, f"{o}/{n}"
return "", "", ""
def _run_status_to_agent_status(thread_status: str | None, run_status: str | None) -> str:
if thread_status == "busy" or run_status in {"pending", "running"}:
return "running"
if run_status in {"error", "failed", "timeout", "interrupted"}:
return "error"
if run_status == "success":
return "finished"
return "idle"
def _thread_summary(
thread: dict[str, Any], *, messages: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
owner, name, full_name = _metadata_repo(metadata)
created_at = metadata.get("created_at_ms")
updated_at = metadata.get("updated_at_ms")
title = metadata.get("title") if isinstance(metadata.get("title"), str) else "Untitled agent"
model = metadata.get("model") if isinstance(metadata.get("model"), str) else "Default"
effort = metadata.get("effort") if isinstance(metadata.get("effort"), str) else None
thread_status = thread.get("status") if isinstance(thread.get("status"), str) else "idle"
latest_run_status = metadata.get("latest_run_status")
status = _run_status_to_agent_status(
thread_status,
latest_run_status if isinstance(latest_run_status, str) else None,
)
pr_number = metadata.get("pr_number")
pr_url = metadata.get("pr_url")
pr_title = metadata.get("pr_title")
pr_state = metadata.get("pr_state")
summary: dict[str, Any] = {
"id": thread.get("thread_id") or thread.get("id"),
"title": title,
"repo": name or "unknown",
"repoFullName": full_name or "unknown/unknown",
"branch": metadata.get("branch_name") or metadata.get("base_branch") or "main",
"model": model,
"effort": effort,
"status": status,
"createdAt": int(created_at) if isinstance(created_at, (int, float)) else _now_ms(),
"updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(),
}
if isinstance(pr_number, int) and isinstance(pr_url, str):
summary["pr"] = {
"number": pr_number,
"title": pr_title if isinstance(pr_title, str) else title,
"state": pr_state if isinstance(pr_state, str) else "open",
"headRef": metadata.get("branch_name") or "",
"baseRef": metadata.get("base_branch") or "main",
"url": pr_url,
}
if messages is not None:
summary["messages"] = messages
else:
summary["messages"] = []
return summary
async def _latest_run_status(thread_id: str) -> str | None:
runs = await langgraph_client().runs.list(thread_id, limit=1)
if not runs:
return None
run = runs[0]
raw = run.get("status") if isinstance(run, dict) else getattr(run, "status", None)
return raw.lower() if isinstance(raw, str) else None
async def list_dashboard_threads(login: str, *, limit: int = 50) -> list[dict[str, Any]]:
threads = await langgraph_client().threads.search(
metadata={"source": _DASHBOARD_SOURCE, "github_login": login},
limit=limit,
sort_by="updated_at",
sort_order="desc",
)
out: list[dict[str, Any]] = []
for thread in threads or []:
if isinstance(thread, dict):
out.append(_thread_summary(thread))
return out
async def get_dashboard_thread(thread_id: str, login: str) -> dict[str, Any]:
client = langgraph_client()
try:
thread = await client.threads.get(thread_id)
except Exception as exc: # noqa: BLE001
logger.debug("Thread lookup failed for %s", thread_id, exc_info=True)
raise HTTPException(404, "thread not found") from exc
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
_assert_thread_owner(metadata, login)
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 [])
latest_run_status = await _latest_run_status(thread_id)
if latest_run_status and latest_run_status != metadata.get("latest_run_status"):
metadata = {**metadata, "latest_run_status": latest_run_status}
thread = {**thread, "metadata": metadata}
return _thread_summary(thread, messages=messages)
async def _resolve_repo_config(login: str, repo: str | None) -> dict[str, str]:
parsed = _parse_repo(repo)
if parsed:
return parsed
profile_repo = await get_profile_default_repo(login)
if profile_repo:
return profile_repo
profile = await get_profile(login)
parsed = _parse_repo(profile.get("default_repo") if isinstance(profile, dict) else None)
if parsed:
return parsed
raise HTTPException(400, "no default repository configured — set one in Cloud Agents settings")
async def _start_agent_run(
thread_id: str,
*,
login: str,
repo_config: dict[str, str],
prompt: str,
title: str | None = None,
model_id: str | None = None,
effort: str | None = None,
) -> dict[str, Any]:
profile = await get_profile(login) or {}
now_ms = _now_ms()
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")
metadata = {
"source": _DASHBOARD_SOURCE,
"github_login": login,
"title": title or prompt[:80] or "New agent",
"repo_owner": repo_config["owner"],
"repo_name": repo_config["name"],
"base_branch": profile.get("base_branch") or "main",
"branch_prefix": profile.get("branch_prefix"),
"model": metadata_model,
"effort": metadata_effort,
"created_at_ms": now_ms,
"updated_at_ms": now_ms,
}
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 _persist_dashboard_github_token(thread_id, login)
configurable: dict[str, Any] = {
"thread_id": thread_id,
"source": _DASHBOARD_SOURCE,
"github_login": login,
"repo": repo_config,
"user_email": profile.get("email"),
}
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": prompt}]},
config={"configurable": configurable, "metadata": _agent_version_metadata()},
if_not_exists="create",
)
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}
)
async def create_dashboard_thread(login: str, body: ThreadCreateBody) -> dict[str, Any]:
repo_config = await _resolve_repo_config(login, body.repo)
thread_id = str(uuid.uuid4())
return await _start_agent_run(
thread_id,
login=login,
repo_config=repo_config,
prompt=body.prompt.strip(),
model_id=body.model_id,
effort=body.effort,
)
async def send_dashboard_message(
thread_id: str, login: str, body: ThreadMessageBody
) -> dict[str, Any]:
client = langgraph_client()
try:
thread = await 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)
owner, name, _ = _metadata_repo(metadata)
if not owner or not name:
raise HTTPException(400, "thread is missing repository metadata")
prompt = body.content.strip()
now_ms = _now_ms()
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
metadata_update: dict[str, Any] = {"updated_at_ms": now_ms}
if chosen_model and chosen_effort:
metadata_update["model"] = chosen_model
metadata_update["effort"] = chosen_effort
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
if await is_thread_active(thread_id):
queued = await queue_message_for_thread(thread_id, prompt)
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}
)
await _persist_dashboard_github_token(thread_id, login)
profile = await get_profile(login) or {}
configurable: dict[str, Any] = {
"thread_id": thread_id,
"source": _DASHBOARD_SOURCE,
"github_login": login,
"repo": {"owner": owner, "name": name},
"user_email": profile.get("email"),
}
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": prompt}]},
config={"configurable": configurable, "metadata": _agent_version_metadata()},
)
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}
)
async def cancel_dashboard_thread(thread_id: str, login: str) -> dict[str, Any]:
client = langgraph_client()
try:
thread = await 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)
run_id = metadata.get("latest_run_id")
if isinstance(run_id, str) and run_id:
try:
await client.runs.cancel(thread_id, run_id, wait=False)
except Exception:
logger.debug("Could not cancel run %s for thread %s", run_id, thread_id, exc_info=True)
await client.threads.update(
thread_id=thread_id,
metadata={"latest_run_status": "interrupted", "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}
)
async def delete_dashboard_thread(thread_id: str, login: str) -> None:
client = langgraph_client()
try:
thread = await 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)
run_id = metadata.get("latest_run_id")
if isinstance(run_id, str) and run_id:
try:
await client.runs.cancel(thread_id, run_id, wait=False)
except Exception:
logger.debug("Could not cancel run %s for thread %s", run_id, thread_id, exc_info=True)
await client.threads.delete(thread_id)
async def stream_dashboard_thread(thread_id: str, login: str) -> AsyncIterator[str]:
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)
stream = await langgraph_client().threads.join_stream(thread_id)
async for part in stream:
payload = part if isinstance(part, dict) else {"event": str(part)}
yield f"data: {json.dumps(payload, default=str)}\n\n"

View file

@ -3,8 +3,11 @@ from uuid import uuid4
from langchain.agents.middleware import AgentState, after_model
from langchain_core.messages import AnyMessage, ToolMessage
from langgraph.config import get_config
from langgraph.runtime import Runtime
_DASHBOARD_SOURCE = "dashboard"
def get_every_message_since_last_human(state: AgentState) -> list[AnyMessage]:
messages = state["messages"]
@ -40,6 +43,17 @@ def check_if_no_op(messages: list[AnyMessage]) -> bool:
return False
def _is_dashboard_source() -> bool:
try:
config = get_config()
except RuntimeError:
return False
configurable = config.get("configurable", {})
if not isinstance(configurable, dict):
return False
return configurable.get("source") == _DASHBOARD_SOURCE
@after_model
def ensure_no_empty_msg(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
last_msg = state["messages"][-1]
@ -67,9 +81,11 @@ def ensure_no_empty_msg(state: AgentState, runtime: Runtime) -> dict[str, Any] |
if has_contents and not has_tool_calls:
messages_since_last_human = get_every_message_since_last_human(state)
if check_if_model_messaged_user(
messages_since_last_human
) or check_if_confirming_completion(messages_since_last_human):
if (
check_if_model_messaged_user(messages_since_last_human)
or check_if_confirming_completion(messages_since_last_human)
or _is_dashboard_source()
):
return None
tc_id = str(uuid4())

View file

@ -19,6 +19,10 @@ from langgraph.pregel import Pregel
warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
from ._patch_messages_reducer import _apply as _apply_messages_reducer_patch
_apply_messages_reducer_patch()
from deepagents import create_deep_agent
from deepagents.backends.protocol import SandboxBackendProtocol
from langchain.agents.middleware import ModelCallLimitMiddleware

View file

@ -26,6 +26,10 @@ from langgraph.pregel import Pregel
warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
from ._patch_messages_reducer import _apply as _apply_messages_reducer_patch
_apply_messages_reducer_patch()
from deepagents import create_deep_agent
from langchain.agents.middleware import ModelCallLimitMiddleware

View file

@ -22,6 +22,10 @@ import asyncio
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
# Now safe to import agent (which imports LangChain modules)
from ._patch_messages_reducer import _apply as _apply_messages_reducer_patch
_apply_messages_reducer_patch()
from deepagents import create_deep_agent
from deepagents.backends import LangSmithSandbox
from deepagents.backends.protocol import SandboxBackendProtocol
@ -34,7 +38,7 @@ from .dashboard.agent_overrides import (
profile_create_prs,
resolve_github_login,
)
from .dashboard.options import DEFAULT_MODEL_ID
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
from .dashboard.team_settings import get_team_default_model
from .integrations.langsmith import _configure_github_proxy
from .middleware import (
@ -398,6 +402,23 @@ async def get_agent(config: RunnableConfig) -> Pregel:
model_id = overridden_model
profile_effort = overridden_effort
configurable = (config or {}).get("configurable") or {}
per_thread_model = configurable.get("agent_model_id")
per_thread_effort = configurable.get("agent_effort")
if (
isinstance(per_thread_model, str)
and per_thread_model in SUPPORTED_MODEL_IDS
and isinstance(per_thread_effort, str)
and model_supports_effort(per_thread_model, per_thread_effort)
):
logger.info(
"Applying per-thread model override: model=%s effort=%s",
per_thread_model,
per_thread_effort,
)
model_id = per_thread_model
profile_effort = per_thread_effort
create_prs = profile_create_prs(profile)
if not create_prs:
logger.info("PR creation disabled by profile for %s", profile_login)

View file

@ -418,6 +418,29 @@ async def resolve_github_token(
if not email:
raise ValueError(f"No email mapping found for GitHub user '{github_login}'")
return await save_encrypted_token_from_email(email, source)
if source == "dashboard":
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
thread_id
)
if cached_token and cached_encrypted:
return cached_token, cached_encrypted, cached_expires_at
github_login = configurable.get("github_login")
if not isinstance(github_login, str) or not github_login.strip():
raise ValueError("missing github_login for dashboard run")
from ..dashboard.profiles import OAUTH_TOKENS_NAMESPACE, get_valid_access_token
from ..dashboard.profiles import _get_value as get_oauth_record
token = await get_valid_access_token(github_login.strip())
if not token:
raise ValueError("github token unavailable, re-login required")
record = await get_oauth_record(OAUTH_TOKENS_NAMESPACE, github_login.strip())
expires_at = record.get("token_expires_at") if isinstance(record, dict) else None
encrypted = await persist_encrypted_github_token(
thread_id,
token,
expires_at=expires_at if isinstance(expires_at, str) else None,
)
return token, encrypted, expires_at if isinstance(expires_at, str) else None
return await save_encrypted_token_from_email(configurable.get("user_email"), source)
except ValueError as exc:
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))

68
agent/utils/thread_ops.py Normal file
View file

@ -0,0 +1,68 @@
"""Shared LangGraph thread helpers for webhooks and the dashboard."""
from __future__ import annotations
import logging
import os
from typing import Any
from langgraph_sdk import get_client
logger = logging.getLogger(__name__)
def langgraph_url() -> str:
return os.environ.get("LANGGRAPH_URL") or os.environ.get(
"LANGGRAPH_URL_PROD", "http://localhost:2024"
)
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."""
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
async def queue_message_for_thread(
thread_id: str, message_content: str | list[dict[str, Any]] | dict[str, Any]
) -> bool:
"""Queue a follow-up message for a busy thread (FIFO store namespace)."""
client = langgraph_client()
try:
namespace = ("queue", thread_id)
key = "pending_messages"
new_message = {"content": message_content}
existing_messages: list[dict[str, Any]] = []
try:
existing_item = await client.store.get_item(namespace, key)
if existing_item and existing_item.get("value"):
existing_messages = existing_item["value"].get("messages", [])
except Exception: # noqa: BLE001
logger.debug("No existing queued messages for thread %s", thread_id)
existing_messages.append(new_message)
await client.store.put_item(namespace, key, {"messages": existing_messages})
logger.info(
"Queued message for thread %s (total queued: %d)",
thread_id,
len(existing_messages),
)
return True
except Exception:
logger.exception("Failed to queue message for thread %s", thread_id)
return False

View file

@ -85,6 +85,7 @@ from .utils.slack_feedback import (
process_slack_reaction_added,
process_slack_reaction_removed,
)
from .utils.thread_ops import is_thread_active, queue_message_for_thread
logger = logging.getLogger(__name__)
@ -556,37 +557,6 @@ async def get_slack_repo_config(
return repo_config
async def is_thread_active(thread_id: str) -> bool:
"""Check if a thread is currently active (has a running run).
Args:
thread_id: The LangGraph thread ID
Returns:
True if the thread status is "busy", False otherwise
"""
langgraph_client = get_client(url=LANGGRAPH_URL)
try:
logger.debug("Fetching thread status for %s from %s", thread_id, LANGGRAPH_URL)
thread = await langgraph_client.threads.get(thread_id)
status = thread.get("status", "idle")
logger.info(
"Thread %s status check: status=%s, is_busy=%s",
thread_id,
status,
status == "busy",
)
except Exception as e: # noqa: BLE001
logger.warning(
"Failed to get thread status for %s: %s (type: %s) - assuming not active",
thread_id,
e,
type(e).__name__,
)
status = "idle"
return status == "busy"
async def _thread_exists(thread_id: str) -> bool:
"""Return whether a LangGraph thread already exists."""
langgraph_client = get_client(url=LANGGRAPH_URL)
@ -611,53 +581,6 @@ async def _ensure_thread_exists_for_metadata(
return False
async def queue_message_for_thread(
thread_id: str, message_content: str | list[dict[str, Any]] | dict[str, Any]
) -> bool:
"""Queue a message for a thread that is currently active.
Stores the message in the langgraph store, namespaced to the thread.
Supports multiple queued messages by storing them as a list (FIFO order).
The before_model middleware will pick them up and inject them into state.
Args:
thread_id: The LangGraph thread ID
message_content: The message content to queue (text or content blocks)
Returns:
True if successfully queued, False otherwise
"""
langgraph_client = get_client(url=LANGGRAPH_URL)
try:
namespace = ("queue", thread_id)
key = "pending_messages"
new_message = {"content": message_content}
existing_messages: list[dict[str, Any]] = []
try:
existing_item = await langgraph_client.store.get_item(namespace, key)
if existing_item and existing_item.get("value"):
existing_messages = existing_item["value"].get("messages", [])
except Exception: # noqa: BLE001
logger.debug("No existing queued messages for thread %s", thread_id)
existing_messages.append(new_message)
value = {"messages": existing_messages}
logger.info(
"Attempting to queue message for thread %s (total queued: %d)",
thread_id,
len(existing_messages),
)
await langgraph_client.store.put_item(namespace, key, value)
logger.info("Successfully queued message for thread %s", thread_id)
return True # noqa: TRY300
except Exception:
logger.exception("Failed to queue message for thread %s", thread_id)
return False
async def process_linear_issue( # noqa: PLR0912, PLR0915
issue_data: dict[str, Any], repo_config: dict[str, str]
) -> None:

View file

@ -0,0 +1,71 @@
"""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_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

@ -1,4 +1,4 @@
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
@ -187,3 +187,21 @@ class TestEnsureNoEmptyMsgNotify:
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is None
def test_skips_confirming_completion_for_dashboard_source(self) -> None:
ai = AIMessage(content="Hi! How can I help?")
state = {
"messages": [
HumanMessage(content="hello"),
ai,
]
}
with patch(
"agent.middleware.ensure_no_empty_msg.get_config",
return_value={"configurable": {"source": "dashboard"}},
):
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is None
assert not ai.tool_calls

View file

@ -0,0 +1,82 @@
import importlib
import sys
import pytest
from langchain_core.messages import HumanMessage
def _clear_deepagents_modules(
*,
include_agent_server: bool = False,
include_agent_reviewer: bool = False,
) -> None:
for name in list(sys.modules):
if name == "deepagents" or name.startswith("deepagents."):
del sys.modules[name]
elif include_agent_server and name == "agent.server":
del sys.modules[name]
elif include_agent_reviewer and name == "agent.reviewer":
del sys.modules[name]
importlib.invalidate_caches()
def _restore_agent_modules() -> None:
patch_mod = importlib.import_module("agent._patch_messages_reducer")
importlib.reload(patch_mod)
for name in ("agent.server", "agent.reviewer"):
mod = sys.modules.get(name)
if mod is not None:
importlib.reload(mod)
else:
importlib.import_module(name)
def _apply_patch() -> None:
_clear_deepagents_modules()
patch_mod = importlib.import_module("agent._patch_messages_reducer")
importlib.reload(patch_mod)
def test_messages_delta_reducer_accepts_none_state() -> None:
_apply_patch()
from deepagents import _messages_reducer as reducer_mod
result = reducer_mod._messages_delta_reducer(None, [])
assert result == []
def test_messages_delta_reducer_still_merges_writes() -> None:
_apply_patch()
from deepagents import _messages_reducer as reducer_mod
msg = HumanMessage(content="hi", id="m1")
result = reducer_mod._messages_delta_reducer(None, [[msg]])
assert len(result) == 1
assert result[0].content == "hi"
def test_graph_binds_patched_reducer_on_server_import() -> None:
_clear_deepagents_modules(include_agent_server=True)
try:
importlib.import_module("agent.server")
from deepagents.graph import _messages_delta_reducer
assert _messages_delta_reducer(None, []) == []
finally:
_restore_agent_modules()
def test_late_graph_import_is_repaired_by_patch() -> None:
_clear_deepagents_modules()
try:
from deepagents.graph import _messages_delta_reducer as unpatched
with pytest.raises(TypeError):
unpatched(None, [])
importlib.reload(importlib.import_module("agent._patch_messages_reducer"))
from deepagents.graph import _messages_delta_reducer as patched
assert patched(None, []) == []
finally:
_restore_agent_modules()

View file

@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from deepagents.backends import LangSmithSandbox
from agent.integrations.langsmith import _configure_github_proxy
@ -336,8 +335,6 @@ class TestRefreshProxyOnSandboxReuse:
inner_sandbox = MagicMock(name="sandbox-stopped")
inner_sandbox.name = "sandbox-stopped"
inner_sandbox._client.get_sandbox_status.return_value = MagicMock(status="stopped")
sandbox_backend = object.__new__(LangSmithSandbox)
sandbox_backend._sandbox = inner_sandbox
with (
patch(
@ -348,7 +345,10 @@ class TestRefreshProxyOnSandboxReuse:
patch("agent.server._configure_github_proxy") as mock_proxy,
patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith"}),
):
from agent.server import _refresh_github_proxy
from agent.server import LangSmithSandbox, _refresh_github_proxy
sandbox_backend = object.__new__(LangSmithSandbox)
sandbox_backend._sandbox = inner_sandbox
await _refresh_github_proxy(sandbox_backend)
@ -362,8 +362,6 @@ class TestRefreshProxyOnSandboxReuse:
inner_sandbox = MagicMock(name="sandbox-ready")
inner_sandbox.name = "sandbox-ready"
inner_sandbox._client.get_sandbox_status.return_value = MagicMock(status="ready")
sandbox_backend = object.__new__(LangSmithSandbox)
sandbox_backend._sandbox = inner_sandbox
with (
patch(
@ -374,7 +372,10 @@ class TestRefreshProxyOnSandboxReuse:
patch("agent.server._configure_github_proxy") as mock_proxy,
patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith"}),
):
from agent.server import _refresh_github_proxy
from agent.server import LangSmithSandbox, _refresh_github_proxy
sandbox_backend = object.__new__(LangSmithSandbox)
sandbox_backend._sandbox = inner_sandbox
await _refresh_github_proxy(sandbox_backend)

View file

@ -8,6 +8,7 @@
"@base-ui/react": "^1.4.1",
"@fontsource-variable/inter": "^5.2.8",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "^1.2.1",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query": "^5.100.10",
@ -19,10 +20,17 @@
"@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",
"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",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
@ -294,6 +302,10 @@
"@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
"@pierre/diffs": ["@pierre/diffs@1.2.1", "", { "dependencies": { "@pierre/theme": "1.0.3", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-T9pYhh1KeaYLTwL1HMiAkeETj9CPjiKq6pqiUWRVJXvIkIvYutMrYyr3kN07cFWX2XPM8eqe6l2dknezs6K2AA=="],
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="],
@ -378,6 +390,24 @@
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="],
"@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="],
"@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="],
"@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="],
"@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="],
"@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="],
@ -512,14 +542,24 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
@ -530,6 +570,8 @@
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="],
@ -552,6 +594,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
"@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="],
@ -636,6 +680,8 @@
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="],
@ -668,10 +714,20 @@
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
@ -698,6 +754,8 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="],
@ -748,6 +806,8 @@
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
@ -768,7 +828,9 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@ -848,6 +910,8 @@
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
@ -868,6 +932,8 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@ -958,6 +1024,12 @@
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="],
"hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="],
@ -966,6 +1038,10 @@
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@ -988,14 +1064,22 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@ -1004,6 +1088,8 @@
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
@ -1098,16 +1184,56 @@
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
"lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
"lucide-react": ["lucide-react@1.16.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@ -1118,6 +1244,62 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
@ -1180,6 +1362,10 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@ -1194,6 +1380,8 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
@ -1242,6 +1430,8 @@
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@ -1258,14 +1448,34 @@
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
"react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@ -1322,6 +1532,8 @@
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
@ -1342,6 +1554,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="],
"stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
@ -1358,6 +1572,8 @@
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@ -1368,6 +1584,10 @@
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
@ -1404,6 +1624,10 @@
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"ts-declaration-location": ["ts-declaration-location@1.0.7", "", { "dependencies": { "picomatch": "^4.0.2" }, "peerDependencies": { "typescript": ">=4.0.0" } }, "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA=="],
@ -1438,6 +1662,18 @@
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
@ -1462,6 +1698,10 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
@ -1528,6 +1768,8 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
@ -1540,6 +1782,14 @@
"@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
"@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@ -1552,6 +1802,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/router-utils/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@tanstack/start-plugin-core/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.40", "", {}, "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w=="],
@ -1604,10 +1856,14 @@
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@ -1626,6 +1882,8 @@
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"shadcn/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
@ -1662,6 +1920,18 @@
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
"@pierre/diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
"@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
"@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
"@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
"@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"cheerio/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],

View file

@ -15,6 +15,7 @@
"@base-ui/react": "^1.4.1",
"@fontsource-variable/inter": "^5.2.8",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "^1.2.1",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query": "^5.100.10",
@ -26,11 +27,17 @@
"@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",
"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",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",

View file

@ -1,19 +1,21 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import {
IoArrowBackOutline,
IoCloudOutline,
IoExtensionPuzzleOutline,
IoGitPullRequestOutline,
IoLogOutOutline,
IoOptionsOutline,
IoSettingsOutline,
} from "react-icons/io5";
import { useEffect, useRef, useState } from "react";
import type {ComponentType, SVGProps} from "react";
import type { ComponentType, SVGProps } from "react";
import type { SessionUser } from "@/lib/api";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { api } from "@/lib/api";
import { SidebarUserMenu } from "@/components/SidebarUserMenu";
import {
SidebarCollapseButton,
SidebarFrame,
useSidebarLayout,
} from "@/components/sidebar-layout";
import { cn } from "@/lib/utils";
type IconType = ComponentType<SVGProps<SVGSVGElement>>;
@ -34,41 +36,10 @@ const NAV: Array<NavItem> = [
];
export function AppSidebar({ user }: { user: SessionUser }) {
const qc = useQueryClient();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!menuOpen) return;
const onClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpen(false);
};
document.addEventListener("mousedown", onClickOutside);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClickOutside);
document.removeEventListener("keydown", onKey);
};
}, [menuOpen]);
const onLogout = async () => {
setMenuOpen(false);
await api.logout();
qc.setQueryData(["session"], null);
navigate({ to: "/login" });
};
const initials = (user.login || "?").slice(0, 2).toUpperCase();
const layout = useSidebarLayout();
return (
<aside className="flex h-svh w-60 shrink-0 flex-col border-r border-border bg-sidebar text-sidebar-foreground">
<div className="px-4 pt-5 pb-4">
<SidebarFrame {...layout} className="border-r border-border bg-sidebar text-sidebar-foreground">
<div className="flex items-center justify-between px-4 pt-5 pb-4">
<Link
to="/my-settings"
className="flex items-center gap-2 font-heading text-sm font-medium tracking-tight"
@ -76,9 +47,20 @@ export function AppSidebar({ user }: { user: SessionUser }) {
<img src="/logo-mark.png" alt="" className="size-5" />
open-swe
</Link>
<SidebarCollapseButton onToggle={layout.toggle} />
</div>
<nav className="flex flex-1 flex-col gap-0.5 px-2">
<Link
to="/agents"
className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<IoArrowBackOutline className="size-4" />
<span>Back to Agents</span>
</Link>
{NAV.filter((n) => !n.adminOnly || user.is_admin).map((item) => {
const Icon = item.icon;
return (
@ -100,42 +82,9 @@ export function AppSidebar({ user }: { user: SessionUser }) {
})}
</nav>
<div ref={menuRef} className="relative border-t border-border p-2">
<button
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((o) => !o)}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left outline-none hover:bg-sidebar-accent"
>
<Avatar className="size-7">
{user.avatar_url && <AvatarImage src={user.avatar_url} alt={user.login} />}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-xs font-medium">{user.login}</span>
{user.email && (
<span className="truncate text-[10px] text-muted-foreground">{user.email}</span>
)}
</div>
</button>
{menuOpen && (
<div
role="menu"
className="absolute bottom-full left-2 right-2 mb-2 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
>
<button
type="button"
role="menuitem"
onClick={() => void onLogout()}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs/relaxed hover:bg-muted"
>
<IoLogOutOutline className="size-3.5" />
Sign out
</button>
</div>
)}
<div className="p-2">
<SidebarUserMenu user={user} />
</div>
</aside>
</SidebarFrame>
);
}

View file

@ -0,0 +1,94 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { IoLogOutOutline, IoSettingsOutline } from "react-icons/io5";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { api, type SessionUser } from "@/lib/api";
interface SidebarUserMenuProps {
user: SessionUser;
showSettingsLink?: boolean;
}
export function SidebarUserMenu({ user, showSettingsLink = false }: SidebarUserMenuProps) {
const qc = useQueryClient();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClickOutside);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClickOutside);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const onLogout = async () => {
setOpen(false);
await api.logout();
qc.setQueryData(["session"], null);
navigate({ to: "/login" });
};
const initials = (user.login || "?").slice(0, 2).toUpperCase();
return (
<div ref={ref} className="relative">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left outline-none hover:bg-sidebar-accent"
>
<Avatar className="size-7">
{user.avatar_url && <AvatarImage src={user.avatar_url} alt={user.login} />}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-xs font-medium">{user.login}</span>
{user.email && (
<span className="truncate text-[10px] text-muted-foreground">{user.email}</span>
)}
</div>
</button>
{open && (
<div
role="menu"
className="absolute right-0 bottom-full left-0 mb-2 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
>
{showSettingsLink && (
<Link
to="/my-settings"
role="menuitem"
onClick={() => setOpen(false)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs/relaxed hover:bg-muted"
>
<IoSettingsOutline className="size-3.5" />
Dashboard settings
</Link>
)}
<button
type="button"
role="menuitem"
onClick={() => void onLogout()}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs/relaxed hover:bg-muted"
>
<IoLogOutOutline className="size-3.5" />
Sign out
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,157 @@
import { useMemo, useState } from "react";
import { MultiFileDiff } from "@pierre/diffs/react";
import { CaretDownIcon, GitPullRequestIcon } from "@phosphor-icons/react";
import { diffOptions } from "@/components/agents/utils/diffUtils";
import type { AgentThread } from "@/lib/agents/types";
import { cn } from "@/lib/utils";
interface AgentGitPanelProps {
thread: AgentThread;
}
export function AgentGitPanel({ thread }: AgentGitPanelProps) {
const [tab, setTab] = useState<"diff" | "review" | "commits">("diff");
const pr = thread.pr;
const fileContents = useMemo(() => {
const defaults: Record<string, { original: string; modified: string }> = {
"app.js": {
original: "export function init() {\n mount();\n}\n",
modified: "export function init() {\n // test comment\n mount();\n}\n",
},
"contextPanel.js": {
original: "export function renderPanel() {\n return panel;\n}\n",
modified: "export function renderPanel() {\n // test comment\n return panel;\n}\n",
},
"settings.js": {
original: "export const defaults = {};\n",
modified: "export const defaults = {};\n// test comment\n",
},
};
return (thread.changedFiles ?? []).map((file) => ({
path: file.path,
additions: file.additions,
original: defaults[file.path]?.original ?? "",
modified: defaults[file.path]?.modified ?? file.patch ?? "",
}));
}, [thread.changedFiles]);
return (
<aside className="flex h-full w-[420px] shrink-0 flex-col border-l border-[var(--ui-border)] bg-[var(--ui-surface)]">
<div className="flex h-11 shrink-0 items-center gap-1 border-b border-[var(--ui-border)] px-3">
{(["Git", "Desktop", "Terminal"] as const).map((label, i) => (
<button
key={label}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-xs transition-colors",
i === 0
? "bg-[var(--ui-accent-bubble)] font-medium text-[var(--ui-text)]"
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]",
)}
>
{label}
</button>
))}
</div>
{pr && (
<div className="border-b border-[var(--ui-border)] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-[var(--ui-text)]">
{pr.title} #{pr.number}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
<span className="inline-flex items-center gap-1 rounded border border-[var(--ui-border)] px-1.5 py-0.5 capitalize">
<GitPullRequestIcon className="size-3" />
{pr.state}
</span>
<span>
{pr.headRef} → {pr.baseRef}
</span>
</div>
</div>
<button
type="button"
className="shrink-0 rounded-md border border-[var(--ui-border)] px-2.5 py-1 text-xs hover:bg-[var(--ui-panel-2)]"
>
Mark as ready
</button>
</div>
</div>
)}
<div className="flex items-center gap-1 border-b border-[var(--ui-border)] px-3 py-2">
{(
[
["diff", "Diff"],
["review", "Review"],
["commits", `Commits (${fileContents.length})`],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn(
"rounded-md px-2.5 py-1 text-xs transition-colors",
tab === id
? "bg-[var(--ui-accent-bubble)] font-medium text-[var(--ui-text)]"
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]",
)}
>
{label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "diff" && fileContents.length > 0 ? (
<div className="space-y-2 p-2">
{fileContents.map((file) => (
<FileDiffSection key={file.path} file={file} />
))}
</div>
) : (
<div className="p-6 text-center text-xs text-[var(--ui-text-dim)]">
{tab === "commits" ? "Commit history will appear here." : "No diff available."}
</div>
)}
</div>
</aside>
);
}
function FileDiffSection({
file,
}: {
file: { path: string; additions: number; original: string; modified: string };
}) {
const [open, setOpen] = useState(true);
return (
<div className="mb-2 overflow-hidden rounded-lg border border-[var(--ui-border)]">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 bg-[var(--ui-panel-2)] px-3 py-2 text-left text-xs"
>
<CaretDownIcon className={cn("size-3 transition-transform", !open && "-rotate-90")} />
<span className="font-medium text-[var(--ui-text)]">{file.path}</span>
<span className="ml-auto text-[var(--ui-success)]">+{file.additions}</span>
</button>
{open && (
<div className="max-h-[320px] overflow-auto bg-[var(--ui-panel)] p-2 font-mono text-[11px] leading-5">
<MultiFileDiff
oldFile={{ name: file.path, contents: file.original }}
newFile={{ name: file.path, contents: file.modified }}
options={diffOptions}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,2 @@
export { CloudPromptBar as AgentPromptBar } from "@/components/agents/ported/CloudPromptBar";
export type { CloudPromptBarProps as AgentPromptBarProps } from "@/components/agents/ported/CloudPromptBar";

View file

@ -0,0 +1,73 @@
import { Link } from "@tanstack/react-router";
import { CheckCircleIcon, GitBranchIcon, GitPullRequestIcon } from "@phosphor-icons/react";
import { formatRelativeTime } from "@/lib/agents/api";
import type { AgentThread } from "@/lib/agents/types";
import { cn } from "@/lib/utils";
interface AgentRunCardProps {
thread: AgentThread;
}
export function AgentRunCard({ thread }: AgentRunCardProps) {
const stats = thread.diffStats;
const hasPr = Boolean(thread.pr);
return (
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
className="group flex items-center gap-4 rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] px-4 py-3 transition-colors hover:border-[var(--ui-accent)]/30 hover:bg-[var(--ui-panel)]"
>
<div className="flex size-[72px] shrink-0 flex-col items-center justify-center rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel-2)] text-center">
{stats ? (
<>
<div className="text-[11px] font-medium text-[var(--ui-text-muted)]">
{stats.files} {stats.files === 1 ? "file" : "files"}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs font-medium">
<span className="text-[var(--ui-success)]">+{stats.additions}</span>
<span className="text-[var(--ui-danger)]">-{stats.deletions}</span>
</div>
</>
) : (
<GitBranchIcon className="size-5 text-[var(--ui-text-dim)]" />
)}
<div className="mt-1.5 flex items-center gap-1 text-[10px] text-[var(--ui-text-dim)]">
{hasPr ? (
<>
<GitPullRequestIcon className="size-3" />
Draft
</>
) : (
<>
<GitBranchIcon className="size-3" />
Branch
</>
)}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[var(--ui-text)]">{thread.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-[var(--ui-text-dim)]">
<span>{thread.model}</span>
<span>·</span>
<span>{thread.repo}</span>
<span>·</span>
<span>{formatRelativeTime(thread.updatedAt)}</span>
</div>
</div>
<CheckCircleIcon
className={cn(
"size-5 shrink-0",
thread.status === "finished"
? "text-[var(--ui-text-dim)] opacity-100"
: "opacity-0",
)}
weight="regular"
/>
</Link>
);
}

View file

@ -0,0 +1,202 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
import { AgentPromptBar } from "@/components/agents/AgentPromptBar";
import { AgentsShell } from "@/components/agents/AgentsSidebar";
import {
MessageView,
summarizeChangedFiles,
type MessageViewScrollControl,
} from "@/components/agents/ported";
import type { SessionUser } from "@/lib/api";
import type { AgentThread, Message } from "@/lib/agents/types";
import { useSendAgentMessage } from "@/lib/agents/queries";
import {
dropPendingPrompts,
getPendingPrompts,
type PendingPrompt,
} from "@/lib/agents/pendingPrompts";
import { useAgentThreadStream } from "@/lib/agents/useThreadStream";
import { useModelOptions, type ModelSelection } from "@/lib/agents/useModelOptions";
interface AgentThreadViewProps {
user: SessionUser;
thread: AgentThread;
}
const PROMPT_OVERLAY_INSET = 128;
export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
const sendMessage = useSendAgentMessage(thread.id);
useAgentThreadStream(thread.id, thread.status === "running");
const scrollControlRef = useRef<MessageViewScrollControl | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [pendingPrompts, setPendingPrompts] = useState<PendingPrompt[]>(() =>
getPendingPrompts(thread.id),
);
const { models, defaultSelection } = useModelOptions();
const threadSelection = useMemo<ModelSelection | null>(() => {
if (!thread.model || !thread.effort) return null;
const supported = models.some(
(m) => m.id === thread.model && m.efforts.includes(thread.effort ?? ""),
);
if (!supported) return null;
return { modelId: thread.model, effort: thread.effort };
}, [models, thread.model, thread.effort]);
const [selection, setSelection] = useState<ModelSelection | null>(null);
useEffect(() => {
if (selection !== null) return;
if (threadSelection) setSelection(threadSelection);
else if (defaultSelection) setSelection(defaultSelection);
}, [defaultSelection, selection, threadSelection]);
const userMessageTexts = useMemo(() => {
return new Set(
thread.messages
.filter((m) => m.author === "user")
.map((m) =>
m.chunks
.filter((c) => c.kind === "text")
.map((c) => (c as { kind: "text"; text: string }).text)
.join(""),
),
);
}, [thread.messages]);
useEffect(() => {
setPendingPrompts((prev) => {
if (prev.length === 0) return prev;
const next = dropPendingPrompts(thread.id, (entry) =>
userMessageTexts.has(entry.prompt),
);
return next.length === prev.length ? prev : next;
});
}, [thread.id, userMessageTexts]);
const displayMessages = useMemo<Message[]>(() => {
if (pendingPrompts.length === 0) return thread.messages;
const baseTimestamp = new Date().toISOString();
const result = thread.messages.slice();
pendingPrompts.forEach((entry, i) => {
const synth: Message = {
id: `pending-user-${i}`,
author: "user",
timestamp: baseTimestamp,
chunks: [{ kind: "text", text: entry.prompt }],
};
const at = Math.min(Math.max(entry.insertAt, 0), result.length);
result.splice(at, 0, synth);
});
return result;
}, [thread.messages, pendingPrompts]);
const changedFiles = useMemo(() => {
const agentMessages = thread.messages.filter((m) => m.author === "agent");
const allChunks = agentMessages.flatMap((m) => m.chunks);
return summarizeChangedFiles(allChunks);
}, [thread.messages]);
const hasMessages = displayMessages.length > 0;
const isStreaming = thread.status === "running" || pendingPrompts.length > 0;
return (
<AgentsShell user={user} activeThreadId={thread.id}>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{hasMessages ? (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<MessageView
messages={displayMessages}
isStreaming={isStreaming}
contentWidthClass="max-w-3xl"
bottomInset={PROMPT_OVERLAY_INSET}
scrollButtonSlot="external"
scrollControlRef={scrollControlRef}
onShowScrollToBottomChange={setShowScrollToBottom}
/>
{changedFiles.length > 0 && (
<div className="mx-auto mt-4 w-full max-w-3xl shrink-0 px-6">
<div className="rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)] p-3">
<div className="mb-2 text-xs font-medium text-[var(--ui-text-muted)]">
{changedFiles.length} Files Changed
</div>
<div className="space-y-1">
{changedFiles.map((file) => (
<div
key={file.filePath}
className="flex items-center justify-between rounded px-2 py-1 text-xs hover:bg-[var(--ui-panel-2)]"
>
<span className="font-mono text-[var(--ui-text)]">{file.filePath}</span>
<span className="text-[var(--ui-success)]">+{file.additions}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 px-6 pb-4">
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[var(--ui-bg)] via-[var(--ui-bg)]/80 to-transparent" />
<div className="pointer-events-auto relative mx-auto max-w-3xl">
{showScrollToBottom && (
<button
type="button"
onClick={() => scrollControlRef.current?.scrollToBottom()}
aria-label="Scroll to bottom"
className="absolute bottom-full left-1/2 z-30 mb-2 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)]"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
)}
<AgentPromptBar
placeholder="Add a follow up"
compact
busy={isStreaming}
disabled={sendMessage.isPending}
onSubmit={(content) =>
sendMessage.mutate({
content,
model_id: selection?.modelId ?? null,
effort: selection?.effort ?? null,
})
}
models={models}
selection={selection ?? threadSelection ?? defaultSelection}
onSelectionChange={setSelection}
/>
</div>
</div>
</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)]">This thread has no messages yet.</p>
<div className="w-full max-w-3xl">
<AgentPromptBar
placeholder="Send the first message"
compact
busy={isStreaming}
disabled={sendMessage.isPending}
onSubmit={(content) =>
sendMessage.mutate({
content,
model_id: selection?.modelId ?? null,
effort: selection?.effort ?? null,
})
}
models={models}
selection={selection ?? threadSelection ?? defaultSelection}
onSelectionChange={setSelection}
/>
</div>
</div>
)}
</div>
</div>
</AgentsShell>
);
}

View file

@ -0,0 +1,67 @@
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { AgentPromptBar } from "@/components/agents/AgentPromptBar";
import { AgentRunCard } from "@/components/agents/AgentRunCard";
import { Logo } from "@/components/agents/ported/Logo";
import { useAgentThreads, useCreateAgentThread } from "@/lib/agents/queries";
import { useModelOptions, type ModelSelection } from "@/lib/agents/useModelOptions";
export function AgentsHome() {
const threadsQuery = useAgentThreads();
const createThread = useCreateAgentThread();
const recentRuns = (threadsQuery.data ?? []).slice(0, 5);
const { models, defaultSelection } = useModelOptions();
const [selection, setSelection] = useState<ModelSelection | null>(null);
useEffect(() => {
if (selection === null && defaultSelection) setSelection(defaultSelection);
}, [defaultSelection, selection]);
return (
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col items-center justify-center">
<div className="flex w-full flex-col items-center gap-6">
<Logo />
<AgentPromptBar
onSubmit={(prompt) =>
createThread.mutate({
prompt,
model_id: selection?.modelId ?? null,
effort: selection?.effort ?? null,
})
}
disabled={createThread.isPending}
models={models}
selection={selection ?? defaultSelection}
onSelectionChange={setSelection}
/>
</div>
<div className="mx-auto mt-6 w-full max-w-[640px] space-y-2">
{threadsQuery.isLoading ? (
<p className="text-center text-sm text-[var(--ui-text-dim)]">Loading agents…</p>
) : recentRuns.length === 0 ? (
<AgentsHomeEmptyState />
) : (
recentRuns.map((thread) => <AgentRunCard key={thread.id} thread={thread} />)
)}
</div>
</div>
</div>
);
}
export function AgentsHomeEmptyState() {
return (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<p className="text-sm text-[var(--ui-text-muted)]">No agents yet.</p>
<Link
to="/agents"
className="text-sm font-medium text-[var(--ui-accent)] hover:underline"
>
Start your first agent
</Link>
</div>
);
}

View file

@ -0,0 +1,176 @@
import { Link } from "@tanstack/react-router";
import { ChartLineUpIcon, PlusIcon, XIcon } from "@phosphor-icons/react";
import type { SessionUser } from "@/lib/api";
import { SidebarUserMenu } from "@/components/SidebarUserMenu";
import {
SidebarCollapseButton,
SidebarFrame,
useSidebarLayout,
} from "@/components/sidebar-layout";
import { groupThreads } from "@/lib/agents/api";
import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries";
import type { AgentThread } from "@/lib/agents/types";
import { cn } from "@/lib/utils";
interface AgentsSidebarProps {
user: SessionUser;
activeThreadId?: string;
}
const NAV = [{ to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon }] as const;
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
const threadsQuery = useAgentThreads();
const threads = threadsQuery.data ?? [];
const groups = groupThreads(threads);
const layout = useSidebarLayout();
return (
<SidebarFrame {...layout} className="border-r border-[var(--ui-border)] bg-[var(--ui-sidebar)]">
<div className="flex items-center justify-between px-4 pt-5 pb-4">
<Link
to="/my-settings"
className="flex items-center gap-2 font-heading text-sm font-medium tracking-tight text-[var(--ui-text)]"
>
<img src="/logo-mark.png" alt="" className="size-5" />
open-swe
</Link>
<SidebarCollapseButton onToggle={layout.toggle} />
</div>
<div className="px-2 pb-1">
<Link
to="/agents"
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-[var(--ui-text)] transition-colors hover:bg-[var(--ui-sidebar-hover)]"
>
<PlusIcon className="size-4" />
New Agent
</Link>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-4">
{NAV.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.to}
to={item.to}
className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-sidebar-hover)] hover:text-[var(--ui-text)]"
>
<Icon className="size-4" />
{item.label}
</Link>
);
})}
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
<ThreadGroup label="Today" threads={groups.today} activeThreadId={activeThreadId} />
<ThreadGroup label="Last 30 days" threads={groups.last30} activeThreadId={activeThreadId} />
<ThreadGroup label="Older" threads={groups.older} activeThreadId={activeThreadId} />
</div>
<div className="p-2">
<SidebarUserMenu user={user} showSettingsLink />
</div>
</SidebarFrame>
);
}
function ThreadGroup({
label,
threads,
activeThreadId,
}: {
label: string;
threads: AgentThread[];
activeThreadId?: string;
}) {
if (threads.length === 0) return null;
return (
<div className="mb-3">
<div className="px-2 py-1 text-[10px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase">
{label}
</div>
{threads.map((thread) => (
<ThreadRow key={thread.id} thread={thread} isActive={thread.id === activeThreadId} />
))}
</div>
);
}
function ThreadRow({ thread, isActive }: { thread: AgentThread; isActive: boolean }) {
const deleteThread = useDeleteAgentThread();
const badge =
thread.diffStats && thread.diffStats.additions > 0
? `+${thread.diffStats.additions}`
: null;
const isDeleting = deleteThread.isPending && deleteThread.variables === thread.id;
const onDelete = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isDeleting) return;
if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`)) return;
deleteThread.mutate(thread.id);
};
return (
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
className={cn(
"group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors",
isActive
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
isDeleting && "opacity-50",
)}
>
<span
className={cn(
"size-2 shrink-0 rounded-full",
thread.status === "running"
? "animate-pulse bg-[var(--ui-accent)]"
: thread.status === "finished"
? "bg-[var(--ui-accent)]"
: "bg-[var(--ui-border)]",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
{badge && (
<span className="shrink-0 rounded bg-[var(--ui-panel-2)] px-1.5 py-0.5 text-[10px] text-[var(--ui-text-dim)] group-hover:hidden">
{badge}
</span>
)}
<button
type="button"
aria-label="Delete thread"
onClick={onDelete}
disabled={isDeleting}
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)] group-hover:flex"
>
<XIcon className="size-3" weight="bold" />
</button>
</Link>
);
}
export function AgentsShell({
user,
activeThreadId,
children,
}: {
user: SessionUser;
activeThreadId?: string;
children: React.ReactNode;
}) {
return (
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
<div className="flex min-w-0 flex-1">{children}</div>
</div>
);
}

View file

@ -0,0 +1,188 @@
import { useState, useRef, useCallback, useMemo } from 'react';
interface BranchSelectorProps {
projectPath: string;
currentBranch: string;
}
export function BranchSelector({ projectPath, currentBranch }: BranchSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [branches, setBranches] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [newBranchName, setNewBranchName] = useState('');
const [showNewBranchInput, setShowNewBranchInput] = useState(false);
const [error, setError] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const filteredBranches = useMemo(() => {
if (!searchQuery.trim()) return branches;
const query = searchQuery.toLowerCase();
return branches.filter((branch) => branch.toLowerCase().includes(query));
}, [branches, searchQuery]);
const loadBranches = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await window.git.listBranches(projectPath);
setBranches(result.branches);
} catch {
setError('Failed to load branches');
} finally {
setLoading(false);
}
}, [projectPath]);
const handleOpen = useCallback(() => {
setIsOpen(true);
setShowNewBranchInput(false);
setNewBranchName('');
setSearchQuery('');
setError(null);
loadBranches();
setTimeout(() => searchInputRef.current?.focus(), 0);
}, [loadBranches]);
const handleClose = useCallback(() => {
setIsOpen(false);
setShowNewBranchInput(false);
setNewBranchName('');
setSearchQuery('');
setError(null);
}, []);
const handleSwitchBranch = useCallback(async (branchName: string) => {
if (branchName === currentBranch) {
handleClose();
return;
}
setLoading(true);
setError(null);
const result = await window.git.switchBranch(projectPath, branchName);
setLoading(false);
if (result.success) {
handleClose();
} else {
setError(result.error || 'Failed to switch branch');
}
}, [projectPath, currentBranch, handleClose]);
const handleCreateBranch = useCallback(async () => {
const name = newBranchName.trim();
if (!name) return;
setLoading(true);
setError(null);
const result = await window.git.createBranch(projectPath, name);
setLoading(false);
if (result.success) {
handleClose();
} else {
setError(result.error || 'Failed to create branch');
}
}, [projectPath, newBranchName, handleClose]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
} else if (e.key === 'Enter' && showNewBranchInput) {
handleCreateBranch();
}
}, [handleClose, showNewBranchInput, handleCreateBranch]);
return (
<div className="relative" onKeyDown={handleKeyDown}>
<button
onClick={isOpen ? handleClose : handleOpen}
className="text-[#5a9bc7] hover:text-[#7ab8de] transition-colors cursor-pointer truncate block max-w-full"
>
{currentBranch}
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={handleClose} />
<div
ref={dropdownRef}
className="absolute top-full left-1/2 -translate-x-1/2 mt-2 w-80 bg-[#1a2332] border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden"
>
<div className="p-2 border-b border-gray-700">
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search branches..."
className="w-full px-2 py-1.5 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-[#5a9bc7]"
/>
</div>
{error && (
<div className="px-3 py-1.5 text-[11px] text-red-400 bg-red-900/20">
{error}
</div>
)}
<div className="max-h-48 overflow-y-auto">
{loading && branches.length === 0 ? (
<div className="px-3 py-1.5 text-xs text-gray-500">Loading...</div>
) : filteredBranches.length === 0 ? (
<div className="px-3 py-1.5 text-xs text-gray-500">No branches found</div>
) : (
filteredBranches.map((branch) => (
<button
key={branch}
onClick={() => handleSwitchBranch(branch)}
disabled={loading}
className={`w-full px-3 py-1.5 text-left text-xs transition-colors ${
branch === currentBranch
? 'bg-[#5a9bc7]/20 text-[#5a9bc7]'
: 'text-gray-300 hover:bg-gray-800'
} ${loading ? 'opacity-50' : ''}`}
>
{branch}
{branch === currentBranch && (
<span className="ml-2 text-[10px] text-gray-500">(current)</span>
)}
</button>
))
)}
</div>
<div className="border-t border-gray-700 p-2">
{showNewBranchInput ? (
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="Branch name"
autoFocus
className="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-[#5a9bc7]"
/>
<button
onClick={handleCreateBranch}
disabled={loading || !newBranchName.trim()}
className="px-2 py-1 text-xs bg-[#5a9bc7] text-white rounded hover:bg-[#4a8bb7] disabled:opacity-50 disabled:cursor-not-allowed"
>
Create
</button>
</div>
) : (
<button
onClick={() => setShowNewBranchInput(true)}
className="w-full px-3 py-1.5 text-left text-xs text-gray-400 hover:text-gray-200 hover:bg-gray-800 rounded transition-colors"
>
+ Create new branch
</button>
)}
</div>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,18 @@
import type { Author } from '@/lib/agents/types';
interface BubblePrefixProps {
author: Author;
}
export function BubblePrefix({ author }: BubblePrefixProps) {
switch (author) {
case 'user':
return <span className="text-[#87CEEB] font-bold mr-2">&gt;</span>;
case 'agent':
return <span className="text-green-400 font-bold mr-2">✦</span>;
case 'tool':
return <span className="text-yellow-400 font-bold mr-2">⚡</span>;
case 'system':
return <span className="text-purple-400 font-bold mr-2">ℹ</span>;
}
}

View file

@ -0,0 +1,591 @@
import { useCallback, useRef, useEffect, useState, forwardRef } from "react";
import { useStore } from "../../store";
import { useShallow } from 'zustand/react/shallow';
import { MessageView, summarizeChangedFiles } from "./MessageView";
import { PromptBar } from "./PromptBar";
import { TodoList } from "./TodoList";
import { Logo } from "./Logo";
import { ThreadPicker } from "./ThreadPicker";
import { CompactingIndicator } from "./CompactingIndicator";
import { executeCommand } from "../../commands";
import type { Message, ChatMessage, ChatMessageContentBlock, ImageChunk, Thread, ToolExecutionChunk, ScheduledTask as ScheduledTaskType } from "@/lib/agents/types";
const PROMPT_CONTENT_WIDTH = "max-w-[44rem]";
const STACKED_STATUS_WIDTH = PROMPT_CONTENT_WIDTH;
const MESSAGE_CONTENT_WIDTH = "max-w-[42rem]";
function messagesToChatMessages(messages: Message[]): ChatMessage[] {
const chatMessages: ChatMessage[] = [];
for (const msg of messages) {
if (msg.author === "user" || msg.author === "agent") {
const role = msg.author === "user" ? "user" as const : "assistant" as const;
const hasImages = msg.chunks.some((c) => c.kind === "image");
if (hasImages) {
const blocks: ChatMessageContentBlock[] = [];
for (const c of msg.chunks) {
if (c.kind === "image") {
blocks.push({ type: "image_url", image_url: { url: `data:${c.mimeType};base64,${c.base64}` } });
} else if (c.kind === "text") {
blocks.push({ type: "text", text: c.text });
} else if (c.kind === "code") {
blocks.push({ type: "text", text: `\n\`\`\`${c.language || ""}\n${c.text}\n\`\`\`\n` });
}
}
if (blocks.length > 0) chatMessages.push({ role, content: blocks });
} else {
const textContent = msg.chunks
.map((c) => {
if (c.kind === "text") return c.text;
if (c.kind === "code") return `\n\`\`\`${c.language || ""}\n${c.text}\n\`\`\`\n`;
return "";
})
.join("");
if (textContent) chatMessages.push({ role, content: textContent });
}
}
}
return chatMessages;
}
function findLatestPendingApproval(messages: Message[]): ToolExecutionChunk | null {
for (let i = messages.length - 1; i >= 0; i -= 1) {
for (let j = messages[i].chunks.length - 1; j >= 0; j -= 1) {
const chunk = messages[i].chunks[j];
if (chunk.kind === "tool-execution" && chunk.status === "pending" && chunk.approvalRequestId) {
return chunk;
}
}
}
return null;
}
interface ChatViewProps {
tabId: string;
}
export function ChatView({ tabId }: ChatViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const dragDepthRef = useRef(0);
const [pendingImages, setPendingImages] = useState<ImageChunk[]>([]);
const [queuedSubmissions, setQueuedSubmissions] = useState<Array<{ query: string; images: ImageChunk[] }>>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [showThreadPicker, setShowThreadPicker] = useState(false);
const [showLoopManager, setShowLoopManager] = useState(false);
const [loopTasks, setLoopTasks] = useState<ScheduledTaskType[]>([]);
const loopManagerRef = useRef<HTMLDivElement>(null);
const tab = useStore(state => state.tabs[tabId] ?? null);
const session = useStore(state => {
const t = state.tabs[tabId];
return t ? state.sessions[t.sessionId] ?? null : null;
});
const hasScheduledPrompt = useStore(state => {
const t = state.tabs[tabId];
if (!t) return false;
return state.scheduledPrompts.some(p => p.sessionId === t.sessionId);
});
const loopTaskCount = useStore(state => {
const t = state.tabs[tabId];
if (!t) return 0;
return state.sessionLoopCounts[t.sessionId] || 0;
});
const actions = useStore(useShallow(state => ({
setTabProject: state.setTabProject,
createSession: state.createSession,
addMessageToSession: state.addMessageToSession,
startStreaming: state.startStreaming,
setAutoApproveSession: state.setAutoApproveSession,
resumeThread: state.resumeThread,
consumeScheduledPrompt: state.consumeScheduledPrompt,
setRightPanelTab: state.setRightPanelTab,
})));
useEffect(() => {
if (session) window.agent.setMode(session.id, session.mode);
}, [session?.id, session?.mode]);
useEffect(() => {
setQueuedSubmissions([]);
}, [session?.id]);
useEffect(() => {
const project = tab?.project;
if (!project?.path) return;
if (project.worktreeType === 'worktree') return;
const sync = () => { window.git.syncLocalBranch(project.path); };
sync();
const interval = setInterval(sync, 3000);
return () => clearInterval(interval);
}, [tab?.project?.path, tab?.project?.worktreeType]);
const handleApprove = useCallback((id: string) => {
window.agent.respondToApproval({ requestId: id, decision: "approve" });
}, []);
const handleReject = useCallback((id: string) => {
window.agent.respondToApproval({ requestId: id, decision: "reject" });
}, []);
const handleAutoApprove = useCallback((id: string) => {
if (session) actions.setAutoApproveSession(session.id, true);
window.agent.respondToApproval({ requestId: id, decision: "auto-approve" });
}, [session, actions]);
const hasFileDragData = (e: React.DragEvent) => e.dataTransfer.types.includes("Files");
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (!hasFileDragData(e)) return;
e.preventDefault(); e.stopPropagation();
dragDepthRef.current += 1;
setIsDragOver(true);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!hasFileDragData(e)) return;
e.preventDefault(); e.stopPropagation();
if (!isDragOver) setIsDragOver(true);
}, [isDragOver]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!hasFileDragData(e)) return;
e.preventDefault(); e.stopPropagation();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
if (!hasFileDragData(e)) return;
e.preventDefault(); e.stopPropagation();
dragDepthRef.current = 0;
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files).filter(
(f) => f.type === "image/png" || f.type === "image/jpeg"
);
for (const file of files) {
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(",")[1];
setPendingImages((prev) => [...prev, { kind: "image", base64, mimeType: file.type, fileName: file.name }]);
};
reader.readAsDataURL(file);
}
}, []);
const handleRemoveImage = useCallback((index: number) => {
setPendingImages((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleThreadSelect = useCallback((thread: Thread) => {
setShowThreadPicker(false);
actions.resumeThread(tabId, thread);
}, [actions, tabId]);
const handleOpenLoopManager = useCallback(async () => {
if (!session?.id) return;
const tasks = await window.cron.list(session.id);
setLoopTasks(tasks);
setShowLoopManager(true);
}, [session?.id]);
const handleDeleteLoop = useCallback(async (taskId: string) => {
await window.cron.delete(taskId);
setLoopTasks(prev => prev.filter(t => t.id !== taskId));
}, []);
const handleDeleteAllLoops = useCallback(async () => {
for (const task of loopTasks) await window.cron.delete(task.id);
setLoopTasks([]);
setShowLoopManager(false);
}, [loopTasks]);
const handleClickOutsideLoopManager = useCallback((e: MouseEvent) => {
if (loopManagerRef.current && !loopManagerRef.current.contains(e.target as Node)) {
setShowLoopManager(false);
}
}, []);
useEffect(() => {
if (showLoopManager) {
document.addEventListener('mousedown', handleClickOutsideLoopManager);
return () => document.removeEventListener('mousedown', handleClickOutsideLoopManager);
}
}, [showLoopManager, handleClickOutsideLoopManager]);
useEffect(() => {
if (loopTaskCount === 0) setShowLoopManager(false);
}, [loopTaskCount]);
const handleOpenDiff = useCallback(
(_diffData: { filePath: string; originalContent: string; modifiedContent: string }) => {
// TODO: pass diffData to the right panel so it can show the specific file diff
actions.setRightPanelTab('source-control');
},
[actions],
);
const handleContainerClick = useCallback(() => {
const textarea = containerRef.current?.querySelector("textarea");
if (textarea) textarea.focus();
}, []);
const dragProps = {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
};
const startAgentRun = useCallback((sessionId: string, query: string, images: ImageChunk[], displayText?: string) => {
const freshSession = useStore.getState().sessions[sessionId];
if (!freshSession) return;
const displayQuery = displayText ?? query;
const chunks = [...images, { kind: "text" as const, text: displayQuery }];
const chatHistory = messagesToChatMessages(freshSession.messages);
if (images.length > 0) {
const blocks: ChatMessageContentBlock[] = [
...images.map((img) => ({ type: "image_url" as const, image_url: { url: `data:${img.mimeType};base64,${img.base64}` } })),
{ type: "text" as const, text: query },
];
chatHistory.push({ role: "user", content: blocks });
} else {
chatHistory.push({ role: "user", content: query });
}
actions.addMessageToSession(sessionId, "user", chunks);
actions.startStreaming(sessionId);
window.agent.stream(sessionId, tabId, chatHistory, freshSession.modelConfig, freshSession.mode);
}, [actions, tabId]);
useEffect(() => {
if (!session) return;
if (session.isCompacting || session.isStreaming || session.busy) return;
if (queuedSubmissions.length === 0) return;
const next = queuedSubmissions[0];
setQueuedSubmissions((prev) => prev.slice(1));
startAgentRun(session.id, next.query, next.images);
}, [session, queuedSubmissions, startAgentRun]);
useEffect(() => {
if (!session) return;
if (session.isCompacting || session.isStreaming || session.busy) return;
if (!hasScheduledPrompt) return;
const scheduledPrompt = actions.consumeScheduledPrompt(session.id);
if (!scheduledPrompt) return;
startAgentRun(session.id, scheduledPrompt.prompt, []);
}, [session, hasScheduledPrompt, actions, startAgentRun]);
const handleSubmit = useCallback(
async (query: string) => {
if (!tab) return;
if (query.startsWith("/")) {
const commandExecuted = executeCommand(query, {
sessionId: session?.id || null,
createSession: () => actions.createSession(tabId),
addSystemMessage: (sessionId, chunks) => actions.addMessageToSession(sessionId, 'system', chunks),
});
if (commandExecuted) return;
}
if (!session) return;
if (session.isCompacting) return;
const images = pendingImages;
setPendingImages([]);
if (session.isStreaming || session.busy) {
setQueuedSubmissions((prev) => [...prev, { query, images }]);
window.agent.cancel(session.id);
return;
}
startAgentRun(session.id, query, images);
},
[tab, session, tabId, pendingImages, startAgentRun, actions],
);
if (!tab || !tab.project) return null;
const hasMessages = session && session.messages.length > 0;
const streamingMessageId = session?.streamingMessageId ?? null;
const streamingMessage = streamingMessageId
? session?.messages.find((m) => m.id === streamingMessageId) ?? null
: null;
const streamingChangedFiles = streamingMessage ? summarizeChangedFiles(streamingMessage.chunks) : [];
let streamingAdditions = 0;
let streamingDeletions = 0;
for (const file of streamingChangedFiles) {
streamingAdditions += file.additions;
streamingDeletions += file.deletions;
}
const todos = session?.todos ?? [];
const hasTodos = todos.length > 0;
const hasStreamingChangedFiles = !!session?.isStreaming && streamingChangedFiles.length > 0;
const hasActiveLoops = loopTaskCount > 0;
const hasConnectedStack = hasTodos || hasStreamingChangedFiles || hasActiveLoops;
const runActive = !!session?.isStreaming || !!session?.busy;
const pendingApproval = session ? findLatestPendingApproval(session.messages) : null;
if (!hasMessages) {
return (
<div
ref={containerRef}
className="relative flex flex-col h-full bg-[var(--ui-bg)] text-[color:var(--ui-text)]"
onClick={handleContainerClick}
{...dragProps}
>
<div className="flex-1 flex flex-col items-center justify-center px-4">
<div className={`w-full ${PROMPT_CONTENT_WIDTH} flex flex-col items-center gap-6 min-w-0`}>
<Logo />
<div className={`w-full ${PROMPT_CONTENT_WIDTH} min-w-0`}>
{hasActiveLoops && (
<LoopManagerBlock
ref={loopManagerRef}
loopTaskCount={loopTaskCount}
runActive={runActive}
showLoopManager={showLoopManager}
loopTasks={loopTasks}
onOpenLoopManager={handleOpenLoopManager}
onDeleteLoop={handleDeleteLoop}
onDeleteAllLoops={handleDeleteAllLoops}
onCloseLoopManager={() => setShowLoopManager(false)}
className="-mb-2"
/>
)}
<PromptBar
onSubmit={handleSubmit}
busy={session?.busy ?? false}
projectPath={tab.project.worktreePath || tab.project.path}
mainProjectPath={tab.project.path}
gitBranch={tab.project.gitBranch}
githubPR={tab.project.githubPR}
sessionId={tab.sessionId}
tabId={tabId}
isFocused={true}
isDragOver={isDragOver}
pendingImages={pendingImages}
onRemoveImage={handleRemoveImage}
dropUp={false}
worktreeType={tab.project.worktreeType}
worktreePath={tab.project.worktreePath}
connectedTop={hasActiveLoops}
pendingApproval={pendingApproval ? {
requestId: pendingApproval.approvalRequestId!,
title: pendingApproval.title,
toolKind: pendingApproval.toolKind,
input: pendingApproval.input ?? {},
diffData: pendingApproval.diffData,
} : null}
onApproveApproval={handleApprove}
onRejectApproval={handleReject}
onAutoApproveApproval={handleAutoApprove}
/>
</div>
</div>
</div>
{showThreadPicker && tab.project && (
<ThreadPicker
projectId={tab.project.id}
onSelect={handleThreadSelect}
onClose={() => setShowThreadPicker(false)}
/>
)}
</div>
);
}
return (
<div
ref={containerRef}
className="relative flex flex-col h-full bg-[var(--ui-bg)] text-[color:var(--ui-text)] overflow-hidden"
onClick={handleContainerClick}
{...dragProps}
>
<MessageView
messages={session!.messages}
isStreaming={session!.isStreaming}
contentWidthClass={MESSAGE_CONTENT_WIDTH}
onApprove={handleApprove}
onReject={handleReject}
onAutoApprove={handleAutoApprove}
onOpenDiff={handleOpenDiff}
project={tab.project}
/>
{session!.isCompacting ? (
<div className="px-4 py-4 shrink-0">
<CompactingIndicator />
</div>
) : (
<>
{hasConnectedStack && (
<div className="px-4 shrink-0">
<div className={`w-full ${STACKED_STATUS_WIDTH} mx-auto min-w-0 -mb-2`}>
{hasActiveLoops && (
<LoopManagerBlock
ref={loopManagerRef}
loopTaskCount={loopTaskCount}
runActive={runActive}
showLoopManager={showLoopManager}
loopTasks={loopTasks}
onOpenLoopManager={handleOpenLoopManager}
onDeleteLoop={handleDeleteLoop}
onDeleteAllLoops={handleDeleteAllLoops}
onCloseLoopManager={() => setShowLoopManager(false)}
/>
)}
{hasTodos && (
<TodoList
todos={todos}
runActive={runActive}
className={`${hasActiveLoops ? "rounded-t-none border-t-0" : "rounded-t-xl"} rounded-b-none ${!hasStreamingChangedFiles ? "pb-2" : ""}`}
/>
)}
{hasStreamingChangedFiles && (
<div
className={`border border-[var(--ui-border)] bg-[var(--ui-accent-bubble)] px-3 pt-2 pb-4 flex items-center justify-between gap-3 text-xs rounded-b-none ${(hasTodos || hasActiveLoops) ? "rounded-t-none border-t-0" : "rounded-t-xl"}`}
>
<span className="text-[color:var(--ui-text-muted)] truncate">
{streamingChangedFiles.length} file{streamingChangedFiles.length === 1 ? "" : "s"} changed
<span className="ml-2 text-green-400">+{streamingAdditions}</span>
<span className="ml-1 text-red-400">-{streamingDeletions}</span>
</span>
<button
type="button"
className="shrink-0 text-[color:var(--ui-accent)] hover:opacity-80 transition-opacity"
onClick={() => handleOpenDiff({
filePath: streamingChangedFiles[streamingChangedFiles.length - 1].filePath,
originalContent: streamingChangedFiles[streamingChangedFiles.length - 1].originalContent,
modifiedContent: streamingChangedFiles[streamingChangedFiles.length - 1].modifiedContent,
})}
>
Review changes
</button>
</div>
)}
</div>
</div>
)}
<div className="px-4 pb-4 shrink-0">
<div className={`w-full ${PROMPT_CONTENT_WIDTH} mx-auto min-w-0`}>
<PromptBar
onSubmit={handleSubmit}
busy={session!.busy}
projectPath={tab.project.worktreePath || tab.project.path}
mainProjectPath={tab.project.path}
gitBranch={tab.project.gitBranch}
githubPR={tab.project.githubPR}
sessionId={tab.sessionId}
tabId={tabId}
isFocused={true}
isDragOver={isDragOver}
pendingImages={pendingImages}
onRemoveImage={handleRemoveImage}
worktreeType={tab.project.worktreeType}
worktreePath={tab.project.worktreePath}
connectedTop={hasConnectedStack}
pendingApproval={pendingApproval ? {
requestId: pendingApproval.approvalRequestId!,
title: pendingApproval.title,
toolKind: pendingApproval.toolKind,
input: pendingApproval.input ?? {},
diffData: pendingApproval.diffData,
} : null}
onApproveApproval={handleApprove}
onRejectApproval={handleReject}
onAutoApproveApproval={handleAutoApprove}
/>
</div>
</div>
</>
)}
{showThreadPicker && tab.project && (
<ThreadPicker
projectId={tab.project.id}
onSelect={handleThreadSelect}
onClose={() => setShowThreadPicker(false)}
/>
)}
</div>
);
}
interface LoopManagerBlockProps {
loopTaskCount: number;
runActive: boolean;
showLoopManager: boolean;
loopTasks: ScheduledTaskType[];
onOpenLoopManager: () => void;
onDeleteLoop: (taskId: string) => void;
onDeleteAllLoops: () => void;
onCloseLoopManager: () => void;
className?: string;
}
const LoopManagerBlock = forwardRef<HTMLDivElement, LoopManagerBlockProps>(function LoopManagerBlock(
{ loopTaskCount, runActive, showLoopManager, loopTasks, onOpenLoopManager, onDeleteLoop, onDeleteAllLoops, onCloseLoopManager, className },
ref,
) {
return (
<div ref={ref} className={`relative ${className || ''}`}>
<button
type="button"
onClick={onOpenLoopManager}
className="w-full border border-[var(--ui-border)] bg-[var(--ui-accent-bubble)] px-3 pt-2 pb-4 flex items-center justify-between gap-3 text-xs rounded-t-xl rounded-b-none mb-0 hover:bg-[var(--ui-bg-hover)] transition-colors"
>
<span className="flex items-center gap-3">
<span className="relative flex h-2 w-2">
<span className={`${runActive ? "" : "animate-ping"} absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75`} />
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-500" />
</span>
<span className="text-[color:var(--ui-text-muted)]">
{loopTaskCount} active loop{loopTaskCount === 1 ? "" : "s"}{runActive ? " — running" : " monitoring"}
</span>
</span>
<span className="text-[color:var(--ui-text-muted)]">Manage</span>
</button>
{showLoopManager && (
<div className="absolute bottom-full left-0 right-0 mb-1 z-30">
<div className="border border-[var(--ui-border)] bg-[var(--ui-bg)] rounded-xl shadow-lg overflow-hidden">
<div className="px-3 py-2 border-b border-[var(--ui-border)] flex items-center justify-between">
<span className="text-xs font-medium text-[color:var(--ui-text)]">Scheduled Loops</span>
<div className="flex items-center gap-2">
{loopTasks.length > 0 && (
<button type="button" onClick={onDeleteAllLoops} className="text-xs text-red-400 hover:text-red-300 transition-colors">
Cancel all
</button>
)}
<button type="button" onClick={onCloseLoopManager} className="text-[color:var(--ui-text-muted)] hover:text-[color:var(--ui-text)] transition-colors">
✕
</button>
</div>
</div>
<div className="max-h-48 overflow-y-auto">
{loopTasks.length === 0 ? (
<div className="px-3 py-4 text-xs text-center text-[color:var(--ui-text-muted)]">No active loops</div>
) : (
loopTasks.map(task => (
<div key={task.id} className="px-3 py-2 border-b border-[var(--ui-border)] last:border-b-0 flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs text-[color:var(--ui-text)] truncate">{task.prompt}</div>
<div className="text-xs text-[color:var(--ui-text-muted)] mt-0.5">
{task.isRecurring ? `Every ${Math.round(task.intervalMs / 60000)}m` : "One-time"}{" · "}<span className="font-mono">{task.id}</span>
</div>
</div>
<button type="button" onClick={() => onDeleteLoop(task.id)} className="shrink-0 text-xs text-red-400 hover:text-red-300 transition-colors p-1">✕</button>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
);
});

View file

@ -0,0 +1,156 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ModelOption } from "@/lib/api";
import {
formatModelSelection,
type ModelSelection,
} from "@/lib/agents/useModelOptions";
import { cn } from "@/lib/utils";
const PROMPT_TEXTAREA_MAX_HEIGHT = 200;
export interface CloudPromptBarProps {
placeholder?: string;
compact?: boolean;
disabled?: boolean;
busy?: boolean;
onSubmit?: (value: string) => void;
models?: ModelOption[];
selection?: ModelSelection | null;
onSelectionChange?: (next: ModelSelection) => void;
}
/** Web-adapted PromptBar from open-swe-app — local state, no Electron/Zustand deps. */
export const CloudPromptBar = memo(function CloudPromptBar({
placeholder = "Ask Open SWE to build, fix bugs, explore",
compact = false,
disabled = false,
busy = false,
onSubmit,
models = [],
selection = null,
onSelectionChange,
}: CloudPromptBarProps) {
const [value, setValue] = useState("");
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const combos = useMemo<ModelSelection[]>(() => {
const list: ModelSelection[] = [];
for (const model of models) {
for (const effort of model.efforts) {
list.push({ modelId: model.id, effort });
}
}
return list;
}, [models]);
const selectionLabel = formatModelSelection(models, selection);
const handleSubmit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSubmit?.(trimmed);
setValue("");
}, [disabled, onSubmit, value]);
useLayoutEffect(() => {
const el = inputRef.current;
if (!el) return;
el.style.height = "auto";
const clampedHeight = Math.min(el.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT);
el.style.height = `${clampedHeight}px`;
el.style.overflowY = el.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden";
}, [value]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
setModelDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && value.trim()) {
e.preventDefault();
handleSubmit();
}
};
const pickerDisabled = combos.length === 0 || !onSelectionChange;
return (
<div className={cn("relative w-full font-sans text-[13px]", compact ? "max-w-none" : "max-w-2xl")}>
<div
className={cn(
"relative flex min-h-[106px] flex-col rounded-2xl border border-[var(--ui-border)] bg-[var(--ui-surface)] px-4 py-3.5 shadow-sm",
compact && "min-h-[88px]",
)}
>
<textarea
ref={inputRef}
rows={1}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={busy ? "Send a message to queue next..." : placeholder}
disabled={disabled}
className={cn(
"w-full min-w-0 resize-none overflow-hidden bg-transparent leading-[1.45] text-[color:var(--ui-text)] outline-none placeholder:text-[color:var(--ui-text-dim)]",
compact ? "min-h-[36px]" : "min-h-[52px]",
)}
style={{ maxHeight: PROMPT_TEXTAREA_MAX_HEIGHT }}
/>
<div className="mt-auto flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 pt-2 text-xs text-[color:var(--ui-text-dim)]">
<div ref={modelDropdownRef} className="relative min-w-0 shrink">
<button
type="button"
disabled={pickerDisabled}
onClick={() => setModelDropdownOpen((open) => !open)}
className="max-w-[220px] cursor-pointer truncate text-[color:var(--ui-text-muted)] transition-opacity hover:opacity-80 disabled:cursor-default disabled:opacity-60"
>
{selectionLabel}
</button>
{modelDropdownOpen && combos.length > 0 && (
<div className="absolute bottom-full left-0 z-50 mb-1 max-h-72 overflow-hidden overflow-y-auto rounded border border-[var(--ui-border)] bg-[var(--ui-surface)] shadow-lg">
{combos.map((combo) => {
const selected =
!!selection &&
selection.modelId === combo.modelId &&
selection.effort === combo.effort;
return (
<button
key={`${combo.modelId}::${combo.effort}`}
type="button"
onClick={() => {
onSelectionChange?.(combo);
setModelDropdownOpen(false);
}}
className={cn(
"flex w-full items-center gap-2 whitespace-nowrap px-3 py-1.5 text-left transition-colors hover:bg-[var(--ui-panel-2)]",
selected
? "text-[color:var(--ui-text)]"
: "text-[color:var(--ui-text-muted)]",
)}
>
{formatModelSelection(models, combo)}
{selected && (
<span className="ml-auto pl-3 text-[color:var(--ui-text-dim)]">✓</span>
)}
</button>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
});

View file

@ -0,0 +1,133 @@
import { useEffect, useMemo, useState } from "react";
import { getSingletonHighlighter, type ThemedToken } from "shiki";
interface CodeBlockProps {
text: string;
language?: string;
}
const TOKEN_CACHE = new Map<string, ThemedToken[][]>();
function normalizeLanguage(language?: string): string {
const raw = (language || "").toLowerCase().trim();
if (!raw) return "text";
const aliases: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
md: "markdown",
yml: "yaml",
sh: "bash",
zsh: "bash",
shell: "bash",
py: "python",
rb: "ruby",
rs: "rust",
csharp: "csharp",
"c#": "csharp",
plaintext: "text",
txt: "text",
};
return aliases[raw] || raw;
}
function languageLabel(language: string): string {
if (language === "text") return "text";
if (language === "typescript") return "ts";
if (language === "javascript") return "js";
return language;
}
export function CodeBlock({ text, language }: CodeBlockProps) {
const [tokens, setTokens] = useState<ThemedToken[][] | null>(null);
const [copied, setCopied] = useState(false);
const normalizedLanguage = useMemo(() => normalizeLanguage(language), [language]);
const displayLanguage = useMemo(() => languageLabel(normalizedLanguage), [normalizedLanguage]);
useEffect(() => {
let cancelled = false;
setTokens(null);
if (normalizedLanguage === "text") return;
const cacheKey = `${normalizedLanguage}::${text}`;
const cached = TOKEN_CACHE.get(cacheKey);
if (cached) {
setTokens(cached);
return;
}
getSingletonHighlighter({
themes: ["github-dark"],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
langs: [normalizedLanguage as any],
})
.then((highlighter) => {
if (cancelled) return;
const result = highlighter.codeToTokens(text, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
lang: normalizedLanguage as any,
theme: "github-dark",
});
if (TOKEN_CACHE.size >= 500) TOKEN_CACHE.clear();
TOKEN_CACHE.set(cacheKey, result.tokens);
setTokens(result.tokens);
})
.catch((err: unknown) => {
console.warn('[code-block] Tokenization failed:', err);
if (!cancelled) {
setTokens(null);
}
});
return () => {
cancelled = true;
};
}, [text, normalizedLanguage]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
} catch {
setCopied(false);
}
};
return (
<div className="my-2 max-w-full overflow-hidden rounded-xl bg-[var(--ui-accent-bubble)]">
<div className="flex items-center justify-between px-3 py-2 text-xs">
<span className="text-[color:var(--ui-text-muted)]">{displayLanguage}</span>
<button
type="button"
onClick={handleCopy}
className="text-[color:var(--ui-text-muted)] hover:text-[color:var(--ui-text)] transition-colors"
title="Copy code"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
<pre className="max-w-full px-3 pb-3 text-[12px] whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{tokens ? (
<code className="block max-w-full">
{tokens.map((lineTokens, lineIndex) => (
<div key={lineIndex} className="max-w-full whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{lineTokens.map((token, tokenIndex) => (
<span key={tokenIndex} style={{ color: token.color }}>
{token.content}
</span>
))}
</div>
))}
</code>
) : (
<code className="block max-w-full text-[color:var(--ui-text)] whitespace-pre-wrap break-words [overflow-wrap:anywhere]">{text}</code>
)}
</pre>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { LoaderCircle } from 'lucide-react';
import { useState, useEffect } from 'react';
export function CompactingIndicator() {
const [dots, setDots] = useState(1);
useEffect(() => {
const interval = setInterval(() => {
setDots((d) => (d % 3) + 1);
}, 400);
return () => clearInterval(interval);
}, []);
return (
<div className="flex items-center gap-2 text-purple-400 text-xs font-sans">
<LoaderCircle size={12} strokeWidth={1.5} className="animate-spin" />
<span>
Compacting conversation{'.'.repeat(dots)}
</span>
</div>
);
}

View file

@ -0,0 +1,136 @@
import { useState, useRef, useEffect } from 'react';
import { useStore } from '../../store';
import { COMPACT_THRESHOLD } from '../../context-limits';
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
interface ContextIndicatorProps {
sessionId: string;
}
export function ContextIndicator({ sessionId }: ContextIndicatorProps) {
const contextWindow = useStore(state => state.sessions[sessionId]?.contextWindow);
const hasMessages = useStore(state => (state.sessions[sessionId]?.messages.length ?? 0) > 0);
const harness = useStore(state => state.harness);
const [showTooltip, setShowTooltip] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const usedTokens = contextWindow?.used ?? 0;
const contextLimit = contextWindow?.size ?? 0;
const showUnavailable = !contextWindow && hasMessages;
const fraction = contextLimit > 0 ? Math.min(usedTokens / contextLimit, 1) : 0;
const percentage = Math.round(fraction * 100);
const isNearLimit = fraction >= COMPACT_THRESHOLD;
const size = 14;
const strokeWidth = 1.5;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const dashOffset = circumference * (1 - fraction);
let strokeColor = '#6b7280'; // gray-500
if (fraction > 0) strokeColor = '#9ca3af'; // gray-400
if (fraction >= 0.5) strokeColor = '#93c5fd'; // blue-300
if (fraction >= 0.75) strokeColor = '#fbbf24'; // amber-400
if (fraction >= COMPACT_THRESHOLD) strokeColor = '#f87171'; // red-400
useEffect(() => {
if (!showTooltip) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowTooltip(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showTooltip]);
if (!contextWindow && !hasMessages) return null;
if (showUnavailable) {
return (
<div
ref={containerRef}
className="relative flex items-center"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<div className="cursor-default flex items-center gap-1 opacity-40">
<svg width={size} height={size} className="block">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#4b5563"
strokeWidth={strokeWidth}
strokeDasharray="2 2"
/>
</svg>
</div>
{showTooltip && (
<div className="absolute bottom-full mb-2 right-0 bg-[#1a1f2e] border border-[#2a3142] rounded-lg shadow-xl px-3 py-2 z-50 whitespace-nowrap text-xs font-sans">
<div className="text-gray-400">
Context usage unavailable
</div>
<div className="text-gray-500 text-[10px] mt-1">
{harness === 'cursor' ? 'Cursor' : harness === 'codex' ? 'Codex' : 'This provider'} doesn't report token usage
</div>
</div>
)}
</div>
);
}
return (
<div
ref={containerRef}
className="relative flex items-center"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<div className="cursor-default flex items-center gap-1">
<svg width={size} height={size} className="block -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#374151"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
strokeLinecap="round"
/>
</svg>
</div>
{showTooltip && (
<div className="absolute bottom-full mb-2 right-0 bg-[#1a1f2e] border border-[#2a3142] rounded-lg shadow-xl px-3 py-2 z-50 whitespace-nowrap text-xs font-sans">
<div className="text-gray-300 font-medium">
Context: {percentage}% — {formatTokens(usedTokens)} / {formatTokens(contextLimit)} tokens
</div>
{isNearLimit && (
<div className="text-amber-400 text-[10px] mt-1">
Approaching context limit
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,316 @@
// @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';
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 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">
Binary file - diff not available
</div>
);
}
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) {
return (
<div className="mt-2 text-gray-500 text-xs font-mono">
No changes
</div>
);
}
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-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>
{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

@ -0,0 +1,82 @@
import { useState, useEffect } from 'react';
import { useStore } from '../../store';
const BUSY_ACTIONS = [
'Calculating',
'Processing',
'Thinking',
'Analyzing',
'Working',
];
function formatTokens(n: number): string {
if (n >= 1000) {
return `${(n / 1000).toFixed(1)}k`;
}
return String(n);
}
function formatDuration(startTime: number): string {
const elapsed = Date.now() - startTime;
const seconds = Math.floor(elapsed / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
export function Footer() {
const activeSession = useStore(state => {
if (!state.activeTabId) return null;
const tab = state.tabs[state.activeTabId];
return tab ? state.sessions[tab.sessionId] ?? null : null;
});
const [busyAction] = useState(() => BUSY_ACTIONS[Math.floor(Math.random() * BUSY_ACTIONS.length)]);
const [startTime, setStartTime] = useState<number | null>(null);
const [, setTick] = useState(0);
const busy = activeSession?.busy ?? false;
const streaming = activeSession?.isStreaming ?? false;
useEffect(() => {
if (busy && !startTime) {
setStartTime(Date.now());
} else if (!busy && startTime) {
setStartTime(null);
}
}, [busy, startTime]);
useEffect(() => {
if (!busy) return;
const interval = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(interval);
}, [busy]);
if (!busy) {
return (
<div className="px-4 py-2 font-sans text-xs text-gray-600">
(ready)
</div>
);
}
const parts: string[] = [];
parts.push('esc to interrupt');
if (startTime) {
parts.push(formatDuration(startTime));
}
const cumulativeTotal = activeSession?.tokenUsage.cumulative.total ?? 0;
parts.push(`↓ ${formatTokens(cumulativeTotal)} session tokens`);
if (streaming) {
parts.push('streaming');
}
return (
<div className="px-4 py-2 font-sans text-xs text-gray-500">
<span className="text-yellow-400">*</span>
<span className="ml-1">{busyAction}...</span>
<span className="text-gray-600 ml-1">({parts.join(' — ')})</span>
</div>
);
}

View file

@ -0,0 +1,27 @@
interface HeaderBarProps {
compact?: boolean;
}
export function HeaderBar({ compact }: HeaderBarProps) {
if (compact) {
return (
<div className="flex items-center gap-3 px-3 py-2 font-sans text-xs border-b border-gray-700/50 shrink-0">
<pre className="text-[#e07a5f] leading-none text-[10px] m-0">{`╭─╮
│◠│
╰─╯`}</pre>
<span className="text-gray-300 font-medium">Open SWE Desktop App</span>
</div>
);
}
return (
<div className="flex items-start gap-4 px-4 py-3 font-sans text-sm">
<pre className="text-[#e07a5f] leading-none text-xs">
{` ╭───╮
│ ◠ │
╰───╯`}
</pre>
<span className="text-gray-200 font-semibold">Open SWE Desktop App v0.1.0</span>
</div>
);
}

View file

@ -0,0 +1,19 @@
export function Logo() {
const ascii = [
" ██████ ████████ ██████ ████████ █████ █████ ███ █████ ██████ ",
" ███░░███░░███░░███ ███░░███░░███░░███ ██████████ ███░░ ░░███ ░███░░███ ███░░███",
"░███ ░███ ░███ ░███░███████ ░███ ░███ ░░░░░░░░░░ ░░█████ ░███ ░███ ░███ ░███████ ",
"░███ ░███ ░███ ░███░███░░░ ░███ ░███ ░░░░███ ░░███████████ ░███░░░ ",
"░░██████ ░███████ ░░██████ ████ █████ ██████ ░░████░████ ░░██████ ",
" ░░░░░░ ░███░░░ ░░░░░░ ░░░░ ░░░░░ ░░░░░░ ░░░░ ░░░░ ░░░░░░ ",
" ░███ ",
" █████ ",
" ░░░░░ ",
].join("\n");
return (
<pre className="m-0 max-w-full overflow-x-auto font-mono text-[7px] leading-none text-[#87CEEB]">
{ascii}
</pre>
);
}

View file

@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CodeBlock } from './CodeBlock';
import { MarkdownTable } from './MarkdownTable';
interface MarkdownProps {
content: string;
}
function extractText(children: ReactNode): string {
return String(children);
}
export function Markdown({ content }: 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}</>,
}}
>
{content}
</ReactMarkdown>
</div>
);
}

View file

@ -0,0 +1,43 @@
// @ts-nocheck — ported from open-swe-app (Electron).
import { Children, isValidElement, type ReactNode } from 'react';
interface MarkdownTableProps {
children: ReactNode;
}
function countCells(node: ReactNode): number {
return Children.toArray(node).filter((child) => isValidElement(child) && (child.type === 'th' || child.type === 'td')).length;
}
function collectColumnCounts(node: ReactNode, counts: number[]) {
Children.forEach(node, (child) => {
if (!isValidElement(child)) return;
if (child.type === 'tr') {
const count = countCells(child.props.children);
if (count > 0) counts.push(count);
return;
}
collectColumnCounts(child.props.children, counts);
});
}
function getTableMinWidth(columnCount: number): string {
if (columnCount <= 1) return '100%';
return `max(100%, ${Math.max(420, columnCount * 160)}px)`;
}
export function MarkdownTable({ children }: MarkdownTableProps) {
const columnCounts: number[] = [];
collectColumnCounts(children, columnCounts);
const columnCount = Math.max(0, ...columnCounts);
return (
<div className="my-2 max-w-full overflow-x-auto pb-1 text-[color:var(--ui-text-muted)]">
<table className="w-max border-collapse align-top" style={{ minWidth: getTableMinWidth(columnCount) }}>
{children}
</table>
</div>
);
}

View file

@ -0,0 +1,890 @@
// @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 { diffOptions } from "@/components/agents/utils/diffUtils";
import { CodeBlock } from "./CodeBlock";
import { Markdown } from "./Markdown";
import { ToolExecution } from "./ToolExecution";
import { ShellCommand } from "./ShellCommand";
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: "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 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 {
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 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) => {
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;
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 "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 }: { isStreaming: 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);
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: BUSY_TEXTS[textIdxRef.current].past,
elapsed: formatElapsed(Date.now() - startTimeRef.current),
});
}
}, [isStreaming]);
useEffect(() => {
if (!isStreaming) 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]);
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">{BUSY_TEXTS[textIdx].present}</span>
</div>
);
}
const BOTTOM_LOCK_THRESHOLD_PX = 24;
export const MessageView = memo(function MessageView({
messages,
isStreaming,
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} />
</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,61 @@
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
interface PanelResizeHandleProps {
onDragStart?: () => void;
onDragMove: (deltaXFromPointerDown: number) => void;
onDoubleClick?: () => void;
/** Which edge of the hit area the visible line sits on (avoids a centered gap next to panels). */
lineAlign?: 'start' | 'end';
className?: string;
}
export function PanelResizeHandle({
onDragStart,
onDragMove,
onDoubleClick,
lineAlign = 'start',
className = '',
}: PanelResizeHandleProps) {
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
onDragStart?.();
const el = e.currentTarget;
const pointerId = e.pointerId;
el.setPointerCapture(pointerId);
const startX = e.clientX;
const move = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return;
onDragMove(ev.clientX - startX);
};
const cleanup = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return;
try {
el.releasePointerCapture(pointerId);
} catch {
/* already released */
}
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', cleanup);
el.removeEventListener('pointercancel', cleanup);
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', cleanup);
el.addEventListener('pointercancel', cleanup);
};
return (
<div
role="separator"
aria-orientation="vertical"
onPointerDown={onPointerDown}
onDoubleClick={onDoubleClick}
style={{ WebkitAppRegion: 'no-drag' } as CSSProperties}
className={`group shrink-0 w-1 cursor-col-resize flex box-border ${
lineAlign === 'end' ? 'justify-end' : 'justify-start'
} ${className}`}
>
<div className="w-px h-full bg-gray-800 group-hover:bg-[var(--ui-accent)]" />
</div>
);
}

View file

@ -0,0 +1,786 @@
import { useState, useRef, useEffect, useLayoutEffect, useMemo, memo, useCallback } from 'react';
import { useStore } from '../../store';
import { useShallow } from 'zustand/react/shallow';
import { CommandAutocomplete, getFilteredCommandCount, getCommandAtIndex } from './CommandAutocomplete';
import {
FileAutocomplete,
buildFileSearchIndex,
getFileAutocompleteContext,
insertFileTag,
searchFileSuggestions,
} from './FileAutocomplete';
import { getModelsForHarness, MODEL_DISPLAY_NAMES, type ModelOption } from '../../config/models';
import { ContextIndicator } from './ContextIndicator';
import { WorktreeSelector } from './WorktreeSelector';
import type { Command } from '../../commands';
import type { ApprovalDecision, DiffData, ImageChunk, ModelConfig, PermissionMode, WorktreeType, AcpToolKind } from '@/lib/agents/types';
const PERMISSION_OPTIONS: Array<{ value: PermissionMode; label: string }> = [
{ value: 'default', label: 'Default permissions' },
{ value: 'full', label: 'Full access' },
];
const PROMPT_TEXTAREA_MAX_HEIGHT = 200;
interface PromptApprovalRequest {
requestId: string;
title: string;
toolKind: AcpToolKind;
input: Record<string, unknown>;
diffData?: DiffData;
}
interface ApprovalOption {
label: string;
decision: ApprovalDecision;
}
interface ApprovalPromptContent {
question: string;
preview: string;
options: ApprovalOption[];
}
function truncateText(text: string, max: number): string {
if (text.length <= max) return text;
return `${text.slice(0, max - 1)}…`;
}
function getFilePathFromApproval(request: PromptApprovalRequest): string | null {
if (request.diffData?.filePath) return request.diffData.filePath;
if (typeof request.input.filePath === 'string') return request.input.filePath;
if (typeof request.input.path === 'string') return request.input.path;
return null;
}
function buildApprovalPromptContent(request: PromptApprovalRequest): ApprovalPromptContent {
const { toolKind, title, input, diffData } = request;
if (toolKind === 'execute') {
const command = typeof input.command === 'string' ? input.command.trim() : '';
const commandPreview = command || '(empty command)';
return {
question: 'Do you want to allow running this command?',
preview: commandPreview,
options: [
{ label: 'Yes', decision: 'approve' },
{
label: `Yes, and don't ask again for commands that start with ${truncateText(commandPreview, 64)}`,
decision: 'auto-approve',
},
{ label: 'No, and tell Open SWE what to do differently', decision: 'reject' },
],
};
}
const targetPath = getFilePathFromApproval(request);
const isFileUpdate = toolKind === 'edit' || toolKind === 'delete' || toolKind === 'move' || diffData != null;
let question = 'Do you want to allow this action?';
if (isFileUpdate) {
if (toolKind === 'delete') {
question = targetPath
? `Do you want to allow deleting ${targetPath}?`
: 'Do you want to allow deleting this file?';
} else if (toolKind === 'move') {
question = targetPath
? `Do you want to allow moving ${targetPath}?`
: 'Do you want to allow moving this file?';
} else {
question = targetPath
? `Do you want to allow updating ${targetPath}?`
: 'Do you want to allow updating this file?';
}
}
const preview = targetPath
? `${title} ${targetPath}`
: `${title} ${truncateText(JSON.stringify(input), 180)}`;
return {
question,
preview,
options: [
{ label: 'Yes', decision: 'approve' },
{
label: isFileUpdate
? 'Yes, and allow all edits for this session'
: 'Yes, and allow similar actions for this session',
decision: 'auto-approve',
},
{ label: 'No, and tell Open SWE what to do differently', decision: 'reject' },
],
};
}
interface PromptBarProps {
onSubmit: (query: string) => void;
busy: boolean;
projectPath?: string;
/** The main repo root path (not the worktree path). Used for git operations. */
mainProjectPath?: string;
gitBranch?: string;
githubPR?: { number: number; url: string } | null;
sessionId: string;
tabId: string;
isFocused: boolean;
isDragOver?: boolean;
pendingImages?: ImageChunk[];
onRemoveImage?: (index: number) => void;
dropUp?: boolean;
worktreeType?: WorktreeType;
worktreePath?: string;
connectedTop?: boolean;
pendingApproval?: PromptApprovalRequest | null;
onApproveApproval?: (approvalRequestId: string) => void;
onRejectApproval?: (approvalRequestId: string) => void;
onAutoApproveApproval?: (approvalRequestId: string) => void;
}
export const PromptBar = memo(function PromptBar({
onSubmit,
busy,
projectPath,
mainProjectPath,
gitBranch,
githubPR,
sessionId,
tabId,
isFocused,
isDragOver = false,
pendingImages,
onRemoveImage,
dropUp = true,
worktreeType,
worktreePath,
connectedTop = false,
pendingApproval = null,
onApproveApproval,
onRejectApproval,
onAutoApproveApproval,
}: PromptBarProps) {
const {
query,
permissionMode,
setPermissionMode,
defaultModelConfig,
sessionModelConfig,
setSessionModelConfig,
setSessionPromptDraft,
harness,
} = useStore(useShallow(state => ({
query: state.sessions[sessionId]?.promptDraft ?? '',
permissionMode: state.permissionMode,
setPermissionMode: state.setPermissionMode,
defaultModelConfig: state.modelConfig,
sessionModelConfig: state.sessions[sessionId]?.modelConfig,
setSessionModelConfig: state.setSessionModelConfig,
setSessionPromptDraft: state.setSessionPromptDraft,
harness: state.harness,
})));
const inputRef = useRef<HTMLTextAreaElement>(null);
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
const [fileSelectedIndex, setFileSelectedIndex] = useState(0);
const [permissionDropdownOpen, setPermissionDropdownOpen] = useState(false);
const permissionDropdownRef = useRef<HTMLDivElement>(null);
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const [modelDropdownIndex, setModelDropdownIndex] = useState(0);
const [projectFiles, setProjectFiles] = useState<string[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [cursorPosition, setCursorPosition] = useState(0);
const [approvalSelectedIndex, setApprovalSelectedIndex] = useState(0);
const modelConfig = sessionModelConfig ?? defaultModelConfig;
const setModelConfig = useCallback((config: Partial<ModelConfig>) => {
setSessionModelConfig(sessionId, config);
}, [sessionId, setSessionModelConfig]);
const setQuery = useCallback((nextQuery: string) => {
setSessionPromptDraft(sessionId, nextQuery);
}, [sessionId, setSessionPromptDraft]);
const hasPendingApproval = Boolean(pendingApproval?.requestId);
const approvalContent = useMemo(
() => (pendingApproval ? buildApprovalPromptContent(pendingApproval) : null),
[pendingApproval],
);
const isTypingCommand = query.startsWith('/');
const commandQuery = isTypingCommand ? query.slice(1).split(/\s/)[0] : '';
const showCommandAutocomplete = !hasPendingApproval && isTypingCommand && !query.includes(' ');
const fileIndex = useMemo(() => buildFileSearchIndex(projectFiles), [projectFiles]);
const fileContext = useMemo(
() => getFileAutocompleteContext(query, cursorPosition),
[query, cursorPosition],
);
const fileSuggestions = useMemo(
() => (fileContext ? searchFileSuggestions(fileIndex, fileContext.fileQuery) : []),
[fileContext, fileIndex],
);
const showFileAutocomplete = !hasPendingApproval && fileContext !== null && !showCommandAutocomplete;
const submitApprovalDecision = useCallback((decision: ApprovalDecision) => {
if (!pendingApproval?.requestId) return;
if (decision === 'approve') {
onApproveApproval?.(pendingApproval.requestId);
return;
}
if (decision === 'auto-approve') {
onAutoApproveApproval?.(pendingApproval.requestId);
return;
}
onRejectApproval?.(pendingApproval.requestId);
}, [pendingApproval?.requestId, onApproveApproval, onAutoApproveApproval, onRejectApproval]);
useEffect(() => {
if (hasPendingApproval) {
setPermissionDropdownOpen(false);
setModelDropdownOpen(false);
}
}, [hasPendingApproval]);
useEffect(() => {
setApprovalSelectedIndex(0);
}, [pendingApproval?.requestId]);
useEffect(() => {
if (!hasPendingApproval || !approvalContent || !isFocused) return;
const options = approvalContent.options;
function handleApprovalKeyDown(e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'ArrowDown') {
e.preventDefault();
setApprovalSelectedIndex((prev) => (prev + 1) % options.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setApprovalSelectedIndex((prev) => (prev - 1 + options.length) % options.length);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const selectedOption = options[approvalSelectedIndex];
if (!selectedOption) return;
submitApprovalDecision(selectedOption.decision);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
submitApprovalDecision('reject');
}
}
document.addEventListener('keydown', handleApprovalKeyDown);
return () => document.removeEventListener('keydown', handleApprovalKeyDown);
}, [approvalContent, approvalSelectedIndex, hasPendingApproval, isFocused, submitApprovalDecision]);
useLayoutEffect(() => {
if (isFocused && !hasPendingApproval) {
inputRef.current?.focus();
}
}, [busy, isFocused, hasPendingApproval]);
useLayoutEffect(() => {
const el = inputRef.current;
if (!el) return;
el.style.height = 'auto';
const clampedHeight = Math.min(el.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT);
el.style.height = `${clampedHeight}px`;
el.style.overflowY = el.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
}, [query]);
useEffect(() => {
setCommandSelectedIndex(0);
}, [commandQuery]);
useEffect(() => {
setFileSelectedIndex(0);
}, [fileContext?.fileQuery]);
useEffect(() => {
let cancelled = false;
if (!projectPath) {
setProjectFiles([]);
setFilesLoading(false);
return () => {
cancelled = true;
};
}
setFilesLoading(true);
window.fs.listFiles(projectPath)
.then((files) => {
if (cancelled) return;
setProjectFiles(files);
})
.catch((err: unknown) => {
console.warn('[prompt-bar] Failed to list files:', err);
if (cancelled) return;
setProjectFiles([]);
})
.finally(() => {
if (!cancelled) {
setFilesLoading(false);
}
});
return () => {
cancelled = true;
};
}, [projectPath]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (permissionDropdownRef.current && !permissionDropdownRef.current.contains(e.target as Node)) {
setPermissionDropdownOpen(false);
}
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
setModelDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (!isFocused) return;
if (hasPendingApproval) return;
if (e.shiftKey && e.key === 'Tab') {
e.preventDefault();
void setPermissionMode(permissionMode === 'default' ? 'full' : 'default');
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isFocused, hasPendingApproval, permissionMode, setPermissionMode]);
const handleCommandSelect = (command: Command) => {
setQuery(`/${command.name} `);
inputRef.current?.focus();
};
const handleFileSelect = (filePath: string) => {
if (!fileContext) return;
const { nextQuery, nextCursor } = insertFileTag(query, filePath, fileContext);
setQuery(nextQuery);
setCursorPosition(nextCursor);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.selectionStart = nextCursor;
inputRef.current.selectionEnd = nextCursor;
inputRef.current.focus();
}
}, 0);
};
const handleModelSelect = (model: ModelOption) => {
setModelConfig({ name: model.id, effort: model.effort || 'default' });
inputRef.current?.focus();
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setQuery(e.target.value);
setCursorPosition(e.target.selectionStart || 0);
};
const handleInputSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
setCursorPosition((e.target as HTMLTextAreaElement).selectionStart || 0);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (hasPendingApproval) {
e.preventDefault();
return;
}
if (showCommandAutocomplete) {
const count = getFilteredCommandCount(commandQuery);
if (e.key === 'ArrowDown') {
e.preventDefault();
setCommandSelectedIndex((prev) => (prev + 1) % count);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setCommandSelectedIndex((prev) => (prev - 1 + count) % count);
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const command = getCommandAtIndex(commandQuery, commandSelectedIndex);
if (command) {
handleCommandSelect(command);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setQuery('');
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const command = getCommandAtIndex(commandQuery, commandSelectedIndex);
if (command) {
onSubmit(`/${command.name}`);
setQuery('');
}
return;
}
}
if (showFileAutocomplete && fileContext) {
const count = fileSuggestions.length;
if (count > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setFileSelectedIndex((prev) => (prev + 1) % count);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setFileSelectedIndex((prev) => (prev - 1 + count) % count);
return;
}
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
const filePath = fileSuggestions[fileSelectedIndex] ?? null;
if (filePath) {
handleFileSelect(filePath);
}
return;
}
}
if (filesLoading && (e.key === 'Enter' || e.key === 'Tab')) {
e.preventDefault();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
if (fileContext.atIndex >= 0) {
const prefix = query.slice(0, fileContext.atIndex);
const suffix = query.slice(fileContext.tokenEnd);
setQuery(prefix + suffix);
setCursorPosition(prefix.length);
}
return;
}
}
if (e.key === 'Enter' && !e.shiftKey && query.trim()) {
e.preventDefault();
onSubmit(query.trim());
setQuery('');
}
};
return (
<div className="relative font-sans text-[13px]">
{showCommandAutocomplete && (
<CommandAutocomplete
query={commandQuery}
selectedIndex={commandSelectedIndex}
onSelect={handleCommandSelect}
/>
)}
{showFileAutocomplete && fileContext && (
<FileAutocomplete
suggestions={fileSuggestions}
selectedIndex={fileSelectedIndex}
onSelect={handleFileSelect}
loading={filesLoading}
/>
)}
{pendingImages && pendingImages.length > 0 && (
<div className="flex gap-2 mb-2 flex-wrap">
{pendingImages.map((img, i) => (
<div key={i} className="relative group">
<img
src={`data:${img.mimeType};base64,${img.base64}`}
alt={img.fileName || "pending image"}
className="w-16 h-16 object-cover rounded border border-gray-600"
/>
<button
type="button"
onClick={() => onRemoveImage?.(i)}
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-gray-700 hover:bg-red-600 rounded-full flex items-center justify-center text-gray-300 text-xs leading-none opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
)}
<div
className={`relative border border-[#2a3140] bg-[#172131]/95 px-4 py-3.5 min-h-[106px] flex flex-col shadow-[0_0_0_1px_rgba(255,255,255,0.02)_inset] rounded-2xl ${
connectedTop ? "-mt-px rounded-t-none border-t-0" : ""
}`}
>
{isDragOver && (
<div className="pointer-events-none absolute inset-0 rounded-2xl border-2 border-[var(--ui-accent)] bg-[#172131]/70 backdrop-blur-sm z-30 flex items-center justify-center">
<span className="rounded-md bg-black/20 px-3 py-1.5 text-[color:var(--ui-accent)] text-sm font-medium">Drop images here</span>
</div>
)}
{hasPendingApproval && approvalContent ? (
<div className="flex flex-col gap-2.5 min-h-[76px]">
<div className="text-[14px] leading-[1.35] text-[color:var(--ui-text)] font-medium">
{approvalContent.question}
</div>
<div className="rounded-lg bg-black/20 px-3 py-2 font-mono text-[12px] text-[color:var(--ui-text-muted)] break-all">
{approvalContent.preview}
</div>
<div className="space-y-1">
{approvalContent.options.map((option, index) => {
const selected = index === approvalSelectedIndex;
return (
<button
key={option.label}
type="button"
onClick={() => {
setApprovalSelectedIndex(index);
submitApprovalDecision(option.decision);
}}
className={`w-full rounded-lg px-3 py-1.5 text-left transition-colors flex items-center gap-2 min-w-0 ${
selected
? 'bg-[var(--ui-accent-bubble)] text-[color:var(--ui-text)]'
: 'hover:bg-white/5 text-[color:var(--ui-text-muted)]'
}`}
>
<span className="w-4 shrink-0 text-[color:var(--ui-text-dim)]">{index + 1}.</span>
<span className="min-w-0 truncate">{option.label}</span>
</button>
);
})}
</div>
<div className="mt-auto pt-2 text-xs text-[color:var(--ui-text-dim)] flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0">
<div ref={modelDropdownRef} className="relative shrink min-w-0">
<button
type="button"
onClick={() => { setModelDropdownOpen(o => !o); setModelDropdownIndex(0); }}
className="cursor-pointer text-[color:var(--ui-text-muted)] hover:opacity-80 transition-opacity truncate max-w-[180px]"
>
{MODEL_DISPLAY_NAMES[modelConfig.name] || modelConfig.name}
</button>
{modelDropdownOpen && (
<div className={`absolute ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} left-0 bg-gray-800 border border-gray-700 rounded shadow-lg overflow-hidden z-50`}>
{getModelsForHarness(harness).map((model, idx) => {
const isCurrent = model.id === modelConfig.name && (model.effort || 'default') === (modelConfig.effort || 'default');
return (
<button
key={`${model.id}-${model.effort ?? ''}`}
type="button"
onClick={() => { handleModelSelect(model); setModelDropdownOpen(false); }}
onMouseEnter={() => setModelDropdownIndex(idx)}
className={`block w-full text-left px-3 py-1.5 whitespace-nowrap transition-colors flex items-center gap-2 ${idx === modelDropdownIndex ? 'bg-gray-700' : 'hover:bg-gray-700'} ${isCurrent ? 'text-gray-200' : 'text-gray-400'}`}
>
{model.name}
{isCurrent && <span className="ml-auto pl-3 text-gray-400">✓</span>}
</button>
);
})}
</div>
)}
</div>
<span className="text-[#435069]">·</span>
<div ref={permissionDropdownRef} className="relative shrink-0">
<button
type="button"
onClick={() => setPermissionDropdownOpen(o => !o)}
className={`cursor-pointer hover:opacity-80 transition-opacity ${permissionMode === 'full' ? 'text-orange-400' : 'text-[color:var(--ui-text-muted)]'}`}
>
{permissionMode === 'full' ? 'Full access' : 'Default permissions'}
</button>
{permissionDropdownOpen && (
<div className={`absolute ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} left-0 bg-gray-800 border border-gray-700 rounded-2xl shadow-lg overflow-hidden z-50 min-w-[220px]`}>
{PERMISSION_OPTIONS.map((option) => {
const selected = option.value === permissionMode;
return (
<button
key={option.value}
type="button"
onClick={() => { void setPermissionMode(option.value); setPermissionDropdownOpen(false); }}
className={`block w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors flex items-center gap-2 ${selected ? 'text-gray-200' : 'text-gray-400'}`}
>
<span className="w-4 text-center">{selected ? '✓' : ''}</span>
<span>{option.label}</span>
</button>
);
})}
</div>
)}
</div>
<span className="ml-auto" />
<ContextIndicator sessionId={sessionId} />
{gitBranch && projectPath && (
<>
<WorktreeSelector
projectPath={mainProjectPath || projectPath}
gitBranch={gitBranch}
worktreeType={worktreeType}
worktreePath={worktreePath}
tabId={tabId}
dropUp={dropUp}
/>
{githubPR && (
<>
<span className="text-[#435069]">·</span>
<a
href={githubPR.url}
onClick={e => { e.preventDefault(); window.open(githubPR.url, '_blank'); }}
className="text-[color:var(--ui-text-muted)] hover:text-[#87CEEB] transition-colors shrink-0"
>
#{githubPR.number}
</a>
</>
)}
</>
)}
<span className="text-[#435069]">·</span>
<button
type="button"
onClick={() => submitApprovalDecision('reject')}
className="text-[color:var(--ui-text-muted)] hover:text-[color:var(--ui-text)] transition-colors cursor-pointer"
>
Skip
</button>
</div>
</div>
) : (
<>
<textarea
ref={inputRef}
rows={1}
value={query}
onChange={handleInputChange}
onSelect={handleInputSelect}
onKeyDown={handleKeyDown}
placeholder={busy ? "Send a message to queue next..." : "Ask Open SWE anything, @ to add files, / for commands"}
className="w-full min-h-[52px] bg-transparent text-[color:var(--ui-text)] outline-none placeholder-[color:var(--ui-text-dim)] resize-none overflow-hidden leading-[1.45] min-w-0"
style={{ maxHeight: PROMPT_TEXTAREA_MAX_HEIGHT }}
/>
<div className="mt-auto pt-2 text-xs text-[color:var(--ui-text-dim)] flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0">
<div ref={modelDropdownRef} className="relative shrink min-w-0">
<button
type="button"
onClick={() => { setModelDropdownOpen(o => !o); setModelDropdownIndex(0); }}
className="cursor-pointer text-[color:var(--ui-text-muted)] hover:opacity-80 transition-opacity truncate max-w-[180px]"
>
{MODEL_DISPLAY_NAMES[modelConfig.name] || modelConfig.name}
</button>
{modelDropdownOpen && (
<div className={`absolute ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} left-0 bg-gray-800 border border-gray-700 rounded shadow-lg overflow-hidden z-50`}>
{getModelsForHarness(harness).map((model, idx) => {
const isCurrent = model.id === modelConfig.name && (model.effort || 'default') === (modelConfig.effort || 'default');
return (
<button
key={`${model.id}-${model.effort ?? ''}`}
type="button"
onClick={() => { handleModelSelect(model); setModelDropdownOpen(false); }}
onMouseEnter={() => setModelDropdownIndex(idx)}
className={`block w-full text-left px-3 py-1.5 whitespace-nowrap transition-colors flex items-center gap-2 ${idx === modelDropdownIndex ? 'bg-gray-700' : 'hover:bg-gray-700'} ${isCurrent ? 'text-gray-200' : 'text-gray-400'}`}
>
{model.name}
{isCurrent && <span className="ml-auto pl-3 text-gray-400">✓</span>}
</button>
);
})}
</div>
)}
</div>
<span className="text-[#435069]">·</span>
<div ref={permissionDropdownRef} className="relative shrink-0">
<button
type="button"
onClick={() => setPermissionDropdownOpen(o => !o)}
className={`cursor-pointer hover:opacity-80 transition-opacity ${permissionMode === 'full' ? 'text-orange-400' : 'text-[color:var(--ui-text-muted)]'}`}
>
{permissionMode === 'full' ? 'Full access' : 'Default permissions'}
</button>
{permissionDropdownOpen && (
<div className={`absolute ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} left-0 bg-gray-800 border border-gray-700 rounded-2xl shadow-lg overflow-hidden z-50 min-w-[220px]`}>
{PERMISSION_OPTIONS.map((option) => {
const selected = option.value === permissionMode;
return (
<button
key={option.value}
type="button"
onClick={() => { void setPermissionMode(option.value); setPermissionDropdownOpen(false); }}
className={`block w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors flex items-center gap-2 ${selected ? 'text-gray-200' : 'text-gray-400'}`}
>
<span className="w-4 text-center">{selected ? '✓' : ''}</span>
<span>{option.label}</span>
</button>
);
})}
</div>
)}
</div>
<span className="ml-auto" />
<ContextIndicator sessionId={sessionId} />
{gitBranch && projectPath && (
<>
<WorktreeSelector
projectPath={mainProjectPath || projectPath}
gitBranch={gitBranch}
worktreeType={worktreeType}
worktreePath={worktreePath}
tabId={tabId}
dropUp={dropUp}
/>
{githubPR && (
<>
<span className="text-[#435069]">·</span>
<a
href={githubPR.url}
onClick={e => { e.preventDefault(); window.open(githubPR.url, '_blank'); }}
className="text-[color:var(--ui-text-muted)] hover:text-[#87CEEB] transition-colors shrink-0"
>
#{githubPR.number}
</a>
</>
)}
</>
)}
</div>
</>
)}
</div>
</div>
);
});

View file

@ -0,0 +1,105 @@
import { memo, useState, useRef, useCallback, useLayoutEffect } from "react";
import type { ToolExecutionChunk } from "@/lib/agents/types";
interface ShellCommandProps {
chunk: ToolExecutionChunk;
projectPath?: string;
}
function getHeaderText(chunk: ToolExecutionChunk): string {
const cmd = (chunk.input?.command as string) || "";
const truncated = cmd.length > 80 ? cmd.slice(0, 80) + "..." : cmd;
if (chunk.status === "in_progress") return `Running ${truncated}`;
if (chunk.status === "pending") return `Run ${truncated}`;
return `Background terminal finished with ${truncated}`;
}
export const ShellCommand = memo(function ShellCommand({
chunk,
}: ShellCommandProps) {
const isSettled = chunk.status === "completed" || chunk.status === "error";
const [expanded, setExpanded] = useState(!isSettled);
const [scrolledFromTop, setScrolledFromTop] = useState(false);
const [scrolledFromBottom, setScrolledFromBottom] = useState(true);
const outputRef = useRef<HTMLDivElement>(null);
const handleOutputScroll = useCallback(() => {
const el = outputRef.current;
if (!el) return;
setScrolledFromTop(el.scrollTop > 0);
setScrolledFromBottom(el.scrollTop < el.scrollHeight - el.clientHeight - 1);
}, []);
const command = (chunk.input?.command as string) || "";
const output = chunk.output || "";
const headerText = getHeaderText(chunk);
useLayoutEffect(() => {
handleOutputScroll();
}, [handleOutputScroll, output, expanded]);
const outputEdgeShadows = [
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="my-1">
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="w-full flex items-center gap-2 py-1 text-left hover:opacity-90 transition-opacity"
>
<span className="text-[color:var(--ui-text-muted)] text-[12px] truncate flex-1 min-w-0">
{headerText}
</span>
<span
className="text-[color:var(--ui-text-dim)] text-xs shrink-0 transition-transform"
style={{ transform: expanded ? "rotate(180deg)" : "rotate(0deg)" }}
>
▾
</span>
</button>
{expanded && (
<div className="rounded-xl bg-[var(--ui-accent-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-text-dim)] 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>
</div>
{output && (
<div
ref={outputRef}
onScroll={handleOutputScroll}
className="min-h-0 flex-1 overflow-auto px-3 pb-1"
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">
{output}
</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

@ -0,0 +1 @@
export { SourceControlPanel } from './SourceControlTile';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
import { MessageSquare } from 'lucide-react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import type { Thread } from '@/lib/agents/types';
import { useStore } from '../../store';
function getTimeGroup(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days}d ago`;
if (days < 14) return '1w ago';
return `${Math.floor(days / 7)}w ago`;
}
function groupThreadsByTime(threads: Thread[]): { group: string; threads: Thread[] }[] {
const map = new Map<string, Thread[]>();
const sorted = [...threads].sort((a, b) => b.updatedAt - a.updatedAt);
for (const thread of sorted) {
const group = getTimeGroup(thread.updatedAt);
const existing = map.get(group) || [];
map.set(group, [...existing, thread]);
}
return Array.from(map.entries()).map(([group, threads]) => ({ group, threads }));
}
interface ThreadPickerProps {
projectId: string;
onSelect: (thread: Thread) => void;
onClose: () => void;
}
export function ThreadPicker({ projectId, onSelect, onClose }: ThreadPickerProps) {
const tabs = useStore((state) => state.tabs);
const [threads, setThreads] = useState<Thread[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIndex, setSelectedIndex] = useState(0);
const [search, setSearch] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const activeSessionIds = useMemo(
() => new Set(Object.values(tabs).map((tab) => tab.sessionId)),
[tabs],
);
useEffect(() => {
window.storage.loadThreadsForProject(projectId).then((loaded) => {
setThreads(
loaded
.filter((thread) => !activeSessionIds.has(thread.id))
.sort((a, b) => b.updatedAt - a.updatedAt),
);
setLoading(false);
});
}, [projectId, activeSessionIds]);
const filtered = search
? threads.filter((t) => t.title.toLowerCase().includes(search.toLowerCase()))
: threads;
const groups = groupThreadsByTime(filtered);
const flatList = groups.flatMap((g) => g.threads);
// Reset selection when search changes
useEffect(() => {
setSelectedIndex(0);
}, [search]);
// Scroll selected item into view
useEffect(() => {
const selected = listRef.current?.querySelector('[data-selected="true"]');
selected?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, flatList.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === 'Enter' && flatList.length > 0) {
e.preventDefault();
onSelect(flatList[selectedIndex]);
return;
}
},
[flatList, selectedIndex, onSelect, onClose],
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [handleKeyDown]);
// Auto-focus search
useEffect(() => {
searchRef.current?.focus();
}, [loading]);
const messageCount = (thread: Thread) => {
const count = thread.messages.filter((m) => m.author === 'user' || m.author === 'agent').length;
return `${count} msg${count !== 1 ? 's' : ''}`;
};
const formatDate = (timestamp: number) => {
const d = new Date(timestamp);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
let currentFlatIndex = 0;
return (
<div className="absolute inset-0 z-40 bg-[#1a2332]/95 flex flex-col items-center pt-16 px-4">
<div className="w-full max-w-lg flex flex-col bg-[#1e2a3a] border border-gray-700 rounded-lg shadow-2xl overflow-hidden max-h-[70vh]">
<div className="p-3 border-b border-gray-700 shrink-0">
<input
ref={searchRef}
type="text"
placeholder="Search conversations..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-[#1a1f2e] text-gray-200 text-sm px-3 py-2 rounded-md border border-gray-600 focus:outline-none focus:border-[#5a9bc7]"
/>
</div>
<div ref={listRef} className="overflow-y-auto flex-1">
{loading ? (
<div className="p-6 text-gray-500 text-sm text-center">Loading...</div>
) : flatList.length === 0 ? (
<div className="p-6 text-gray-500 text-sm text-center">
{search ? 'No matching conversations' : 'No previous conversations for this project'}
</div>
) : (
groups.map(({ group, threads: groupThreads }) => (
<div key={group}>
<div className="px-4 py-2 text-xs text-gray-500 font-medium sticky top-0 bg-[#1e2a3a]">
{group}
</div>
{groupThreads.map((thread) => {
const idx = currentFlatIndex++;
const isSelected = idx === selectedIndex;
return (
<button
key={thread.id}
data-selected={isSelected}
onClick={() => onSelect(thread)}
className={`w-full px-4 py-3 text-left flex items-center gap-3 transition-colors ${
isSelected ? 'bg-[#2a3142]' : 'hover:bg-[#252a3a]'
}`}
>
<ChatIcon />
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-200 truncate">{thread.title}</div>
<div className="text-xs text-gray-500 mt-0.5">
{messageCount(thread)} — {formatDate(thread.updatedAt)}
</div>
</div>
{isSelected && (
<span className="text-xs text-gray-500 shrink-0">Enter</span>
)}
</button>
);
})}
</div>
))
)}
</div>
<div className="px-4 py-2 border-t border-gray-700 text-xs text-gray-500 flex gap-4 shrink-0">
<span><kbd className="px-1 py-0.5 bg-[#1a1f2e] rounded text-gray-400">Up/Down</kbd> navigate</span>
<span><kbd className="px-1 py-0.5 bg-[#1a1f2e] rounded text-gray-400">Enter</kbd> select</span>
<span><kbd className="px-1 py-0.5 bg-[#1a1f2e] rounded text-gray-400">Esc</kbd> cancel</span>
</div>
</div>
</div>
);
}
function ChatIcon() {
return <MessageSquare size={14} strokeWidth={2} className="text-gray-500 flex-shrink-0" />;
}

View file

@ -0,0 +1,104 @@
import { useRef, useEffect, useState } from "react";
import {
CheckCircle2,
ChevronDown,
ChevronRight,
Circle,
ListChecks,
Loader2,
} from "lucide-react";
import type { TodoItem } from "@/lib/agents/types";
interface TodoListProps {
todos: TodoItem[];
className?: string;
runActive?: boolean;
}
function StatusIcon({ status }: { status: TodoItem["status"] }) {
switch (status) {
case "pending":
return <Circle className="h-3.5 w-3.5 text-[color:var(--ui-text-dim)] shrink-0" />;
case "in_progress":
return <Loader2 className="h-3.5 w-3.5 text-[color:var(--ui-accent)] animate-spin shrink-0" />;
case "completed":
return <CheckCircle2 className="h-3.5 w-3.5 text-green-400 shrink-0" />;
}
}
const MAX_HEIGHT = 160;
export function TodoList({ todos, className = "", runActive = false }: TodoListProps) {
const [collapsed, setCollapsed] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const prevRunActiveRef = useRef(runActive);
useEffect(() => {
if (prevRunActiveRef.current && !runActive) {
setCollapsed(true);
}
prevRunActiveRef.current = runActive;
}, [runActive]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const firstActive = todos.findIndex((t) => t.status === "in_progress");
const targetIndex = firstActive !== -1 ? firstActive : todos.length - 1;
const targetEl = el.children[targetIndex] as HTMLElement | undefined;
targetEl?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [todos]);
if (todos.length === 0) return null;
const completed = todos.filter((t) => t.status === "completed").length;
return (
<div className={`font-sans text-xs rounded-xl border border-[var(--ui-border)] bg-[var(--ui-accent-bubble)] overflow-hidden ${className}`}>
<button
type="button"
className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-white/5 transition-colors"
onClick={() => setCollapsed((c) => !c)}
>
<ListChecks className="h-3.5 w-3.5 text-[color:var(--ui-text-muted)] shrink-0" />
<span className="text-[color:var(--ui-text-muted)] truncate">
{completed} out of {todos.length} task{todos.length === 1 ? "" : "s"} completed
</span>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5 text-[color:var(--ui-text-dim)] ml-auto shrink-0" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-[color:var(--ui-text-dim)] ml-auto shrink-0" />
)}
</button>
{!collapsed && (
<div
ref={scrollRef}
className="border-t border-[var(--ui-border)] px-3 py-2 space-y-1.5 overflow-y-auto"
style={{ maxHeight: MAX_HEIGHT }}
>
{todos.map((todo, index) => (
<div
key={index}
className={`flex items-start gap-2 ${todo.status === "completed" ? "opacity-70" : ""}`}
>
<StatusIcon status={todo.status} />
<span
className={
todo.status === "completed"
? "text-[color:var(--ui-text-dim)] line-through"
: todo.status === "in_progress"
? "text-[color:var(--ui-text)]"
: "text-[color:var(--ui-text-muted)]"
}
>
<span className="mr-1 text-[color:var(--ui-text-dim)]">{index + 1}.</span>
{todo.content}
</span>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,287 @@
import { memo, useState, useCallback, 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 { diffOptions } from "@/components/agents/utils/diffUtils";
interface ToolExecutionProps {
chunk: ToolExecutionChunk;
projectPath?: string;
onApprove?: (approvalRequestId: string) => void;
onReject?: (approvalRequestId: string) => void;
onAutoApprove?: (approvalRequestId: string) => void;
onOpenDiff?: (diffData: { filePath: string; originalContent: string; modifiedContent: string }) => void;
resolvedDiffData?: { originalContent: string; modifiedContent: string };
}
function stripProjectPath(path: string, projectPath?: string): string {
if (!projectPath || !path.startsWith(projectPath)) return path;
const relative = path.slice(projectPath.length);
return relative.startsWith("/") ? "." + relative : "./" + relative;
}
function getFileName(path: string): string {
const normalized = path.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
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,
input: Record<string, unknown> | undefined,
projectPath?: string,
): string {
const path = input?.path as string | undefined;
const pattern = input?.pattern as string | undefined;
const query = input?.query as string | undefined;
const url = input?.url as string | undefined;
const command = input?.command as string | undefined;
switch (toolKind) {
case "read": {
if (path) {
const displayPath = stripProjectPath(path, projectPath);
return `Read(${displayPath})`;
}
return title;
}
case "search": {
if (pattern) {
const truncated = pattern.length > 40 ? pattern.slice(0, 40) + "..." : pattern;
return `Search("${truncated}")`;
}
if (query) {
return `Search("${query.slice(0, 40)}${query.length > 40 ? "..." : ""}")`;
}
return title;
}
case "fetch": {
if (url) {
return `Fetch(${url.slice(0, 50)}${url.length > 50 ? "..." : ""})`;
}
return title;
}
case "execute": {
if (command) {
const truncated = command.length > 60 ? command.slice(0, 60) + "..." : command;
return `Shell(${truncated})`;
}
return title;
}
case "edit":
case "delete":
case "move":
return title;
case "think":
return "Thinking...";
default:
return title;
}
}
const inlineDiffOptions = {
...diffOptions,
disableFileHeader: true,
};
const InlineDiffCollapsible = memo(function InlineDiffCollapsible({
filePath,
fileName,
originalContent,
newContent,
additions,
deletions,
isError,
}: {
filePath: string;
fileName: string;
originalContent: string;
newContent: string;
additions: number;
deletions: number;
isError: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
const scrollRef = useRef<HTMLDivElement>(null);
const [scrolledFromTop, setScrolledFromTop] = useState(false);
const [scrolledFromBottom, setScrolledFromBottom] = useState(false);
const updateScrollIndicators = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setScrolledFromTop(el.scrollTop > 0);
setScrolledFromBottom(el.scrollTop < el.scrollHeight - el.clientHeight - 1);
}, []);
useLayoutEffect(() => {
if (expanded) updateScrollIndicators();
}, [expanded, updateScrollIndicators]);
const edgeShadows = [
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(", ");
const oldFile = { name: filePath, contents: originalContent };
const newFile = { name: filePath, contents: newContent };
if (!expanded) {
return (
<div className="my-0.5 text-[12px] leading-5">
<button
type="button"
onClick={toggle}
className="inline-flex items-center gap-1.5 text-left hover:brightness-125 transition-colors"
>
<span className={isError ? "text-red-400" : "text-[color:var(--ui-text-muted)]"}>
Edited <span className="text-[color:var(--ui-accent)]">{fileName}</span>
</span>
</button>
</div>
);
}
return (
<div className="my-1">
<div className="my-0.5 text-[12px] leading-5 mb-1.5">
<button
type="button"
onClick={toggle}
className="inline-flex items-center gap-1.5 text-left hover:brightness-125 transition-colors"
>
<span className={isError ? "text-red-400" : "text-[color:var(--ui-text-muted)]"}>
Edited file
</span>
<span className="text-[color:var(--ui-text-dim)] text-[10px]">▾</span>
</button>
</div>
<div className="rounded-lg bg-[var(--ui-accent-bubble)] overflow-hidden border border-[var(--ui-border-subtle)]">
<div className="px-3 py-2 flex items-center gap-2">
<span className={`text-[13px] truncate flex-1 min-w-0 ${isError ? "text-red-400" : "text-[color:var(--ui-accent)]"}`}>
{filePath}
</span>
<span className="shrink-0 text-xs flex items-center gap-2">
<span className="text-green-400">+{additions}</span>
<span className="text-red-400">-{deletions}</span>
</span>
</div>
<div
ref={scrollRef}
onScroll={updateScrollIndicators}
className="border-t border-[var(--ui-border)] max-h-[250px] overflow-auto"
style={{ boxShadow: edgeShadows || "none" }}
>
<MultiFileDiff
oldFile={oldFile}
newFile={newFile}
options={inlineDiffOptions}
/>
</div>
</div>
</div>
);
});
export const ToolExecution = memo(function ToolExecution({
chunk,
projectPath,
resolvedDiffData,
}: ToolExecutionProps) {
const { title, toolKind, input, status, output } = chunk;
const diffs = chunk.diffs?.length ? chunk.diffs : (chunk.diffData ? [chunk.diffData] : []);
const diffData = diffs[diffs.length - 1];
const isEditOp = toolKind === "edit" || toolKind === "delete" || toolKind === "move" || diffData != null;
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;
if (isCompletedEditOp && diffStats && diffData) {
return (
<InlineDiffCollapsible
filePath={editedFilePath || diffData.filePath}
fileName={editedFileName || editedFilePath || diffData.filePath}
originalContent={resolvedDiffData?.originalContent ?? diffData.originalContent ?? ""}
newContent={resolvedDiffData?.modifiedContent ?? diffData.newContent}
additions={diffStats.additions}
deletions={diffStats.deletions}
isError={status === "error"}
/>
);
}
if (isEditOp && status === "pending" && diffData) {
return (
<div className="my-1 text-[12px] leading-5">
<DiffView diffData={diffData} />
<span className="text-[color:var(--ui-text-dim)]">Waiting for approval...</span>
</div>
);
}
if (isEditOp && status === "in_progress") {
const path = stripProjectPath(
diffData?.filePath ||
(input?.filePath as string) ||
(input?.path as string) || "file",
projectPath,
);
return (
<div className="my-0.5 text-[12px] leading-5">
<span className="text-yellow-400">Editing {getFileName(path)}...</span>
</div>
);
}
const displayName = formatToolDisplay(title, toolKind, input, projectPath);
const statusTextClass =
status === "error"
? "text-red-400"
: status === "in_progress" || status === "pending"
? "text-yellow-400"
: "text-[color:var(--ui-text-muted)]";
return (
<div className="my-0.5 text-[12px] leading-5">
<div className="flex items-center gap-2 min-w-0">
<span className={`${statusTextClass} truncate`}>{displayName}</span>
{status === "error" && output && (
<span className="text-red-400/80 truncate">{output.slice(0, 80)}</span>
)}
</div>
</div>
);
});

View file

@ -0,0 +1,14 @@
export { BubblePrefix } from "./BubblePrefix";
export { CloudPromptBar } from "./CloudPromptBar";
export { CodeBlock } from "./CodeBlock";
export { CompactingIndicator } from "./CompactingIndicator";
export { DiffView } from "./DiffView";
export { HeaderBar } from "./HeaderBar";
export { Logo } from "./Logo";
export { Markdown } from "./Markdown";
export { MarkdownTable } from "./MarkdownTable";
export { MessageView, summarizeChangedFiles, type MessageViewScrollControl } from "./MessageView";
export { PanelResizeHandle } from "./PanelResizeHandle";
export { ShellCommand } from "./ShellCommand";
export { TodoList } from "./TodoList";
export { ToolExecution } from "./ToolExecution";

View file

@ -0,0 +1,63 @@
export const DIFF_UNSAFE_CSS = `
[data-diffs-header],
[data-diff],
[data-file],
[data-error-wrapper],
[data-virtualizer-buffer] {
--diffs-bg: var(--ui-panel) !important;
--diffs-light-bg: var(--ui-panel) !important;
--diffs-dark-bg: var(--ui-panel) !important;
--diffs-token-light-bg: transparent;
--diffs-token-dark-bg: transparent;
--diffs-bg-context-override: var(--ui-panel);
--diffs-bg-hover-override: var(--ui-panel-2);
--diffs-bg-separator-override: var(--ui-accent-bubble);
--diffs-bg-buffer-override: var(--ui-bg);
--diffs-bg-addition-override: color-mix(in srgb, var(--ui-panel) 80%, #22c55e);
--diffs-bg-addition-number-override: color-mix(in srgb, var(--ui-panel) 75%, #22c55e);
--diffs-bg-addition-hover-override: color-mix(in srgb, var(--ui-panel) 70%, #22c55e);
--diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--ui-panel) 60%, #22c55e);
--diffs-bg-deletion-override: color-mix(in srgb, var(--ui-panel) 80%, #ef4444);
--diffs-bg-deletion-number-override: color-mix(in srgb, var(--ui-panel) 75%, #ef4444);
--diffs-bg-deletion-hover-override: color-mix(in srgb, var(--ui-panel) 70%, #ef4444);
--diffs-bg-deletion-emphasis-override: color-mix(in srgb, var(--ui-panel) 60%, #ef4444);
--diffs-fg-number-override: var(--ui-text-dim);
--diffs-font-size: 12px;
--diffs-line-height: 1.5;
--diffs-font-family: "SF Mono", "Fira Code", "Cascadia Code", Menlo, Monaco, monospace;
background-color: var(--ui-panel) !important;
}
[data-file-info] {
background-color: var(--ui-accent-bubble) !important;
border-block-color: var(--ui-border) !important;
color: var(--ui-text) !important;
}
[data-diffs-header] {
position: sticky !important;
top: 0;
z-index: 4;
background-color: var(--ui-accent-bubble) !important;
border-bottom: 1px solid var(--ui-border) !important;
}
[data-separator] {
background-color: var(--ui-accent-bubble) !important;
color: var(--ui-text-dim) !important;
}
`;
export const diffOptions = {
theme: "pierre-light" as const,
diffStyle: "unified" as const,
overflow: "scroll" as const,
disableFileHeader: true,
unsafeCSS: DIFF_UNSAFE_CSS,
collapsedContextThreshold: 4,
};

View file

@ -0,0 +1,6 @@
export const Z = {
FLOAT: 30,
OVERLAY: 40,
DROPDOWN: 50,
MODAL: 60,
} as const;

View file

@ -0,0 +1,158 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { SidebarSimpleIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
const STORAGE_WIDTH = "open-swe.sidebar.width";
const STORAGE_COLLAPSED = "open-swe.sidebar.collapsed";
export const SIDEBAR_DEFAULT_WIDTH = 260;
export const SIDEBAR_MIN_WIDTH = 200;
export const SIDEBAR_MAX_WIDTH = 420;
function readStoredWidth(): number {
if (typeof window === "undefined") return SIDEBAR_DEFAULT_WIDTH;
const raw = window.localStorage.getItem(STORAGE_WIDTH);
const parsed = raw ? Number(raw) : NaN;
if (!Number.isFinite(parsed)) return SIDEBAR_DEFAULT_WIDTH;
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, parsed));
}
function readStoredCollapsed(): boolean {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(STORAGE_COLLAPSED) === "1";
}
export function useSidebarLayout() {
const [width, setWidthState] = useState<number>(() => readStoredWidth());
const [collapsed, setCollapsedState] = useState<boolean>(() => readStoredCollapsed());
const setWidth = useCallback((next: number) => {
const clamped = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, next));
setWidthState(clamped);
window.localStorage.setItem(STORAGE_WIDTH, String(clamped));
}, []);
const setCollapsed = useCallback((next: boolean) => {
setCollapsedState(next);
window.localStorage.setItem(STORAGE_COLLAPSED, next ? "1" : "0");
}, []);
const toggle = useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
return { width, collapsed, setWidth, setCollapsed, toggle };
}
interface SidebarFrameProps {
width: number;
setWidth: (next: number) => void;
collapsed: boolean;
toggle: () => void;
className?: string;
children: React.ReactNode;
}
export function SidebarFrame({
width,
setWidth,
collapsed,
toggle,
className,
children,
}: SidebarFrameProps) {
if (collapsed) {
return (
<button
type="button"
aria-label="Expand sidebar"
onClick={toggle}
className="fixed top-3 left-3 z-30 flex size-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground"
>
<SidebarSimpleIcon className="size-4" />
</button>
);
}
return (
<aside
style={{ width }}
className={cn("relative flex h-svh shrink-0 flex-col", className)}
>
{children}
<ResizeHandle width={width} onResize={setWidth} />
</aside>
);
}
function ResizeHandle({ width, onResize }: { width: number; onResize: (next: number) => void }) {
const startRef = useRef<{ x: number; width: number } | null>(null);
const [dragging, setDragging] = useState(false);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
startRef.current = { x: e.clientX, width };
setDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!startRef.current) return;
const next = startRef.current.width + (e.clientX - startRef.current.x);
onResize(next);
};
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
startRef.current = null;
setDragging(false);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
};
useEffect(() => {
if (!dragging) return;
const prev = document.body.style.cursor;
document.body.style.cursor = "col-resize";
return () => {
document.body.style.cursor = prev;
};
}, [dragging]);
return (
<div
role="separator"
aria-orientation="vertical"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={cn(
"absolute top-0 right-0 z-20 h-full w-1 cursor-col-resize touch-none select-none",
"after:absolute after:inset-y-0 after:right-0 after:w-px after:bg-transparent after:transition-colors",
"hover:after:bg-border",
dragging && "after:bg-border",
)}
/>
);
}
interface SidebarCollapseButtonProps {
onToggle: () => void;
className?: string;
}
export function SidebarCollapseButton({ onToggle, className }: SidebarCollapseButtonProps) {
return (
<button
type="button"
aria-label="Collapse sidebar"
onClick={onToggle}
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground",
className,
)}
>
<SidebarSimpleIcon className="size-4" />
</button>
);
}

107
ui/src/lib/agents/api.ts Normal file
View file

@ -0,0 +1,107 @@
import type { AgentThread, Message } from "./types";
export type { AgentThread, Message };
export interface ThreadCreateRequest {
prompt: string;
repo?: string | null;
model_id?: string | null;
effort?: string | null;
}
export interface ThreadMessageRequest {
content: string;
model_id?: string | null;
effort?: string | null;
}
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(/\/$/, "");
async function agentsRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}/dashboard/api${path}`, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) {
let message = res.statusText;
try {
const body = await res.json();
if (body?.detail) {
message = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
}
} catch {
/* ignore */
}
throw new Error(message);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const agentsApi = {
listThreads: () => agentsRequest<AgentThread[]>("/threads"),
getThread: (threadId: string) =>
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}`),
createThread: (body: ThreadCreateRequest) =>
agentsRequest<AgentThread>("/threads", {
method: "POST",
body: JSON.stringify(body),
}),
sendMessage: (threadId: string, body: ThreadMessageRequest) =>
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}/messages`, {
method: "POST",
body: JSON.stringify(body),
}),
cancelThread: (threadId: string) =>
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}/cancel`, {
method: "POST",
}),
deleteThread: (threadId: string) =>
agentsRequest<void>(`/threads/${encodeURIComponent(threadId)}`, {
method: "DELETE",
}),
streamUrl: (threadId: string) =>
`${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" | "last30" | "older";
export function groupThreads(threads: AgentThread[]): Record<ThreadGroup, AgentThread[]> {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
const groups: Record<ThreadGroup, AgentThread[]> = {
today: [],
last30: [],
older: [],
};
for (const thread of [...threads].sort((a, b) => b.updatedAt - a.updatedAt)) {
if (thread.updatedAt >= todayStart.getTime()) {
groups.today.push(thread);
} else if (thread.updatedAt >= thirtyDaysAgo) {
groups.last30.push(thread);
} else {
groups.older.push(thread);
}
}
return groups;
}

View file

@ -0,0 +1,53 @@
const STORAGE_KEY = (threadId: string) => `open-swe:pending-prompts:${threadId}`;
export interface PendingPrompt {
prompt: string;
insertAt: number;
}
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"
);
}
function safeRead(threadId: string): 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: PendingPrompt[]): void {
if (typeof window === "undefined") return;
if (prompts.length === 0) {
window.sessionStorage.removeItem(STORAGE_KEY(threadId));
return;
}
window.sessionStorage.setItem(STORAGE_KEY(threadId), JSON.stringify(prompts));
}
export function getPendingPrompts(threadId: string): PendingPrompt[] {
return safeRead(threadId);
}
export function addPendingPrompt(threadId: string, prompt: string, insertAt: number): void {
safeWrite(threadId, [...safeRead(threadId), { prompt, insertAt }]);
}
export function dropPendingPrompts(
threadId: string,
predicate: (entry: PendingPrompt) => boolean,
): PendingPrompt[] {
const next = safeRead(threadId).filter((p) => !predicate(p));
safeWrite(threadId, next);
return next;
}

View file

@ -0,0 +1,111 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { agentsApi } from "./api";
import { addPendingPrompt } from "./pendingPrompts";
export const agentThreadKeys = {
all: ["agent-threads"] as const,
detail: (threadId: string) => ["agent-threads", threadId] as const,
};
export function useAgentThreads() {
return useQuery({
queryKey: agentThreadKeys.all,
queryFn: () => agentsApi.listThreads(),
});
}
export function useAgentThread(threadId: string) {
return useQuery({
queryKey: agentThreadKeys.detail(threadId),
queryFn: () => agentsApi.getThread(threadId),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "running" ? 2000 : false;
},
});
}
export function useCreateAgentThread() {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: agentsApi.createThread,
onSuccess: (thread, variables) => {
addPendingPrompt(thread.id, variables.prompt, thread.messages.length);
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 } });
},
});
}
export interface SendAgentMessageVariables {
content: string;
model_id?: string | null;
effort?: string | null;
}
export function useSendAgentMessage(threadId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (vars: SendAgentMessageVariables) =>
agentsApi.sendMessage(threadId, {
content: vars.content,
model_id: vars.model_id,
effort: vars.effort,
}),
onMutate: (vars) => {
const cached = queryClient.getQueryData<{ messages?: unknown[] }>(
agentThreadKeys.detail(threadId),
);
const insertAt = Array.isArray(cached?.messages) ? cached.messages.length : 0;
addPendingPrompt(threadId, vars.content, insertAt);
},
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), thread);
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
},
});
}
export function useDeleteAgentThread() {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
onSuccess: (_, threadId) => {
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) });
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
const path = window.location.pathname;
if (path.includes(`/agents/${threadId}`)) {
navigate({ to: "/agents" });
}
},
});
}

169
ui/src/lib/agents/types.ts Normal file
View file

@ -0,0 +1,169 @@
export type Author = "user" | "agent" | "system" | "tool";
export type ChunkKind =
| "text"
| "code"
| "error"
| "list"
| "tool-execution"
| "todo"
| "image";
export type TodoStatus = "pending" | "in_progress" | "completed";
export type AgentStatus = "idle" | "running" | "finished" | "interrupted" | "error";
export interface TodoItem {
content: string;
status: TodoStatus;
}
export type AcpToolKind =
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "other";
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error";
export interface AcpToolLocation {
path: string;
line?: number;
}
export interface DiffData {
originalContent: string | null;
newContent: string;
filePath: string;
isNewFile: boolean;
isBinary: boolean;
isTruncated: boolean;
totalLines: number;
}
export interface ToolExecutionChunk {
kind: "tool-execution";
toolCallId: string;
title: string;
toolKind: AcpToolKind;
input?: Record<string, unknown>;
status: AcpToolStatus;
output?: string;
elapsedMs?: number;
approvalRequestId?: string;
diffData?: DiffData;
diffs?: DiffData[];
locations?: AcpToolLocation[];
}
export interface TextChunk {
kind: "text";
text: string;
}
export interface CodeChunk {
kind: "code";
text: string;
language?: string;
}
export interface ErrorChunk {
kind: "error";
text: string;
}
export interface ListChunk {
kind: "list";
lines: string[];
}
export interface TodoChunk {
kind: "todo";
todos: TodoItem[];
}
export interface ImageChunk {
kind: "image";
base64: string;
mimeType: string;
fileName?: string;
}
export type Chunk =
| TextChunk
| CodeChunk
| ErrorChunk
| ListChunk
| ToolExecutionChunk
| TodoChunk
| ImageChunk;
export interface Message {
id: string;
author: Author;
timestamp: string;
chunks: Chunk[];
hidden?: boolean;
}
export interface Project {
id: string;
path: string;
name: string;
createdAt: number;
lastOpenedAt: number;
gitBranch?: string;
}
export interface AgentThread {
id: string;
title: string;
repo: string;
repoFullName: string;
branch: string;
model: string;
effort?: string | null;
status: AgentStatus;
createdAt: number;
updatedAt: number;
messages: Message[];
pr?: {
number: number;
title: string;
state: "draft" | "open" | "merged";
headRef: string;
baseRef: string;
url: string;
};
diffStats?: {
files: number;
additions: number;
deletions: number;
};
changedFiles?: Array<{
path: string;
additions: number;
deletions: number;
patch?: string;
}>;
}
export type GitFileStatus =
| "index-modified"
| "index-added"
| "index-deleted"
| "modified"
| "deleted"
| "untracked";
export interface GitStatusEntry {
path: string;
status: GitFileStatus;
staged: boolean;
originalPath?: string;
}

View file

@ -0,0 +1,69 @@
import { useQuery } from "@tanstack/react-query";
import { api, type ModelOption } from "@/lib/api";
export interface ModelSelection {
modelId: string;
effort: string;
}
export interface ModelOptionsResult {
models: ModelOption[];
defaultSelection: ModelSelection | null;
isLoading: boolean;
}
export function useModelOptions(): ModelOptionsResult {
const optionsQuery = useQuery({
queryKey: ["dashboard", "options"],
queryFn: () => api.options(),
staleTime: 5 * 60_000,
});
const profileQuery = useQuery({
queryKey: ["dashboard", "profile"],
queryFn: () => api.profile(),
staleTime: 5 * 60_000,
});
const models = optionsQuery.data?.models ?? [];
const profile = profileQuery.data;
let defaultSelection: ModelSelection | null = null;
const profileModelId = profile?.default_model;
const profileEffort = profile?.reasoning_effort;
if (
profileModelId &&
profileEffort &&
models.some((m) => m.id === profileModelId && m.efforts.includes(profileEffort))
) {
defaultSelection = { modelId: profileModelId, effort: profileEffort };
} else if (models.length > 0) {
const first = models[0]!;
defaultSelection = { modelId: first.id, effort: first.default_effort };
}
return {
models,
defaultSelection,
isLoading: optionsQuery.isLoading || profileQuery.isLoading,
};
}
const EFFORT_LABELS: Record<string, string> = {
low: "Low",
medium: "Medium",
high: "High",
xhigh: "XHigh",
max: "Max",
};
export function formatModelSelection(
models: ModelOption[],
selection: ModelSelection | null,
): string {
if (!selection) return "Default";
const model = models.find((m) => m.id === selection.modelId);
const modelLabel = model?.label ?? selection.modelId;
const effortLabel = EFFORT_LABELS[selection.effort] ?? selection.effort;
return `${modelLabel} ${effortLabel}`;
}

View file

@ -0,0 +1,167 @@
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

@ -14,9 +14,12 @@ import { Route as MySettingsRouteImport } from './routes/my-settings'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IntegrationsRouteImport } from './routes/integrations'
import { Route as CloudAgentsRouteImport } from './routes/cloud-agents'
import { Route as AgentsRouteImport } from './routes/agents'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
const ReviewRoute = ReviewRouteImport.update({
@ -44,6 +47,11 @@ const CloudAgentsRoute = CloudAgentsRouteImport.update({
path: '/cloud-agents',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsRoute = AgentsRouteImport.update({
id: '/agents',
path: '/agents',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
@ -54,11 +62,21 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsIndexRoute = AgentsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AgentsRoute,
} as any)
const ReviewStylesRoute = ReviewStylesRouteImport.update({
id: '/review_/styles',
path: '/review/styles',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
id: '/$threadId',
path: '/$threadId',
getParentRoute: () => AgentsRoute,
} as any)
const ReviewRepositoriesOwnerRoute = ReviewRepositoriesOwnerRouteImport.update({
id: '/review_/repositories/$owner',
path: '/review/repositories/$owner',
@ -68,12 +86,15 @@ const ReviewRepositoriesOwnerRoute = ReviewRepositoriesOwnerRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/agents': typeof AgentsRouteWithChildren
'/cloud-agents': typeof CloudAgentsRoute
'/integrations': typeof IntegrationsRoute
'/login': typeof LoginRoute
'/my-settings': typeof MySettingsRoute
'/review': typeof ReviewRoute
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
}
export interface FileRoutesByTo {
@ -84,19 +105,24 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/my-settings': typeof MySettingsRoute
'/review': typeof ReviewRoute
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review/styles': typeof ReviewStylesRoute
'/agents': typeof AgentsIndexRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/agents': typeof AgentsRouteWithChildren
'/cloud-agents': typeof CloudAgentsRoute
'/integrations': typeof IntegrationsRoute
'/login': typeof LoginRoute
'/my-settings': typeof MySettingsRoute
'/review': typeof ReviewRoute
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review_/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
}
export interface FileRouteTypes {
@ -104,12 +130,15 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/admin'
| '/agents'
| '/cloud-agents'
| '/integrations'
| '/login'
| '/my-settings'
| '/review'
| '/agents/$threadId'
| '/review/styles'
| '/agents/'
| '/review/repositories/$owner'
fileRoutesByTo: FileRoutesByTo
to:
@ -120,24 +149,30 @@ export interface FileRouteTypes {
| '/login'
| '/my-settings'
| '/review'
| '/agents/$threadId'
| '/review/styles'
| '/agents'
| '/review/repositories/$owner'
id:
| '__root__'
| '/'
| '/admin'
| '/agents'
| '/cloud-agents'
| '/integrations'
| '/login'
| '/my-settings'
| '/review'
| '/agents/$threadId'
| '/review_/styles'
| '/agents/'
| '/review_/repositories/$owner'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRoute
AgentsRoute: typeof AgentsRouteWithChildren
CloudAgentsRoute: typeof CloudAgentsRoute
IntegrationsRoute: typeof IntegrationsRoute
LoginRoute: typeof LoginRoute
@ -184,6 +219,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CloudAgentsRouteImport
parentRoute: typeof rootRouteImport
}
'/agents': {
id: '/agents'
path: '/agents'
fullPath: '/agents'
preLoaderRoute: typeof AgentsRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': {
id: '/admin'
path: '/admin'
@ -198,6 +240,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/agents/': {
id: '/agents/'
path: '/'
fullPath: '/agents/'
preLoaderRoute: typeof AgentsIndexRouteImport
parentRoute: typeof AgentsRoute
}
'/review_/styles': {
id: '/review_/styles'
path: '/review/styles'
@ -205,6 +254,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ReviewStylesRouteImport
parentRoute: typeof rootRouteImport
}
'/agents/$threadId': {
id: '/agents/$threadId'
path: '/$threadId'
fullPath: '/agents/$threadId'
preLoaderRoute: typeof AgentsThreadIdRouteImport
parentRoute: typeof AgentsRoute
}
'/review_/repositories/$owner': {
id: '/review_/repositories/$owner'
path: '/review/repositories/$owner'
@ -215,9 +271,23 @@ declare module '@tanstack/react-router' {
}
}
interface AgentsRouteChildren {
AgentsThreadIdRoute: typeof AgentsThreadIdRoute
AgentsIndexRoute: typeof AgentsIndexRoute
}
const AgentsRouteChildren: AgentsRouteChildren = {
AgentsThreadIdRoute: AgentsThreadIdRoute,
AgentsIndexRoute: AgentsIndexRoute,
}
const AgentsRouteWithChildren =
AgentsRoute._addFileChildren(AgentsRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRoute,
AgentsRoute: AgentsRouteWithChildren,
CloudAgentsRoute: CloudAgentsRoute,
IntegrationsRoute: IntegrationsRoute,
LoginRoute: LoginRoute,

32
ui/src/routes/agents.tsx Normal file
View file

@ -0,0 +1,32 @@
import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router";
import { Skeleton } from "@/components/ui/skeleton";
import agentsCss from "@/styles/agents.css?url";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents")({
head: () => ({
links: [{ rel: "stylesheet", href: agentsCss }],
}),
component: AgentsLayout,
});
function AgentsLayout() {
const session = useSession();
if (session.isLoading) {
return (
<main className="agents-ui flex h-svh items-center justify-center bg-[var(--ui-bg)] p-6">
<Skeleton className="h-40 w-full max-w-md" />
</main>
);
}
if (!session.data) return <Navigate to="/login" />;
return (
<div className="agents-ui h-svh overflow-hidden">
<Outlet />
</div>
);
}

View file

@ -0,0 +1,33 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
import { AgentThreadView } from "@/components/agents/AgentThreadView";
import { Skeleton } from "@/components/ui/skeleton";
import { useAgentThread } from "@/lib/agents/queries";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/$threadId")({
component: AgentThreadPage,
});
function AgentThreadPage() {
const { threadId } = Route.useParams();
const session = useSession();
const threadQuery = useAgentThread(threadId);
if (session.isLoading) return null;
if (!session.data) return <Navigate to="/login" />;
if (threadQuery.isLoading) {
return (
<main className="agents-ui flex h-svh items-center justify-center bg-[var(--ui-bg)] p-6">
<Skeleton className="h-40 w-full max-w-md" />
</main>
);
}
if (threadQuery.isError || !threadQuery.data) {
return <Navigate to="/agents" />;
}
return <AgentThreadView user={session.data} thread={threadQuery.data} />;
}

View file

@ -0,0 +1,20 @@
import { createFileRoute } from "@tanstack/react-router";
import { AgentsHome } from "@/components/agents/AgentsHome";
import { AgentsShell } from "@/components/agents/AgentsSidebar";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/")({
component: AgentsIndexPage,
});
function AgentsIndexPage() {
const session = useSession();
if (!session.data) return null;
return (
<AgentsShell user={session.data}>
<AgentsHome />
</AgentsShell>
);
}

View file

@ -3,5 +3,5 @@ import { Navigate, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: Index });
function Index() {
return <Navigate to="/my-settings" />;
return <Navigate to="/agents" />;
}

View file

@ -2,6 +2,7 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@import "./styles/agents.css";
@custom-variant dark (&:is(.dark *));

47
ui/src/styles/agents.css Normal file
View file

@ -0,0 +1,47 @@
.agents-ui {
--ui-bg: oklch(0.99 0.002 197.1);
--ui-surface: oklch(1 0 0);
--ui-panel: oklch(0.985 0.002 197.1);
--ui-panel-2: oklch(0.967 0.001 286.375);
--ui-border: oklch(0.925 0.005 214.3);
--ui-border-subtle: oklch(0.945 0.004 214.3);
--ui-text: oklch(0.148 0.004 228.8);
--ui-text-muted: oklch(0.45 0.017 213.2);
--ui-text-dim: oklch(0.56 0.021 213.5);
--ui-accent: oklch(0.52 0.105 223.128);
--ui-accent-bubble: oklch(0.955 0.015 223);
--ui-sidebar: oklch(0.987 0.002 197.1);
--ui-sidebar-hover: oklch(0.963 0.002 197.1);
--ui-success: oklch(0.62 0.17 145);
--ui-danger: oklch(0.577 0.245 27.325);
}
.agents-ui {
color: var(--ui-text);
background: var(--ui-bg);
font-size: 13px;
line-height: 1.45;
}
.agents-ui * {
scrollbar-width: thin;
scrollbar-color: oklch(0.85 0.01 214) transparent;
}
.agents-ui .shimmer-text {
background: linear-gradient(90deg, var(--ui-text-dim) 40%, var(--ui-accent) 50%, var(--ui-text-dim) 60%);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: agents-shimmer 4s linear infinite;
}
@keyframes agents-shimmer {
0% {
background-position: 200% center;
}
100% {
background-position: -200% center;
}
}

View file

@ -1,5 +1,15 @@
{
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
"exclude": [
"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",
"src/components/agents/ported/SourceControlTile.tsx",
"src/components/agents/ported/Footer.tsx",
"src/components/agents/ported/ThreadPicker.tsx"
],
"compilerOptions": {
"target": "ES2022",