diff --git a/agent/dashboard/plan_api.py b/agent/dashboard/plan_api.py new file mode 100644 index 00000000..8dc271cb --- /dev/null +++ b/agent/dashboard/plan_api.py @@ -0,0 +1,188 @@ +"""REST API for the plan-review page: read the plan, approve, or request changes. + +Comment threads themselves live in the collaborative Yjs document (BlockNote's +``YjsThreadStore``) and are harvested client-side; on approve/reject the client +sends the harvested feedback here, where it is formatted and handed to the agent +as the instruction for the follow-up run. The agent never sees comments during +review — only this aggregated feedback at the decision point. + +Permissions: any authenticated org member can read a surfaced thread and request +changes (reject); only the thread owner can approve. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from langgraph_sdk import get_client +from pydantic import BaseModel, Field + +from .oauth import require_same_origin_for_mutations, require_session +from .plan_store import ( + PLAN_STATUS_APPROVED, + PLAN_STATUS_REVISING, + get_plan_content, + set_plan_status, +) +from .thread_api import ( + _repo_config_from_metadata, + _thread_is_readable, + _thread_source, + _user_owns_thread, +) + +logger = logging.getLogger(__name__) + +plan_router = APIRouter( + prefix="/dashboard/api/plan", + tags=["plan"], + dependencies=[Depends(require_same_origin_for_mutations)], +) +_SESSION_DEP = Depends(require_session) + + +class PlanComment(BaseModel): + author: str | None = None + body: str = "" + quote: str | None = None + resolved: bool = False + + +class PlanDecisionBody(BaseModel): + comments: list[PlanComment] = Field(default_factory=list) + + +async def _thread_metadata(thread_id: str) -> dict[str, Any]: + client = get_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, dict) else getattr(thread, "metadata", None) + ) + return metadata if isinstance(metadata, dict) else {} + + +@plan_router.get("/{thread_id}") +async def get_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]: + metadata = await _thread_metadata(thread_id) + if not _thread_is_readable(metadata): + raise HTTPException(404, "thread not found") + login = session["sub"] + email = session.get("email") + content = await get_plan_content(thread_id) or {} + return { + "threadId": thread_id, + "status": content.get("status") or metadata.get("plan_status") or "planning", + "markdown": content.get("markdown", ""), + "isOwner": _user_owns_thread(metadata, login, email), + "user": { + "id": login, + "login": login, + "email": email, + "name": session.get("name") or login, + }, + } + + +@plan_router.post("/{thread_id}/approve") +async def approve_plan( + thread_id: str, + body: PlanDecisionBody, + session: dict[str, Any] = _SESSION_DEP, +) -> dict[str, Any]: + metadata = await _thread_metadata(thread_id) + if not _user_owns_thread(metadata, session["sub"], session.get("email")): + raise HTTPException(403, "only the plan owner can approve") + await set_plan_status(thread_id, PLAN_STATUS_APPROVED, plan_mode=False) + feedback = _format_comments(body.comments) + if feedback: + text = ( + "The plan has been approved. Implement it now, taking this reviewer " + f"feedback into account:\n\n{feedback}" + ) + else: + text = "The plan has been approved. Implement it now as described in the plan." + await _dispatch_followup(thread_id, metadata, text, plan_mode=False) + return {"status": PLAN_STATUS_APPROVED} + + +@plan_router.post("/{thread_id}/reject") +async def reject_plan( + thread_id: str, + body: PlanDecisionBody, + session: dict[str, Any] = _SESSION_DEP, +) -> dict[str, Any]: + metadata = await _thread_metadata(thread_id) + if not _thread_is_readable(metadata): + raise HTTPException(404, "thread not found") + await set_plan_status(thread_id, PLAN_STATUS_REVISING, plan_mode=True) + feedback = _format_comments(body.comments) + text = ( + "The plan needs changes before implementation. Address this reviewer " + "feedback and publish an updated plan with the save_plan tool:\n\n" + f"{feedback or '(no specific comments were left)'}" + ) + await _dispatch_followup(thread_id, metadata, text, plan_mode=True) + return {"status": PLAN_STATUS_REVISING} + + +def _format_comments(comments: list[PlanComment]) -> str: + lines: list[str] = [] + for index, comment in enumerate(comments, start=1): + body = comment.body.strip() + if not body: + continue + author = (comment.author or "reviewer").strip() + quote = (comment.quote or "").strip() + status = " (resolved)" if comment.resolved else "" + if quote: + lines.append(f'{index}. On "{quote}"{status}:\n - {author}: {body}') + else: + lines.append(f"{index}. {author}{status}: {body}") + return "\n".join(lines) + + +async def _dispatch_followup( + thread_id: str, metadata: dict[str, Any], text: str, *, plan_mode: bool +) -> None: + """Continue the existing thread with a new instruction run. + + Runs on the same LangGraph thread, so the agent resumes from the checkpoint + with the full planning history plus this instruction. The configurable is + rebuilt from the thread's stored owner/repo/Slack context so the agent can + push, open a PR, and reply in the original channel. + """ + configurable: dict[str, Any] = { + "thread_id": thread_id, + "source": _thread_source(metadata) or "slack", + } + email = metadata.get("triggering_user_email") + if isinstance(email, str) and email: + configurable["user_email"] = email + login = metadata.get("github_login") + if isinstance(login, str) and login: + configurable["github_login"] = login + repo = _repo_config_from_metadata(metadata) + if repo: + configurable["repo"] = repo + source_context = metadata.get("source_context") + if isinstance(source_context, dict): + slack_thread = source_context.get("slack_thread") + if isinstance(slack_thread, dict): + configurable["slack_thread"] = slack_thread + # Carry the decision to the follow-up run: approve continues out of plan + # mode (implement), reject stays in plan mode (revise the plan). + configurable["plan_mode"] = plan_mode + + client = get_client() + await client.runs.create( + thread_id, + "agent", + input={"messages": [{"role": "user", "content": text}]}, + config={"configurable": configurable}, + if_not_exists="create", + ) diff --git a/agent/dashboard/plan_collab.py b/agent/dashboard/plan_collab.py new file mode 100644 index 00000000..5810e5db --- /dev/null +++ b/agent/dashboard/plan_collab.py @@ -0,0 +1,178 @@ +"""Yjs collaboration server for the plan-review document. + +A single :class:`WebsocketServer` (started for the app lifetime via +:func:`collab_lifespan`) relays Yjs sync/awareness frames between the reviewers +connected to a plan. Each plan is a room keyed by ``thread_id``; the room's +document is seeded from / snapshotted to the LangGraph store so a plan and its +comment threads survive a restart. + +The browser connects with the standard ``y-websocket`` client, which appends the +room name to the provider URL — hence the ``/yjs/{thread_id}`` route shape. + +Any user who can read the thread can both edit the plan and comment — there is +no document-level owner/commenter split (only plan *approval* is owner-gated, in +the REST API). So relaying frames to every readable connection is intentional. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from langgraph_sdk import get_client +from pycrdt.websocket import WebsocketServer, YRoom +from pycrdt.websocket.websocket import HttpxWebsocket + +from .oauth import COOKIE_NAME, _origin_of, allowed_dashboard_origins, decode_session +from .plan_store import load_yjs_snapshot, save_yjs_snapshot +from .thread_api import _thread_is_readable + +logger = logging.getLogger(__name__) + +collab_router = APIRouter(prefix="/dashboard/api/plan", tags=["plan-collab"]) + +_SNAPSHOT_DEBOUNCE_SECONDS = 1.5 + +_server: WebsocketServer | None = None +_dirty: set[str] = set() +# Per-room state, alive only while at least one reviewer is connected. +_connections: dict[str, int] = {} +_flushers: dict[str, asyncio.Task[None]] = {} +_subscriptions: dict[str, Any] = {} + + +@asynccontextmanager +async def collab_lifespan() -> AsyncIterator[None]: + """Run the Yjs collaboration server for the app's lifetime.""" + global _server + async with WebsocketServer(auto_clean_rooms=False) as server: + _server = server + try: + yield + finally: + _server = None + for task in _flushers.values(): + task.cancel() + _flushers.clear() + _subscriptions.clear() + _connections.clear() + _dirty.clear() + + +def _session_login(websocket: WebSocket) -> str | None: + token = websocket.cookies.get(COOKIE_NAME) + if not token: + return None + try: + session = decode_session(token) + except Exception: + return None + sub = session.get("sub") if isinstance(session, dict) else None + return sub if isinstance(sub, str) and sub else None + + +def _origin_allowed(websocket: WebSocket) -> bool: + """Same-origin (CSRF) gate for the WS handshake, mirroring the REST + ``require_same_origin`` check: a no-op when no dashboard origins are + configured (local/dev), otherwise the handshake ``Origin`` must be in the + allowlist so a malicious site can't open a cross-site socket on the + victim's cookie.""" + allowed = allowed_dashboard_origins() + if not allowed: + return True + origin = _origin_of(websocket.headers.get("origin", "") or "") + return bool(origin) and origin in allowed + + +async def _can_read_thread(thread_id: str) -> bool: + """Same read gate as the plan REST API: only surfaced threads are joinable.""" + try: + thread = await get_client().threads.get(thread_id) + except Exception: + return False + metadata = ( + thread.get("metadata") if isinstance(thread, dict) else getattr(thread, "metadata", None) + ) + return _thread_is_readable(metadata if isinstance(metadata, dict) else {}) + + +async def _open_room(server: WebsocketServer, thread_id: str) -> None: + """Seed the room from the store and start its snapshot flusher (idempotent + while connected).""" + if thread_id in _flushers: + return + room = await server.get_room(thread_id) + snapshot = await load_yjs_snapshot(thread_id) + if snapshot: + try: + room.ydoc.apply_update(snapshot) + except Exception: + logger.warning("Failed to seed plan doc %s from snapshot", thread_id, exc_info=True) + + def _on_change(_event: object) -> None: + _dirty.add(thread_id) + + _subscriptions[thread_id] = room.ydoc.observe(_on_change) + _flushers[thread_id] = asyncio.create_task(_flush_loop(thread_id, room)) + + +async def _close_room(thread_id: str) -> None: + """Tear down a room's flusher + observer once the last reviewer leaves.""" + task = _flushers.pop(thread_id, None) + _subscriptions.pop(thread_id, None) + if task is not None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + if _server is not None and thread_id in _server.rooms: + await _persist(thread_id, _server.rooms[thread_id]) + _dirty.discard(thread_id) + + +async def _flush_loop(thread_id: str, room: YRoom) -> None: + while True: + await asyncio.sleep(_SNAPSHOT_DEBOUNCE_SECONDS) + if thread_id in _dirty: + _dirty.discard(thread_id) + await _persist(thread_id, room) + + +async def _persist(thread_id: str, room: YRoom) -> None: + try: + await save_yjs_snapshot(thread_id, bytes(room.ydoc.get_update())) + except Exception: + logger.warning("Failed to snapshot plan doc %s", thread_id, exc_info=True) + + +@collab_router.websocket("/yjs/{thread_id}") +async def plan_yjs_socket(websocket: WebSocket, thread_id: str) -> None: + if _session_login(websocket) is None: + await websocket.close(code=4401) + return + if not _origin_allowed(websocket): + await websocket.close(code=4403) + return + if not await _can_read_thread(thread_id): + await websocket.close(code=4403) + return + if _server is None: + await websocket.close(code=1013) # try again later + return + + await websocket.accept() + await _open_room(_server, thread_id) + _connections[thread_id] = _connections.get(thread_id, 0) + 1 + channel = HttpxWebsocket(websocket, thread_id) + try: + with contextlib.suppress(WebSocketDisconnect): + await _server.serve(channel) + finally: + _connections[thread_id] = _connections.get(thread_id, 1) - 1 + if _connections.get(thread_id, 0) <= 0: + _connections.pop(thread_id, None) + await _close_room(thread_id) diff --git a/agent/dashboard/plan_store.py b/agent/dashboard/plan_store.py new file mode 100644 index 00000000..d130fa34 --- /dev/null +++ b/agent/dashboard/plan_store.py @@ -0,0 +1,114 @@ +"""Persistence for the collaborative plan-review feature. + +The plan lives in two places: + - the agent's sandbox, as a real ``plan.md`` file (written by the ``save_plan`` + tool — the source artifact the agent produces and can re-read), and + - the LangGraph store, as the published snapshot the dashboard renders and the + seed for the collaborative Yjs document. + +Comment threads live in the Yjs document (BlockNote's ``YjsThreadStore``); the +binary Yjs state is snapshotted here so a plan + its comments survive a sandbox +teardown or a server restart. +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from langgraph_sdk import get_client + +PLAN_CONTENT_NAMESPACE = ["plan", "content"] +PLAN_YJS_NAMESPACE = ["plan", "yjs"] + +# Plan lifecycle, stored on both the content record and the thread metadata. +PLAN_STATUS_PLANNING = "planning" +PLAN_STATUS_READY = "ready" +PLAN_STATUS_REVISING = "revising" +PLAN_STATUS_APPROVED = "approved" +PLAN_STATUS_CANCELLED = "cancelled" + + +def _client() -> Any: + return get_client() + + +def _item_value(item: Any) -> dict[str, Any] | None: + if item is None: + return None + value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) + return value if isinstance(value, dict) else None + + +async def save_plan_content( + thread_id: str, *, markdown: str, status: str = PLAN_STATUS_READY +) -> None: + """Publish the plan markdown + status for the dashboard to render.""" + client = _client() + await client.store.put_item( + PLAN_CONTENT_NAMESPACE, + thread_id, + {"markdown": markdown, "status": status}, + ) + await _merge_thread_metadata(thread_id, {"plan_status": status, "plan_mode": True}) + + +async def get_plan_content(thread_id: str) -> dict[str, Any] | None: + client = _client() + try: + item = await client.store.get_item(PLAN_CONTENT_NAMESPACE, thread_id) + except Exception: + return None + return _item_value(item) + + +async def set_plan_status(thread_id: str, status: str, *, plan_mode: bool | None = None) -> None: + """Update the plan lifecycle status on both the content record and metadata.""" + existing = await get_plan_content(thread_id) or {} + client = _client() + await client.store.put_item( + PLAN_CONTENT_NAMESPACE, + thread_id, + {"markdown": existing.get("markdown", ""), "status": status}, + ) + metadata: dict[str, Any] = {"plan_status": status} + if plan_mode is not None: + metadata["plan_mode"] = plan_mode + await _merge_thread_metadata(thread_id, metadata) + + +async def load_yjs_snapshot(thread_id: str) -> bytes | None: + client = _client() + try: + item = await client.store.get_item(PLAN_YJS_NAMESPACE, thread_id) + except Exception: + return None + value = _item_value(item) + if not value: + return None + encoded = value.get("b64") + if not isinstance(encoded, str) or not encoded: + return None + try: + return base64.b64decode(encoded) + except (ValueError, TypeError): + return None + + +async def save_yjs_snapshot(thread_id: str, data: bytes) -> None: + client = _client() + await client.store.put_item( + PLAN_YJS_NAMESPACE, + thread_id, + {"b64": base64.b64encode(data).decode("ascii")}, + ) + + +async def _merge_thread_metadata(thread_id: str, metadata: dict[str, Any]) -> None: + client = _client() + try: + await client.threads.update(thread_id=thread_id, metadata=metadata) + except Exception: + # The thread always exists by the time a plan is saved (the run created + # it); a transient update failure must not crash the agent mid-run. + pass diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index 10a91b1c..cdad0161 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -114,6 +114,7 @@ class ThreadMessageBody(BaseModel): images: list[DashboardImageBody] = Field(default_factory=list) model_id: str | None = None effort: str | None = None + plan_mode: bool = False class ThreadResolveBody(BaseModel): @@ -358,6 +359,8 @@ def _thread_summary( "branch": metadata.get("branch_name") or metadata.get("base_branch") or "main", "model": model, "effort": effort, + "planMode": metadata.get("plan_mode") is True, + "planStatus": metadata.get("plan_status"), "source": _thread_source(metadata), "status": status, "viewed": _is_thread_viewed(metadata, latest_run_id), @@ -913,6 +916,7 @@ async def _create_dashboard_thread_record( title: str | None = None, model_id: str | None = None, effort: str | None = None, + plan_mode: bool = False, ) -> dict[str, Any]: """Create or update dashboard thread metadata without starting a run.""" profile = await get_profile(login) or {} @@ -937,6 +941,7 @@ async def _create_dashboard_thread_record( "effort": metadata_effort, "resolved_model": resolved_model, "resolved_effort": resolved_effort, + "plan_mode": plan_mode, "created_at_ms": now_ms, "updated_at_ms": now_ms, } @@ -985,6 +990,8 @@ async def _build_dashboard_configurable( if isinstance(source_context, dict): for key, value in source_context.items(): configurable.setdefault(key, value) + if metadata.get("plan_mode") is True: + configurable["plan_mode"] = True if overrides: for key, value in overrides.items(): if value is not None: @@ -1123,6 +1130,7 @@ async def _enrich_run_start_command( client_configurable.get("agent_model_id"), client_configurable.get("agent_effort"), ) + plan_mode_requested = client_configurable.get("plan_mode") is True content = _command_message_content(params) overrides: dict[str, Any] = {} @@ -1142,6 +1150,7 @@ async def _enrich_run_start_command( images=_dashboard_images_from_content(content), model_id=client_configurable.get("agent_model_id"), effort=client_configurable.get("agent_effort"), + plan_mode=plan_mode_requested, ) metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata if chosen_model and chosen_effort: @@ -1152,7 +1161,7 @@ async def _enrich_run_start_command( prefix = _attribution_prefix(metadata, login, email) if prefix: _set_command_last_message_content(params, _prefix_message_content(content, prefix)) - metadata_update: dict[str, Any] = {} + metadata_update: dict[str, Any] = {"plan_mode": plan_mode_requested} if chosen_model and chosen_effort: overrides["agent_model_id"] = chosen_model overrides["agent_effort"] = chosen_effort @@ -1202,7 +1211,11 @@ async def send_dashboard_message( prompt = f"{_attribution_prefix(metadata, login, email)}{body.content.strip()}" now_ms = _now_ms() chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort) - metadata_update: dict[str, Any] = {"source": _DASHBOARD_SOURCE, "updated_at_ms": now_ms} + metadata_update: dict[str, Any] = { + "source": _DASHBOARD_SOURCE, + "updated_at_ms": now_ms, + "plan_mode": body.plan_mode, + } if chosen_model and chosen_effort: metadata_update["model"] = chosen_model metadata_update["effort"] = chosen_effort diff --git a/agent/middleware/__init__.py b/agent/middleware/__init__.py index 78c8a8b5..c27d3982 100644 --- a/agent/middleware/__init__.py +++ b/agent/middleware/__init__.py @@ -2,6 +2,7 @@ from .check_message_queue import check_message_queue_before_model from .exclude_tools import ExcludeToolsMiddleware from .model_fallback import ModelFallbackMiddleware from .notify_step_limit import notify_step_limit_reached +from .plan_mode import PlanModeMiddleware from .refresh_github_proxy import refresh_github_proxy_before_model from .refresh_slack_status import SlackAssistantStatusMiddleware from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware @@ -14,6 +15,7 @@ from .tool_error_handler import ToolErrorMiddleware __all__ = [ "ExcludeToolsMiddleware", "ModelFallbackMiddleware", + "PlanModeMiddleware", "SanitizeThinkingBlocksMiddleware", "SanitizeToolInputsMiddleware", "ToolArtifactMiddleware", diff --git a/agent/middleware/plan_mode.py b/agent/middleware/plan_mode.py new file mode 100644 index 00000000..342fcd40 --- /dev/null +++ b/agent/middleware/plan_mode.py @@ -0,0 +1,83 @@ +"""Plan-mode tool gating. + +Hides the mutating tools whenever plan mode is active — either when the run +starts in plan mode (the per-thread ``plan_mode`` carried in configurable, e.g. +a reject re-dispatch) OR after the model calls ``enter_plan_mode`` mid-run, which +sets ``plan_mode`` in the run state. Installed unconditionally so self-activation +actually restricts the *next* model turn (the tool list is recomputed on every +model call), rather than only affecting a future run. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any, NotRequired + +from langchain.agents.middleware.types import ( + AgentMiddleware, + AgentState, + ModelRequest, + ModelResponse, +) +from langchain_core.tools import BaseTool + + +class PlanModeState(AgentState): + # Declared so ``enter_plan_mode``'s Command update is a tracked channel that + # this middleware can read back from ``request.state``. + plan_mode: NotRequired[bool] + + +def _tool_name(tool: BaseTool | dict[str, Any] | Any) -> str | None: + if isinstance(tool, dict): + name = tool.get("name") + return name if isinstance(name, str) else None + name = getattr(tool, "name", None) + return name if isinstance(name, str) else None + + +class PlanModeMiddleware(AgentMiddleware): + """Strip mutating tools from each model request while plan mode is active.""" + + state_schema = PlanModeState + + def __init__(self, *, excluded: frozenset[str], initial: bool = False) -> None: + self._excluded = excluded + self._initial = initial + + def before_agent(self, state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ARG002 + # Reset plan_mode to the value resolved for THIS run so a stale ``True`` + # left in the thread state by a previous run's ``enter_plan_mode`` does + # not silently force a later (e.g. approved/implementing) run back into + # plan mode. ``enter_plan_mode`` can still flip it on within this run. + return {"plan_mode": self._initial} + + def _active(self, request: ModelRequest) -> bool: + if self._initial: + return True + state = getattr(request, "state", None) + if isinstance(state, dict): + return state.get("plan_mode") is True + return getattr(state, "plan_mode", None) is True + + def _filter(self, request: ModelRequest) -> ModelRequest: + if not self._excluded or not self._active(request): + return request + filtered = [t for t in request.tools if _tool_name(t) not in self._excluded] + if len(filtered) == len(request.tools): + return request + return request.override(tools=filtered) + + def wrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], ModelResponse], + ) -> ModelResponse: + return handler(self._filter(request)) + + async def awrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], Awaitable[ModelResponse]], + ) -> ModelResponse: + return await handler(self._filter(request)) diff --git a/agent/prompt.py b/agent/prompt.py index bee73037..45ee49d0 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -69,6 +69,67 @@ You are currently executing a software engineering task. You have access to: - Project-specific rules and conventions from the repository's `AGENTS.md` file (read after cloning — see Repository Setup)""" +PLAN_MODE_GUIDANCE_SECTION = """--- + +### Plan Mode + +If you believe the task would benefit from a structured implementation plan before writing any code — e.g. when the request is complex, touches many files, or has multiple valid approaches — call the `enter_plan_mode` tool. This is NOT triggered by the word "plan" appearing in the request; use your judgment about whether planning is genuinely warranted. Once plan mode is active, stay read-only: research the code, then record your plan with the `save_plan` tool (it writes `plan.md` and publishes the plan to a review page) and share the plan-review link with the user. The user reviews and approves the plan before you implement. + +Plan-review link for this conversation (share it with the user when you enter plan mode): {plan_review_url}""" + +PLAN_MODE_SECTION = """--- + +### Plan Mode (ACTIVE) + +**Plan mode is enabled for this run. This section supersedes any other instruction that tells you to edit code, commit, push, or open a pull request.** + +You are in a read-only research-and-planning phase. Your single deliverable is a clear, reviewable implementation plan saved with the `save_plan` tool — NOT code changes. The user (and any collaborators) review the plan on the plan-review page, leave inline comments, and approve it (or request changes); only then do you implement. + +**Plan-review link:** {plan_url} +Share this exact link with the user (via `slack_thread_reply` or `linear_comment`) right after you enter plan mode, so they know where to follow along, and again when the plan is ready for review. + +**You MUST NOT:** +- Edit, create, or delete any files in the repository (no `write_file`, no `edit_file`). +- Run any state-changing command via `execute` — no `git commit`, `git push`, `git checkout -b`, package installs, code generators, formatters that rewrite files, or anything that mutates the filesystem, git state, or remote services. Keep `execute` to read-only commands only. +- Commit, push, open or update a pull request, or call `request_pr_review`. +- Create, update, or delete Linear issues, or otherwise mutate external systems. + +**You MAY (read-only):** +- Clone the repo and read it: `read_file`, `ls`, `glob`, `grep`, and read-only `execute` commands (`git clone`, `git status`, `git log`, `git diff`, `cat`, `rg`, `ls`). +- Research the web with `web_search` / `fetch_url`. +- Ask the user clarifying questions via `slack_thread_reply` (Slack) or `linear_comment` (Linear) when the source channel is known. + +(The `task` subagent tool is disabled in plan mode because subagents would not inherit these read-only restrictions. Do your research directly with the read-only tools above.) + +**Workflow:** +1. **Explore** — Clone (if needed) and read the relevant code to understand existing patterns, the files involved, and constraints. Read aggressively; a good plan is grounded in the actual codebase, not assumptions. +2. **Clarify** — If the request is ambiguous or has multiple valid approaches, ask focused questions before finalizing the plan. +3. **Plan** — Write ONE recommended implementation plan and save it with the `save_plan` tool (pass the full Markdown as `plan_markdown`). Use this structure: + + ``` + ## Plan: + + ### Overview + <1-3 sentences on the approach and why.> + + ### Files to change + - `path/to/file` — + - ... + + ### Steps + 1. + 2. ... + + ### Risks & considerations + - + + ### Verification + - + ``` + +**Ending your turn:** After saving the plan with `save_plan`, post a brief completion message with the plan-review link via `slack_thread_reply` (Slack) or `linear_comment` (Linear), then stop. Explicitly invite the user to review the plan, comment, and approve it. Do not begin implementing — wait until the plan is approved (you will be re-invoked with the approval and any reviewer feedback).""" + + SELF_AWARENESS_SECTION = """--- ### About You @@ -443,6 +504,8 @@ def _render_repo_instructions_section(instructions: str | None) -> str: SYSTEM_PROMPT_TEMPLATE = ( WORKING_ENV_SECTION + TASK_OVERVIEW_SECTION + + PLAN_MODE_GUIDANCE_SECTION + + "{plan_mode_section}" + SELF_AWARENESS_SECTION + "{default_prompt_section}" + REPO_SETUP_SECTION @@ -471,6 +534,8 @@ def construct_system_prompt( triggering_user_identity: CollaboratorIdentity | None = None, create_prs: bool = False, default_repo: dict[str, str] | None = None, + plan_mode: bool = False, + plan_url: str | None = None, repo_custom_instructions: str | None = None, thread_url: str | None = None, corridor_enabled: bool = False, @@ -494,6 +559,12 @@ def construct_system_prompt( working_dir=working_dir, linear_project_id=linear_project_id or "", linear_issue_number=linear_issue_number or "", + plan_review_url=plan_url or "(the dashboard plan-review page)", + plan_mode_section=( + PLAN_MODE_SECTION.format(plan_url=plan_url or "(plan-review link unavailable)") + if plan_mode + else "" + ), default_prompt_section=default_prompt_section, corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "", pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "", diff --git a/agent/server.py b/agent/server.py index c89b455a..757a9466 100644 --- a/agent/server.py +++ b/agent/server.py @@ -41,7 +41,10 @@ from .dashboard.agent_overrides import ( ) from .dashboard.agent_usage import record_agent_thread_usage from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort -from .dashboard.team_settings import get_team_default_model_pair, get_team_default_repo +from .dashboard.team_settings import ( + get_team_default_model_pair, + get_team_default_repo, +) from .dashboard.user_mappings import email_for_login from .integrations.corridor_mcp import load_corridor_tools from .integrations.currents_tools import load_currents_tools @@ -50,6 +53,7 @@ from .integrations.langsmith import _configure_github_proxy from .integrations.langsmith_tools import load_langsmith_tools from .middleware import ( ModelFallbackMiddleware, + PlanModeMiddleware, SandboxCircuitBreakerMiddleware, SanitizeThinkingBlocksMiddleware, SanitizeToolInputsMiddleware, @@ -62,6 +66,7 @@ from .middleware import ( ) from .prompt import construct_system_prompt from .tools import ( + enter_plan_mode, fetch_url, http_request, linear_comment, @@ -73,6 +78,7 @@ from .tools import ( linear_update_issue, open_pull_request, request_pr_review, + save_plan, schedule_thread_wakeup, slack_read_thread_messages, slack_thread_reply, @@ -84,7 +90,7 @@ from .utils.authorship import ( OPEN_SWE_BOT_NAME, resolve_triggering_user_identity, ) -from .utils.dashboard_links import dashboard_thread_url +from .utils.dashboard_links import dashboard_plan_url, dashboard_thread_url from .utils.github_app import ( get_github_app_installation_token_with_expiry, ) @@ -477,6 +483,28 @@ DEFAULT_LLM_MAX_TOKENS = 64_000 DEFAULT_RECURSION_LIMIT = 9_999 MODEL_CALL_RECURSION_LIMIT = 5_000 # ~half the recursion limit to account for tool calls +# Mutating tools hidden from the model while plan mode is active so it can only +# research and propose a plan. `execute` stays available; plan-mode shell +# discipline (no mutating commands) is instructed via the system prompt rather +# than enforced. `http_request` is excluded because it can POST/PUT/PATCH/DELETE +# to external services — read-only web research goes through `web_search` / +# `fetch_url`. `task` is excluded because the general-purpose subagent is built +# with its own filesystem/PR/Linear tools and does not inherit this exclusion, so +# delegating to it would bypass the read-only intent. +PLAN_MODE_EXCLUDED_TOOLS: frozenset[str] = frozenset( + { + "write_file", + "edit_file", + "task", + "http_request", + "open_pull_request", + "request_pr_review", + "linear_create_issue", + "linear_update_issue", + "linear_delete_issue", + } +) + def _general_purpose_subagent(model: BaseChatModel) -> SubAgent: return { @@ -662,6 +690,19 @@ async def get_agent(config: RunnableConfig) -> Pregel: ) logger.info("Configured model fallback %s -> %s", model_id, fallback_model_id) + # Plan mode is entered only when the model decides to (the `enter_plan_mode` + # tool sets it in run state). The configurable value just carries that + # decision across a thread's messages and the approve/reject follow-ups; a + # fresh run with nothing set starts out of plan mode. + plan_mode = configurable.get("plan_mode") is True + if plan_mode: + logger.info("Plan mode enabled for thread %s", thread_id) + # Installed unconditionally and state-aware: it also restricts tools after a + # mid-run `enter_plan_mode` call, not just when plan mode is set up front. + plan_mode_middleware: list[Any] = [ + PlanModeMiddleware(excluded=PLAN_MODE_EXCLUDED_TOOLS, initial=plan_mode) + ] + source = ( configurable.get("source") if isinstance(configurable.get("source"), str) else "dashboard" ) @@ -675,6 +716,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: "model": model_id, "effort": profile_effort, "source": source, + "plan_mode": plan_mode, }, ) await record_agent_thread_usage( @@ -716,6 +758,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: triggering_user_identity=triggering_user_identity, create_prs=always_create_prs, default_repo=prompt_default_repo, + plan_mode=plan_mode, + plan_url=dashboard_plan_url(thread_id), repo_custom_instructions=repo_custom_instructions, thread_url=dashboard_thread_url(thread_id), corridor_enabled=bool(corridor_tools), @@ -724,6 +768,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: http_request, fetch_url, web_search, + enter_plan_mode, + save_plan, linear_comment, linear_create_issue, linear_delete_issue, @@ -753,6 +799,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: notify_step_limit_reached, SandboxCircuitBreakerMiddleware(), *fallback_middleware, + *plan_mode_middleware, SanitizeThinkingBlocksMiddleware(), ], ).with_config(config) diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index 041630a5..ef411c17 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -1,4 +1,5 @@ from .add_finding import add_finding +from .enter_plan_mode import enter_plan_mode from .fetch_url import fetch_url from .http_request import http_request from .linear_comment import linear_comment @@ -16,6 +17,7 @@ from .read_repo_file import read_repo_file from .reply_to_finding_thread import reply_to_finding_thread from .request_pr_review import request_pr_review from .resolve_finding_thread import resolve_finding_thread +from .save_plan import save_plan from .schedule_thread_wakeup import schedule_thread_wakeup from .search_repo_code import search_repo_code from .slack_read_thread_messages import slack_read_thread_messages @@ -25,6 +27,7 @@ from .web_search import web_search __all__ = [ "add_finding", + "enter_plan_mode", "fetch_url", "http_request", "linear_comment", @@ -42,6 +45,7 @@ __all__ = [ "request_pr_review", "reply_to_finding_thread", "resolve_finding_thread", + "save_plan", "schedule_thread_wakeup", "search_repo_code", "slack_read_thread_messages", diff --git a/agent/tools/enter_plan_mode.py b/agent/tools/enter_plan_mode.py new file mode 100644 index 00000000..9b3ab1c7 --- /dev/null +++ b/agent/tools/enter_plan_mode.py @@ -0,0 +1,62 @@ +"""Tool: ``enter_plan_mode``. Switch the run into read-only planning.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Annotated + +from langchain_core.messages import ToolMessage +from langchain_core.tools import InjectedToolCallId +from langgraph.config import get_config +from langgraph.types import Command + +from ..dashboard.plan_store import PLAN_STATUS_PLANNING, set_plan_status + +logger = logging.getLogger(__name__) + +_ENTERED_MESSAGE = ( + "Plan mode is active. Stay read-only: research the codebase, then record your " + "implementation plan with the `save_plan` tool (it publishes the plan to the " + "review page) and share the plan-review link in the source channel. Do not edit " + "files, commit, push, or open a PR — wait for the user to approve the plan." +) + + +def enter_plan_mode(tool_call_id: Annotated[str, InjectedToolCallId]) -> Command: + """Activate plan mode mid-run. + + Call this when you believe the task would benefit from a structured + implementation plan before writing any code — e.g. when the request is + complex, touches many files, or has multiple valid approaches. This is + NOT triggered by the word "plan" appearing in the request; use your + judgment about whether planning is genuinely warranted. + + Once activated, stay read-only: research the codebase, then record your plan + with the ``save_plan`` tool (it publishes the plan to the review page) and + share the plan-review link with the user. Do not edit files, commit, push, + or open a PR — the user reviews the plan and approves it before you + implement. + """ + thread_id = _thread_id_from_config() + if thread_id: + try: + asyncio.run(set_plan_status(thread_id, PLAN_STATUS_PLANNING, plan_mode=True)) + except Exception: + logger.warning("Failed to persist plan-mode entry for %s", thread_id, exc_info=True) + return Command( + update={ + "plan_mode": True, + "messages": [ToolMessage(content=_ENTERED_MESSAGE, tool_call_id=tool_call_id)], + } + ) + + +def _thread_id_from_config() -> str | None: + try: + config = get_config() + except Exception: + return None + configurable = config.get("configurable", {}) if isinstance(config, dict) else {} + thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None + return str(thread_id) if thread_id else None diff --git a/agent/tools/save_plan.py b/agent/tools/save_plan.py new file mode 100644 index 00000000..bab39953 --- /dev/null +++ b/agent/tools/save_plan.py @@ -0,0 +1,81 @@ +"""Tool: ``save_plan``. Record the implementation plan for review. + +Writes the plan as a real ``plan.md`` file in the sandbox (the artifact the +agent produces and can re-read) and publishes it to the plan-review page, where +the user and collaborators read it, comment inline, and approve or request +changes. Available in plan mode (it does not modify the repository under review). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from langgraph.config import get_config + +from ..dashboard.plan_store import PLAN_STATUS_READY, save_plan_content + +logger = logging.getLogger(__name__) + +PLAN_FILE_PATH = "plan.md" + + +def save_plan(plan_markdown: str) -> dict[str, Any]: + """Write your implementation plan as a markdown file and publish it for review. + + Use this in plan mode once your plan is ready. The plan is saved as + ``plan.md`` in the sandbox and published to the plan-review page linked in + the conversation, where the user (the owner) and any collaborators can read + it, leave inline comments, and then approve it or request changes. Call it + again to overwrite the plan with a revised version when addressing feedback. + + Write the plan in standard Markdown — headings, bullet/numbered lists, and + fenced code blocks all render. Structure it clearly (overview, files to + change, ordered steps, risks). + + Args: + plan_markdown: The full plan, as a Markdown document. + + Returns: + ``{success: True, path}`` on success, or ``{success: False, error}``. + """ + content = plan_markdown.strip() + if not content: + return {"success": False, "error": "plan_markdown cannot be empty"} + + try: + config = get_config() + except Exception: + config = {} + configurable = config.get("configurable", {}) if isinstance(config, dict) else {} + thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None + if not thread_id: + return {"success": False, "error": "no thread_id in run config"} + + try: + path = asyncio.run(_save(str(thread_id), content)) + except Exception as exc: # noqa: BLE001 + logger.exception("save_plan failed for thread %s", thread_id) + return {"success": False, "error": f"failed to save plan: {exc}"} + return {"success": True, "path": path} + + +async def _save(thread_id: str, content: str) -> str: + sandbox_path = await _write_to_sandbox(thread_id, content) + await save_plan_content(thread_id, markdown=content, status=PLAN_STATUS_READY) + return sandbox_path + + +async def _write_to_sandbox(thread_id: str, content: str) -> str: + """Write ``plan.md`` into the thread's sandbox. Best-effort: a missing sandbox + must not block publishing the plan to the review page.""" + try: + from ..utils.sandbox_state import get_sandbox_backend + + backend = await get_sandbox_backend(thread_id) + await backend.awrite(PLAN_FILE_PATH, content) + return PLAN_FILE_PATH + except Exception: + logger.warning("Could not write plan.md to sandbox for %s", thread_id, exc_info=True) + return PLAN_FILE_PATH diff --git a/agent/tools/slack_thread_reply.py b/agent/tools/slack_thread_reply.py index a65e2d1e..6bd5cb7d 100644 --- a/agent/tools/slack_thread_reply.py +++ b/agent/tools/slack_thread_reply.py @@ -21,6 +21,7 @@ def slack_thread_reply( message: str, options: list[str] | None = None, blocks: list[dict[str, Any]] | None = None, + plan_approval: bool = False, ) -> dict[str, Any]: """Post a message to the current Slack thread. @@ -39,6 +40,8 @@ def slack_thread_reply( render interactive buttons and the web UI will render the same choices. The user can still reply manually in the Slack thread. + To present a plan for approval with action buttons, pass plan_approval=True. This renders "Approve & Implement", "Revise Plan", and "Cancel" buttons. The plan itself should be posted to the dashboard thread; use this to post a summary with a link to the dashboard thread view where the full plan can be reviewed. + To mention/tag a user, use Slack's mention format: <@USER_ID>. You can find user IDs in the conversation context (e.g. @Name(U06KD8BFY95)). Example: <@U06KD8BFY95> will tag that user in the message.""" @@ -58,7 +61,10 @@ def slack_thread_reply( return {"success": False, "error": "Message cannot be empty"} message = convert_mentions_to_slack_format(message) - slack_blocks = blocks or _build_option_blocks(message, options) + if plan_approval: + slack_blocks = _build_plan_approval_blocks(message) + else: + slack_blocks = blocks or _build_option_blocks(message, options) message_ts, slack_error = asyncio.run( _post_and_store_mapping(channel_id, thread_ts, message, blocks=slack_blocks) ) @@ -96,6 +102,37 @@ def _build_option_blocks(message: str, options: list[str] | None) -> list[dict[s ] +def _build_plan_approval_blocks(message: str) -> list[dict[str, Any]]: + return [ + {"type": "section", "text": {"type": "mrkdwn", "text": message}}, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Approve & Implement", "emoji": True}, + "style": "primary", + "value": json.dumps({"type": "plan_approval", "action": "approve"}), + "action_id": "open_swe_option_select", + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Revise Plan", "emoji": True}, + "value": json.dumps({"type": "plan_approval", "action": "revise"}), + "action_id": "open_swe_option_select", + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Cancel", "emoji": True}, + "style": "danger", + "value": json.dumps({"type": "plan_approval", "action": "cancel"}), + "action_id": "open_swe_option_select", + }, + ], + }, + ] + + def _slack_reply_failure_hint(slack_error: str | None) -> str: if slack_error == "msg_too_long": return "Slack rejected the message as too long; retry with a shorter message." diff --git a/agent/utils/dashboard_links.py b/agent/utils/dashboard_links.py index a343a4db..5bfab9bc 100644 --- a/agent/utils/dashboard_links.py +++ b/agent/utils/dashboard_links.py @@ -18,6 +18,14 @@ def dashboard_thread_url(thread_id: str) -> str | None: return f"{base_url}/agents/{quote(thread_id, safe='')}" +def dashboard_plan_url(thread_id: str) -> str | None: + """Build the dashboard plan-review URL for a given thread id.""" + base_url = _dashboard_base_url() + if not base_url or not thread_id: + return None + return f"{base_url}/agents/{quote(thread_id, safe='')}/plan" + + def dashboard_review_url(owner: str, repo: str, pr_number: int) -> str | None: """Build the dashboard review-detail URL for a PR.""" base_url = _dashboard_base_url() diff --git a/agent/utils/langsmith.py b/agent/utils/langsmith.py index c0af0924..9071cb22 100644 --- a/agent/utils/langsmith.py +++ b/agent/utils/langsmith.py @@ -33,54 +33,50 @@ def _build_prod_langsmith_client() -> LangSmithClient | None: def _resolve_project_id_by_name(project_name: str) -> str | None: - """Resolve a LangSmith project id from its name, caching successful lookups.""" - cached = _PROJECT_ID_CACHE.get(project_name) - if cached: - return cached + """Resolve a LangSmith project id from its name, caching both successful and + failed lookups so an unconfigured/unauthorized tenant isn't re-queried per call.""" + if project_name in _PROJECT_ID_CACHE: + return _PROJECT_ID_CACHE[project_name] or None client = _build_prod_langsmith_client() if client is None: return None try: project = client.read_project(project_name=project_name) except LangSmithNotFoundError: + _PROJECT_ID_CACHE[project_name] = "" return None except Exception: # noqa: BLE001 - logger.warning("Failed to resolve LangSmith project id for %s", project_name, exc_info=True) + logger.debug("Could not resolve LangSmith project id for %s", project_name) + _PROJECT_ID_CACHE[project_name] = "" return None project_id = getattr(project, "id", None) - if not project_id: - return None - resolved = str(project_id) + resolved = str(project_id) if project_id else "" _PROJECT_ID_CACHE[project_name] = resolved - return resolved + return resolved or None -def _compose_langsmith_project_url(project_name: str = AGENT_TRACING_PROJECT) -> str: - """Build the LangSmith project URL base for a given tracing project name.""" - host_url = os.environ.get("LANGSMITH_URL_PROD", "https://smith.langchain.com") +def _compose_langsmith_project_url(project_name: str = AGENT_TRACING_PROJECT) -> str | None: + """Build the LangSmith project URL base, or None when tracing isn't configured + for the prod tenant. Bails before any API call when the tenant id is unset.""" tenant_id = os.environ.get("LANGSMITH_TENANT_ID_PROD") + if not tenant_id: + return None + host_url = os.environ.get("LANGSMITH_URL_PROD", "https://smith.langchain.com") project_id = _resolve_project_id_by_name(project_name) or os.environ.get( "LANGSMITH_TRACING_PROJECT_ID_PROD" ) - if not tenant_id or not project_id: - raise ValueError( - "LANGSMITH_TENANT_ID_PROD must be set and the tracing project id must be resolvable" - ) + if not project_id: + return None return f"{host_url}/o/{tenant_id}/projects/p/{project_id}" def get_langsmith_trace_url( thread_id: str, project_name: str = AGENT_TRACING_PROJECT ) -> str | None: - """Build the LangSmith thread URL for a given thread ID.""" - try: - project_url = _compose_langsmith_project_url(project_name) - return f"{project_url}/t/{thread_id}" - except Exception: # noqa: BLE001 - logger.warning( - "Failed to build LangSmith trace URL for thread %s", thread_id, exc_info=True - ) - return None + """Build the LangSmith thread URL for a given thread ID, or None if tracing + isn't configured. This is a best-effort convenience link, not an error path.""" + project_url = _compose_langsmith_project_url(project_name) + return f"{project_url}/t/{thread_id}" if project_url else None def _build_langsmith_feedback_clients() -> tuple[LangSmithClient, ...]: diff --git a/agent/webapp.py b/agent/webapp.py index 575885d4..ba13bc70 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -131,12 +131,14 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + from .dashboard.plan_collab import collab_lifespan from .utils.model import validate_local_dev_llm_config from .utils.sandbox import validate_sandbox_startup_config validate_sandbox_startup_config() validate_local_dev_llm_config() - yield + async with collab_lifespan(): + yield app = FastAPI(lifespan=lifespan) @@ -159,6 +161,12 @@ if DASHBOARD_ALLOWED_ORIGINS: app.include_router(dashboard_router) +from .dashboard.plan_api import plan_router # noqa: E402 +from .dashboard.plan_collab import collab_router # noqa: E402 + +app.include_router(plan_router) +app.include_router(collab_router) + LINEAR_WEBHOOK_SECRET = os.environ.get("LINEAR_WEBHOOK_SECRET", "") GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "") SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "") @@ -691,6 +699,68 @@ async def _ensure_thread_exists_for_metadata( return False +async def _slack_user_is_thread_owner(thread_id: str, slack_user_id: str) -> bool: + """Whether the clicking Slack user is the user who requested the plan. + + Plan approval is owner-only (mirrors the dashboard plan API's + ``_user_owns_thread`` gate). The original requester's Slack id is stored in + ``source_context.slack_thread.triggering_user_id`` when the run is created. + Fails closed when ownership can't be determined. + """ + if not slack_user_id: + return False + langgraph_client = get_client(url=LANGGRAPH_URL) + try: + thread = await langgraph_client.threads.get(thread_id) + except Exception: # noqa: BLE001 + return False + metadata = thread.get("metadata") if isinstance(thread, dict) else None + if not isinstance(metadata, dict): + return False + source_context = metadata.get("source_context") + slack_thread = source_context.get("slack_thread") if isinstance(source_context, dict) else None + owner_id = slack_thread.get("triggering_user_id") if isinstance(slack_thread, dict) else None + return isinstance(owner_id, str) and bool(owner_id) and owner_id == slack_user_id + + +async def _get_thread_plan_mode(thread_id: str) -> bool | None: + """Return the persisted plan-mode flag for a thread, or ``None`` if unset.""" + langgraph_client = get_client(url=LANGGRAPH_URL) + try: + thread = await langgraph_client.threads.get(thread_id) + except Exception as exc: # noqa: BLE001 + if _is_not_found_error(exc): + return None + logger.warning("Failed to fetch plan-mode metadata for thread %s", thread_id) + return None + metadata = thread.get("metadata") if isinstance(thread, dict) else None + if not isinstance(metadata, dict): + return None + value = metadata.get("plan_mode") + return value if isinstance(value, bool) else None + + +async def _set_thread_plan_mode(thread_id: str, enabled: bool) -> None: + """Persist the plan-mode flag onto thread metadata.""" + langgraph_client = get_client(url=LANGGRAPH_URL) + try: + await langgraph_client.threads.update( + thread_id=thread_id, metadata={"plan_mode": bool(enabled)} + ) + except Exception as exc: # noqa: BLE001 + if _is_not_found_error(exc): + try: + await langgraph_client.threads.create( + thread_id=thread_id, + if_exists="do_nothing", + metadata={"plan_mode": bool(enabled)}, + ) + except Exception: # noqa: BLE001 + logger.exception("Failed to create thread %s while persisting plan_mode", thread_id) + return + logger.exception("Failed to persist plan_mode for thread %s", thread_id) + + async def process_linear_issue( # noqa: PLR0912, PLR0915 issue_data: dict[str, Any], repo_config: dict[str, str] ) -> None: @@ -1173,6 +1243,10 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st if mapped_login: configurable["github_login"] = mapped_login + thread_plan_mode = await _get_thread_plan_mode(thread_id) + if thread_plan_mode is not None: + configurable["plan_mode"] = thread_plan_mode + langgraph_client = get_client(url=LANGGRAPH_URL) is_first_mention = not await _thread_exists(thread_id) await _upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client) @@ -1566,6 +1640,56 @@ async def slack_interactivity( action_value = json.loads(str(action.get("value") or "{}")) except json.JSONDecodeError: return {"status": "ignored", "reason": "Invalid action value"} + if action_value.get("type") == "plan_approval": + plan_action = str(action_value.get("action") or "").strip() + channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {} + message = payload.get("message") if isinstance(payload.get("message"), dict) else {} + container = payload.get("container") if isinstance(payload.get("container"), dict) else {} + user = payload.get("user") if isinstance(payload.get("user"), dict) else {} + channel_id = str(channel.get("id") or container.get("channel_id") or "") + thread_ts = str( + message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or "" + ) + user_id = str(user.get("id") or "") + if not channel_id or not thread_ts: + return {"status": "ignored", "reason": "Missing Slack action context"} + + thread_id = generate_thread_id_from_slack_thread(channel_id, thread_ts) + + if plan_action == "cancel": + await post_slack_thread_reply( + channel_id=channel_id, + thread_ts=thread_ts, + text="Plan cancelled. No changes will be made.", + ) + return {"status": "accepted", "message": "Plan cancelled"} + + if plan_action == "approve": + if not await _slack_user_is_thread_owner(thread_id, user_id): + await post_slack_thread_reply( + channel_id=channel_id, + thread_ts=thread_ts, + text="Only the person who requested this plan can approve it. Anyone can reply with feedback or use *Revise Plan*.", + ) + return {"status": "ignored", "reason": "approver is not the thread owner"} + await _set_thread_plan_mode(thread_id, False) + repo_config = await get_slack_repo_config(channel_id, thread_ts, slack_user_id=user_id) + background_tasks.add_task( + process_slack_mention, + { + "channel_id": channel_id, + "thread_ts": thread_ts, + "event_ts": str(message.get("ts") or ""), + "user_id": user_id, + "text": "Proceed with the approved plan. Implement the changes as described in the plan.", + "bot_user_id": SLACK_BOT_USER_ID, + }, + repo_config, + ) + return {"status": "accepted", "message": "Plan approved, starting implementation"} + + return {"status": "accepted", "message": "Reply to revise the plan"} + if action_value.get("type") != "open_swe_option": return {"status": "ignored", "reason": "Unknown action type"} diff --git a/package.json b/package.json new file mode 100644 index 00000000..4feb374d --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "open-swe", + "private": true, + "scripts": { + "dev:mock": "bash tests/e2e/dev-mock.sh" + } +} diff --git a/pyproject.toml b/pyproject.toml index 5d43456f..c53a4196 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ dependencies = [ "exa-py>=2.13.0", "langchain-google-genai>=4.2.4", "langchain-mcp-adapters>=0.2.2", + "pycrdt>=0.14.1,<0.15", + "pycrdt-websocket>=0.16.3,<0.17", ] [project.optional-dependencies] diff --git a/tests/e2e/dev-mock.sh b/tests/e2e/dev-mock.sh new file mode 100644 index 00000000..5a53106e --- /dev/null +++ b/tests/e2e/dev-mock.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Local dev against the mock harness, with a REAL LLM and a Vite HMR dev server. +# The browser uses a SINGLE origin ($UI, the Vite HMR server): your ui/src edits +# hot-reload, and Vite proxies the API + Yjs collaboration WebSocket + mock +# Slack/GitHub + sign-in to the harness ($API). Same-origin is what lets the +# session cookie ride the plan-review WebSocket (cross-origin WS drops it). +# The LLM is real; only the Slack/GitHub SaaS boundaries are faked. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +API_PORT="${E2E_PORT:-2024}" +UI_PORT="${UI_PORT:-3000}" +API="http://127.0.0.1:${API_PORT}" +UI="http://127.0.0.1:${UI_PORT}" + +# Use a real OpenAI key. The agent talks to OpenAI directly, so a LangSmith +# gateway key (lsv2_*) stray in the shell won't authenticate — prefer .env's +# OPENAI_API_KEY unless the shell already holds an OpenAI-looking key (sk-…). +# Pulled line-by-line, not by sourcing .env, which would clobber the harness's +# test env (SANDBOX_TYPE, DASHBOARD_*, …). +case "${OPENAI_API_KEY:-}" in + sk-*) : ;; + *) + if [ -f .env ]; then + export OPENAI_API_KEY="$(grep -E '^OPENAI_API_KEY=' .env | head -1 | cut -d= -f2- | tr -d "\"'")" + fi + ;; +esac +case "${OPENAI_API_KEY:-}" in + sk-*) : ;; + *) echo "WARNING: OPENAI_API_KEY is not an OpenAI key (sk-…); model calls will 401." >&2 ;; +esac + +# Single origin = the Vite HMR server ($UI). Agent links, the sign-in redirect, +# and the same-origin/WS allowlist all point there; the UI calls the API on its +# own origin and Vite proxies it (and the WS) to the harness. +export DASHBOARD_BASE_URL="$UI" +export DASHBOARD_ALLOWED_ORIGINS="$UI" + +[ -d ui/node_modules ] || (cd ui && bun install) + +echo +echo "┌──────────────────────────────────────────────────────────────────────┐" +echo "│ START HERE → ${UI}/login │" +echo "│ Continue with GitHub → pick Alice or Bob (mock sign-in) │" +echo "└──────────────────────────────────────────────────────────────────────┘" +echo " Mock Slack: ${UI}/mock/slack (send as Alice or Bob)" +echo " Everything is on ${UI} (Vite HMR; API + WS proxied to the harness)." +echo " LLM is real; Slack/GitHub are mocked. Open two browser profiles to be" +echo " two users (Alice owns the plan; Bob can comment + request changes)." +echo + +E2E_REAL_LLM=1 uv run langgraph dev --config tests/e2e/langgraph.e2e.json \ + --port "${API_PORT}" --no-browser --allow-blocking --no-reload & +HARNESS=$! +trap 'kill "${HARNESS}" 2>/dev/null || true' EXIT INT TERM + +# E2E_HARNESS activates the dev-only proxy plugin in ui/vite.config.ts; an empty +# API base means the UI calls its own origin (proxied), keeping it same-origin. +cd ui +exec env E2E_HARNESS="${API}" VITE_DASHBOARD_API_BASE_URL="" \ + ./node_modules/.bin/vite dev --host 127.0.0.1 --port "${UI_PORT}" diff --git a/tests/e2e/e2e_env.py b/tests/e2e/e2e_env.py index 9c658844..a193f4f7 100644 --- a/tests/e2e/e2e_env.py +++ b/tests/e2e/e2e_env.py @@ -67,11 +67,20 @@ _DEFAULTS = { "DASHBOARD_JWT_SECRET": "test-dashboard-jwt-secret", } -# The Slack run's triggering user (fake users.info returns this email) — the -# thread owner. A session with this email may continue the thread on the web. -SAME_USER = {"login": "dev-user", "email": "dev@example.com"} -# Any other logged-in user: can view the thread read-only, cannot post. -OTHER_USER = {"login": "someone-else", "email": "someone@example.com"} +# Named test users (the Slack sender dropdown + the dashboard login picker, and +# the identities the automated tests log in as). Each maps a Slack sender id to +# a dashboard login with a matching email, so the Slack thread's owner (resolved +# by email) is the same person when they sign in. The first (Alice) is the +# default Slack sender, hence the default thread owner. +TEST_USERS = [ + {"name": "Alice", "slack_id": "U_ALICE", "login": "alice", "email": "alice@example.com"}, + {"name": "Bob", "slack_id": "U_BOB", "login": "bob", "email": "bob@example.com"}, +] + +# The default Slack sender / thread owner; a session with this email may continue +# the thread on the web. Any other logged-in user is read-only. +SAME_USER = {"login": TEST_USERS[0]["login"], "email": TEST_USERS[0]["email"]} +OTHER_USER = {"login": TEST_USERS[1]["login"], "email": TEST_USERS[1]["email"]} for _k, _v in _DEFAULTS.items(): os.environ.setdefault(_k, _v) diff --git a/tests/e2e/fake_llm.py b/tests/e2e/fake_llm.py index 2552a996..21dfe5d4 100644 --- a/tests/e2e/fake_llm.py +++ b/tests/e2e/fake_llm.py @@ -46,6 +46,19 @@ echo PUSHED_OK """.strip() +_PLAN_URL_RE = re.compile(r"https?://[^\s\"'<>)\]|]+/plan\b") + + +def _text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + return " ".join( + part.get("text", "") if isinstance(part, dict) else str(part) for part in content + ) + return str(content) + + def _pr_url_from_messages(messages: list[BaseMessage]) -> str | None: for msg in reversed(messages): if isinstance(msg, ToolMessage): @@ -56,6 +69,28 @@ def _pr_url_from_messages(messages: list[BaseMessage]) -> str | None: return None +def _plan_url_from_messages(messages: list[BaseMessage]) -> str | None: + """The plan-review URL is injected into the system prompt; a real model would + read it the same way.""" + for msg in messages: + match = _PLAN_URL_RE.search(_text(msg.content)) + if match: + return match.group(0).rstrip(".,") + return None + + +def _reviewer_feedback(messages: list[BaseMessage]) -> str | None: + """The harvested reviewer comments the backend hands the agent on approval.""" + humans = [m for m in messages if isinstance(m, HumanMessage)] + if not humans: + return None + text = _text(humans[-1].content) + idx = text.lower().find("feedback") + if "approved" in text.lower() and idx != -1: + return text[idx:].strip() + return None + + def _step_implement(_messages: list[BaseMessage]) -> AIMessage: return AIMessage( content="Setting up the repo and implementing the change.", @@ -86,9 +121,11 @@ def _step_open_pr(_messages: list[BaseMessage]) -> AIMessage: def _step_reply(messages: list[BaseMessage]) -> AIMessage: url = _pr_url_from_messages(messages) or "(PR url unavailable)" + feedback = _reviewer_feedback(messages) + extra = f"\n\nReviewer feedback I addressed:\n{feedback}" if feedback else "" text = ( f"✅ Done! I implemented the change and opened a PR: <{url}|{PR_TITLE}>\n\n" - f"• Added `{FEATURE_FILE}` with a `greet()` helper.\n" + f"• Added `{FEATURE_FILE}` with a `greet()` helper.{extra}\n" "Let me know if you'd like any changes." ) return AIMessage( @@ -97,6 +134,97 @@ def _step_reply(messages: list[BaseMessage]) -> AIMessage: ) +# --- plan-mode flow -------------------------------------------------------- +PLAN_MARKDOWN = """## Plan: Add greet() helper + +### Overview +Add a tiny greeting helper to the demo repo. + +### Files to change +- `greet.py` — new module exposing a `greet(name)` function. + +### Steps +1. Create `greet.py` with a `greet(name)` function. +2. Open a draft PR with the change. + +### Verification +- Import `greet` and confirm it returns the expected string. +""" + + +def _step_enter_plan(_messages: list[BaseMessage]) -> AIMessage: + return AIMessage( + content="This is worth planning first — entering plan mode.", + tool_calls=[{"name": "enter_plan_mode", "args": {}, "id": "call-enter-plan"}], + ) + + +def _step_plan_link(messages: list[BaseMessage]) -> AIMessage: + url = _plan_url_from_messages(messages) or "(plan link unavailable)" + return AIMessage( + content="Sharing the plan-review link.", + tool_calls=[ + { + "name": "slack_thread_reply", + "args": { + "message": f"I'm putting together a plan. Follow along and review it here: <{url}|plan review>" + }, + "id": "call-plan-link", + } + ], + ) + + +def _step_plan_research(_messages: list[BaseMessage]) -> AIMessage: + return AIMessage( + content="Reading the repo to ground the plan.", + tool_calls=[ + {"name": "execute", "args": {"command": "echo planning && ls"}, "id": "call-plan-read"} + ], + ) + + +def _step_save_plan(_messages: list[BaseMessage]) -> AIMessage: + return AIMessage( + content="Saving the plan for review.", + tool_calls=[ + {"name": "save_plan", "args": {"plan_markdown": PLAN_MARKDOWN}, "id": "call-save-plan"} + ], + ) + + +def _step_plan_complete(messages: list[BaseMessage]) -> AIMessage: + url = _plan_url_from_messages(messages) or "(plan link unavailable)" + return AIMessage( + content="Announcing the plan is ready.", + tool_calls=[ + { + "name": "slack_thread_reply", + "args": { + "message": f"✅ The plan is ready for review: <{url}|open the plan>. " + "Take a look, leave comments, and approve it when you're happy." + }, + "id": "call-plan-done", + } + ], + ) + + +def _step_plan_end(_messages: list[BaseMessage]) -> AIMessage: + return AIMessage(content="I'll wait for your review and approval before implementing.") + + +def build_plan_script() -> list[Any]: + return [ + _step_enter_plan, + _step_plan_link, + _step_plan_research, + _step_save_plan, + _step_plan_complete, + _step_plan_end, + ] + + FOLLOW_UP_REPLY = "Thanks! The PR is ready for review — anything else you'd like changed?" @@ -133,13 +261,23 @@ class FakeScriptedChatModel(BaseChatModel): run_manager: CallbackManagerForLLMRun | None = None, # noqa: ARG002 **kwargs: Any, ) -> ChatResult: - # First human turn implements + opens the PR; later turns (a web/Slack - # follow-up on the same thread) just reply. - human_turns = sum(1 for m in messages if isinstance(m, HumanMessage)) - script = build_script() if human_turns <= 1 else build_followup_script() + humans = [m for m in messages if isinstance(m, HumanMessage)] + first_text = _text(humans[0].content) if humans else "" + last_text = _text(humans[-1].content) if humans else "" + + # Pick the script for the current turn by what the latest human asked. + if _is_approval(last_text): + script = build_script() # implement + open PR + reply + elif _is_revision(last_text): + script = build_plan_script() # re-plan after requested changes + elif _is_plan_request(first_text) and len(humans) <= 1: + script = build_plan_script() # first ask was to plan + elif len(humans) <= 1: + script = build_script() + else: + script = build_followup_script() # Step within the *current* turn: AIMessages since the last human turn. - # (Counting the whole thread would short-circuit reused/multi-turn threads.) last_human = max( (i for i, m in enumerate(messages) if isinstance(m, HumanMessage)), default=-1 ) @@ -147,5 +285,19 @@ class FakeScriptedChatModel(BaseChatModel): if step < len(script): message = script[step](messages) else: - message = AIMessage(content="All set — the PR is open and linked in the thread.") + message = AIMessage(content="All set — let me know if you'd like anything else.") return ChatResult(generations=[ChatGeneration(message=message)]) + + +def _is_plan_request(text: str) -> bool: + return "plan" in text.lower() + + +def _is_approval(text: str) -> bool: + t = text.lower() + return "approved" in t and "implement" in t + + +def _is_revision(text: str) -> bool: + t = text.lower() + return "needs changes" in t or "publish an updated plan" in t diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index e192c75d..e3d98cb6 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -36,9 +36,26 @@ from e2e_env import ( # noqa: E402 DEMO_CHANNEL, HUMAN_USER, REPO_ROOT, + TEST_USERS, ) from fastapi import HTTPException, Request # noqa: E402 -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse # noqa: E402 +from fastapi.responses import ( # noqa: E402 + FileResponse, + HTMLResponse, + JSONResponse, + RedirectResponse, + Response, +) + +# Slack-user directory the fake ``users.info`` resolves: the default sender used +# by the automated tests plus the named manual-test users. +_SLACK_USERS: dict[str, dict[str, str]] = { + HUMAN_USER: {"name": "devuser", "real_name": "Dev User", "email": "dev@example.com"}, + **{ + u["slack_id"]: {"name": u["login"], "real_name": u["name"], "email": u["email"]} + for u in TEST_USERS + }, +} from agent.dashboard.oauth import COOKIE_NAME, issue_session # noqa: E402 from agent.webapp import app, generate_thread_id_from_slack_thread # noqa: E402 @@ -74,11 +91,14 @@ async def slack_send(request: Request) -> JSONResponse: form = await request.json() text = str(form.get("text", "")) mention_bot = bool(form.get("mention_bot", True)) + # Sender defaults to the first test user (Alice) — the canonical owner the + # automated tests log in as; the mock UI passes the chosen test user. + user_id = str(form.get("user") or TEST_USERS[0]["slack_id"]) channel = DEMO_CHANNEL ts = fakes.new_thread_ts() CURRENT_THREAD["thread_ts"] = ts - fakes.add_slack_message(channel, ts, user=HUMAN_USER, text=text, is_bot=False) + fakes.add_slack_message(channel, ts, user=user_id, text=text, is_bot=False) payload = { "type": "event_callback", @@ -87,7 +107,7 @@ async def slack_send(request: Request) -> JSONResponse: "event": { "type": "app_mention" if mention_bot else "message", "channel": channel, - "user": HUMAN_USER, + "user": user_id, "text": text, "ts": ts, "thread_ts": ts, @@ -131,6 +151,82 @@ async def control_login(request: Request) -> JSONResponse: return resp +@app.get("/control/login") +async def control_login_get(login: str = "", email: str = "", next_url: str = "") -> Response: + """Browser login. With no ``login``, render a dropdown of the test users; + with ``?login=`` (email resolved from the registry, or pass ``&email=``), + mint the session cookie and redirect into the dashboard. Use a separate + browser/profile per user — each has its own cookie jar.""" + # Land on the dashboard origin (DASHBOARD_BASE_URL — the Vite HMR server in + # dev:mock), not this harness, so the cookie + the hot-reloading UI line up. + ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/") + dest = next_url or (f"{ui}/agents" if ui else "/agents") + if not login: + options = "".join(f'' for u in TEST_USERS) + return HTMLResponse( + f"""Mock login + +

Sign in (mock)

+
+ + +
+

Tip: use a separate browser or profile per + user so their sessions don't overwrite each other.

+ """ + ) + if not email: + match = next((u for u in TEST_USERS if u["login"] == login), None) + email = match["email"] if match else f"{login}@example.com" + token = issue_session(login=login, email=email, avatar_url=None) + resp = RedirectResponse(url=dest, status_code=303) + resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, path="/") + return resp + + +@app.get("/dashboard/api/auth/login") +async def mock_github_login(redirect_to: str = "", login: str = "") -> Response: + """Mock stand-in for GitHub OAuth: the dashboard's "Continue with GitHub" + button lands here. With no ``login``, render a picker of the fake GitHub + test users; once one is chosen, mint the real session cookie and redirect + back into the dashboard (``redirect_to``).""" + ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/") + dest = redirect_to or (f"{ui}/agents" if ui else "/agents") + if not login: + options = "".join( + f'' for u in TEST_USERS + ) + return HTMLResponse( + f"""Continue with GitHub (mock) + +

Continue with GitHub (mock)

+

Pick a fake GitHub account to sign in as.

+
+ + + +
+

Tip: use a separate browser or profile per + user so their sessions don't overwrite each other.

+ """ + ) + match = next((u for u in TEST_USERS if u["login"] == login), None) + email = match["email"] if match else f"{login}@example.com" + token = issue_session(login=login, email=email, avatar_url=None) + resp = RedirectResponse(url=dest, status_code=303) + resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, path="/") + return resp + + +# The real dashboard registered /dashboard/api/auth/login first (via +# include_router), so Starlette would match it before ours. Move ours to the +# front of the table so the mock picker shadows the real OAuth redirect. +for _i, _route in enumerate(app.router.routes): + if getattr(_route, "endpoint", None) is mock_github_login: + app.router.routes.insert(0, app.router.routes.pop(_i)) + break + + @app.post("/control/logout") async def control_logout() -> JSONResponse: resp = JSONResponse({"ok": True}) @@ -195,11 +291,22 @@ async def ui_agents_thread(thread_id: str) -> FileResponse: # noqa: ARG001 return _ui_file("_shell.html") +@app.get("/agents/{thread_id}/plan", response_class=HTMLResponse) +async def ui_agents_plan(thread_id: str) -> FileResponse: # noqa: ARG001 + return _ui_file("_shell.html") + + @app.get("/login", response_class=HTMLResponse) async def ui_login() -> FileResponse: return _ui_file("_shell.html") +@app.get("/mock/users") +async def mock_users() -> JSONResponse: + """The named test users that drive the Slack sender + login dropdowns.""" + return JSONResponse(TEST_USERS) + + @app.get("/mock/slack/messages") async def slack_messages() -> JSONResponse: thread = CURRENT_THREAD["thread_ts"] @@ -363,16 +470,19 @@ async def slack_reactions_add(request: Request) -> JSONResponse: @app.get("/fake-slack/users.info") async def slack_users_info(user: str = "") -> JSONResponse: + info = _SLACK_USERS.get( + user, {"name": "devuser", "real_name": "Dev User", "email": "dev@example.com"} + ) return _ok( { "user": { "id": user, - "name": "devuser", - "real_name": "Dev User", + "name": info["name"], + "real_name": info["real_name"], "profile": { - "email": "dev@example.com", - "display_name": "Dev User", - "real_name": "Dev User", + "email": info["email"], + "display_name": info["real_name"], + "real_name": info["real_name"], }, } } diff --git a/tests/e2e/patches.py b/tests/e2e/patches.py index eb6332ad..a9fb0115 100644 --- a/tests/e2e/patches.py +++ b/tests/e2e/patches.py @@ -12,8 +12,13 @@ process), so it runs before the first run regardless of import order. Idempotent from __future__ import annotations +import logging +import os + import e2e_env # noqa: F401 (sets env before any agent import) +logger = logging.getLogger(__name__) + _applied = False @@ -34,12 +39,20 @@ def apply() -> None: opr = importlib.import_module("agent.tools.open_pull_request") from e2e_env import FAKE_GITHUB_API, FAKE_SLACK_API - from fake_llm import FakeScriptedChatModel, build_script - def _fake_make_model(model_id: str, **kwargs: object): # noqa: ARG001 - return FakeScriptedChatModel(script=build_script()) + # The LLM is the only agent-internal piece we fake, and only by default. + # Set E2E_REAL_LLM=1 to drive the harness (mock Slack/GitHub, real agent) + # with a real model — useful for manually exercising plan review etc. The + # provider key (e.g. ANTHROPIC_API_KEY) must be in the environment. + if os.environ.get("E2E_REAL_LLM"): + logger.warning("E2E_REAL_LLM set — using the real model factory, not the scripted fake") + else: + from fake_llm import FakeScriptedChatModel, build_script - server.make_model = _fake_make_model + def _fake_make_model(model_id: str, **kwargs: object): # noqa: ARG001 + return FakeScriptedChatModel(script=build_script()) + + server.make_model = _fake_make_model async def _dummy_install_token_with_expiry() -> tuple[str, str | None]: return "dummy-installation-token", None diff --git a/tests/e2e/static/slack.html b/tests/e2e/static/slack.html index 5743a30a..ba806672 100644 --- a/tests/e2e/static/slack.html +++ b/tests/e2e/static/slack.html @@ -20,6 +20,7 @@

#demo — Slack (mock)

+ @@ -29,11 +30,22 @@ diff --git a/tests/e2e/tests/dashboard.spec.ts b/tests/e2e/tests/dashboard.spec.ts index e1aa72c1..d6fa48bf 100644 --- a/tests/e2e/tests/dashboard.spec.ts +++ b/tests/e2e/tests/dashboard.spec.ts @@ -2,8 +2,8 @@ import { test, expect, type Page } from "@playwright/test"; // Drives the REAL built ui/ app (served same-origin from the harness) for the // Slack → web handoff. Only the LLM/GitHub/Slack/token boundaries are faked. -const SAME_USER = { login: "dev-user", email: "dev@example.com" }; -const OTHER_USER = { login: "someone-else", email: "someone@example.com" }; +const SAME_USER = { login: "alice", email: "alice@example.com" }; +const OTHER_USER = { login: "bob", email: "bob@example.com" }; async function loginAs(page: Page, user: { login: string; email: string }) { const res = await page.request.post("/control/login", { data: user }); diff --git a/tests/e2e/tests/plan_review.spec.ts b/tests/e2e/tests/plan_review.spec.ts new file mode 100644 index 00000000..006da900 --- /dev/null +++ b/tests/e2e/tests/plan_review.spec.ts @@ -0,0 +1,145 @@ +import { + test, + expect, + type APIRequestContext, + type Page, +} from "@playwright/test"; + +// Full plan-review flow, driven through the mock Slack UI + the real dashboard: +// user asks Open SWE in Slack to PLAN something -> +// agent calls enter_plan_mode, posts the plan-review link to Slack, writes the +// plan as a markdown file (save_plan), and posts "ready" back to Slack -> +// owner (user1) and a collaborator (user2) both open the plan and leave inline +// comments using BlockNote's native comment UI (synced live via Yjs) -> +// only the owner can approve -> on approval the agent implements, opens a PR, +// and replies in Slack with the link, having received the reviewers' comments. +// Only the LLM is faked; all agent + dashboard code runs for real. + +const OWNER = { login: "alice", email: "alice@example.com" }; +const COLLABORATOR = { login: "bob", email: "bob@example.com" }; + +async function botMessages(request: APIRequestContext): Promise> { + const res = await request.get("/mock/slack/messages"); + const msgs = (await res.json()) as Array<{ text: string; is_bot: boolean }>; + return msgs.filter((m) => m.is_bot).map((m) => m.text); +} + +// Create an inline comment via BlockNote's native UI: select text in the plan, +// click "Add comment" in the formatting toolbar, type, and save. +async function addInlineComment(page: Page, anchorText: string, comment: string) { + await page.locator(".bn-editor").getByText(anchorText, { exact: false }).first().selectText(); + await page.locator('[data-test="addcomment"]').click(); + await page.locator(".bn-comment-editor .ProseMirror").last().waitFor({ state: "visible" }); + await page.keyboard.type(comment); + await page.locator('[data-test="save"]').click(); +} + +test.describe("Plan review (BlockNote native comments)", () => { + test("Slack plan request → inline comments → owner approves → PR", async ({ + browser, + request, + }) => { + // 1. A user asks the bot to PLAN something in Slack. + await request.post("/control/reset"); + const send = await request.post("/mock/slack/send", { + data: { text: "<@U0BOT> plan how to add a greet() helper", mention_bot: true }, + }); + const { thread_id: threadId } = (await send.json()) as { thread_id: string }; + expect(threadId).toBeTruthy(); + const planPath = `/agents/${threadId}/plan`; + + // 1a. enter_plan_mode must actually engage, not error out. Its Command must + // carry a terminating ToolMessage; without it the tool call fails and is + // swallowed into an error tool message while the agent silently proceeds + // as a normal run. Assert the tool's success message landed in the + // thread (it wouldn't if the call had errored). + await expect + .poll( + async () => { + const res = await request.get(`/threads/${threadId}/state`); + const state = (await res.json()) as { + values?: { messages?: Array<{ content?: unknown }> }; + }; + return (state.values?.messages ?? []) + .map((m) => + typeof m.content === "string" ? m.content : JSON.stringify(m.content), + ) + .some((c) => c.includes("Plan mode is active")); + }, + { timeout: 60_000 }, + ) + .toBe(true); + + // 2. The agent shares the plan-review link, then announces the plan is ready. + await expect + .poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 }) + .toMatch(/\/agents\/[^/]+\/plan\b/); + await expect + .poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 }) + .toMatch(/ready for review/i); + + // 3. The OWNER opens the conversation, sees the "Review plan" banner, and + // follows it into the plan — which renders inside the agents shell. + const ownerCtx = await browser.newContext(); + await ownerCtx.request.post("/control/login", { data: OWNER }); + const owner = await ownerCtx.newPage(); + await owner.goto(`/agents/${threadId}`); + const reviewLink = owner.getByTestId("review-plan-link"); + await expect(reviewLink).toBeVisible({ timeout: 30_000 }); + await reviewLink.click(); + await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}/plan$`)); + await expect(owner.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 }); + // The agents sidebar is present (plan lives inside the shell). + await expect(owner.getByText("Back to conversation")).toBeVisible(); + await expect(owner.getByTestId("plan-document")).toContainText("greet", { + timeout: 30_000, + }); + await expect(owner.getByTestId("approve-plan")).toBeVisible(); + + // Owner leaves an inline comment on the plan heading (a comment wraps the + // text in a `.bn-thread-mark`). + await addInlineComment(owner, "Add greet() helper", "Owner: looks solid, ship it."); + await expect(owner.locator(".bn-thread-mark")).toHaveCount(1); + + // 4. A COLLABORATOR opens the same plan: sees it AND the owner's comment + // (synced live via Yjs), but has NO approve button. + const collabCtx = await browser.newContext(); + await collabCtx.request.post("/control/login", { data: COLLABORATOR }); + const collab = await collabCtx.newPage(); + await collab.goto(planPath); + await expect(collab.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 }); + await expect(collab.getByTestId("plan-document")).toContainText("greet", { + timeout: 30_000, + }); + await expect(collab.locator(".bn-thread-mark")).toHaveCount(1); // owner's comment, synced + await expect(collab.getByTestId("approve-plan")).toHaveCount(0); + await expect(collab.getByTestId("reject-plan")).toBeVisible(); + + // Collaborator leaves inline feedback on a different part of the plan. + await addInlineComment( + collab, + "tiny greeting helper", + "Reviewer: please also add a docstring.", + ); + await expect(collab.locator(".bn-thread-mark")).toHaveCount(2); + + // 5. The owner sees the collaborator's comment sync in, then approves. + await expect(owner.locator(".bn-thread-mark")).toHaveCount(2); + await owner.getByTestId("approve-plan").click(); + await expect(owner.getByTestId("plan-decision")).toContainText(/implementing/i); + + // 6. The agent implements, opens a PR, and links it back in the Slack thread, + // echoing the reviewers' feedback it received — which proves the + // collaborator's comment synced to the owner and was harvested on approve. + await expect + .poll(async () => (await botMessages(request)).join("\n"), { timeout: 90_000 }) + .toMatch(/\/pull\//); + expect((await botMessages(request)).join("\n")).toMatch(/docstring/); + + const prs = (await (await request.get("/mock/github/data")).json()) as Array; + expect(prs.length).toBeGreaterThan(0); + + await ownerCtx.close(); + await collabCtx.close(); + }); +}); diff --git a/tests/test_langsmith_trace_url.py b/tests/test_langsmith_trace_url.py index 22310072..bd89ed11 100644 --- a/tests/test_langsmith_trace_url.py +++ b/tests/test_langsmith_trace_url.py @@ -66,3 +66,29 @@ def test_resolve_project_id_caches_success(monkeypatch: pytest.MonkeyPatch) -> N assert first == "pid-123" assert second == "pid-123" assert calls == [AGENT_TRACING_PROJECT] + + +def test_resolve_project_id_caches_failure(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + class _FakeClient: + def read_project(self, *, project_name: str) -> None: + calls.append(project_name) + raise RuntimeError("403 Forbidden") + + monkeypatch.setattr(ls_utils, "_build_prod_langsmith_client", lambda: _FakeClient()) + + assert ls_utils._resolve_project_id_by_name(AGENT_TRACING_PROJECT) is None + assert ls_utils._resolve_project_id_by_name(AGENT_TRACING_PROJECT) is None + assert calls == [AGENT_TRACING_PROJECT] + + +def test_trace_url_none_when_tenant_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LANGSMITH_TENANT_ID_PROD", raising=False) + + def _boom() -> None: + raise AssertionError("must not build a client when the tenant id is unset") + + monkeypatch.setattr(ls_utils, "_build_prod_langsmith_client", _boom) + + assert ls_utils.get_langsmith_trace_url("t5") is None diff --git a/tests/test_plan_mode.py b/tests/test_plan_mode.py new file mode 100644 index 00000000..397f8d30 --- /dev/null +++ b/tests/test_plan_mode.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from agent import server +from agent.dashboard import thread_api +from agent.prompt import construct_system_prompt + + +def test_plan_mode_prompt_included_when_enabled() -> None: + prompt = construct_system_prompt(working_dir="/work", plan_mode=True) + assert "Plan Mode (ACTIVE)" in prompt + assert "read-only research-and-planning phase" in prompt + + +def test_plan_mode_prompt_absent_by_default() -> None: + prompt = construct_system_prompt(working_dir="/work") + assert "Plan Mode (ACTIVE)" not in prompt + + +def test_plan_mode_excluded_tools_cover_mutating_tools() -> None: + excluded = server.PLAN_MODE_EXCLUDED_TOOLS + for tool in ( + "write_file", + "edit_file", + "task", + "open_pull_request", + "request_pr_review", + "linear_create_issue", + "linear_update_issue", + "linear_delete_issue", + ): + assert tool in excluded + # Read-only tools must stay available. + assert "read_file" not in excluded + assert "execute" not in excluded + + +class _FakeThreadsClient: + async def create( + self, *, thread_id: str, metadata: dict[str, Any], if_exists: str + ) -> dict[str, Any]: + return {"thread_id": thread_id, "metadata": metadata} + + async def update(self, *, thread_id: str, metadata: dict[str, Any]) -> dict[str, Any]: + return {"thread_id": thread_id, "metadata": metadata} + + async def get(self, thread_id: str) -> dict[str, Any]: + return {"thread_id": thread_id, "metadata": {}} + + +class _FakeRunsClient: + def __init__(self) -> None: + self.configurable: dict[str, Any] | None = None + + async def create( + self, + thread_id: str, + assistant_id: str, + *, + input: dict[str, Any], + config: dict[str, Any], + if_not_exists: str = "reject", + stream_mode: list[str] | None = None, + stream_resumable: bool = False, + ) -> dict[str, str]: + self.configurable = config["configurable"] + return {"run_id": "run-id"} + + +class _FakeLangGraphClient: + def __init__(self) -> None: + self.threads = _FakeThreadsClient() + self.runs = _FakeRunsClient() + + +@pytest.fixture +def dashboard_run_client(monkeypatch: pytest.MonkeyPatch) -> _FakeLangGraphClient: + client = _FakeLangGraphClient() + + async def fake_get_profile(login: str) -> dict[str, Any]: + return {} + + async def fake_ensure_token(login: str) -> None: + return None + + async def fake_resolve_email(login: str, profile: dict[str, Any]) -> str: + return "octo@example.com" + + monkeypatch.setattr(thread_api, "langgraph_client", lambda: client) + monkeypatch.setattr(thread_api, "get_profile", fake_get_profile) + monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token) + monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email) + return client + + +def _run_start_command(plan_mode: bool | None) -> dict[str, Any]: + configurable: dict[str, Any] = {} + if plan_mode is not None: + configurable["plan_mode"] = plan_mode + return { + "method": "run.start", + "params": { + "input": {"messages": [{"role": "user", "content": "do work"}]}, + "config": {"configurable": configurable}, + }, + } + + +def test_run_start_passes_plan_mode_when_enabled( + dashboard_run_client: _FakeLangGraphClient, +) -> None: + enriched = asyncio.run( + thread_api._enrich_run_start_command( + "thread-id", + "octo", + _run_start_command(True), + metadata={"source": "dashboard", "github_login": "octo"}, + creating=False, + ) + ) + + configurable = enriched["params"]["config"]["configurable"] + assert configurable["plan_mode"] is True + + +def test_run_start_omits_plan_mode_when_disabled( + dashboard_run_client: _FakeLangGraphClient, +) -> None: + enriched = asyncio.run( + thread_api._enrich_run_start_command( + "thread-id", + "octo", + _run_start_command(None), + metadata={"source": "dashboard", "github_login": "octo"}, + creating=False, + ) + ) + + configurable = enriched["params"]["config"]["configurable"] + assert "plan_mode" not in configurable + + +def test_thread_summary_reports_plan_mode() -> None: + summary = thread_api._thread_summary( + {"thread_id": "t1", "metadata": {"source": "dashboard", "plan_mode": True}} + ) + assert summary["planMode"] is True + + summary_off = thread_api._thread_summary( + {"thread_id": "t2", "metadata": {"source": "dashboard"}} + ) + assert summary_off["planMode"] is False + + +def test_plan_mode_guidance_section_always_present() -> None: + """The guidance section telling the agent about enter_plan_mode should be in every prompt.""" + prompt = construct_system_prompt(working_dir="/work", plan_mode=False) + assert "enter_plan_mode" in prompt + assert "Plan Mode" in prompt + + +def test_plan_mode_guidance_section_present_when_enabled() -> None: + prompt = construct_system_prompt(working_dir="/work", plan_mode=True) + assert "enter_plan_mode" in prompt + assert "Plan Mode (ACTIVE)" in prompt + + +def test_enter_plan_mode_tool_returns_command() -> None: + from langchain_core.messages import ToolMessage + from langchain_core.tools import tool as as_tool + from langgraph.types import Command + + from agent.tools.enter_plan_mode import enter_plan_mode + + # Wrap as the agent does so the InjectedToolCallId is supplied from the call. + wrapped = as_tool(enter_plan_mode) + result = wrapped.invoke( + {"name": "enter_plan_mode", "args": {}, "id": "call-1", "type": "tool_call"} + ) + assert isinstance(result, Command) + assert result.update["plan_mode"] is True + messages = result.update["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert messages[0].tool_call_id == "call-1" + + +def test_enter_plan_mode_exported() -> None: + from agent.tools import enter_plan_mode + + assert callable(enter_plan_mode) + + +def test_build_plan_approval_blocks_has_three_buttons() -> None: + from agent.tools.slack_thread_reply import _build_plan_approval_blocks + + blocks = _build_plan_approval_blocks("Here is my plan") + assert len(blocks) == 2 + assert blocks[0]["type"] == "section" + actions = blocks[1] + assert actions["type"] == "actions" + elements = actions["elements"] + assert len(elements) == 3 + texts = [e["text"]["text"] for e in elements] + assert "Approve & Implement" in texts + assert "Revise Plan" in texts + assert "Cancel" in texts + + +def test_build_plan_approval_blocks_values_have_plan_approval_type() -> None: + import json + + from agent.tools.slack_thread_reply import _build_plan_approval_blocks + + blocks = _build_plan_approval_blocks("plan text") + for element in blocks[1]["elements"]: + value = json.loads(element["value"]) + assert value["type"] == "plan_approval" + assert value["action"] in ("approve", "revise", "cancel") diff --git a/tests/test_plan_review.py b/tests/test_plan_review.py new file mode 100644 index 00000000..84ee4bb9 --- /dev/null +++ b/tests/test_plan_review.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from typing import Any + +import pytest + + +def test_dashboard_plan_url_uses_plan_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DASHBOARD_BASE_URL", "https://example.test") + from agent.utils.dashboard_links import dashboard_plan_url + + assert dashboard_plan_url("abc-123") == "https://example.test/agents/abc-123/plan" + + +def test_dashboard_plan_url_none_without_thread() -> None: + from agent.utils.dashboard_links import dashboard_plan_url + + assert dashboard_plan_url("") is None + + +def test_format_comments_with_quote_and_author() -> None: + from agent.dashboard.plan_api import PlanComment, _format_comments + + text = _format_comments( + [ + PlanComment(author="alice", body="add a docstring", quote="def greet"), + PlanComment(author="bob", body="looks good", resolved=True), + PlanComment(author="carol", body=" "), # blank → skipped + ] + ) + assert 'On "def greet"' in text + assert "alice: add a docstring" in text + assert "bob (resolved): looks good" in text + assert "carol" not in text + + +def test_format_comments_empty() -> None: + from agent.dashboard.plan_api import _format_comments + + assert _format_comments([]) == "" + + +def test_plan_decision_body_defaults() -> None: + from agent.dashboard.plan_api import PlanComment, PlanDecisionBody + + body = PlanDecisionBody() + assert body.comments == [] + comment = PlanComment(body="hi") + assert comment.author is None + assert comment.resolved is False + + +def test_save_plan_requires_run_context() -> None: + from agent.tools.save_plan import save_plan + + # No LangGraph run context → no thread_id → graceful error, not a crash. + result = save_plan("## Plan") + assert result["success"] is False + assert "thread_id" in result["error"] + + +def test_save_plan_rejects_empty_markdown() -> None: + from agent.tools.save_plan import save_plan + + result = save_plan(" ") + assert result["success"] is False + assert "empty" in result["error"] + + +def test_plan_routes_registered() -> None: + from agent.webapp import app + + paths = {getattr(route, "path", "") for route in app.routes} + assert "/dashboard/api/plan/{thread_id}" in paths + assert "/dashboard/api/plan/{thread_id}/approve" in paths + assert "/dashboard/api/plan/{thread_id}/reject" in paths + assert "/dashboard/api/plan/yjs/{thread_id}" in paths + + +def test_save_plan_exported_and_wired() -> None: + from agent.tools import save_plan + + assert callable(save_plan) + + +def test_plan_status_constants() -> None: + from agent.dashboard import plan_store + + assert plan_store.PLAN_STATUS_READY == "ready" + assert plan_store.PLAN_STATUS_PLANNING == "planning" + assert plan_store.PLAN_STATUS_APPROVED == "approved" + assert plan_store.PLAN_STATUS_REVISING == "revising" + + +def test_http_request_excluded_in_plan_mode() -> None: + from agent.server import PLAN_MODE_EXCLUDED_TOOLS + + assert "http_request" in PLAN_MODE_EXCLUDED_TOOLS + + +class _FakeWS: + def __init__(self, origin: str | None) -> None: + self.headers = {"origin": origin} if origin is not None else {} + + +def test_collab_origin_allowed_noop_without_allowlist(monkeypatch: pytest.MonkeyPatch) -> None: + from agent.dashboard.plan_collab import _origin_allowed + + monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False) + monkeypatch.delenv("DASHBOARD_ALLOWED_ORIGINS", raising=False) + # No configured origins (local/dev): the gate is a no-op. + assert _origin_allowed(_FakeWS(None)) is True + assert _origin_allowed(_FakeWS("https://evil.test")) is True + + +def test_collab_origin_allowed_enforced_with_allowlist(monkeypatch: pytest.MonkeyPatch) -> None: + from agent.dashboard.plan_collab import _origin_allowed + + monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example") + monkeypatch.delenv("DASHBOARD_ALLOWED_ORIGINS", raising=False) + assert _origin_allowed(_FakeWS("https://app.example")) is True + assert _origin_allowed(_FakeWS("https://evil.test")) is False + assert _origin_allowed(_FakeWS(None)) is False + + +class _FakeReq: + def __init__(self, tools: list[Any], state: dict[str, Any]) -> None: + self.tools = tools + self.state = state + + def override(self, **kw: Any) -> _FakeReq: + return _FakeReq(kw.get("tools", self.tools), self.state) + + +def _names(req: _FakeReq) -> set[str]: + return {t["name"] for t in req.tools} + + +def test_plan_mode_middleware_initial_always_filters() -> None: + from agent.middleware import PlanModeMiddleware + + mw = PlanModeMiddleware(excluded=frozenset({"write_file"}), initial=True) + req = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {}) + assert _names(mw._filter(req)) == {"read_file"} + + +def test_plan_mode_middleware_self_activation_via_state() -> None: + from agent.middleware import PlanModeMiddleware + + mw = PlanModeMiddleware(excluded=frozenset({"write_file"}), initial=False) + # Plan mode not yet active: nothing filtered. + off = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {}) + assert _names(mw._filter(off)) == {"read_file", "write_file"} + # After enter_plan_mode sets state: the next request is filtered. + on = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {"plan_mode": True}) + assert _names(mw._filter(on)) == {"read_file"} diff --git a/ui/bun.lock b/ui/bun.lock index 6a5cfc04..bb46dc59 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -6,6 +6,9 @@ "name": "open-swe-dashboard", "dependencies": { "@base-ui/react": "^1.4.1", + "@blocknote/core": "^0.51.4", + "@blocknote/mantine": "^0.51.4", + "@blocknote/react": "^0.51.4", "@fontsource-variable/inter": "^5.2.8", "@langchain/core": "^1.1.48", "@langchain/langgraph-sdk": "^1.9.21", @@ -38,6 +41,8 @@ "tailwindcss": "^4.2.1", "tw-animate-css": "^1.4.0", "vite-tsconfig-paths": "^5.1.4", + "y-websocket": "^3.0.0", + "yjs": "^13.6.27", }, "devDependencies": { "@tanstack/devtools-vite": "^0.6.0", @@ -52,7 +57,7 @@ "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "^5.9.3", - "vite": "^7.3.1", + "vite": "^7.3.5", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.0", "web-vitals": "^5.1.0", @@ -272,6 +277,12 @@ "@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="], + "@blocknote/core": ["@blocknote/core@0.51.4", "", { "dependencies": { "@emoji-mart/data": "^1.2.1", "@handlewithcare/prosemirror-inputrules": "^0.1.4", "@shikijs/types": "^4", "@tanstack/store": "^0.7.7", "@tiptap/core": "^3.13.0", "@tiptap/extension-bold": "^3.13.0", "@tiptap/extension-code": "^3.13.0", "@tiptap/extension-horizontal-rule": "^3.13.0", "@tiptap/extension-italic": "^3.13.0", "@tiptap/extension-paragraph": "^3.13.0", "@tiptap/extension-strike": "^3.13.0", "@tiptap/extension-text": "^3.13.0", "@tiptap/extension-underline": "^3.13.0", "@tiptap/extensions": "^3.13.0", "@tiptap/pm": "^3.13.0", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", "prosemirror-highlight": "^0.15.1", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.3", "prosemirror-transform": "^1.11.0", "prosemirror-view": "^1.41.4", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", "yjs": "^13.6.27" } }, "sha512-uR7/BlW6VVlCx/JzfGbkaLGz7O5uI3bu2tbNIdzGTfSloiEp8Qc14if36Dy7DDs+dVWfZgwds0jWiGfE3AzJiw=="], + + "@blocknote/mantine": ["@blocknote/mantine@0.51.4", "", { "dependencies": { "@blocknote/core": "0.51.4", "@blocknote/react": "0.51.4", "react-icons": "^5.5.0" }, "peerDependencies": { "@mantine/core": "^8.3.11 || ^9.0.2", "@mantine/hooks": "^8.3.11 || ^9.0.2", "react": "^18.0 || ^19.0 || >= 19.0.0-rc", "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, "sha512-Ka7QjzhgB/T2ekwtXgwa9zIiiixAm4dob3FklJIedu+9j/eO4auokmRnWAhZQmNJsOdE7mQTUmnv6X4k/lHyCQ=="], + + "@blocknote/react": ["@blocknote/react@0.51.4", "", { "dependencies": { "@blocknote/core": "0.51.4", "@emoji-mart/data": "^1.2.1", "@floating-ui/react": "^0.27.18", "@floating-ui/utils": "^0.2.10", "@tanstack/react-store": "0.7.7", "@tiptap/core": "^3.13.0", "@tiptap/pm": "^3.13.0", "@tiptap/react": "^3.13.0", "@types/use-sync-external-store": "1.5.0", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "lodash.merge": "^4.6.2", "react-icons": "^5.5.0", "use-sync-external-store": "1.6.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, "sha512-tWe039t/Gkj9YDoEXruclXQvI+toI0ctcyikbHa8dm3Q6zTgy24+T/P1t1fYSNPBkSnhq+ILz2fLLnCK994qkA=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], @@ -300,6 +311,8 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -374,12 +387,16 @@ "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + "@floating-ui/react": ["@floating-ui/react@0.27.19", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + "@handlewithcare/prosemirror-inputrules": ["@handlewithcare/prosemirror-inputrules@0.1.4", "", { "dependencies": { "prosemirror-history": "^1.4.1", "prosemirror-transform": "^1.0.0" }, "peerDependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -418,6 +435,10 @@ "@langchain/react": ["@langchain/react@1.0.22", "", { "dependencies": { "@langchain/langgraph-sdk": "1.9.21" }, "peerDependencies": { "@langchain/core": "^1.1.48", "react": "^18 || ^19" } }, "sha512-e3llirWyH6RI6XoTwm7i9e7jiV3A6GDp50JCFKyo8XfZiufE6HiMzZHtpiMLkR/JezfdOTEHDyEC148C0gVB8A=="], + "@mantine/core": ["@mantine/core@8.3.18", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA=="], + + "@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -660,7 +681,7 @@ "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.19", "", { "dependencies": { "@tanstack/react-router": "1.170.15", "@tanstack/router-core": "1.171.13", "@tanstack/start-server-core": "1.169.14" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-+eMpAwDreQvCwgX45MdUHTUCF/Wad36+PwQafe6W5wa3qVkGyN3P131ShGyRwT/0WwKa5EVGdW1zFgwby8UNqA=="], - "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/react-store": ["@tanstack/react-store@0.7.7", "", { "dependencies": { "@tanstack/store": "0.7.7", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg=="], "@tanstack/router-core": ["@tanstack/router-core@1.171.13", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA=="], @@ -684,7 +705,7 @@ "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.15", "", { "dependencies": { "@tanstack/router-core": "1.171.13" } }, "sha512-Jy0q4vdG6pv76N92+X+ag3fuOV2zINQagYyMN1/es7tPI1vzpKECIU8AqHqzI6ahkwaph7XDvmfUkiLJ3i4LOA=="], - "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], @@ -692,6 +713,34 @@ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@tiptap/core": ["@tiptap/core@3.27.1", "", { "peerDependencies": { "@tiptap/pm": "3.27.1" } }, "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ=="], + + "@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.27.1", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w=="], + + "@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.27.1", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg=="], + + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ=="], + + "@tiptap/extensions": ["@tiptap/extensions@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw=="], + + "@tiptap/pm": ["@tiptap/pm@3.27.1", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.7", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.0", "prosemirror-transform": "^1.12.0", "prosemirror-view": "^1.41.8" } }, "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw=="], + + "@tiptap/react": ["@tiptap/react@3.27.1", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.27.1", "@tiptap/extension-floating-menu": "^3.27.1" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg=="], + "@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="], "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], @@ -804,6 +853,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@1.5.0", "", {}, "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw=="], + "@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.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], @@ -1158,6 +1209,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -1178,6 +1231,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.371", "", {}, "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w=="], + "emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -1274,6 +1329,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], + "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=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -1336,6 +1393,8 @@ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], "get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="], @@ -1538,6 +1597,8 @@ "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], @@ -1590,6 +1651,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1622,6 +1685,8 @@ "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], @@ -1818,6 +1883,8 @@ "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], @@ -1902,6 +1969,34 @@ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="], + + "prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="], + + "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="], + + "prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="], + + "prosemirror-highlight": ["prosemirror-highlight@0.15.2", "", { "peerDependencies": { "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@shikijs/types": "^1.29.2 || ^2.0.0 || ^3.0.0 || ^4.0.0", "@types/hast": "^3.0.0", "highlight.js": "^11.9.0", "lowlight": "^3.1.0", "prosemirror-model": "^1.19.3", "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.8.0", "prosemirror-view": "^1.32.4", "refractor": "^5.0.0", "sugar-high": "^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^1.0.0" }, "optionalPeers": ["@lezer/common", "@lezer/highlight", "@shikijs/types", "@types/hast", "highlight.js", "lowlight", "prosemirror-model", "prosemirror-state", "prosemirror-transform", "prosemirror-view", "refractor", "sugar-high"] }, "sha512-G5Wn4kn+MZMus/7vSh4ZNsC+3yrMpjKsgV6QKKYfwKOPMknRX+3H6FWr+E+Qji4xhmkqdXiToG1VTQ2bZn5b0w=="], + + "prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="], + + "prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="], + + "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], + + "prosemirror-model": ["prosemirror-model@1.25.9", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA=="], + + "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], + + "prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="], + + "prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="], + + "prosemirror-transform": ["prosemirror-transform@1.12.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="], + + "prosemirror-view": ["prosemirror-view@1.41.9", "", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A=="], + "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=="], @@ -1922,8 +2017,18 @@ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-number-format": ["react-number-format@5.4.5", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "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=="], @@ -1984,6 +2089,8 @@ "rollup": ["rollup@4.61.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="], + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], @@ -2114,6 +2221,8 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], @@ -2234,6 +2343,16 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="], + + "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], + + "use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -2262,6 +2381,8 @@ "vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -2336,8 +2457,16 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "y-prosemirror": ["y-prosemirror@1.3.7", "", { "dependencies": { "lib0": "^0.2.109" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg=="], + + "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], + + "y-websocket": ["y-websocket@3.0.0", "", { "dependencies": { "lib0": "^0.2.102", "y-protocols": "^1.0.5" }, "peerDependencies": { "yjs": "^13.5.6" } }, "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yjs": ["yjs@13.6.31", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-spinner": ["yocto-spinner@1.2.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="], @@ -2360,6 +2489,8 @@ "@langchain/langgraph-sdk/p-queue": ["p-queue@9.3.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang=="], + "@mantine/core/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "@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=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -2380,12 +2511,16 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@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/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "@tiptap/react/@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], @@ -2508,6 +2643,8 @@ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], diff --git a/ui/package.json b/ui/package.json index 1af93dc2..0ad00616 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,6 +13,9 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", + "@blocknote/core": "^0.51.4", + "@blocknote/mantine": "^0.51.4", + "@blocknote/react": "^0.51.4", "@fontsource-variable/inter": "^5.2.8", "@langchain/core": "^1.1.48", "@langchain/langgraph-sdk": "^1.9.21", @@ -44,7 +47,9 @@ "tailwind-merge": "^3.6.0", "tailwindcss": "^4.2.1", "tw-animate-css": "^1.4.0", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "y-websocket": "^3.0.0", + "yjs": "^13.6.27" }, "devDependencies": { "@tanstack/devtools-vite": "^0.6.0", diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index 9397e7ef..cf1d7bb8 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -1,5 +1,7 @@ import { useMemo, useState } from "react" +import { Link } from "@tanstack/react-router" import { useStreamContext as useAgentThreadStream } from "@langchain/react" +import { Map as MapIcon } from "lucide-react" import type { AgentThread, Message } from "@/lib/agents/types" import type { ModelSelection } from "@/lib/agents/provider/useModelOptions" @@ -36,9 +38,15 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { }, [models, thread.model, thread.effort]) const [selection, setSelection] = useState(null) const activeSelection = selection ?? threadSelection ?? defaultSelection + const [planMode, setPlanMode] = useState(null) + const activePlanMode = planMode ?? thread.planMode ?? false const baseMessages = useMemo>(() => { - const live = streamMessagesToUi(stream.messages, stream.toolCalls, stream.subagents) + const live = streamMessagesToUi( + stream.messages, + stream.toolCalls, + stream.subagents + ) if (live.length > 0) return live // Optimistic transcript seeded by `AgentsHome` on thread creation (the // only case where a fetched thread carries messages — `getThread` returns @@ -46,12 +54,7 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { // lands in `stream.messages`. if (thread.messages.length > 0) return thread.messages return live - }, [ - stream.messages, - stream.toolCalls, - stream.subagents, - thread.messages, - ]) + }, [stream.messages, stream.toolCalls, stream.subagents, thread.messages]) const hasMessages = baseMessages.length > 0 const isStreaming = thread.status === "running" || stream.isLoading @@ -67,6 +70,26 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { className="flex min-w-0 flex-1 flex-col" style={isMobile ? undefined : { minWidth: PANEL_MIN_CHAT_WIDTH }} > + {thread.planStatus && + thread.planStatus !== "approved" && + thread.planStatus !== "cancelled" && ( + + + + {thread.planStatus === "revising" + ? "The agent is revising the plan." + : "A plan is ready for your review."} + + + Review plan → + + + )} {hasMessages ? (
diff --git a/ui/src/components/agents/AgentsHome.tsx b/ui/src/components/agents/AgentsHome.tsx index 9339e2e4..b5ac974d 100644 --- a/ui/src/components/agents/AgentsHome.tsx +++ b/ui/src/components/agents/AgentsHome.tsx @@ -44,6 +44,7 @@ export function AgentsHome() { const { models, defaultSelection } = useModelOptions() const [selection, setSelection] = useState(null) const activeSelection = selection ?? defaultSelection + const [planMode, setPlanMode] = useState(false) const [submitting, setSubmitting] = useState(false) const reposQuery = useRepos() @@ -94,6 +95,7 @@ export function AgentsHome() { } if (repo) configurable.repo = repo if (repoOverride === null) configurable.repo_explicitly_none = true + if (planMode) configurable.plan_mode = true stream .submit( @@ -125,6 +127,8 @@ export function AgentsHome() { repos={reposQuery.data?.repositories} selectedRepo={repo} onRepoChange={setRepoOverride} + planMode={planMode} + onPlanModeChange={setPlanMode} /> diff --git a/ui/src/components/agents/PlanReview.tsx b/ui/src/components/agents/PlanReview.tsx new file mode 100644 index 00000000..9f1bae12 --- /dev/null +++ b/ui/src/components/agents/PlanReview.tsx @@ -0,0 +1,256 @@ +import "@blocknote/core/fonts/inter.css" +import "@blocknote/mantine/style.css" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import * as Y from "yjs" +import { WebsocketProvider } from "y-websocket" +import { + CommentsExtension, + DefaultThreadStoreAuth, + YjsThreadStore, +} from "@blocknote/core/comments" +import { useCreateBlockNote } from "@blocknote/react" +import { BlockNoteView } from "@blocknote/mantine" + +import type { HarvestedComment, PlanData } from "@/lib/plan" +import { approvePlan, planCollabUrl, rejectPlan } from "@/lib/plan" +import { Button } from "@/components/ui/button" +import { useResolvedTheme } from "@/lib/theme" + +const CURSOR_COLORS = [ + "#e11d48", + "#2563eb", + "#059669", + "#d97706", + "#7c3aed", + "#0891b2", +] + +function colorFor(id: string): string { + let hash = 0 + for (let i = 0; i < id.length; i++) + hash = (hash * 31 + id.charCodeAt(i)) >>> 0 + return CURSOR_COLORS[hash % CURSOR_COLORS.length] ?? "#2563eb" +} + +function bodyToText(body: unknown): string { + if (typeof body === "string") return body + if (!Array.isArray(body)) return "" + const parts: Array = [] + for (const block of body) { + const content = (block as { content?: unknown }).content + if (typeof content === "string") parts.push(content) + else if (Array.isArray(content)) + parts.push( + content.map((node) => (node as { text?: string }).text ?? "").join("") + ) + } + return parts.join("\n").trim() +} + +export function PlanReview({ plan }: { plan: PlanData }) { + const resolvedTheme = useResolvedTheme() + + const { doc, provider, threadStore } = useMemo(() => { + const ydoc = new Y.Doc() + const wsProvider = new WebsocketProvider( + planCollabUrl(), + plan.threadId, + ydoc, + { connect: true } + ) + wsProvider.awareness.setLocalStateField("user", { + name: plan.user.name, + color: colorFor(plan.user.id), + }) + // Everyone with read access edits the plan and comments alike — there's no + // owner/commenter split for the document (only approval is owner-gated). The + // collab WS already relays to any readable user, so this matches the server. + const auth = new DefaultThreadStoreAuth(plan.user.id, "editor") + const store = new YjsThreadStore(plan.user.id, ydoc.getMap("threads"), auth) + return { doc: ydoc, provider: wsProvider, threadStore: store } + }, [plan.threadId, plan.user.id, plan.user.name]) + + // Defer teardown so React StrictMode's dev-only unmount→remount of the same + // memoized provider doesn't destroy instances the remount reuses: the remount + // cancels the pending destroy before it runs. A real unmount still tears down. + const pendingDestroy = useRef | null>(null) + useEffect(() => { + if (pendingDestroy.current) { + clearTimeout(pendingDestroy.current) + pendingDestroy.current = null + } + return () => { + pendingDestroy.current = setTimeout(() => { + provider.destroy() + doc.destroy() + }, 0) + } + }, [provider, doc]) + + // Track reviewers' display names from awareness so harvested comments are + // attributed to people, not raw ids. + const usersRef = useRef>({ + [plan.user.id]: plan.user.name, + }) + useEffect(() => { + const sync = () => { + for (const state of provider.awareness.getStates().values()) { + const user = (state as { user?: { name?: string } }).user + if (user?.name) usersRef.current[user.name] = user.name + } + } + provider.awareness.on("change", sync) + sync() + return () => provider.awareness.off("change", sync) + }, [provider]) + + const resolveUsers = useCallback( + (userIds: Array) => + Promise.resolve( + userIds.map((id) => ({ + id, + username: usersRef.current[id] ?? id, + avatarUrl: "", + })) + ), + [] + ) + + const editor = useCreateBlockNote( + { + collaboration: { + provider, + fragment: doc.getXmlFragment("blocknote"), + user: { name: plan.user.name, color: colorFor(plan.user.id) }, + showCursorLabels: "activity", + }, + extensions: [CommentsExtension({ threadStore, resolveUsers })], + }, + [provider, threadStore, resolveUsers] + ) + + // Seed the shared document from the agent's plan markdown the first time + // anyone opens an empty plan. The `seeded` flag + sync barrier prevent a + // second reviewer from double-seeding. + useEffect(() => { + let cancelled = false + const seed = async (isSynced: boolean) => { + if (!isSynced || cancelled) return + const meta = doc.getMap("meta") + if (meta.get("seeded")) return + const blocks = editor.document + const empty = + blocks.length === 0 || + (blocks.length === 1 && bodyToText(blocks) === "") + if (!empty || !plan.markdown.trim()) return + const parsed = await editor.tryParseMarkdownToBlocks(plan.markdown) + // Re-check after the await: another reviewer may have seeded concurrently. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (cancelled || !parsed.length || meta.get("seeded")) return + editor.replaceBlocks(editor.document, parsed) + meta.set("seeded", true) + } + provider.on("sync", seed) + if (provider.synced) void seed(true) + return () => { + cancelled = true + provider.off("sync", seed) + } + }, [editor, provider, doc, plan.markdown]) + + const harvest = useCallback((): Array => { + const out: Array = [] + for (const thread of threadStore.getThreads().values()) { + for (const comment of thread.comments) { + if (!comment.body) continue + out.push({ + author: usersRef.current[comment.userId] ?? comment.userId, + body: bodyToText(comment.body), + resolved: thread.resolved, + }) + } + } + return out + }, [threadStore]) + + const [decision, setDecision] = useState(null) + const [busy, setBusy] = useState<"approve" | "reject" | null>(null) + const decide = useCallback( + async (kind: "approve" | "reject") => { + setBusy(kind) + try { + const comments = harvest() + if (kind === "approve") await approvePlan(plan.threadId, comments) + else await rejectPlan(plan.threadId, comments) + setDecision( + kind === "approve" + ? "Plan approved — the agent is implementing it." + : "Changes requested — the agent is revising the plan." + ) + } finally { + setBusy(null) + } + }, + [harvest, plan.threadId] + ) + + return ( +
+
+
+

+ Implementation plan +

+

+ Reviewing as {plan.user.name} + {plan.isOwner ? " (owner)" : ""} · status:{" "} + {plan.status} + {" · edit the plan, or select text to comment"} +

+
+
+ {decision && ( + + {decision} + + )} + {plan.isOwner && ( + + )} + +
+
+
+ +
+
+ ) +} diff --git a/ui/src/components/agents/ported/CloudPromptBar.tsx b/ui/src/components/agents/ported/CloudPromptBar.tsx index b8cd5b19..cd6ebadd 100644 --- a/ui/src/components/agents/ported/CloudPromptBar.tsx +++ b/ui/src/components/agents/ported/CloudPromptBar.tsx @@ -1,4 +1,11 @@ -import { ArrowUp, ChevronDown, ImagePlus, LoaderCircle, X } from "lucide-react" +import { + ArrowUp, + ChevronDown, + ImagePlus, + LoaderCircle, + Map as MapIcon, + X, +} from "lucide-react" import { StopIcon } from "@phosphor-icons/react" import { useQueryClient } from "@tanstack/react-query" import { useStreamContext as useAgentThreadStream } from "@langchain/react" @@ -119,6 +126,9 @@ export interface CloudPromptBarProps { repos?: Array<{ full_name: string }> selectedRepo?: string | null onRepoChange?: (repo: string | null) => void + /** When provided, a Plan mode toggle is shown. Plan mode researches read-only and proposes a plan before editing. */ + planMode?: boolean + onPlanModeChange?: (next: boolean) => void } function fileToImageChunk(file: File): Promise { @@ -160,6 +170,8 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ repos, selectedRepo = null, onRepoChange, + planMode = false, + onPlanModeChange, }: CloudPromptBarProps) { const [value, setValue] = useState("") const [pendingImages, setPendingImages] = useState>([]) @@ -320,6 +332,10 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ e.preventDefault() void handleSubmit() } + if (e.key === "Tab" && e.shiftKey && onPlanModeChange) { + e.preventDefault() + onPlanModeChange(!planMode) + } } const pickerDisabled = combos.length === 0 || !onSelectionChange @@ -469,6 +485,24 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ )} + {onPlanModeChange && ( + + )} +