From ca9280d25c7538c14de9cac1a108cbfad62fdaf6 Mon Sep 17 00:00:00 2001 From: Ramon Nogueira Date: Tue, 23 Jun 2026 18:12:42 -0400 Subject: [PATCH] refactor(open-swe): plain HTTP comments instead of Yjs/BlockNote collab (#1601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add plan mode for read-only research and planning Adds a per-run plan_mode flag that puts the agent in a read-only research phase: a strong prompt section is injected and mutating tools are stripped via ExcludeToolsMiddleware so the agent proposes a reviewable implementation plan before any edits. Surfaced in the dashboard UI with a Plan toggle (Shift+Tab) wired through the thread API. Co-authored-by: open-swe[bot] * fix: enforce plan-mode read-only at tool layer and disable subagents Addresses PR review: plan mode previously relied on prompt text to keep the shell read-only and left the task subagent (built with its own write/PR/Linear tools) unrestricted. Now `task` is excluded so research cannot be delegated to a mutating subagent, and a new PlanModeShellGuardMiddleware enforces a read-only command allowlist on `execute`, blocking writes, git state changes, installs, redirection, and command substitution regardless of model/prompt-injection compliance. Co-authored-by: open-swe[bot] * fix: harden plan-mode shell guard against wrapped mutations Block git global options that take values (-C, --git-dir, ...) from being misread as the subcommand, reject config-injection options (-c, --config-env, --exec-path), and drop the env command wrapper that could run arbitrary commands. Co-authored-by: open-swe[bot] * feat: add plan mode with enter_plan_mode tool, profile/team defaults, Slack commands and approval flow - enter_plan_mode tool: agent self-activates plan mode via Command(update={'plan_mode': True}) - Plan mode resolution: per-thread > profile default > team default > False - PLAN_MODE_GUIDANCE_SECTION: always-present prompt section telling agent about the tool - profile_plan_mode_default and team plan_mode_default settings - Slack plan on/off/status commands with thread metadata persistence - slack_thread_reply plan_approval=True renders Approve/Revise/Cancel buttons - Interactivity handler: approve triggers implementation run, cancel posts confirmation - Frontend: plan_mode_default in Profile/ProfileUpdate/TeamSettings types and UI toggles Co-authored-by: open-swe[bot] * test: add tests for enter_plan_mode tool, profile/team defaults, Slack plan commands, approval blocks Co-authored-by: open-swe[bot] * refactor(plan-mode): drop shell guard, rely on prompt for read-only discipline Remove PlanModeShellGuardMiddleware and its enforcement of read-only shell commands during plan mode. Plan mode now relies on the system prompt to instruct the agent not to run mutating commands; the mutating-tool exclusion (ExcludeToolsMiddleware) is retained. * test(open-swe): add Playwright E2E for the Slack → PR → web handoff Local, secrets-free end-to-end suite that drives the full happy path through mock Slack/GitHub control panels and the real dashboard UI. Only the LLM and external SaaS HTTP boundaries (GitHub/Slack APIs, OAuth token mint) are faked — the real process_slack_mention, get_agent, deepagents loop, tools, middleware, and dashboard authorization all run under `langgraph dev` with a scripted fake chat model and a local temp-dir sandbox. - full_flow: a Slack mention runs the agent, which implements a change in the sandbox, opens a PR against a fake GitHub remote, and replies with the PR link in the same thread. - dashboard: clicking the bot's real "Open in Web" link loads the built ui/ app (served same-origin); the thread owner can continue the conversation, while a different user sees the same thread read-only (no composer). Wired into Agent CI as a `Playwright E2E` job that runs on pull requests. * fix(open-swe): serve E2E UI assets via explicit route; pin Playwright The dashboard E2E served the built ui/ SPA's /assets via app.mount(StaticFiles), but LangGraph's custom-app loader serves APIRoutes and drops sub-app Mounts, so /assets 404'd under `langgraph dev` in CI — the React app never booted and the composer/transcript never rendered. Serve assets via an explicit route instead. Also pin @playwright/test to the latest (1.61.0) for reproducible runs, and make the owner composer assertion tolerant of either hydration state. * test(open-swe): record Playwright trace + video on every E2E run Capture a replayable trace (DOM snapshots, network, console, source) and a screen recording for every test, not just retries, plus a screenshot on failure. The CI job already uploads playwright-report/ and test-results/, so each run now has a downloadable replay; documented how to open it. * feat(plan-mode): collaborative plan review with BlockNote + Yjs When the agent enters plan mode it writes the plan as a markdown file in the sandbox (save_plan tool), publishes it, and posts a review link to the source channel. Reviewers open the plan inside the dashboard (under the /agents shell), read it rendered in a BlockNote editor, and leave inline comments synced live over Yjs. Only the thread owner can approve; any reviewer can request changes. On approve/reject the comments are harvested and handed to the agent for the follow-up run; the agent never sees comments mid-review. - agent: enter_plan_mode persists plan state; new save_plan tool; prompt shares the plan-review link. - dashboard: Yjs WebSocket collab server (pycrdt-websocket) with store-backed snapshots; plan content/status store; plan REST API (get/approve/reject, owner-only approve, client-harvested comments); planStatus on thread summaries. - ui: BlockNote native comments (CommentsExtension + YjsThreadStore) plan page mounted under the agents shell, with a "Review plan" banner in the thread view and a back-link; theme-aware (dark mode) using the dashboard tokens. - e2e: Playwright coverage of the full Slack -> plan -> review -> approve -> PR flow, including cross-user comment sync and owner-only approval. * fix(plan-mode): address review feedback (authz, overrides, leaks, deps) - plan-collab WS: authorize per-thread before joining a room (same read gate as the REST API) — previously any logged-in user could join any thread (IDOR). - plan-collab: tie the snapshot flusher to active connections (refcount) so each opened plan no longer leaks a permanent 1.5s task on the shared event loop. - plan decisions: include thread_id in the follow-up run configurable so the run resumes the existing thread; set plan_mode explicitly so approve forces it off. - get_agent: an explicit per-thread plan_mode (Slack `plan off`, approved plan, dashboard toggle) now overrides profile/team defaults instead of falling back. - plan mode tool gating moved to a state-aware PlanModeMiddleware installed unconditionally, so a mid-run enter_plan_mode restricts the next model turn; before_agent resets stale plan_mode so a later run isn't forced back into it. - exclude write-capable http_request from plan mode. - pin pycrdt / pycrdt-websocket with upper bounds. Includes the latest base (#1583): E2E UI assets served via explicit route (fixes the Playwright CI failure — LangGraph's app loader drops sub-app mounts). * style: ruff format plan_collab.py * fix(plan-mode): owner-gate Slack approval + same-origin check on collab WS - Slack "Approve & Implement" now verifies the clicking user is the plan requester (owner, via the stored triggering_user_id) before implementing — matching the dashboard API's owner-only approval. Non-owners are pointed to Revise / feedback. - The plan-collab WebSocket validates the handshake Origin against the dashboard allowlist before accept() (no-op when unconfigured, e.g. local/dev), mirroring the REST require_same_origin CSRF defense. * fix(plan-mode): enter plan mode only via the model + local mock dev harness Plan mode is now entered solely when the model calls enter_plan_mode. Removed the per-user and team plan_mode_default settings (backend + UI) and the Slack `plan on/off/status` toggle. - enter_plan_mode returns a terminating ToolMessage, fixing the missing ToolMessage error that silently dropped plan mode mid-run. - PlanReview: defer Yjs provider/doc teardown so React StrictMode's dev remount doesn't destroy and then reuse the collaboration provider. - e2e plan_review spec asserts plan_mode actually engages. - LangSmith trace-url resolution is best-effort: bail before any API call when the tenant is unset, cache failures, log at debug. - Add `pnpm run dev:mock`: same-origin Vite HMR harness with a real LLM, Alice/Bob mock users, and a GitHub login picker. * docs(plan-mode): drop stale references to removed profile/team defaults The plan_mode middleware docstring and the approve/reject dispatch comment still described the profile/team plan_mode_default resolution that no longer exists; reword to match model-driven entry + the per-thread carry. * feat(plan-mode): let any reviewer edit the plan, not just comment Drop the owner/commenter split for the plan document: everyone with read access edits and comments alike (DefaultThreadStoreAuth "editor" for all, editor always editable until a decision, anyone seeds the empty doc). This matches the collab WS, which already relays frames to every readable user. Plan approval stays owner-gated. * test(plan-mode): assert plan-mode entry via the tool's success message plan_mode lives only in run state for tool gating; it is not a persisted thread-state channel, so the previous `values.plan_mode === true` poll could never pass. Assert instead that enter_plan_mode's success ToolMessage ("Plan mode is active …") lands in the thread — which only happens when the tool's Command applies cleanly, the exact regression this guards. * refactor(plan-mode): replace Yjs/BlockNote collab with plain HTTP comments Drop the realtime collaborative editor (it can't work behind Vercel's rewrite — WebSocket upgrades aren't proxied to the external LangGraph backend) in favor of a simple whole-document comments API over plain HTTP. Backend: - Remove the Yjs WebSocket server (plan_collab.py), its lifespan, and the collab router; drop pycrdt / pycrdt-websocket deps. - plan_store: replace the Yjs snapshot with comment CRUD (one store item per comment under ["plan","comments",thread_id]). - plan_api: add GET/POST/DELETE comment endpoints; approve/reject now read comments server-side and format them for the follow-up run (no longer client-harvested). Comment delete is author-or-owner; approve stays owner-only. Frontend: - PlanReview renders the plan markdown read-only and shows a comments panel (list + add, polled every 4s for cross-user visibility). - Drop @blocknote/*, y-websocket, yjs; lib/plan exposes get/add/deletePlanComment. Tests: unit tests for the comments API + route registration; e2e drives the HTTP comment UI (owner + collaborator, cross-user visibility, owner-only approve, PR echoes the harvested feedback). * fix(open-swe): clear stale plan comments on republish; fail loud on store errors Address reviewer feedback: - Clear comments when a revised plan is published (save_plan_content) so feedback on the prior revision doesn't resurface and get re-fed to the agent. - list_plan_comments gains raise_on_error; approve/reject read comments before mutating state and propagate store failures (500) instead of silently dispatching the follow-up run with no feedback. --------- Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> Co-authored-by: open-swe[bot] --- agent/dashboard/plan_api.py | 114 +++++--- agent/dashboard/plan_collab.py | 178 ------------- agent/dashboard/plan_store.py | 93 +++++-- agent/webapp.py | 6 +- pyproject.toml | 2 - tests/e2e/tests/plan_review.spec.ts | 60 ++--- tests/test_plan_review.py | 104 +++++--- ui/bun.lock | 141 +--------- ui/package.json | 7 +- ui/src/components/agents/PlanReview.tsx | 334 +++++++++++------------- ui/src/lib/plan.ts | 61 +++-- uv.lock | 115 -------- 12 files changed, 419 insertions(+), 796 deletions(-) delete mode 100644 agent/dashboard/plan_collab.py diff --git a/agent/dashboard/plan_api.py b/agent/dashboard/plan_api.py index 8dc271cb..7f381e29 100644 --- a/agent/dashboard/plan_api.py +++ b/agent/dashboard/plan_api.py @@ -1,13 +1,15 @@ -"""REST API for the plan-review page: read the plan, approve, or request changes. +"""REST API for the plan-review page: read the plan, comment, approve, or request +changes — all plain HTTP, no CRDT/WebSocket. -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. +Reviewers leave whole-document comments via this API; they're stored server-side +and listed for everyone who can read the thread. On approve/reject the comments +are read back here, 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. +Permissions: any authenticated org member can read a surfaced thread, comment, and +request changes (reject); only the thread owner can approve. A comment can be +deleted by its author or the thread owner. """ from __future__ import annotations @@ -17,13 +19,16 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException from langgraph_sdk import get_client -from pydantic import BaseModel, Field +from pydantic import BaseModel from .oauth import require_same_origin_for_mutations, require_session from .plan_store import ( PLAN_STATUS_APPROVED, PLAN_STATUS_REVISING, + add_plan_comment, + delete_plan_comment, get_plan_content, + list_plan_comments, set_plan_status, ) from .thread_api import ( @@ -43,15 +48,8 @@ plan_router = APIRouter( _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) +class CommentBody(BaseModel): + body: str async def _thread_metadata(thread_id: str) -> dict[str, Any]: @@ -88,17 +86,60 @@ async def get_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) -> di } -@plan_router.post("/{thread_id}/approve") -async def approve_plan( - thread_id: str, - body: PlanDecisionBody, - session: dict[str, Any] = _SESSION_DEP, +@plan_router.get("/{thread_id}/comments") +async def get_plan_comments( + 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") + return {"comments": await list_plan_comments(thread_id)} + + +@plan_router.post("/{thread_id}/comments") +async def post_plan_comment( + thread_id: str, body: CommentBody, 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") + text = body.body.strip() + if not text: + raise HTTPException(422, "comment body cannot be empty") + login = session["sub"] + return await add_plan_comment( + thread_id, author=session.get("name") or login, author_login=login, body=text + ) + + +@plan_router.delete("/{thread_id}/comments/{comment_id}") +async def remove_plan_comment( + thread_id: str, comment_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") + comments = await list_plan_comments(thread_id) + target = next((c for c in comments if c.get("id") == comment_id), None) + if target is None: + raise HTTPException(404, "comment not found") + login = session["sub"] + is_owner = _user_owns_thread(metadata, login, session.get("email")) + if target.get("author_login") != login and not is_owner: + raise HTTPException(403, "only the author or the plan owner can delete a comment") + await delete_plan_comment(thread_id, comment_id) + return {"ok": True} + + +@plan_router.post("/{thread_id}/approve") +async def approve_plan(thread_id: str, 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") + # Read comments BEFORE mutating state: a store failure here aborts the + # decision (500) rather than dispatching the run without the feedback. + feedback = _format_comments(await list_plan_comments(thread_id, raise_on_error=True)) 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 " @@ -111,16 +152,12 @@ async def approve_plan( @plan_router.post("/{thread_id}/reject") -async def reject_plan( - thread_id: str, - body: PlanDecisionBody, - session: dict[str, Any] = _SESSION_DEP, -) -> dict[str, Any]: +async def reject_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") + feedback = _format_comments(await list_plan_comments(thread_id, raise_on_error=True)) 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" @@ -130,19 +167,16 @@ async def reject_plan( return {"status": PLAN_STATUS_REVISING} -def _format_comments(comments: list[PlanComment]) -> str: +def _format_comments(comments: list[dict[str, Any]]) -> str: lines: list[str] = [] - for index, comment in enumerate(comments, start=1): - body = comment.body.strip() + index = 1 + for comment in comments: + body = str(comment.get("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}") + author = str(comment.get("author") or "reviewer").strip() + lines.append(f"{index}. {author}: {body}") + index += 1 return "\n".join(lines) diff --git a/agent/dashboard/plan_collab.py b/agent/dashboard/plan_collab.py deleted file mode 100644 index 5810e5db..00000000 --- a/agent/dashboard/plan_collab.py +++ /dev/null @@ -1,178 +0,0 @@ -"""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 index d130fa34..b6098163 100644 --- a/agent/dashboard/plan_store.py +++ b/agent/dashboard/plan_store.py @@ -1,25 +1,25 @@ -"""Persistence for the collaborative plan-review feature. +"""Persistence for the 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. + - the LangGraph store, as the published snapshot the dashboard renders. -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. +Reviewers leave whole-document comments, stored one item per comment under +``["plan", "comments", thread_id]`` so listing and deletion are simple plain +store operations (no CRDT/WebSocket). """ from __future__ import annotations -import base64 +import uuid +from datetime import UTC, datetime from typing import Any from langgraph_sdk import get_client PLAN_CONTENT_NAMESPACE = ["plan", "content"] -PLAN_YJS_NAMESPACE = ["plan", "yjs"] +PLAN_COMMENTS_NAMESPACE = ["plan", "comments"] # Plan lifecycle, stored on both the content record and the thread metadata. PLAN_STATUS_PLANNING = "planning" @@ -43,13 +43,22 @@ def _item_value(item: Any) -> dict[str, Any] | 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.""" + """Publish the plan markdown + status for the dashboard to render. + + A republished (revised) plan supersedes the prior revision, so comments left + on it are cleared — otherwise stale feedback would resurface on the new plan + and be fed back to the agent on the next approve/reject.""" client = _client() await client.store.put_item( PLAN_CONTENT_NAMESPACE, thread_id, {"markdown": markdown, "status": status}, ) + try: + await clear_plan_comments(thread_id) + except Exception: + # Best-effort: a failed cleanup must not block publishing the new plan. + pass await _merge_thread_metadata(thread_id, {"plan_status": status, "plan_mode": True}) @@ -77,31 +86,57 @@ async def set_plan_status(thread_id: str, status: str, *, plan_mode: bool | None await _merge_thread_metadata(thread_id, metadata) -async def load_yjs_snapshot(thread_id: str) -> bytes | None: +def _comments_namespace(thread_id: str) -> list[str]: + return [*PLAN_COMMENTS_NAMESPACE, thread_id] + + +async def list_plan_comments( + thread_id: str, *, raise_on_error: bool = False +) -> list[dict[str, Any]]: + """All comments on a plan, oldest first. + + With ``raise_on_error=True`` a store/search failure propagates instead of + resolving to ``[]``. Approve/reject use this so a transient failure surfaces + (the decision endpoint errors) rather than silently feeding the agent an + empty comment set and dropping the reviewer's feedback.""" client = _client() try: - item = await client.store.get_item(PLAN_YJS_NAMESPACE, thread_id) + items = await client.store.search_items(_comments_namespace(thread_id), limit=1000) 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 + if raise_on_error: + raise + return [] + raw = items.get("items", []) if isinstance(items, dict) else getattr(items, "items", []) + comments = [v for v in (_item_value(item) for item in raw) if v] + comments.sort(key=lambda c: str(c.get("created_at", ""))) + return comments -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 clear_plan_comments(thread_id: str) -> None: + """Delete every comment on a thread (called when a revised plan is published).""" + for comment in await list_plan_comments(thread_id): + comment_id = comment.get("id") + if isinstance(comment_id, str) and comment_id: + await delete_plan_comment(thread_id, comment_id) + + +async def add_plan_comment( + thread_id: str, *, author: str, author_login: str, body: str +) -> dict[str, Any]: + """Append a whole-document comment; returns the stored comment.""" + comment = { + "id": uuid.uuid4().hex, + "author": author, + "author_login": author_login, + "body": body, + "created_at": datetime.now(UTC).isoformat(), + } + await _client().store.put_item(_comments_namespace(thread_id), comment["id"], comment) + return comment + + +async def delete_plan_comment(thread_id: str, comment_id: str) -> None: + await _client().store.delete_item(_comments_namespace(thread_id), comment_id) async def _merge_thread_metadata(thread_id: str, metadata: dict[str, Any]) -> None: diff --git a/agent/webapp.py b/agent/webapp.py index ba13bc70..55ace137 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -131,14 +131,12 @@ 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() - async with collab_lifespan(): - yield + yield app = FastAPI(lifespan=lifespan) @@ -162,10 +160,8 @@ 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", "") diff --git a/pyproject.toml b/pyproject.toml index c53a4196..5d43456f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,6 @@ 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/tests/plan_review.spec.ts b/tests/e2e/tests/plan_review.spec.ts index 006da900..85b57ec0 100644 --- a/tests/e2e/tests/plan_review.spec.ts +++ b/tests/e2e/tests/plan_review.spec.ts @@ -9,10 +9,11 @@ import { // 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) -> +// owner (user1) and a collaborator (user2) open the plan and leave whole-document +// comments over plain HTTP (each polls and sees the other's) -> // 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. +// and replies in Slack with the link, echoing the reviewers' comments (which the +// server harvested from the comment store). // Only the LLM is faked; all agent + dashboard code runs for real. const OWNER = { login: "alice", email: "alice@example.com" }; @@ -24,18 +25,13 @@ async function botMessages(request: APIRequestContext): Promise> { 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(); +async function addComment(page: Page, text: string) { + await page.getByTestId("comment-input").fill(text); + await page.getByTestId("comment-submit").click(); } -test.describe("Plan review (BlockNote native comments)", () => { - test("Slack plan request → inline comments → owner approves → PR", async ({ +test.describe("Plan review (HTTP comments)", () => { + test("Slack plan request → comments → owner approves → PR", async ({ browser, request, }) => { @@ -51,8 +47,7 @@ test.describe("Plan review (BlockNote native comments)", () => { // 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). + // as a normal run. Assert the tool's success message landed in the thread. await expect .poll( async () => { @@ -78,8 +73,8 @@ test.describe("Plan review (BlockNote native comments)", () => { .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. + // 3. The OWNER opens the conversation, follows the "Review plan" banner, and + // sees the rendered plan. const ownerCtx = await browser.newContext(); await ownerCtx.request.post("/control/login", { data: OWNER }); const owner = await ownerCtx.newPage(); @@ -89,20 +84,18 @@ test.describe("Plan review (BlockNote native comments)", () => { 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); + // Owner leaves a comment. + await addComment(owner, "Owner: looks solid, ship it."); + await expect(owner.getByTestId("plan-comment")).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. + // (fetched over HTTP), but has NO approve button. const collabCtx = await browser.newContext(); await collabCtx.request.post("/control/login", { data: COLLABORATOR }); const collab = await collabCtx.newPage(); @@ -111,26 +104,23 @@ test.describe("Plan review (BlockNote native comments)", () => { 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("plan-comment")).toHaveCount(1, { timeout: 30_000 }); + await expect(collab.getByTestId("plan-comment")).toContainText("looks solid"); 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); + // Collaborator leaves feedback. + await addComment(collab, "Reviewer: please also add a docstring."); + await expect(collab.getByTestId("plan-comment")).toHaveCount(2); - // 5. The owner sees the collaborator's comment sync in, then approves. - await expect(owner.locator(".bn-thread-mark")).toHaveCount(2); + // 5. The owner sees the collaborator's comment (polled), then approves. + await expect(owner.getByTestId("plan-comment")).toHaveCount(2, { timeout: 30_000 }); 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. + // echoing the reviewers' feedback — which proves the comments were stored + // and harvested server-side on approve. await expect .poll(async () => (await botMessages(request)).join("\n"), { timeout: 90_000 }) .toMatch(/\/pull\//); diff --git a/tests/test_plan_review.py b/tests/test_plan_review.py index 84ee4bb9..b9a43035 100644 --- a/tests/test_plan_review.py +++ b/tests/test_plan_review.py @@ -18,19 +18,18 @@ def test_dashboard_plan_url_none_without_thread() -> None: assert dashboard_plan_url("") is None -def test_format_comments_with_quote_and_author() -> None: - from agent.dashboard.plan_api import PlanComment, _format_comments +def test_format_comments_numbers_and_skips_blank() -> None: + from agent.dashboard.plan_api import _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 + {"author": "alice", "body": "add a docstring"}, + {"author": "bob", "body": "looks good"}, + {"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 "1. alice: add a docstring" in text + assert "2. bob: looks good" in text assert "carol" not in text @@ -40,14 +39,60 @@ def test_format_comments_empty() -> None: assert _format_comments([]) == "" -def test_plan_decision_body_defaults() -> None: - from agent.dashboard.plan_api import PlanComment, PlanDecisionBody +def test_plan_comment_helpers_exported() -> None: + from agent.dashboard import plan_store - body = PlanDecisionBody() - assert body.comments == [] - comment = PlanComment(body="hi") - assert comment.author is None - assert comment.resolved is False + assert plan_store.PLAN_COMMENTS_NAMESPACE == ["plan", "comments"] + assert callable(plan_store.add_plan_comment) + assert callable(plan_store.list_plan_comments) + assert callable(plan_store.delete_plan_comment) + assert callable(plan_store.clear_plan_comments) + + +def _fake_client(store: Any) -> Any: + return type("C", (), {"store": store})() + + +async def test_list_plan_comments_swallows_errors_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agent.dashboard import plan_store + + class _Store: + async def search_items(self, *a: Any, **k: Any) -> Any: + raise RuntimeError("boom") + + monkeypatch.setattr(plan_store, "_client", lambda: _fake_client(_Store())) + assert await plan_store.list_plan_comments("t") == [] + + +async def test_list_plan_comments_raises_with_flag(monkeypatch: pytest.MonkeyPatch) -> None: + from agent.dashboard import plan_store + + class _Store: + async def search_items(self, *a: Any, **k: Any) -> Any: + raise RuntimeError("boom") + + monkeypatch.setattr(plan_store, "_client", lambda: _fake_client(_Store())) + with pytest.raises(RuntimeError): + await plan_store.list_plan_comments("t", raise_on_error=True) + + +async def test_clear_plan_comments_deletes_each(monkeypatch: pytest.MonkeyPatch) -> None: + from agent.dashboard import plan_store + + deleted: list[str] = [] + + class _Store: + async def search_items(self, *a: Any, **k: Any) -> Any: + return {"items": [{"value": {"id": "a"}}, {"value": {"id": "b"}}]} + + async def delete_item(self, _ns: Any, key: str) -> None: + deleted.append(key) + + monkeypatch.setattr(plan_store, "_client", lambda: _fake_client(_Store())) + await plan_store.clear_plan_comments("t") + assert deleted == ["a", "b"] def test_save_plan_requires_run_context() -> None: @@ -74,7 +119,9 @@ def test_plan_routes_registered() -> None: 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 + assert "/dashboard/api/plan/{thread_id}/comments" in paths + assert "/dashboard/api/plan/{thread_id}/comments/{comment_id}" in paths + assert "/dashboard/api/plan/yjs/{thread_id}" not in paths def test_save_plan_exported_and_wired() -> None: @@ -98,31 +145,6 @@ def test_http_request_excluded_in_plan_mode() -> None: 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 diff --git a/ui/bun.lock b/ui/bun.lock index bb46dc59..f484806d 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -6,9 +6,6 @@ "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", @@ -41,8 +38,6 @@ "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", @@ -277,12 +272,6 @@ "@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=="], @@ -311,8 +300,6 @@ "@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=="], @@ -387,16 +374,12 @@ "@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=="], @@ -435,10 +418,6 @@ "@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=="], @@ -681,7 +660,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.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/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-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=="], @@ -705,7 +684,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.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="], + "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], @@ -713,34 +692,6 @@ "@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=="], @@ -853,8 +804,6 @@ "@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=="], @@ -1209,8 +1158,6 @@ "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=="], @@ -1231,8 +1178,6 @@ "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=="], @@ -1329,8 +1274,6 @@ "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=="], @@ -1393,8 +1336,6 @@ "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=="], @@ -1597,8 +1538,6 @@ "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=="], @@ -1651,8 +1590,6 @@ "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=="], @@ -1685,8 +1622,6 @@ "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=="], @@ -1883,8 +1818,6 @@ "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=="], @@ -1969,34 +1902,6 @@ "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=="], @@ -2017,18 +1922,8 @@ "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=="], @@ -2089,8 +1984,6 @@ "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=="], @@ -2221,8 +2114,6 @@ "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=="], @@ -2343,16 +2234,6 @@ "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=="], @@ -2381,8 +2262,6 @@ "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=="], @@ -2457,16 +2336,8 @@ "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=="], @@ -2489,8 +2360,6 @@ "@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=="], @@ -2511,16 +2380,12 @@ "@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=="], @@ -2643,8 +2508,6 @@ "@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 0ad00616..1af93dc2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,9 +13,6 @@ }, "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", @@ -47,9 +44,7 @@ "tailwind-merge": "^3.6.0", "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" + "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { "@tanstack/devtools-vite": "^0.6.0", diff --git a/ui/src/components/agents/PlanReview.tsx b/ui/src/components/agents/PlanReview.tsx index 9f1bae12..ea1c9c71 100644 --- a/ui/src/components/agents/PlanReview.tsx +++ b/ui/src/components/agents/PlanReview.tsx @@ -1,198 +1,94 @@ -import "@blocknote/core/fonts/inter.css" -import "@blocknote/mantine/style.css" +import { useCallback, useEffect, useState } from "react" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import * as Y from "yjs" -import { WebsocketProvider } from "y-websocket" +import type { PlanComment, PlanData } from "@/lib/plan" 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" + addPlanComment, + approvePlan, + deletePlanComment, + getPlanComments, + rejectPlan, +} from "@/lib/plan" import { Button } from "@/components/ui/button" +import { Markdown } from "@/components/agents/ported" 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() -} +const POLL_MS = 4000 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 [comments, setComments] = useState>([]) + const [draft, setDraft] = useState("") + const [posting, setPosting] = useState(false) const [decision, setDecision] = useState(null) const [busy, setBusy] = useState<"approve" | "reject" | null>(null) + const [error, setError] = useState(null) + + // Poll so reviewers see each other's comments without a realtime transport. + useEffect(() => { + let cancelled = false + const load = async () => { + try { + const next = await getPlanComments(plan.threadId) + if (!cancelled) setComments(next) + } catch { + /* transient; next tick retries */ + } + } + void load() + const timer = setInterval(load, POLL_MS) + return () => { + cancelled = true + clearInterval(timer) + } + }, [plan.threadId]) + + const submitComment = useCallback(async () => { + const body = draft.trim() + if (!body) return + setPosting(true) + setError(null) + try { + const created = await addPlanComment(plan.threadId, body) + setComments((prev) => [...prev, created]) + setDraft("") + } catch (e) { + setError((e as Error).message) + } finally { + setPosting(false) + } + }, [draft, plan.threadId]) + + const removeComment = useCallback( + async (id: string) => { + try { + await deletePlanComment(plan.threadId, id) + setComments((prev) => prev.filter((c) => c.id !== id)) + } catch (e) { + setError((e as Error).message) + } + }, + [plan.threadId] + ) + const decide = useCallback( async (kind: "approve" | "reject") => { setBusy(kind) + setError(null) try { - const comments = harvest() - if (kind === "approve") await approvePlan(plan.threadId, comments) - else await rejectPlan(plan.threadId, comments) + if (kind === "approve") await approvePlan(plan.threadId) + else await rejectPlan(plan.threadId) setDecision( kind === "approve" ? "Plan approved — the agent is implementing it." : "Changes requested — the agent is revising the plan." ) + } catch (e) { + setError((e as Error).message) } finally { setBusy(null) } }, - [harvest, plan.threadId] + [plan.threadId] ) return ( @@ -209,7 +105,6 @@ export function PlanReview({ plan }: { plan: PlanData }) { Reviewing as {plan.user.name} {plan.isOwner ? " (owner)" : ""} · status:{" "} {plan.status} - {" · edit the plan, or select text to comment"}

@@ -240,16 +135,89 @@ export function PlanReview({ plan }: { plan: PlanData }) {
-
- + +
+
+ {plan.markdown.trim() ? ( + + ) : ( +

+ The plan hasn't been written yet. +

+ )} +
+ +