mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
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:
parent
0ceca8d0db
commit
e87085139b
65 changed files with 8778 additions and 170 deletions
72
agent/_patch_messages_reducer.py
Normal file
72
agent/_patch_messages_reducer.py
Normal 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()
|
||||
238
agent/dashboard/message_adapter.py
Normal file
238
agent/dashboard/message_adapter.py
Normal 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
|
||||
|
|
@ -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"},
|
||||
)
|
||||
|
|
|
|||
441
agent/dashboard/thread_api.py
Normal file
441
agent/dashboard/thread_api.py
Normal 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"
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
68
agent/utils/thread_ops.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
71
tests/test_dashboard_message_adapter.py
Normal file
71
tests/test_dashboard_message_adapter.py
Normal 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."
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
82
tests/test_messages_reducer_patch.py
Normal file
82
tests/test_messages_reducer_patch.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
272
ui/bun.lock
272
ui/bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
94
ui/src/components/SidebarUserMenu.tsx
Normal file
94
ui/src/components/SidebarUserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
ui/src/components/agents/AgentGitPanel.tsx
Normal file
157
ui/src/components/agents/AgentGitPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
ui/src/components/agents/AgentPromptBar.tsx
Normal file
2
ui/src/components/agents/AgentPromptBar.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { CloudPromptBar as AgentPromptBar } from "@/components/agents/ported/CloudPromptBar";
|
||||
export type { CloudPromptBarProps as AgentPromptBarProps } from "@/components/agents/ported/CloudPromptBar";
|
||||
73
ui/src/components/agents/AgentRunCard.tsx
Normal file
73
ui/src/components/agents/AgentRunCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
ui/src/components/agents/AgentThreadView.tsx
Normal file
202
ui/src/components/agents/AgentThreadView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
ui/src/components/agents/AgentsHome.tsx
Normal file
67
ui/src/components/agents/AgentsHome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
ui/src/components/agents/AgentsSidebar.tsx
Normal file
176
ui/src/components/agents/AgentsSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
ui/src/components/agents/ported/BranchSelector.tsx
Normal file
188
ui/src/components/agents/ported/BranchSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
ui/src/components/agents/ported/BubblePrefix.tsx
Normal file
18
ui/src/components/agents/ported/BubblePrefix.tsx
Normal 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">></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>;
|
||||
}
|
||||
}
|
||||
591
ui/src/components/agents/ported/ChatView.tsx
Normal file
591
ui/src/components/agents/ported/ChatView.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
156
ui/src/components/agents/ported/CloudPromptBar.tsx
Normal file
156
ui/src/components/agents/ported/CloudPromptBar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
133
ui/src/components/agents/ported/CodeBlock.tsx
Normal file
133
ui/src/components/agents/ported/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
ui/src/components/agents/ported/CompactingIndicator.tsx
Normal file
22
ui/src/components/agents/ported/CompactingIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
ui/src/components/agents/ported/ContextIndicator.tsx
Normal file
136
ui/src/components/agents/ported/ContextIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
ui/src/components/agents/ported/DiffView.tsx
Normal file
316
ui/src/components/agents/ported/DiffView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
ui/src/components/agents/ported/Footer.tsx
Normal file
82
ui/src/components/agents/ported/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
ui/src/components/agents/ported/HeaderBar.tsx
Normal file
27
ui/src/components/agents/ported/HeaderBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
ui/src/components/agents/ported/Logo.tsx
Normal file
19
ui/src/components/agents/ported/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
ui/src/components/agents/ported/Markdown.tsx
Normal file
59
ui/src/components/agents/ported/Markdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
ui/src/components/agents/ported/MarkdownTable.tsx
Normal file
43
ui/src/components/agents/ported/MarkdownTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
890
ui/src/components/agents/ported/MessageView.tsx
Normal file
890
ui/src/components/agents/ported/MessageView.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
61
ui/src/components/agents/ported/PanelResizeHandle.tsx
Normal file
61
ui/src/components/agents/ported/PanelResizeHandle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
786
ui/src/components/agents/ported/PromptBar.tsx
Normal file
786
ui/src/components/agents/ported/PromptBar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
105
ui/src/components/agents/ported/ShellCommand.tsx
Normal file
105
ui/src/components/agents/ported/ShellCommand.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
1
ui/src/components/agents/ported/SourceControlPanel.tsx
Normal file
1
ui/src/components/agents/ported/SourceControlPanel.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SourceControlPanel } from './SourceControlTile';
|
||||
1198
ui/src/components/agents/ported/SourceControlTile.tsx
Normal file
1198
ui/src/components/agents/ported/SourceControlTile.tsx
Normal file
File diff suppressed because it is too large
Load diff
201
ui/src/components/agents/ported/ThreadPicker.tsx
Normal file
201
ui/src/components/agents/ported/ThreadPicker.tsx
Normal 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" />;
|
||||
}
|
||||
104
ui/src/components/agents/ported/TodoList.tsx
Normal file
104
ui/src/components/agents/ported/TodoList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
287
ui/src/components/agents/ported/ToolExecution.tsx
Normal file
287
ui/src/components/agents/ported/ToolExecution.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
14
ui/src/components/agents/ported/index.ts
Normal file
14
ui/src/components/agents/ported/index.ts
Normal 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";
|
||||
63
ui/src/components/agents/utils/diffUtils.ts
Normal file
63
ui/src/components/agents/utils/diffUtils.ts
Normal 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,
|
||||
};
|
||||
6
ui/src/components/agents/z-index.ts
Normal file
6
ui/src/components/agents/z-index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const Z = {
|
||||
FLOAT: 30,
|
||||
OVERLAY: 40,
|
||||
DROPDOWN: 50,
|
||||
MODAL: 60,
|
||||
} as const;
|
||||
158
ui/src/components/sidebar-layout.tsx
Normal file
158
ui/src/components/sidebar-layout.tsx
Normal 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
107
ui/src/lib/agents/api.ts
Normal 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;
|
||||
}
|
||||
53
ui/src/lib/agents/pendingPrompts.ts
Normal file
53
ui/src/lib/agents/pendingPrompts.ts
Normal 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;
|
||||
}
|
||||
111
ui/src/lib/agents/queries.ts
Normal file
111
ui/src/lib/agents/queries.ts
Normal 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
169
ui/src/lib/agents/types.ts
Normal 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;
|
||||
}
|
||||
69
ui/src/lib/agents/useModelOptions.ts
Normal file
69
ui/src/lib/agents/useModelOptions.ts
Normal 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}`;
|
||||
}
|
||||
167
ui/src/lib/agents/useThreadStream.ts
Normal file
167
ui/src/lib/agents/useThreadStream.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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
32
ui/src/routes/agents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
ui/src/routes/agents/$threadId.tsx
Normal file
33
ui/src/routes/agents/$threadId.tsx
Normal 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} />;
|
||||
}
|
||||
20
ui/src/routes/agents/index.tsx
Normal file
20
ui/src/routes/agents/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
47
ui/src/styles/agents.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue