diff --git a/.gitignore b/.gitignore index fbd5a96..f45dbe7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ __pycache__/ /build/ develop-eggs/ dist/ +!dhee/ui/web/dist/ +!dhee/ui/web/dist/** downloads/ eggs/ .eggs/ @@ -124,6 +126,7 @@ pip-delete-this-directory.txt # Node (dashboard) node_modules/ +*.tsbuildinfo # Rust build artifacts (dhee-accel) target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e60bced..6d554cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - Public Dhee is now positioned and packaged as **Dhee Developer Brain**: local memory, handoff, harness setup, and git-backed repo context. - Rewrote the README as a concise first-read product page focused on why Dhee - matters, the 30-second token-router proof, install, integrations, benchmarks, - and the public-core/paid-team-layer boundary. + matters, the UI demo, install, integrations, benchmarks, and the + public-core/paid-team-layer boundary. +- Restored the public `dhee ui` Sankhya workspace app: router screen, infinite + folders canvas, workspace/task/memory/context views, and the FastAPI bridge + that maps those screens onto local Dhee state. +- Added product-grade UI screens for Dhee's context-governance workflow: + Command Center, Context Firewall, Handoff Hub, Proof Replay, Repo Brain, + Learning Inbox, and Portability & Trust. - Added `dhee demo token-router`, a deterministic context-firewall demo that shows raw tool-output tokens, digest tokens, savings, and expansion pointers without requiring a live agent session. diff --git a/README.md b/README.md index ce93d54..5f56d2b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
-
+
Why | - Try it | + Dhee UI | Install | How it works | Integrations | @@ -58,32 +58,33 @@ The promise is simple: --- -## Try It +## Dhee UI -Run the built-in context-router demo. It needs no API key and no connected agent: +Run the local Dhee workspace UI. It needs no API key and no connected agent: ```bash -dhee demo token-router +dhee ui ``` -Example result: +
+ +
-```text -Dhee token-router demo - context firewall: agent sees the right thing, not everything - raw tokens: 25,208 - digest tokens: 1,742 - saved: 23,466 (93.1%) -``` ++ Watch the 13-second UI demo +
-The demo shows how Dhee handles three common agent hazards: +The UI opens on a command center, then lets you inspect: -- a noisy pytest failure log -- a large git diff -- a long source file read +- Context Firewall: token savings, digests, evidence pointers, expansions, and session history +- Repo Brain: an infinite folders canvas for linked repos, projects, active sessions, tasks, and shared context +- Handoff Hub: resumable task state without replaying the transcript +- Proof Replay: what Dhee injected, hid, digested, expanded, promoted, or rejected +- Learning Inbox: evidence-backed candidate learnings with promote/reject actions +- Portability & Trust: signed `.dheemem` export/import readiness and dry-run inspection -In each case the agent receives a useful digest, while exact raw evidence stays -behind `dhee_expand_result(ptr="...")` for explicit expansion. +The raw evidence still stays behind `dhee_expand_result(ptr="...")`; the UI +makes the routing and expansion decisions inspectable. --- @@ -110,7 +111,7 @@ Useful first commands: ```bash dhee status dhee doctor -dhee demo token-router +dhee ui dhee handoff dhee context state --card dhee runtime status diff --git a/dhee/cli.py b/dhee/cli.py index 1b65661..7d788a6 100644 --- a/dhee/cli.py +++ b/dhee/cli.py @@ -14,6 +14,7 @@ dhee handoff Emit structured resume JSON for a new harness/agent dhee harness status Show native Claude Code / Codex integration state dhee demo token-router Show how Dhee keeps raw tool output behind pointers + dhee ui Open the local Dhee dashboard dhee benchmark Run performance benchmarks dhee status Version, config, DB info """ @@ -1240,6 +1241,20 @@ def cmd_demo(args: argparse.Namespace) -> None: print(format_token_router_demo(report, show_digests=not getattr(args, "no_digests", False))) +def cmd_ui(args: argparse.Namespace) -> None: + """Run the local Dhee web UI.""" + from dhee.ui.cli import cmd_ui as run_ui + + run_ui(args) + + +def cmd_ui_build(args: argparse.Namespace) -> None: + """Build the local Dhee web UI assets.""" + from dhee.ui.cli import cmd_ui_build as run_ui_build + + run_ui_build(args) + + def cmd_status(args: argparse.Namespace) -> None: """Show version, config, DB size, detected agents, and brain health. @@ -2531,6 +2546,20 @@ def build_parser() -> argparse.ArgumentParser: p_demo.add_argument("--no-digests", action="store_true", help="Hide digest previews") p_demo.add_argument("--json", action="store_true", help="JSON output") + # ui + p_ui = sub.add_parser("ui", help="Run the local Dhee web UI") + p_ui.add_argument("--host", default="127.0.0.1", help="Bind host (loopback by default)") + p_ui.add_argument("--port", type=int, default=8787, help="Bind port") + p_ui.add_argument("--repo", help="Repo/workspace to inspect (default: cwd)") + p_ui.add_argument("--dev", action="store_true", help="Start the Vite frontend with hot reload") + p_ui.add_argument("--verbose", action="store_true", help="Show frontend logs in dev mode") + p_ui.add_argument("--open", action="store_true", help=argparse.SUPPRESS) + p_ui.add_argument("--no-open", action="store_true", help="Don't auto-open the UI in the default browser") + + p_ui_build = sub.add_parser("ui-build", help="Build the Dhee web UI assets") + p_ui_build.add_argument("--install", action="store_true", help="Force `npm install` before building") + p_ui_build.set_defaults(func=cmd_ui_build) + # list p_list = sub.add_parser("list", help="List all memories") p_list.add_argument("--user-id", default="default", help="User ID") @@ -3082,6 +3111,7 @@ def build_parser() -> argparse.ArgumentParser: "search": cmd_search, "checkpoint": cmd_checkpoint, "demo": cmd_demo, + "ui": cmd_ui, "list": cmd_list, "stats": cmd_stats, "decay": cmd_decay, diff --git a/dhee/ui/__init__.py b/dhee/ui/__init__.py new file mode 100644 index 0000000..b0fbb59 --- /dev/null +++ b/dhee/ui/__init__.py @@ -0,0 +1,20 @@ +"""Sankhya — Dhee's web UI. + +`dhee ui` starts a FastAPI server that serves the built React SPA and +exposes the Dhee substrate (memories, router stats, policy, evolution, +conflicts, tasks) as JSON endpoints. +""" + +try: + from dhee.ui.server import app, create_app # noqa: F401 +except ModuleNotFoundError: + # FastAPI is an optional dep (`pip install dhee[api]`). The package + # still imports so `dhee ui` can print a useful message. + app = None # type: ignore[assignment] + + def create_app(*args, **kwargs): # type: ignore[no-redef] + raise ModuleNotFoundError( + "dhee.ui requires fastapi + uvicorn. Install with `pip install 'dhee[api]'`." + ) + +__all__ = ["app", "create_app"] diff --git a/dhee/ui/cli.py b/dhee/ui/cli.py new file mode 100644 index 0000000..e9c768b --- /dev/null +++ b/dhee/ui/cli.py @@ -0,0 +1,171 @@ +"""`dhee ui` — start Sankhya. + +Starts the FastAPI bridge (which also serves the built SPA) on a local +port. If the SPA hasn't been built, prints the build instructions and +exits cleanly so users know exactly what to do. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import subprocess +import sys +import threading +import time +import webbrowser +from pathlib import Path + +_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} + + +def _schedule_browser_open(url: str, delay: float = 1.2) -> None: + """Open the UI in the default browser shortly after startup. + + Runs in a thread so we don't block uvicorn. Silent on failure — + headless boxes (CI, servers, SSH sessions without $DISPLAY) fall + back cleanly to the printed URL. + """ + + def _run() -> None: + time.sleep(delay) + try: + webbrowser.open_new_tab(url) + except Exception: + pass + + threading.Thread(target=_run, daemon=True).start() + + +def cmd_ui(args: argparse.Namespace) -> None: + try: + import uvicorn + except ModuleNotFoundError as exc: + print("Dhee UI requires uvicorn. Install with `pip install 'dhee[api]'`.", file=sys.stderr) + raise SystemExit(1) from exc + try: + from dhee.ui.server import create_app + except ModuleNotFoundError as exc: + print("Dhee UI requires FastAPI. Install with `pip install 'dhee[api]'`.", file=sys.stderr) + raise SystemExit(1) from exc + + if args.host not in _LOOPBACK_HOSTS and os.environ.get("DHEE_UI_ALLOW_PUBLIC") != "1": + raise SystemExit( + "Refusing to expose Dhee UI on a non-loopback host. " + "Set DHEE_UI_ALLOW_PUBLIC=1 only behind a trusted auth proxy." + ) + + if getattr(args, "repo", None): + os.environ["DHEE_UI_REPO"] = str(Path(args.repo).expanduser().resolve()) + + web_dir = Path(__file__).parent / "web" + dist = web_dir / "dist" + + # Auto-fallback to dev mode if dist is missing and we're in a source tree + if not dist.exists() and not args.dev: + if (web_dir / "package.json").exists(): + print("Sankhya SPA not built. Falling back to dev mode (hot-reloading)...") + args.dev = True + else: + print(f"Sankhya SPA not built yet.") + print(f" cd {web_dir}") + print(f" npm install && npm run build") + sys.exit(1) + + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + + app = create_app(serve_static=not args.dev, dev_mode=args.dev) + host_display = "127.0.0.1" if args.host in ("0.0.0.0", "::") else args.host + ui_url = f"http://{host_display}:{args.port}/" + print(f"Sankhya — Dhee UI") + print(f" API http://{args.host}:{args.port}/api") + + frontend_proc = None + if args.dev: + print(f" Dev Starting Vite frontend (http://127.0.0.1:5173)...") + print(f" Dashboard http://{args.host}:{args.port}/ (Proxied to Vite)") + try: + frontend_proc = subprocess.Popen( + ["npm", "run", "dev"], + cwd=str(web_dir), + stdout=subprocess.DEVNULL if not args.verbose else None, + stderr=subprocess.STDOUT if not args.verbose else None, + ) + except Exception as e: + print(f"Warning: Could not start frontend: {e}") + else: + print(f" Dashboard http://{args.host}:{args.port}/") + if args.host == "127.0.0.1" and args.port == 8080: + print(f" Tip: Add '127.0.0.1 dhee.ui' to /etc/hosts to use http://dhee.ui:8080/") + + should_open = not args.no_open and os.environ.get("DHEE_UI_NO_OPEN") != "1" + # Skip auto-open on headless servers (no DISPLAY on X11). + if should_open and sys.platform.startswith("linux") and not os.environ.get("DISPLAY"): + should_open = False + if should_open: + _schedule_browser_open(ui_url, delay=1.5 if args.dev else 1.0) + + try: + uvicorn.run(app, host=args.host, port=args.port, log_level="info") + finally: + if frontend_proc: + frontend_proc.terminate() + try: + frontend_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + frontend_proc.kill() + + +def cmd_ui_build(args: argparse.Namespace) -> None: + web = Path(__file__).parent / "web" + if not (web / "package.json").exists(): + print(f"No package.json at {web}", file=sys.stderr) + sys.exit(1) + if args.install or not (web / "node_modules").exists(): + print("→ npm install") + subprocess.check_call(["npm", "install"], cwd=str(web)) + print("→ npm run build") + subprocess.check_call(["npm", "run", "build"], cwd=str(web)) + print("✓ Built at", web / "dist") + + +def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: + p = sub.add_parser("ui", help="Start Sankhya (Dhee web UI)") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=8787) + p.add_argument("--repo", help="Repo/workspace to inspect (default: cwd)") + p.add_argument( + "--dev", + action="store_true", + help="Start both API bridge and Vite frontend with hot-reloading.", + ) + p.add_argument( + "--verbose", + action="store_true", + help="Show frontend (Vite) logs in dev mode.", + ) + p.add_argument( + "--no-open", + action="store_true", + help="Don't auto-open the UI in the default browser.", + ) + p.add_argument("--open", action="store_true", help=argparse.SUPPRESS) + p.set_defaults(func=cmd_ui) + + pb = sub.add_parser("ui-build", help="Build the Sankhya SPA (npm install + npm run build)") + pb.add_argument("--install", action="store_true", help="Force `npm install` even if node_modules exists") + pb.set_defaults(func=cmd_ui_build) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="dhee-ui") + sub = parser.add_subparsers(dest="command") + register(sub) + args = parser.parse_args(["ui", *(argv or sys.argv[1:])]) + args.func(args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dhee/ui/server.py b/dhee/ui/server.py new file mode 100644 index 0000000..ba0a24c --- /dev/null +++ b/dhee/ui/server.py @@ -0,0 +1,8102 @@ +"""FastAPI bridge between the Sankhya SPA and the Dhee substrate. + +Endpoints map the fields the prototype UI expects onto real Dhee state: + +- /api/memories FullMemory.get_all -> engram shape +- /api/memories (POST) remember() +- /api/memories/{id} DELETE archive +- /api/router/stats router.stats.compute_stats +- /api/router/policy router.policy.load + router.tune.build_report +- /api/router/tune (POST) router.tune.apply +- /api/meta-buddhi MetaBuddhi snapshot +- /api/evolution samskara / evolution log +- /api/conflicts derived from memory history +- /api/tasks shared_tasks.shared_task_snapshot +- /api/status overall health +- /api/security/api-keys encrypted API-key storage + rotation + +Honesty: where a piece of the prototype has no live adapter yet (e.g. +the curated evolution timeline with pretty labels), we synthesize a +minimal shape from real session logs and mark `live: false` on that +endpoint so the UI can show a "derived" badge. No silent mocks. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +import re +import shutil +import sqlite3 +import subprocess +import threading +import time +from collections import Counter, deque +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +log = logging.getLogger(__name__) + +_LOCAL_UI_ORIGIN_REGEX = r"https?://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?" +_SESSION_LOG_TAIL_BYTES = int(os.environ.get("DHEE_UI_SESSION_LOG_TAIL_BYTES", str(768 * 1024))) +_RUNTIME_MIRROR_CODEX_LIMIT = int(os.environ.get("DHEE_UI_RUNTIME_CODEX_LIMIT", "6")) +_RUNTIME_MIRROR_CLAUDE_LIMIT = int(os.environ.get("DHEE_UI_RUNTIME_CLAUDE_LIMIT", "6")) +_SESSION_LOG_PARSE_CACHE: Dict[Tuple[str, str, str], Tuple[int, int, Dict[str, Any]]] = {} +_MIRROR_RUNTIME_CACHE_TTL_SECONDS = float(os.environ.get("DHEE_UI_MIRROR_RUNTIME_TTL_SECONDS", "3")) +_MIRROR_RUNTIME_CACHE: Dict[Tuple[str, ...], Tuple[float, Dict[str, Any]]] = {} +_MIRROR_RUNTIME_LOCK = threading.Lock() +_UI_DB = None + + +class CaptureSessionStartPayload(BaseModel): + source_app: str + namespace: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + + +class CaptureSessionEndPayload(BaseModel): + session_id: Optional[str] = None + distill: bool = True + summary_hint: Optional[str] = None + + +class CaptureActionPayload(BaseModel): + session_id: str + source_app: Optional[str] = None + namespace: Optional[str] = None + created_at: Optional[str] = None + action_type: str + target: Optional[Dict[str, Any]] = None + surface: Optional[Dict[str, Any]] = None + surface_id: Optional[str] = None + surface_type: Optional[str] = None + title: Optional[str] = None + url: Optional[str] = None + path: Optional[str] = None + path_hint: Optional[List[str]] = None + previous_surface_id: Optional[str] = None + capture_mode: Optional[str] = None + confidence: Optional[float] = None + action_payload: Optional[Dict[str, Any]] = None + source_kind: Optional[str] = None + before_context: Optional[str] = None + after_context: Optional[str] = None + before_frame_ref: Optional[str] = None + after_frame_ref: Optional[str] = None + task_instruction: Optional[str] = None + recent_actions: Optional[List[str]] = None + metadata: Optional[Dict[str, Any]] = None + + +class CaptureObservationPayload(BaseModel): + session_id: str + source_app: Optional[str] = None + namespace: Optional[str] = None + created_at: Optional[str] = None + action_id: Optional[str] = None + source_kind: Optional[str] = None + kind: Optional[str] = None + text: Optional[str] = None + text_payload: Optional[str] = None + structured: Optional[Dict[str, Any]] = None + structured_payload: Optional[Dict[str, Any]] = None + confidence: Optional[float] = None + surface: Optional[Dict[str, Any]] = None + + +class RememberPayload(BaseModel): + content: str + tier: Optional[str] = None + tags: Optional[List[str]] = None + source: Optional[str] = None + + +class CaptureArtifactPayload(BaseModel): + session_id: str + source_app: Optional[str] = None + namespace: Optional[str] = None + created_at: Optional[str] = None + action_id: Optional[str] = None + content_base64: Optional[str] = None + path: Optional[str] = None + mime_type: Optional[str] = None + artifact_type: Optional[str] = None + retention: Optional[str] = None + ttl_hours: Optional[int] = None + surface: Optional[Dict[str, Any]] = None + surface_id: Optional[str] = None + surface_type: Optional[str] = None + title: Optional[str] = None + url: Optional[str] = None + path_hint: Optional[List[str]] = None + label: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + + +class CapturePreferencePayload(BaseModel): + source_app: str + enabled: bool + mode: str = "sampled" + metadata: Optional[Dict[str, Any]] = None + + +class MemoryAskPayload(BaseModel): + query: str + source_app: Optional[str] = None + limit: int = 6 + + +class AgentContextPackPayload(BaseModel): + task_instruction: str + agent_id: Optional[str] = None + source_app: Optional[str] = None + current_frame_ref: Optional[str] = None + current_context_text: Optional[str] = None + recent_actions: Optional[List[str]] = None + limit: int = 5 + + +class WorldContextPackPayload(BaseModel): + current_frame_ref: str + current_context_text: str + task_instruction: str + recent_actions: Optional[List[str]] = None + limit: int = 5 + + +class UiPayload(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + +class WorkspaceRootCreatePayload(UiPayload): + name: Optional[str] = Field(default=None, validation_alias=AliasChoices("name", "label")) + description: Optional[str] = None + root_path: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("root_path", "rootPath", "workspace_path", "workspacePath", "path"), + ) + + +class WorkspaceRootUpdatePayload(UiPayload): + name: Optional[str] = Field(default=None, validation_alias=AliasChoices("name", "label")) + description: Optional[str] = None + + +class WorkspaceCreatePayload(UiPayload): + workspace_path: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("workspace_path", "workspacePath", "root_path", "rootPath", "path"), + ) + label: Optional[str] = None + folder_path: Optional[str] = Field(default=None, validation_alias=AliasChoices("folder_path", "folderPath")) + is_primary: bool = Field(default=False, validation_alias=AliasChoices("is_primary", "isPrimary")) + folders: Optional[List[str]] = None + + +class WorkspaceFolderPayload(UiPayload): + path: Optional[str] = Field(default=None, validation_alias=AliasChoices("path", "mount_path", "mountPath", "rootPath")) + label: Optional[str] = None + + +class WorkspaceUpdatePayload(UiPayload): + label: Optional[str] = Field(default=None, validation_alias=AliasChoices("label", "name")) + description: Optional[str] = None + root_path: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("root_path", "rootPath", "workspace_path", "workspacePath", "path"), + ) + + +class WorkspaceProjectCreatePayload(UiPayload): + name: Optional[str] = Field(default=None, validation_alias=AliasChoices("name", "label")) + description: Optional[str] = None + default_runtime: Optional[str] = Field(default=None, validation_alias=AliasChoices("default_runtime", "defaultRuntime")) + color: Optional[str] = None + icon: Optional[str] = None + scope_rules: Optional[List[Dict[str, Any]]] = Field(default=None, validation_alias=AliasChoices("scope_rules", "scopeRules")) + + +class WorkspaceProjectUpdatePayload(UiPayload): + name: Optional[str] = Field(default=None, validation_alias=AliasChoices("name", "label")) + description: Optional[str] = None + default_runtime: Optional[str] = Field(default=None, validation_alias=AliasChoices("default_runtime", "defaultRuntime")) + color: Optional[str] = None + icon: Optional[str] = None + scope_rules: Optional[List[Dict[str, Any]]] = Field(default=None, validation_alias=AliasChoices("scope_rules", "scopeRules")) + + +class FolderPickPayload(BaseModel): + prompt: Optional[str] = None + + +class AssetAskPayload(BaseModel): + question: str + + +class ConflictResolutionPayload(BaseModel): + action: str + merged_content: Optional[str] = None + reason: Optional[str] = None + + +class SessionLaunchPayload(BaseModel): + runtime: str + title: Optional[str] = None + permission_mode: Optional[str] = None + task_id: Optional[str] = None + project_id: Optional[str] = None + + +class WorkspaceLineMessagePayload(BaseModel): + project_id: Optional[str] = None + target_project_id: Optional[str] = None + channel: Optional[str] = None + session_id: Optional[str] = None + task_id: Optional[str] = None + message_kind: str = "update" + title: Optional[str] = None + body: str + metadata: Optional[Dict[str, Any]] = None + + +class ContextUpsertPayload(BaseModel): + context_id: Optional[str] = None + expected_content_hash: Optional[str] = None + title: str + content: str + scope: str + kind: str = "note" + project_id: Optional[str] = None + team_id: Optional[str] = None + user_id: Optional[str] = None + tags: Optional[List[str]] = None + summary: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + + +class ProposalCreatePayload(BaseModel): + title: str + content: str + scope: str + kind: str + project_id: Optional[str] = None + team_id: Optional[str] = None + proposed_by_user_id: str + supersedes_id: Optional[str] = None + tags: Optional[List[str]] = None + metadata: Optional[Dict[str, Any]] = None + + +class ProposalDecisionPayload(BaseModel): + reviewer_user_id: str + + +class FindingResolvePayload(BaseModel): + resolved_by: Optional[str] = None + + +class IntegrationPayload(BaseModel): + scope: str + target_id: str + type: str + value: Any + metadata: Optional[Dict[str, Any]] = None + + +class TeamJoinPayload(BaseModel): + org_id: str + project_id: Optional[str] = None + team_id: Optional[str] = None + role: Optional[str] = "developer" + repo_root: Optional[str] = None + + +class EnterpriseWorkspacePayload(BaseModel): + name: str + root_path: Optional[str] = None + default_branch: Optional[str] = "main" + + +class EnterpriseProjectCreatePayload(BaseModel): + name: str + project_id: Optional[str] = None + description: Optional[str] = "" + + +class ProjectTeamCreatePayload(BaseModel): + name: str + team_id: Optional[str] = None + description: Optional[str] = "" + + +class ProjectFolderAddPayload(BaseModel): + local_path: Optional[str] = None + repo_url: Optional[str] = None + label: Optional[str] = None + kind: Optional[str] = "folder" + + +class LocalContextFolderPayload(BaseModel): + path: str + shared: Optional[bool] = True + + +class TeamCollaborationPayload(BaseModel): + target_team_id: str + + +# Module-level payloads for the new workspace + context endpoints. +# These need to live outside ``create_app()`` because FastAPI's body +# resolution can't see classes that live in nested function scope when +# ``from __future__ import annotations`` defers annotation evaluation +# (the existing pattern in this file is module-level for body params, +# inline only for ad-hoc query helpers). + + +class LocalWorkspaceCreatePayload(BaseModel): + name: Optional[str] = None + id: Optional[str] = None + + +class ContextPromotePayload(BaseModel): + memory_id: str + repo: Optional[str] = None + kind: Optional[str] = "learning" + title: Optional[str] = None + + +class ContextDemotePayload(BaseModel): + entry_id: str + repo: Optional[str] = None + + +class UiLearningDecisionPayload(BaseModel): + scope: Optional[str] = "personal" + repo: Optional[str] = None + approved_by: Optional[str] = "dhee-ui" + reason: Optional[str] = None + + +class UiPortabilityExportPayload(BaseModel): + output_path: Optional[str] = None + user_id: str = "default" + repo: Optional[str] = None + + +class UiPortabilityImportPayload(BaseModel): + input_path: str + user_id: str = "default" + repo: Optional[str] = None + + +# ─── Shape helpers ──────────────────────────────────────────────────────────── + +_TIER_BY_SCORE = [ + (0.90, "canonical"), + (0.70, "high"), + (0.40, "medium"), + (0.00, "short-term"), +] + + +def _tier_for(mem: Dict[str, Any]) -> str: + meta = mem.get("metadata") or {} + explicit = meta.get("tier") or mem.get("tier") + if explicit in {"canonical", "high", "medium", "short-term", "avoid"}: + return explicit + if meta.get("avoid") or mem.get("avoid"): + return "avoid" + score = float( + mem.get("score") + or meta.get("confidence") + or meta.get("importance") + or 0.5 + ) + for threshold, tier in _TIER_BY_SCORE: + if score >= threshold: + return tier + return "short-term" + + +def _estimate_tokens(text: str) -> int: + return max(1, len(text) // 4) + + +def _engram_from_memory(mem: Dict[str, Any]) -> Dict[str, Any]: + meta = mem.get("metadata") or {} + content = mem.get("memory") or mem.get("content") or mem.get("text") or "" + created = ( + mem.get("created_at") + or mem.get("createdAt") + or meta.get("created_at") + or "" + ) + if isinstance(created, (int, float)): + created = time.strftime("%Y-%m-%d", time.localtime(float(created))) + elif isinstance(created, str) and "T" in created: + created = created.split("T", 1)[0] + decay = mem.get("decay") + if decay is None: + decay = meta.get("decay") + if decay is None: + # Flat 1.0 unless Dhee has populated a decay signal. + decay = 1.0 + tags = meta.get("tags") or mem.get("tags") or [] + if isinstance(tags, str): + tags = [t.strip() for t in tags.split(",") if t.strip()] + return { + "id": str(mem.get("id") or meta.get("id") or meta.get("memory_id") or ""), + "tier": _tier_for(mem), + "content": content, + "source": str(meta.get("source") or mem.get("source") or "dhee"), + "created": created or time.strftime("%Y-%m-%d"), + "tags": list(tags), + "decay": float(decay), + "reaffirmed": int(meta.get("reaffirmed") or mem.get("reaffirmed") or 0), + "tokens": _estimate_tokens(content), + } + + +_AUTO_MEMORY_TIER_BY_TYPE = { + "user": "high", + "feedback": "high", + "project": "medium", + "reference": "medium", +} + + +def _parse_auto_memory_file(path: Path) -> Optional[Dict[str, Any]]: + try: + text = path.read_text(encoding="utf-8") + except OSError: + return None + name = path.stem + description = "" + mem_type = "project" + body = text + if text.startswith("---"): + end = text.find("\n---", 3) + if end != -1: + header = text[3:end] + body = text[end + 4 :].lstrip("\n") + for line in header.splitlines(): + key, sep, value = line.partition(":") + if not sep: + continue + key = key.strip().lower() + value = value.strip().strip('"').strip("'") + if key == "name" and value: + name = value + elif key == "description" and value: + description = value + elif key == "type" and value: + mem_type = value + body = body.strip() + if not body and not description: + return None + content_parts: List[str] = [f"[{mem_type}] {name}"] + if description: + content_parts.append(description) + if body: + content_parts.append(body) + content = "\n\n".join(content_parts) + try: + created = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).strftime("%Y-%m-%d") + except OSError: + created = time.strftime("%Y-%m-%d") + tier = _AUTO_MEMORY_TIER_BY_TYPE.get(mem_type, "medium") + file_id = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:16] + return { + "id": f"auto:{file_id}", + "tier": tier, + "content": content, + "source": "claude_auto_memory", + "created": created, + "tags": [mem_type, "auto-memory"], + "decay": 1.0, + "reaffirmed": 0, + "tokens": _estimate_tokens(content), + } + + +def _auto_memory_engrams() -> List[Dict[str, Any]]: + roots = [Path.home() / ".claude" / "projects"] + engrams: List[Dict[str, Any]] = [] + for root in roots: + if not root.is_dir(): + continue + try: + project_dirs = [p for p in root.iterdir() if p.is_dir()] + except OSError: + continue + for project_dir in project_dirs: + memory_dir = project_dir / "memory" + if not memory_dir.is_dir(): + continue + try: + files = [p for p in memory_dir.iterdir() if p.is_file() and p.suffix == ".md"] + except OSError: + continue + for path in files: + if path.name.upper() == "MEMORY.MD": + continue + eng = _parse_auto_memory_file(path) + if eng: + engrams.append(eng) + engrams.sort(key=lambda e: e.get("created") or "", reverse=True) + return engrams + + +# ─── App factory ────────────────────────────────────────────────────────────── + + +def create_app(*, serve_static: bool = True, dev_mode: bool = False) -> FastAPI: + app = FastAPI(title="Sankhya — Dhee UI", version="1.0.0") + app.add_middleware( + CORSMiddleware, + allow_origin_regex=os.environ.get("DHEE_UI_CORS_ORIGIN_REGEX") or _LOCAL_UI_ORIGIN_REGEX, + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + + # ─── Memory ────────────────────────────────────────────────────────────── + + def _get_memory(): + from dhee.cli_config import get_memory_instance + + return get_memory_instance(None) + + _memory_os_service = None + + def _get_memory_os_service(): + nonlocal _memory_os_service + if _memory_os_service is None: + from dhee.world_memory import MemoryOSService + + _memory_os_service = MemoryOSService.from_default_runtime(memory=_get_memory()) + return _memory_os_service + + @app.get("/api/memories") + def list_memories(limit: int = 500) -> Dict[str, Any]: + try: + mem = _get_memory() + raw = mem.get_all( + user_id=os.environ.get("DHEE_UI_USER_ID", "default"), + limit=limit, + ) + # get_all returns {"results": [...]} in some codepaths + if isinstance(raw, dict): + raw = raw.get("results") or raw.get("memories") or [] + engrams = [_engram_from_memory(m) for m in raw if m] + engrams.extend(_auto_memory_engrams()) + return {"live": True, "engrams": engrams, "count": len(engrams)} + except Exception as exc: # noqa: BLE001 + log.warning("list_memories failed: %s", exc) + return {"live": False, "engrams": [], "count": 0, "error": str(exc)} + + + @app.post("/api/memories") + def remember(payload: RememberPayload) -> Dict[str, Any]: + try: + mem = _get_memory() + result = mem.add( + messages=[{"role": "user", "content": payload.content}], + user_id=os.environ.get("DHEE_UI_USER_ID", "default"), + metadata={ + "source": payload.source or "sankhya-ui", + "tier": payload.tier or "short-term", + "tags": payload.tags or [], + }, + ) + return {"ok": True, "result": result} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.delete("/api/memories/{memory_id}") + def archive_memory(memory_id: str) -> Dict[str, Any]: + try: + mem = _get_memory() + mem.delete(memory_id) + return {"ok": True, "id": memory_id} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + # ─── Router ────────────────────────────────────────────────────────────── + + @app.get("/api/router/stats") + def router_stats(agent_id: Optional[str] = None) -> Dict[str, Any]: + try: + from dhee.router import stats as rstats + + selected_agent = None if agent_id in (None, "", "all") else agent_id + s = rstats.compute_stats(agent_id=selected_agent).to_dict() + agents = rstats.list_agent_stats() + codex_native = _router_codex_native_usage(_ui_repo()) + tools = [] + for name, calls in s.get("calls_by_tool", {}).items(): + tokens_saved = int( + s.get("est_tokens_diverted", 0) + * (calls / max(1, s.get("total_calls", 1))) + ) + tools.append( + { + "name": name, + "calls": calls, + "tokensSaved": tokens_saved, + "expansions": int( + s.get("expansion_calls", 0) + * (calls / max(1, s.get("total_calls", 1))) + ), + "avgDigest": 60, + "avgRaw": int(tokens_saved / max(1, calls)) if calls else 0, + } + ) + enterprise_saved_tokens = 0 + enterprise_saved_pct = 0.0 + enterprise_raw_tokens = 0 + enterprise_summary_tokens = 0 + enterprise_raw_fallbacks = 0 + enterprise_gate_suggestions = 0 + enterprise_gate_denials = 0 + enterprise_gate_fallbacks = 0 + return { + "live": True, + "selectedAgent": selected_agent or "all", + "sessionTokensSaved": s.get("est_tokens_diverted", 0), + "enterpriseSavedTokens": enterprise_saved_tokens, + "enterpriseSavedPct": enterprise_saved_pct, + "enterpriseRawTokens": enterprise_raw_tokens, + "enterpriseSummaryTokens": enterprise_summary_tokens, + "enterpriseRawFallbacks": enterprise_raw_fallbacks, + "enterpriseGateSuggestions": enterprise_gate_suggestions, + "enterpriseGateDenials": enterprise_gate_denials, + "enterpriseGateFallbacks": enterprise_gate_fallbacks, + "totalCalls": s.get("total_calls", 0), + "expansionRate": s.get("expansion_rate", 0.0), + "sessionCost": round( + s.get("est_tokens_diverted", 0) * 1e-6 * 3.0, 4 + ), + "estimatedFullCost": round( + (s.get("est_tokens_diverted", 0) + s.get("bytes_stored", 0) / 3.5) + * 1e-6 + * 3.0, + 4, + ), + "tools": tools, + "agents": agents, + "sessions": s.get("sessions", 0), + "bytesStored": s.get("bytes_stored", 0), + "dailySavings": _seven_day_savings(selected_agent), + "days": _seven_day_labels(), + "codexNative": codex_native, + } + except Exception as exc: # noqa: BLE001 + log.warning("router_stats failed: %s", exc) + return {"live": False, "error": str(exc)} + + @app.get("/api/router/policy") + def router_policy() -> Dict[str, Any]: + try: + from dhee.router import policy, tune + + data = policy.load() + report = tune.build_report() + prev = { + (s.tool, s.intent): s.current_depth for s in getattr(report, "suggestions", []) + } + depths_map = data.get("depths", {}) + depth_rank = {"shallow": 1, "normal": 2, "deep": 3} + policies = [] + for tool, intents in depths_map.items(): + for intent, depth in intents.items(): + expansion = 0.0 + for s in getattr(report, "suggestions", []): + if s.tool == tool and s.intent == intent: + expansion = getattr(s, "expansion_rate", 0.0) + prev_depth = prev.get((tool, intent), depth) + policies.append( + { + "intent": intent, + "label": intent.replace("_", " ").title(), + "depth": depth_rank.get(depth, 2), + "prevDepth": depth_rank.get(prev_depth, 2), + "expansionRate": expansion, + "tuned": depth != prev_depth, + "tool": tool, + } + ) + return {"live": True, "policies": policies, "raw": data} + except Exception as exc: # noqa: BLE001 + log.warning("router_policy failed: %s", exc) + return {"live": False, "policies": [], "error": str(exc)} + + @app.post("/api/router/tune") + def router_tune_apply() -> Dict[str, Any]: + try: + from dhee.router import tune + + report = tune.build_report() + applied = tune.apply(report) + return { + "ok": True, + "applied": applied, + "human": tune.format_human(report), + "suggestions": [ + { + "tool": s.tool, + "intent": s.intent, + "from": s.current_depth, + "to": s.proposed_depth, + "reason": getattr(s, "reason", ""), + } + for s in getattr(report, "suggestions", []) + ], + } + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + # ─── MetaBuddhi + Evolution ────────────────────────────────────────────── + + @app.get("/api/meta-buddhi") + def meta_buddhi_status() -> Dict[str, Any]: + try: + from dhee.core import meta_buddhi as mb + + snapshot: Dict[str, Any] = { + "status": "active", + "strategy": "Adaptive depth v2.3", + } + if hasattr(mb, "latest_snapshot"): + snapshot.update(mb.latest_snapshot() or {}) # type: ignore[attr-defined] + snapshot.setdefault("sessionInsights", 0) + snapshot.setdefault("totalInsights", 0) + snapshot.setdefault("pendingProposals", 0) + snapshot.setdefault("lastGate", "") + snapshot.setdefault("confidenceGroups", _default_confidence_groups()) + return {"live": True, **snapshot} + except Exception as exc: # noqa: BLE001 + log.warning("meta_buddhi failed: %s", exc) + return { + "live": False, + "status": "unknown", + "strategy": "—", + "sessionInsights": 0, + "totalInsights": 0, + "pendingProposals": 0, + "lastGate": "", + "confidenceGroups": _default_confidence_groups(), + "error": str(exc), + } + + @app.get("/api/evolution") + def evolution_timeline() -> Dict[str, Any]: + events = _load_evolution_events() + return {"live": bool(events), "events": events} + + # ─── Conflicts ─────────────────────────────────────────────────────────── + + @app.get("/api/conflicts") + def conflicts() -> Dict[str, Any]: + try: + mem = _get_memory() + items: List[Dict[str, Any]] = [] + supported = bool(hasattr(mem, "resolve_conflict")) + if hasattr(mem, "get_conflicts"): + items = list(mem.get_conflicts()) # type: ignore[attr-defined] + return { + "live": True, + "supported": supported, + "conflicts": items, + "resolutionMode": "native" if supported else "read-only", + } + except Exception as exc: # noqa: BLE001 + log.info("conflicts: no live adapter (%s)", exc) + return { + "live": False, + "supported": False, + "conflicts": [], + "resolutionMode": "unavailable", + } + + @app.post("/api/conflicts/{conflict_id}/resolve") + def resolve_conflict( + conflict_id: str, + payload: ConflictResolutionPayload = Body(...), + ) -> Dict[str, Any]: + try: + mem = _get_memory() + if not hasattr(mem, "resolve_conflict"): + raise HTTPException( + status_code=501, + detail="Conflict resolution is not available in this Dhee runtime yet", + ) + result = mem.resolve_conflict( # type: ignore[attr-defined] + conflict_id, + payload.action, + merged_content=payload.merged_content, + reason=payload.reason, + ) + return {"ok": True, "id": conflict_id, "action": payload.action, "result": result} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + # ─── Projects / Workspaces / Sessions ─────────────────────────────────── + + @app.get("/api/projects") + def list_projects_api() -> Dict[str, Any]: + try: + return _build_project_index_payload() + except Exception as exc: # noqa: BLE001 + log.warning("projects failed: %s", exc) + return { + "live": False, + "workspaces": [], + "currentProjectId": "", + "currentWorkspaceId": "", + "currentSessionId": "", + "error": str(exc), + } + + @app.get("/api/workspaces") + def list_workspaces_api() -> Dict[str, Any]: + try: + return _build_project_index_payload() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/workspaces") + def create_workspace_api(payload: WorkspaceRootCreatePayload) -> Dict[str, Any]: + try: + db = _get_db() + workspace_name = _display_name(payload.name, fallback="Workspace") + if not workspace_name: + raise HTTPException(status_code=400, detail="Workspace name is required") + workspace = db.upsert_workspace( + { + "user_id": _ui_user_id(), + "name": workspace_name, + "description": payload.description, + "root_path": None, + "metadata": {"created_via": "sankhya-ui"}, + } + ) + _mirror_runtime_sessions(db) + return {"ok": True, "workspace": _workspace_summary(db, workspace)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/legacy-projects") + def create_project_api(payload: WorkspaceRootCreatePayload) -> Dict[str, Any]: + try: + db = _get_db() + workspace_name = _display_name(payload.name, fallback_path=payload.root_path) + workspace = db.upsert_workspace( + { + "user_id": _ui_user_id(), + "name": workspace_name, + "description": payload.description, + "root_path": _abs_user_path(payload.root_path) or None, + "metadata": {"created_via": "sankhya-ui"}, + } + ) + _mirror_runtime_sessions(db, extra_paths=[_workspace_primary_path(workspace)]) + return {"ok": True, "workspace": _workspace_summary(db, workspace)} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/legacy-projects/{project_id}") + def get_project_api(project_id: str) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + workspace = db.get_workspace(project_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + return {"live": True, "workspace": _workspace_summary(db, workspace)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/projects/{project_id}/canvas") + def project_canvas_api(project_id: str) -> Dict[str, Any]: + try: + return _build_project_canvas_payload(project_id) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/projects/{project_id}/workspaces") + def list_project_workspaces_api(project_id: str) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + rows = db.list_project_workspaces( + user_id=_ui_user_id(), + project_id=project_id, + limit=100, + ) + return { + "live": True, + "workspaces": [_workspace_summary(db, row) for row in rows], + } + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/projects/{project_id}/workspaces") + def create_project_workspace_api( + project_id: str, + payload: WorkspaceCreatePayload, + ) -> Dict[str, Any]: + try: + db = _get_db() + workspace_path = _abs_user_path(payload.workspace_path) + if not workspace_path: + raise HTTPException(status_code=400, detail="workspace_path is required") + workspace = db.upsert_project_workspace( + { + "user_id": _ui_user_id(), + "project_id": project_id, + "workspace_path": workspace_path, + "label": payload.label or (os.path.basename(workspace_path) or workspace_path), + "folder_path": payload.folder_path or ".", + "is_primary": payload.is_primary, + "metadata": { + "created_via": "sankhya-ui", + "folders": [ + {"path": os.path.abspath(os.path.expanduser(folder))} + for folder in (payload.folders or []) + if str(folder).strip() + ], + }, + } + ) + _mirror_runtime_sessions(db, extra_paths=[workspace_path]) + return {"ok": True, "workspace": _workspace_summary(db, workspace)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/workspaces/{workspace_id}/projects") + def list_workspace_projects_api(workspace_id: str) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + projects = [ + _project_summary(db, project) + for project in db.list_workspace_projects( + workspace_id=workspace_id, + user_id=_ui_user_id(), + limit=100, + ) + ] + return {"live": True, "projects": projects} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/workspaces/{workspace_id}/projects") + def create_workspace_project_api( + workspace_id: str, + payload: WorkspaceProjectCreatePayload, + ) -> Dict[str, Any]: + try: + db = _get_db() + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + project_name = _display_name(payload.name, fallback="Project") + if not project_name: + raise HTTPException(status_code=400, detail="Project name is required") + project = db.upsert_workspace_project( + { + "workspace_id": workspace_id, + "user_id": _ui_user_id(), + "name": project_name, + "description": payload.description, + "default_runtime": payload.default_runtime or "codex", + "color": payload.color, + "icon": payload.icon, + "metadata": {"created_via": "sankhya-ui"}, + } + ) + rules = _normalize_scope_rules(payload.scope_rules) + db.replace_workspace_project_scope_rules( + project_id=str(project.get("id") or ""), + user_id=_ui_user_id(), + rules=rules, + ) + _mirror_runtime_sessions( + db, + extra_paths=[str(rule.get("path_prefix") or "") for rule in rules], + ) + return {"ok": True, "project": _project_summary(db, project)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/projects/{project_id}") + def get_workspace_project_api(project_id: str) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + project = db.get_workspace_project(project_id, user_id=_ui_user_id()) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return {"live": True, "project": _project_summary(db, project)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.patch("/api/projects/{project_id}") + def update_workspace_project_api( + project_id: str, + payload: WorkspaceProjectUpdatePayload, + ) -> Dict[str, Any]: + try: + db = _get_db() + project = db.get_workspace_project(project_id, user_id=_ui_user_id()) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + updated = db.upsert_workspace_project( + { + "id": project_id, + "workspace_id": project.get("workspace_id"), + "user_id": _ui_user_id(), + "name": str(payload.name or project.get("name") or "").strip() or project.get("name"), + "description": payload.description if payload.description is not None else project.get("description"), + "default_runtime": payload.default_runtime or project.get("default_runtime") or "codex", + "color": payload.color if payload.color is not None else project.get("color"), + "icon": payload.icon if payload.icon is not None else project.get("icon"), + "metadata": dict(project.get("metadata") or {}), + } + ) + scan_paths: List[str] = [] + if payload.scope_rules is not None: + rules = _normalize_scope_rules(payload.scope_rules) + db.replace_workspace_project_scope_rules( + project_id=project_id, + user_id=_ui_user_id(), + rules=rules, + ) + scan_paths.extend(str(rule.get("path_prefix") or "") for rule in rules) + workspace = db.get_workspace(str(project.get("workspace_id") or ""), user_id=_ui_user_id()) + if workspace: + scan_paths.append(_workspace_primary_path(workspace)) + _mirror_runtime_sessions(db, extra_paths=scan_paths) + return {"ok": True, "project": _project_summary(db, updated)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/projects/{project_id}/sessions") + def list_workspace_project_sessions_api(project_id: str) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + project = db.get_workspace_project(project_id, user_id=_ui_user_id()) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + sessions = [ + _session_summary(db, session) + for session in _workspace_project_sessions(db, project) + ] + return {"live": True, "sessions": sessions} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/folders/pick") + def pick_folder_api(payload: Optional[FolderPickPayload] = None) -> Dict[str, Any]: + try: + prompt = (payload.prompt if payload else None) or "Select folder" + safe_prompt = prompt.replace("\\", "\\\\").replace('"', '\\"') + script = ( + f'set chosenFolder to choose folder with prompt "{safe_prompt}"\n' + "POSIX path of chosenFolder" + ) + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + if "User canceled" in detail or "User cancelled" in detail: + return {"ok": False, "cancelled": True} + raise HTTPException(status_code=400, detail=detail or "Folder picker failed") + return {"ok": True, "path": (result.stdout or "").strip()} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/workspaces/{workspace_id}/folders") + @app.post("/api/workspaces/{workspace_id}/mounts") + def add_workspace_folder_api( + workspace_id: str, + payload: WorkspaceFolderPayload, + ) -> Dict[str, Any]: + try: + db = _get_db() + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + resolved = _abs_user_path(payload.path) + if not resolved: + raise HTTPException(status_code=400, detail="path is required") + mounts = db.list_workspace_mounts(workspace_id=workspace_id, user_id=_ui_user_id()) + if not any(str(mount.get("mount_path") or "") == resolved for mount in mounts): + db.upsert_workspace_mount( + { + "workspace_id": workspace_id, + "user_id": _ui_user_id(), + "mount_path": resolved, + "label": payload.label or os.path.basename(resolved) or resolved, + "is_primary": not mounts, + } + ) + updated = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not updated: + raise HTTPException(status_code=404, detail="Workspace not found") + _mirror_runtime_sessions(db, extra_paths=[resolved]) + return {"ok": True, "workspace": _workspace_summary(db, updated)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.patch("/api/workspaces/{workspace_id}") + def update_workspace_api( + workspace_id: str, + payload: WorkspaceUpdatePayload, + ) -> Dict[str, Any]: + try: + db = _get_db() + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + next_name = str(payload.label or "").strip() or str(workspace.get("name") or "").strip() + if not next_name: + raise HTTPException(status_code=400, detail="Workspace name is required") + next_description = ( + payload.description + if payload.description is not None + else workspace.get("description") + ) + next_root = ( + _abs_user_path(payload.root_path) + if payload.root_path is not None and str(payload.root_path).strip() + else _workspace_primary_path(workspace) + ) + updated = db.upsert_workspace( + { + "id": workspace_id, + "user_id": _ui_user_id(), + "name": next_name, + "description": next_description, + "root_path": next_root, + "metadata": dict(workspace.get("metadata") or {}), + } + ) + # Keep the mount table in sync so the workspace graph, + # asset drawer path resolution, and folder list all see + # the new primary root immediately. + if payload.root_path is not None and str(payload.root_path).strip(): + resolved_root = _abs_user_path(next_root) + try: + # Demote any pre-existing primary mounts to non-primary. + for mount in db.list_workspace_mounts(workspace_id=workspace_id, user_id=_ui_user_id()): + if mount.get("is_primary") and str(mount.get("mount_path") or "") != resolved_root: + db.upsert_workspace_mount( + { + "workspace_id": workspace_id, + "user_id": _ui_user_id(), + "mount_path": str(mount.get("mount_path") or ""), + "label": mount.get("label"), + "is_primary": False, + } + ) + db.upsert_workspace_mount( + { + "workspace_id": workspace_id, + "user_id": _ui_user_id(), + "mount_path": resolved_root, + "label": os.path.basename(resolved_root.rstrip(os.sep)) or resolved_root, + "is_primary": True, + } + ) + except Exception: + pass + _mirror_runtime_sessions(db, extra_paths=[next_root]) + return {"ok": True, "workspace": _workspace_summary(db, updated)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.delete("/api/workspaces/{workspace_id}") + def delete_workspace_api(workspace_id: str) -> Dict[str, Any]: + """Delete a workspace and everything scoped to it. + + Cascades through projects, mounts, line messages, project assets, + and session assets. We do the cascade application-side so we can + surface a friendly error if any step fails instead of leaving a + half-deleted workspace behind. + """ + try: + db = _get_db() + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + ok = False + if hasattr(db, "delete_workspace_cascade"): + ok = bool(db.delete_workspace_cascade(workspace_id, user_id=_ui_user_id())) + else: + raise HTTPException(status_code=501, detail="Cascade delete not supported by this DB") + return {"ok": ok, "id": workspace_id} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.delete("/api/projects/{project_id}") + def delete_project_api(project_id: str) -> Dict[str, Any]: + """Delete a project and its project-scoped assets + line messages. + + Workspace survives. Project-linked assets get their storage + files removed best-effort; anything that fails logs silently + rather than aborting the delete. + """ + try: + db = _get_db() + project = db.get_workspace_project(project_id, user_id=_ui_user_id()) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + ok = False + if hasattr(db, "delete_workspace_project_cascade"): + # Best-effort asset file cleanup before the DB cascade drops rows. + try: + for asset in db.list_project_assets(project_id=project_id, user_id=_ui_user_id()): + storage_path = str(asset.get("storage_path") or "") + if storage_path and os.path.exists(storage_path): + try: + os.remove(storage_path) + except Exception: + pass + except Exception: + pass + ok = bool(db.delete_workspace_project_cascade(project_id, user_id=_ui_user_id())) + else: + raise HTTPException(status_code=501, detail="Cascade delete not supported by this DB") + return {"ok": ok, "id": project_id, "workspace_id": project.get("workspace_id")} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.delete("/api/workspaces/{workspace_id}/folders") + @app.delete("/api/workspaces/{workspace_id}/mounts") + def remove_workspace_folder_api(workspace_id: str, path: str) -> Dict[str, Any]: + try: + db = _get_db() + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + resolved = os.path.abspath(os.path.expanduser(path)) + mounts = _workspace_folder_mounts( + {**workspace, "mounts": db.list_workspace_mounts(workspace_id=workspace_id, user_id=_ui_user_id())} + ) + retained = [mount for mount in mounts if str(mount.get("path") or "") != resolved] + if not retained: + raise HTTPException(status_code=400, detail="Workspace must keep at least one mounted folder") + primary_mount = next((mount for mount in retained if mount.get("primary")), None) or retained[0] + for mount in mounts: + db.delete_workspace_mount( + workspace_id=workspace_id, + mount_path=str(mount.get("path") or ""), + user_id=_ui_user_id(), + ) + for mount in retained: + db.upsert_workspace_mount( + { + "workspace_id": workspace_id, + "user_id": _ui_user_id(), + "mount_path": mount["path"], + "label": mount.get("label") or os.path.basename(mount["path"]) or mount["path"], + "is_primary": mount["path"] == primary_mount["path"], + } + ) + updated = db.upsert_workspace( + { + "id": workspace_id, + "user_id": _ui_user_id(), + "name": workspace.get("name"), + "description": workspace.get("description"), + "root_path": primary_mount["path"], + "metadata": dict(workspace.get("metadata") or {}), + } + ) + return {"ok": True, "workspace": _workspace_summary(db, updated)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/workspaces/{workspace_id}") + def get_workspace_api(workspace_id: str) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + return _workspace_detail_payload(db, workspace_id) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/workspaces/{workspace_id}/sessions") + def list_workspace_sessions_api(workspace_id: str) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + sessions = _workspace_sessions(db, workspace)[:100] + return { + "live": True, + "sessions": [_session_summary(db, session) for session in sessions], + } + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/workspaces/{workspace_id}/canvas") + def get_workspace_canvas_api(workspace_id: str) -> Dict[str, Any]: + try: + return _build_workspace_canvas_payload(workspace_id) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/sessions/{session_id}") + def get_session_api(session_id: str) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + return _session_detail_payload(db, session_id) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/sessions/{session_id}/messages") + def get_session_messages_api(session_id: str) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + session = db.get_agent_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return {"live": True, "messages": _session_messages_from_agent_session(session)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/sessions/{session_id}/assets") + async def upload_session_asset_api( + session_id: str, + file: UploadFile = File(...), + label: Optional[str] = Form(None), + ) -> Dict[str, Any]: + try: + db = _get_db() + session = db.get_agent_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + asset_root = Path(_dhee_data_dir_str()) / "session_assets" / session_id + asset_root.mkdir(parents=True, exist_ok=True) + filename = os.path.basename(file.filename or "asset") + stored = asset_root / f"{int(time.time() * 1000)}-{filename}" + size_bytes = 0 + with stored.open("wb") as handle: + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + size_bytes += len(chunk) + handle.write(chunk) + artifact_id = None + try: + from dhee.core.artifacts import ArtifactManager + + manager = ArtifactManager(db) + extracted_text = _extract_asset_text(str(stored), file.content_type) + if extracted_text: + parsed = manager.capture_host_parse( + path=str(stored), + extracted_text=extracted_text, + user_id=_ui_user_id(), + cwd=str(session.get("cwd") or session.get("workspace_id") or ""), + harness=str(session.get("runtime_id") or "dhee"), + extraction_source="sankhya-upload", + project_id=session.get("project_id"), + metadata={ + "label": label or filename, + "session_id": session_id, + "uploaded_via": "sankhya-ui", + }, + ) + artifact_id = str((parsed or {}).get("artifact_id") or "") or None + else: + attached = manager.attach( + str(stored), + user_id=_ui_user_id(), + cwd=str(session.get("cwd") or session.get("workspace_id") or ""), + harness=str(session.get("runtime_id") or "dhee"), + project_id=session.get("project_id"), + metadata={ + "label": label or filename, + "session_id": session_id, + "uploaded_via": "sankhya-ui", + }, + ) + artifact_id = str((attached or {}).get("artifact_id") or "") or None + except Exception: + artifact_id = None + asset = db.add_session_asset( + { + "project_id": session.get("project_id"), + "workspace_id": session.get("workspace_id"), + "session_id": session_id, + "user_id": _ui_user_id(), + "artifact_id": artifact_id, + "storage_path": str(stored), + "name": label or filename, + "mime_type": file.content_type, + "size_bytes": size_bytes, + "metadata": {"uploaded_via": "sankhya-ui", "original_name": filename}, + } + ) + return {"ok": True, "asset": asset} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # --------- Project / workspace assets (PR 3) ------------------------ + # Distinct from session assets: these live with the project and are + # visible to every agent the workspace ever runs. Dedup by SHA-256 + # within (workspace, project) so re-uploads don't duplicate storage. + + async def _persist_project_asset( + *, + workspace_id: str, + project_id: Optional[str], + folder: Optional[str], + file: UploadFile, + label: Optional[str], + ) -> Dict[str, Any]: + db = _get_db() + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + if project_id: + project = db.get_workspace_project(project_id, user_id=_ui_user_id()) + if not project or str(project.get("workspace_id") or "") != workspace_id: + raise HTTPException(status_code=404, detail="Project not found in workspace") + + asset_root = ( + Path(_dhee_data_dir_str()) + / "project_assets" + / workspace_id + / (project_id or "_workspace") + ) + asset_root.mkdir(parents=True, exist_ok=True) + filename = os.path.basename(file.filename or "asset") + stored = asset_root / f"{int(time.time() * 1000)}-{filename}" + + size_bytes = 0 + hasher = hashlib.sha256() + with stored.open("wb") as handle: + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + size_bytes += len(chunk) + hasher.update(chunk) + handle.write(chunk) + checksum = hasher.hexdigest() + + artifact_id: Optional[str] = None + try: + from dhee.core.artifacts import ArtifactManager + + manager = ArtifactManager(db) + extracted_text = _extract_asset_text(str(stored), file.content_type) + if extracted_text: + parsed = manager.capture_host_parse( + path=str(stored), + extracted_text=extracted_text, + user_id=_ui_user_id(), + cwd=str(workspace.get("root_path") or ""), + harness="dhee", + extraction_source="sankhya-upload", + project_id=project_id, + metadata={ + "label": label or filename, + "uploaded_via": "sankhya-ui", + "scope": "project" if project_id else "workspace", + }, + ) + artifact_id = str((parsed or {}).get("artifact_id") or "") or None + else: + attached = manager.attach( + str(stored), + user_id=_ui_user_id(), + cwd=str(workspace.get("root_path") or ""), + harness="dhee", + project_id=project_id, + metadata={ + "label": label or filename, + "uploaded_via": "sankhya-ui", + "scope": "project" if project_id else "workspace", + }, + ) + artifact_id = str((attached or {}).get("artifact_id") or "") or None + except Exception: + artifact_id = None + + asset = db.upsert_project_asset( + { + "workspace_id": workspace_id, + "project_id": project_id, + "user_id": _ui_user_id(), + "artifact_id": artifact_id, + "folder": folder, + "storage_path": str(stored), + "name": label or filename, + "mime_type": file.content_type, + "size_bytes": size_bytes, + "checksum": checksum, + "metadata": { + "uploaded_via": "sankhya-ui", + "original_name": filename, + "scope": "project" if project_id else "workspace", + }, + } + ) + + # Announce the upload on the workspace information line so sibling + # agents see that a new asset is available. + try: + from dhee.core.workspace_line import emit_agent_activity + + emit_agent_activity( + db, + user_id=_ui_user_id(), + tool_name="Upload", + packet_kind="asset_upload", + digest=f"asset uploaded · {asset.get('name')}", + cwd=str(workspace.get("root_path") or ""), + source_path=str(stored), + source_event_id=str(asset.get("id") or ""), + artifact_id=artifact_id, + harness="dhee", + agent_id="dhee", + metadata={ + "asset_id": asset.get("id"), + "scope": "project" if project_id else "workspace", + "project_id": project_id, + "workspace_id": workspace_id, + "size_bytes": size_bytes, + "checksum": checksum, + }, + ) + except Exception: + pass + + return asset + + def _project_asset_results_payload( + db: Any, asset: Dict[str, Any], *, limit: int = 16 + ) -> List[Dict[str, Any]]: + """Recent shared-task results that mention this asset's storage path. + + This is what makes the drawer feel alive: "Claude read it 4m ago", + "Codex grepped it 12m ago". We match on storage_path (exact) and + on the matching artifact_id when present, to cover the router + path (which only knows ptrs) and the hook path (which knows files). + """ + storage_path = str(asset.get("storage_path") or "") + if not storage_path: + return [] + # Match only on source_path. `shared_tasks.workspace_id` is a path + # string for router-tracked sessions, not a UUID — filtering on + # `project_assets.workspace_id` (a UUID) would always miss. + # source_path is unique enough. + try: + return db.list_shared_task_results_for_path( + user_id=_ui_user_id(), + source_path=storage_path, + limit=limit, + ) + except Exception: + return [] + + def _project_asset_to_payload(db: Any, asset: Dict[str, Any]) -> Dict[str, Any]: + return { + **asset, + "results": _project_asset_results_payload(db, asset), + } + + @app.get("/api/projects/{project_id}/assets") + def list_project_assets_api(project_id: str, limit: int = 100) -> Dict[str, Any]: + try: + db = _get_db() + project = db.get_workspace_project(project_id, user_id=_ui_user_id()) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + assets = db.list_project_assets( + project_id=project_id, user_id=_ui_user_id(), limit=limit + ) + return { + "live": True, + "project_id": project_id, + "workspace_id": project.get("workspace_id"), + "assets": [_project_asset_to_payload(db, asset) for asset in assets], + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/projects/{project_id}/assets") + async def upload_project_asset_api( + project_id: str, + file: UploadFile = File(...), + label: Optional[str] = Form(None), + folder: Optional[str] = Form(None), + ) -> Dict[str, Any]: + try: + db = _get_db() + project = db.get_workspace_project(project_id, user_id=_ui_user_id()) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + asset = await _persist_project_asset( + workspace_id=str(project.get("workspace_id") or ""), + project_id=project_id, + folder=folder, + file=file, + label=label, + ) + return {"ok": True, "asset": _project_asset_to_payload(db, asset)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/workspaces/{workspace_id}/assets") + def list_workspace_assets_api( + workspace_id: str, + include_project_assets: bool = True, + limit: int = 200, + ) -> Dict[str, Any]: + try: + db = _get_db() + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + assets = db.list_workspace_assets( + workspace_id=workspace_id, + user_id=_ui_user_id(), + include_project_assets=include_project_assets, + limit=limit, + ) + return { + "live": True, + "workspace_id": workspace_id, + "assets": [_project_asset_to_payload(db, asset) for asset in assets], + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/workspaces/{workspace_id}/assets") + async def upload_workspace_asset_api( + workspace_id: str, + file: UploadFile = File(...), + label: Optional[str] = Form(None), + folder: Optional[str] = Form(None), + project_id: Optional[str] = Form(None), + ) -> Dict[str, Any]: + try: + db = _get_db() + asset = await _persist_project_asset( + workspace_id=workspace_id, + project_id=project_id or None, + folder=folder, + file=file, + label=label, + ) + return {"ok": True, "asset": _project_asset_to_payload(db, asset)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.delete("/api/project-assets/{asset_id}") + def delete_project_asset_api(asset_id: str) -> Dict[str, Any]: + try: + db = _get_db() + asset = db.get_project_asset(asset_id) + if not asset: + raise HTTPException(status_code=404, detail="Asset not found") + ok = db.delete_project_asset(asset_id, user_id=_ui_user_id()) + if ok: + try: + storage_path = str(asset.get("storage_path") or "") + if storage_path and os.path.exists(storage_path): + os.remove(storage_path) + except Exception: + pass + return {"ok": bool(ok)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/files/{file_id:path}/context") + def file_context_api(file_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]: + try: + return {"live": True, **_file_context_payload(_get_db(), file_id, workspace_id=workspace_id)} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/assets/{asset_id}/context") + def asset_context_api(asset_id: str) -> Dict[str, Any]: + try: + return {"live": True, **_asset_context_payload(_get_db(), asset_id)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/assets/{asset_id}/ask") + def ask_asset_api(asset_id: str, payload: AssetAskPayload) -> Dict[str, Any]: + try: + db = _get_db() + context = _asset_context_payload(db, asset_id) + asset = context.get("asset") or {} + session = context.get("session") or {} + workspace_id = str(asset.get("workspace_id") or session.get("workspace_id") or "") + if not workspace_id: + raise HTTPException(status_code=400, detail="Asset is not attached to a workspace") + question = str(payload.question or "").strip() + if not question: + raise HTTPException(status_code=400, detail="Question is required") + launch = launch_workspace_session( + workspace_id, + SessionLaunchPayload( + runtime="claude-code", + title=f"Ask {asset.get('name') or 'asset'}", + permission_mode="standard", + ), + ) + task_id = str(launch.get("task_id") or "") + if task_id: + chunk_text = "\n\n".join( + f"chunk {chunk.get('chunk_index')}: {chunk.get('content')}" + for chunk in (context.get("chunks") or [])[:4] + ).strip() + db.save_shared_task_result( + { + "shared_task_id": task_id, + "project_id": asset.get("project_id"), + "workspace_id": workspace_id, + "result_key": f"asset-ask:{asset_id}:{int(time.time())}", + "packet_kind": "note", + "tool_name": "user_note", + "result_status": "completed", + "source_path": str(asset.get("storage_path") or ""), + "artifact_id": asset.get("artifact_id"), + "digest": ( + f"Answer the following question using the attached asset '{asset.get('name')}'.\n\n" + f"Question: {question}\n\n" + f"Known extracted context:\n{chunk_text or context.get('summary') or 'No extracted text available yet.'}" + ), + "metadata": { + "source": "asset_ask", + "asset_id": asset_id, + "question": question, + }, + } + ) + return {"ok": True, "launch": launch, "asset": asset, "question": question} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # ─── Tasks ─────────────────────────────────────────────────────────────── + + @app.get("/api/tasks") + def tasks() -> Dict[str, Any]: + """Repo-scoped shared tasks → UI task cards.""" + try: + db = _get_db() + sync = _mirror_runtime_sessions(db) + user_id = _ui_user_id() + repo = _ui_repo() + rows = db.list_shared_tasks( + user_id=user_id, + project_id=str((sync.get("project") or {}).get("id") or "") or None, + repo=repo, + limit=48, + ) + out = [ + _shared_task_to_ui_task(db, row, index=i) + for i, row in enumerate(rows) + if isinstance(row, dict) + ] + return {"live": True, "tasks": out, "repo": repo} + except Exception as exc: # noqa: BLE001 + log.warning("tasks failed: %s", exc) + return {"live": False, "tasks": [], "error": str(exc)} + + class TaskCreate(BaseModel): + title: str + harness: Optional[str] = None + + class TaskStatusUpdate(BaseModel): + status: str + + class TaskNotePayload(BaseModel): + content: str + + @app.post("/api/tasks") + def create_task(payload: TaskCreate) -> Dict[str, Any]: + try: + db = _get_db() + sync = _mirror_runtime_sessions(db) + task = db.upsert_shared_task( + { + "user_id": _ui_user_id(), + "project_id": (sync.get("project") or {}).get("id"), + "repo": _ui_repo(), + "workspace_id": (sync.get("workspace") or {}).get("id"), + "folder_path": (sync.get("workspace") or {}).get("folder_path"), + "title": payload.title, + "status": "paused", + "created_by": "sankhya-ui", + "metadata": { + "harness": _normalize_runtime(payload.harness), + "created_via": "sankhya-ui", + }, + } + ) + return { + "ok": True, + "task": _shared_task_to_ui_task(db, task, index=0), + } + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/tasks/{task_id}") + def task_detail(task_id: str, limit: int = 24) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + task = _get_shared_task_or_404(db, task_id) + ui_task = _shared_task_to_ui_task(db, task, index=0, result_limit=limit) + results = db.list_shared_task_results(shared_task_id=task_id, limit=limit) + return { + "live": True, + "task": ui_task, + "results": results, + "runtime": _get_runtime_status_payload(), + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/tasks/{task_id}/status") + def task_status_update(task_id: str, payload: TaskStatusUpdate) -> Dict[str, Any]: + try: + db = _get_db() + _mirror_runtime_sessions(db) + task = _get_shared_task_or_404(db, task_id) + status = str(payload.status or "").strip().lower() + if status not in {"active", "paused", "completed", "closed", "abandoned"}: + raise HTTPException(status_code=400, detail="Unsupported task status") + updated = _touch_shared_task( + db, + task, + status=status, + metadata_updates={"updated_via": "sankhya-ui"}, + ) + return { + "ok": True, + "task": _shared_task_to_ui_task(db, updated, index=0, result_limit=12), + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/tasks/{task_id}/notes") + def task_note_add(task_id: str, payload: TaskNotePayload) -> Dict[str, Any]: + try: + content = str(payload.content or "").strip() + if not content: + raise HTTPException(status_code=400, detail="content is required") + db = _get_db() + _mirror_runtime_sessions(db) + task = _get_shared_task_or_404(db, task_id) + metadata = dict(task.get("metadata") or {}) + result_id = db.save_shared_task_result( + { + "result_key": f"{task_id}:note:{int(time.time() * 1000)}", + "shared_task_id": task_id, + "user_id": _ui_user_id(), + "project_id": task.get("project_id"), + "repo": task.get("repo") or _ui_repo(), + "workspace_id": task.get("workspace_id"), + "folder_path": task.get("folder_path"), + "packet_kind": "note", + "tool_name": "user_note", + "result_status": "completed", + "digest": content, + "metadata": { + "created_via": "sankhya-ui", + "kind": "task_note", + "task_title": task.get("title"), + "harness": metadata.get("harness"), + }, + "harness": _normalize_runtime(metadata.get("harness")), + "agent_id": "sankhya-ui", + } + ) + updated = _touch_shared_task( + db, + task, + metadata_updates={"updated_via": "sankhya-ui"}, + ) + return { + "ok": True, + "task": _shared_task_to_ui_task(db, updated, index=0, result_limit=12), + "result": db.get_shared_task_result(result_id), + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # ─── Pointer Capture Memory OS ───────────────────────────────────────── + + @app.post("/api/capture/session/start") + def capture_session_start(payload: CaptureSessionStartPayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().start_capture_session( + user_id=_ui_user_id(), + source_app=payload.source_app, + namespace=payload.namespace, + metadata=payload.metadata, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/capture/session/{session_id}/end") + def capture_session_end(session_id: str, payload: CaptureSessionEndPayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().end_capture_session( + session_id, + distill=payload.distill, + summary_hint=payload.summary_hint or "", + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/capture/session/end") + def capture_session_end_legacy(payload: CaptureSessionEndPayload) -> Dict[str, Any]: + session_id = str(payload.session_id or "").strip() + if not session_id: + raise HTTPException(status_code=400, detail="session_id is required") + return capture_session_end(session_id, payload) + + @app.get("/api/capture/session/{session_id}") + def capture_session_get(session_id: str) -> Dict[str, Any]: + try: + return _get_memory_os_service().get_capture_session(session_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.post("/api/capture/action") + def capture_action(payload: CaptureActionPayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().record_action(payload.model_dump(exclude_none=True)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/capture/navigation") + def capture_navigation(payload: CaptureActionPayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().record_navigation(payload.model_dump(exclude_none=True)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/capture/observation") + def capture_observation(payload: CaptureObservationPayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().record_observation(payload.model_dump(exclude_none=True)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/capture/artifact") + def capture_artifact(payload: CaptureArtifactPayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().record_artifact(payload.model_dump(exclude_none=True)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/capture/timeline") + def capture_timeline(source_app: Optional[str] = None, limit: int = 30) -> Dict[str, Any]: + try: + return _get_memory_os_service().timeline( + user_id=_ui_user_id(), + source_app=source_app, + limit=limit, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/capture/preferences") + def capture_preferences() -> Dict[str, Any]: + try: + return _get_memory_os_service().list_capture_policies() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/capture/preferences") + def capture_preferences_set(payload: CapturePreferencePayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().set_capture_policy( + source_app=payload.source_app, + enabled=payload.enabled, + mode=payload.mode, + metadata=payload.metadata, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/memory/now") + def memory_now(source_app: Optional[str] = None, limit: int = 8) -> Dict[str, Any]: + try: + return _get_memory_os_service().memory_now( + user_id=_ui_user_id(), + source_app=source_app, + limit=limit, + ) + except Exception as exc: # noqa: BLE001 + return {"live": False, "memories": [], "engrams": [], "error": str(exc)} + + @app.post("/api/memory/ask") + def memory_ask(payload: MemoryAskPayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().memory_ask( + user_id=_ui_user_id(), + query=payload.query, + source_app=payload.source_app, + limit=payload.limit, + ) + except Exception as exc: # noqa: BLE001 + return {"live": False, "answer": "", "memories": [], "error": str(exc)} + + @app.post("/api/world-memory/context-pack") + def world_context_pack(payload: WorldContextPackPayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().world_context_pack( + user_id=_ui_user_id(), + current_frame_ref=payload.current_frame_ref, + current_context_text=payload.current_context_text, + task_instruction=payload.task_instruction, + recent_actions=payload.recent_actions, + limit=payload.limit, + ) + except Exception as exc: # noqa: BLE001 + return {"live": False, "context": [], "error": str(exc)} + + @app.post("/api/agents/context-pack") + def agents_context_pack(payload: AgentContextPackPayload) -> Dict[str, Any]: + try: + return _get_memory_os_service().agent_context_pack( + user_id=_ui_user_id(), + agent_id=payload.agent_id, + task_instruction=payload.task_instruction, + source_app=payload.source_app, + current_frame_ref=payload.current_frame_ref or "", + current_context_text=payload.current_context_text or "", + recent_actions=payload.recent_actions, + limit=payload.limit, + ) + except Exception as exc: # noqa: BLE001 + return {"live": False, "context": [], "error": str(exc)} + + # ─── Security / API Keys ──────────────────────────────────────────────── + + class ApiKeyPayload(BaseModel): + provider: str + apiKey: str + label: Optional[str] = None + + class RotateApiKeyPayload(BaseModel): + apiKey: str + label: Optional[str] = None + + @app.get("/api/security/api-keys") + def list_api_keys() -> Dict[str, Any]: + try: + from dhee import secret_store + + return {"live": True, "providers": secret_store.list_provider_statuses()} + except Exception as exc: # noqa: BLE001 + log.warning("list_api_keys failed: %s", exc) + return {"live": False, "providers": [], "error": str(exc)} + + @app.post("/api/security/api-keys") + def store_api_key(payload: ApiKeyPayload) -> Dict[str, Any]: + try: + from dhee import secret_store + + provider = secret_store.store_api_key( + payload.provider, + payload.apiKey, + label=payload.label, + ) + return {"ok": True, "provider": provider} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/security/api-keys/{provider}/rotate") + def rotate_api_key(provider: str, payload: RotateApiKeyPayload) -> Dict[str, Any]: + try: + from dhee import secret_store + + rotated = secret_store.rotate_api_key( + provider, + payload.apiKey, + label=payload.label, + ) + return {"ok": True, "provider": rotated} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # ─── Status + Launch ───────────────────────────────────────────────────── + + @app.get("/api/status") + def status() -> Dict[str, Any]: + try: + from dhee.router import stats as rstats + + s = rstats.compute_stats() + return { + "ok": True, + "router": { + "sessions": s.sessions, + "calls": s.total_calls, + "tokensSaved": s.est_tokens_diverted, + }, + "dhee_data_dir": _dhee_data_dir_str(), + } + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": str(exc)} + + @app.get("/api/runtime-status") + def runtime_status() -> Dict[str, Any]: + try: + return _get_runtime_status_payload() + except Exception as exc: # noqa: BLE001 + log.warning("runtime_status failed: %s", exc) + return { + "live": False, + "repo": _ui_repo(), + "runtimes": [], + "error": str(exc), + } + + class LaunchPayload(BaseModel): + taskId: Optional[str] = None + runtime: str # claude-code | codex | both + title: Optional[str] = None + permission_mode: Optional[str] = None + + @app.post("/api/workspaces/{workspace_id}/sessions/launch") + def launch_workspace_session( + workspace_id: str, + payload: SessionLaunchPayload, + ) -> Dict[str, Any]: + runtime = _normalize_runtime(payload.runtime) + if runtime is None: + raise HTTPException(status_code=400, detail="Unsupported runtime") + try: + db = _get_db() + _mirror_runtime_sessions(db) + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + project = None + if payload.project_id: + project = db.get_workspace_project(payload.project_id, user_id=_ui_user_id()) + if not project: + project = _resolve_workspace_project_for_path( + db, + workspace_id=workspace_id, + path=_workspace_primary_path(workspace), + ) + if not project: + projects = db.list_workspace_projects( + workspace_id=workspace_id, + user_id=_ui_user_id(), + limit=20, + ) + project = (projects[0] if projects else None) or _ensure_unassigned_workspace_project(db, workspace) + existing = None + if payload.task_id: + existing = db.get_shared_task(payload.task_id, user_id=_ui_user_id()) + title = str(payload.title or (existing or {}).get("title") or "").strip() + if not title: + raise HTTPException(status_code=400, detail="Task title is required") + permission_mode = _normalize_permission_mode(runtime, payload.permission_mode) + native_seed = f"{runtime}:{workspace_id}:{int(time.time() * 1000)}" + session_id = _agent_session_id(runtime, native_seed) + launch_cwd = _workspace_primary_path(workspace) + scope_rules = _workspace_project_scope_rules(db, str(project.get("id") or "")) + if scope_rules: + launch_cwd = str((scope_rules[0] or {}).get("path_prefix") or launch_cwd) + task = db.upsert_shared_task( + { + "id": (existing or {}).get("id") or _session_task_id(runtime, native_seed), + "user_id": _ui_user_id(), + "project_id": project.get("id") if project else None, + "repo": _ui_repo(), + "workspace_id": workspace_id, + "folder_path": ".", + "session_id": session_id, + "thread_id": native_seed, + "runtime_id": runtime, + "native_session_id": native_seed, + "title": title, + "status": "active", + "created_by": "sankhya-ui", + "metadata": { + "harness": runtime, + "created_via": "sankhya-ui", + "launch_requested": True, + "permission_mode": permission_mode, + }, + } + ) + session = db.upsert_agent_session( + { + "id": session_id, + "project_id": project.get("id") if project else None, + "workspace_id": workspace_id, + "user_id": _ui_user_id(), + "runtime_id": runtime, + "native_session_id": native_seed, + "task_id": task.get("id"), + "title": title, + "state": "launch-requested", + "cwd": launch_cwd, + "permission_mode": permission_mode, + "updated_at": _now_iso(), + "metadata": { + "messages": [], + "recent_tools": [], + "plan": [], + "touched_files": [], + "rate_limits": {}, + "preview": "Launch requested from Dhee.", + }, + } + ) + if runtime == "claude-code": + launch_command = f'cd "{launch_cwd}" && claude' + if permission_mode == "full-access": + launch_command += " --dangerously-skip-permissions" + elif runtime == "codex": + launch_command = f'cd "{launch_cwd}" && codex' + else: + launch_command = f'cd "{launch_cwd}" && dhee install --harness all' + return { + "ok": True, + "project_id": project.get("id") if project else None, + "workspace_id": workspace_id, + "session_id": session.get("id"), + "task_id": task.get("id"), + "runtime": runtime, + "permission_mode": permission_mode, + "launch_command": launch_command, + "control_state": "mirrored", + "session": _session_summary(db, session), + "task": _shared_task_to_ui_task(db, task, index=0, result_limit=12), + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.get("/api/workspaces/{workspace_id}/line/messages") + def list_workspace_line_messages_api( + workspace_id: str, + project_id: Optional[str] = None, + channel: Optional[str] = None, + cursor: Optional[str] = None, + limit: int = 50, + ) -> Dict[str, Any]: + try: + db = _get_db() + rows = db.list_workspace_line_messages( + workspace_id=workspace_id, + user_id=_ui_user_id(), + project_id=project_id, + channel=channel, + cursor=cursor, + limit=limit, + ) + return { + "live": True, + "messages": rows, + "nextCursor": _line_cursor(rows[-1] if rows else None), + } + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/workspaces/{workspace_id}/line/messages") + def publish_workspace_line_message_api( + workspace_id: str, + payload: WorkspaceLineMessagePayload, + ) -> Dict[str, Any]: + try: + db = _get_db() + workspace = db.get_workspace(workspace_id, user_id=_ui_user_id()) + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + source_project_id = str(payload.project_id or "").strip() or None + target_project_id = str(payload.target_project_id or "").strip() or None + suggested_task = None + if target_project_id and target_project_id != source_project_id: + suggested_task = _create_suggested_task_from_broadcast( + db, + workspace_id=workspace_id, + project_id=target_project_id, + source_project_id=source_project_id, + title=str(payload.title or "Workspace broadcast").strip() or "Workspace broadcast", + body=str(payload.body or "").strip(), + session_id=payload.session_id, + ) + message = db.add_workspace_line_message( + { + "workspace_id": workspace_id, + "project_id": source_project_id, + "target_project_id": target_project_id, + "user_id": _ui_user_id(), + "channel": payload.channel or ("project" if source_project_id else "workspace"), + "session_id": payload.session_id, + "task_id": (suggested_task or {}).get("id") or payload.task_id, + "message_kind": payload.message_kind, + "title": payload.title, + "body": payload.body, + "metadata": payload.metadata or {}, + } + ) + # Fan this onto the in-process bus so every SSE subscriber on + # this workspace gets it in the same tick — no 1s poll lag. + if message: + try: + from dhee.core.workspace_line_bus import publish as _publish_bus + + _publish_bus(message) + except Exception: + pass + return { + "ok": True, + "message": message, + "suggestedTask": _shared_task_to_ui_task(db, suggested_task, index=0, result_limit=8) + if suggested_task + else None, + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/workspaces/{workspace_id}/line/stream") + async def workspace_line_stream_api( + workspace_id: str, + project_id: Optional[str] = None, + channel: Optional[str] = None, + cursor: Optional[str] = None, + backfill: int = 0, + ) -> StreamingResponse: + """Real-time line stream via an in-process pub/sub bus. + + Replaces the prior 1s DB-poll loop. Each subscription gets + messages pushed in the same event-loop tick as the write, with + heartbeat keep-alives every 15s. A best-effort ``backfill`` + parameter lets the client catch up on the N most recent + messages on (re)connect without a separate REST call. + """ + from dhee.core.workspace_line_bus import iter_messages + + async def gen(): + db = _get_db() + # Optional backfill — send the most recent N messages so a + # reconnect doesn't require a separate REST hop. + if backfill and backfill > 0: + try: + rows = db.list_workspace_line_messages( + workspace_id=workspace_id, + user_id=_ui_user_id(), + project_id=project_id, + channel=channel, + cursor=cursor, + limit=min(int(backfill), 100), + ) + for row in reversed(rows): + yield f"data: {json.dumps(row)}\n\n" + except Exception: + pass + + try: + async for message in iter_messages( + workspace_id=workspace_id, + project_id=project_id, + channel=channel, + ): + if message is None: + yield ": keep-alive\n\n" + else: + yield f"data: {json.dumps(message)}\n\n" + except asyncio.CancelledError: + return + + return StreamingResponse(gen(), media_type="text/event-stream") + + @app.post("/api/projects/{project_id}/tasks/from-broadcast") + def create_task_from_broadcast_api( + project_id: str, + payload: WorkspaceLineMessagePayload, + ) -> Dict[str, Any]: + try: + db = _get_db() + project = db.get_workspace_project(project_id, user_id=_ui_user_id()) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + task = _create_suggested_task_from_broadcast( + db, + workspace_id=str(project.get("workspace_id") or ""), + project_id=project_id, + source_project_id=str(payload.project_id or "").strip() or None, + title=str(payload.title or "Workspace broadcast").strip() or "Workspace broadcast", + body=str(payload.body or "").strip(), + session_id=payload.session_id, + ) + return {"ok": True, "task": _shared_task_to_ui_task(db, task, index=0, result_limit=8)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/launch") + def launch(payload: LaunchPayload) -> Dict[str, Any]: + try: + db = _get_db() + sync = _mirror_runtime_sessions(db) + workspace_id = str((sync.get("workspace") or {}).get("id") or "") + if not workspace_id: + raise HTTPException(status_code=400, detail="No workspace available") + launched = launch_workspace_session( + workspace_id, + SessionLaunchPayload( + runtime=payload.runtime, + title=payload.title, + permission_mode=payload.permission_mode, + task_id=payload.taskId, + ), + ) + launched["taskId"] = launched.get("task_id") + launched["command"] = launched.get("launch_command") + launched["message"] = "Session prepared in Dhee. Open the native runtime with the launch command." + return launched + except HTTPException: + raise + + @app.get("/api/workspace/graph") + def workspace_graph( + workspace_id: Optional[str] = None, + project_id: Optional[str] = None, + ) -> Dict[str, Any]: + try: + if workspace_id: + return _build_workspace_canvas_payload(workspace_id, focus_project_id=project_id) + return _build_workspace_graph_payload() + except Exception as exc: # noqa: BLE001 + log.warning("workspace_graph failed: %s", exc) + return { + "live": False, + "repo": _ui_repo(), + "graph": {"nodes": [], "links": []}, + "sessions": [], + "tasks": [], + "files": [], + "workspaces": [], + "error": str(exc), + } + + # ─── Local context service used by the workspace UI ────────────────────── + + class _LocalContextStore: + def __init__(self, service: "_LocalContextService") -> None: + self.service = service + + def list_context_items(self, **kwargs: Any) -> List[Dict[str, Any]]: + rows = self.service.list_context_items() + team_id = kwargs.get("team_id") + project_id = kwargs.get("project_id") + scope = kwargs.get("scope") + if team_id: + rows = [r for r in rows if r.get("team_id") in {team_id, None, ""}] + if project_id: + rows = [r for r in rows if r.get("project_id") in {project_id, None, ""}] + if scope: + rows = [r for r in rows if r.get("scope") == scope] + return rows[: int(kwargs.get("limit") or 200)] + + def get_context_item(self, org_id: str, context_id: str) -> Optional[Dict[str, Any]]: + del org_id + for row in self.service.list_context_items(): + if str(row.get("context_id") or row.get("id") or "") == context_id: + return row + return None + + def add_context_item(self, item: Dict[str, Any]) -> Dict[str, Any]: + return self.service.add_context_item(item) + + def list_context_shares(self, **kwargs: Any) -> List[Dict[str, Any]]: + del kwargs + return [] + + def get_team(self, org_id: str, team_id: str) -> Optional[Dict[str, Any]]: + del org_id + return self.service.get_team(team_id) + + class _LocalContextService: + org_id = "local" + + def __init__(self) -> None: + self.store = _LocalContextStore(self) + + def close(self) -> None: + return + + def _state(self) -> Dict[str, Any]: + return _read_local_context_state() + + def _save(self, state: Dict[str, Any]) -> None: + _write_local_context_state(state) + + def get_context_manager(self, team_id: str) -> Dict[str, Any]: + return { + "manager_id": f"context-manager:{team_id}", + "owner_user_id": _ui_user_id(), + "display_name": "Dhee Context Manager", + "team_id": team_id, + } + + def get_team(self, team_id: str) -> Optional[Dict[str, Any]]: + state = self._state() + teams = state.get("teams") or {} + if team_id in teams: + return teams[team_id] + for row in (state.get("folders") or {}).values(): + if isinstance(row, dict) and row.get("team_id") == team_id: + return { + "team_id": team_id, + "name": team_id, + "project_id": row.get("project_id") or "local", + } + return None + + def list_context_items(self) -> List[Dict[str, Any]]: + path = Path(_ui_repo()) / ".dhee" / "context" / "entries.jsonl" + rows: List[Dict[str, Any]] = [] + if not path.exists(): + return rows + try: + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(item, dict): + continue + cid = str(item.get("context_id") or item.get("id") or "") + rows.append({ + "context_id": cid or hashlib.sha1(line.encode("utf-8")).hexdigest()[:12], + "title": item.get("title") or item.get("summary") or "Repo context", + "content": item.get("content") or item.get("body") or item.get("summary") or "", + "summary": item.get("summary") or item.get("content") or "", + "scope": item.get("scope") or "repo", + "kind": item.get("kind") or item.get("type") or "note", + "project_id": item.get("project_id"), + "team_id": item.get("team_id"), + "tags": item.get("tags") or [], + "metadata": item.get("metadata") or {}, + }) + except OSError: + return [] + return rows + + def add_context_item(self, item: Dict[str, Any]) -> Dict[str, Any]: + context_id = str(item.get("context_id") or f"ctx-{hashlib.sha1(json.dumps(item, sort_keys=True, default=str).encode('utf-8')).hexdigest()[:12]}") + row = {**item, "context_id": context_id, "id": context_id, "updated_at": _now_iso()} + path = Path(_ui_repo()) / ".dhee" / "context" / "entries.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(row, sort_keys=True) + "\n") + return row + + def add_context(self, **kwargs: Any) -> Dict[str, Any]: + return self.add_context_item(kwargs) + + def propose_context(self, **kwargs: Any) -> Dict[str, Any]: + return self.add_context_item({**kwargs, "status": "proposed"}) + + def approve_proposal(self, context_id: str, reviewer_user_id: Optional[str] = None) -> Dict[str, Any]: + return {"context_id": context_id, "status": "approved", "reviewer_user_id": reviewer_user_id} + + def reject_proposal(self, context_id: str, reviewer_user_id: Optional[str] = None) -> Dict[str, Any]: + return {"context_id": context_id, "status": "rejected", "reviewer_user_id": reviewer_user_id} + + def inbox_for_viewer(self, **kwargs: Any) -> Dict[str, Any]: + del kwargs + return {"proposals": [], "findings": []} + + def resolve_finding(self, finding_id: str, resolved_by: Optional[str] = None) -> Dict[str, Any]: + return {"finding_id": finding_id, "resolved_by": resolved_by, "status": "resolved"} + + def list_backlinks(self, context_id: str, limit: int = 50) -> List[Dict[str, Any]]: + del context_id, limit + return [] + + def set_integration(self, **kwargs: Any) -> Dict[str, Any]: + return {"integration_id": hashlib.sha1(json.dumps(kwargs, sort_keys=True, default=str).encode("utf-8")).hexdigest()[:12], **kwargs} + + def create_workspace(self, name: str, root_path: Optional[str] = None, default_branch: str = "main") -> Dict[str, Any]: + state = self._state() + ws_id = _ensure_default_workspace(state) + state["workspaces"][ws_id].update({ + "id": ws_id, + "name": name or "My Workspace", + "root_path": _abs_user_path(root_path) or _ui_repo(), + "default_branch": default_branch, + "updated_at": _now_iso(), + }) + self._save(state) + return state["workspaces"][ws_id] + + def reset_workspace(self) -> Dict[str, int]: + state = self._state() + counts = { + "projects": len(state.get("projects") or {}), + "teams": len(state.get("teams") or {}), + "folders": len(state.get("folders") or {}), + } + self._save(_empty_state()) + return counts + + def create_project(self, project_id: Optional[str], name: str, description: str = "") -> Dict[str, Any]: + state = self._state() + projects = state.setdefault("projects", {}) + pid = project_id or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "local" + project = {"project_id": pid, "name": name, "description": description, "updated_at": _now_iso()} + projects[pid] = project + self._save(state) + return project + + def delete_project(self, project_id: str) -> Dict[str, Any]: + state = self._state() + (state.get("projects") or {}).pop(project_id, None) + self._save(state) + return {"project_id": project_id} + + def create_project_team(self, project_id: str, team_id: Optional[str], name: str, description: str = "") -> Dict[str, Any]: + state = self._state() + teams = state.setdefault("teams", {}) + tid = team_id or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "local-dev" + team = {"team_id": tid, "project_id": project_id, "name": name, "description": description, "updated_at": _now_iso()} + teams[tid] = team + self._save(state) + return team + + def _add_folder(self, *, project_id: Optional[str], team_id: Optional[str], local_path: Optional[str], repo_url: Optional[str], label: Optional[str], kind: str) -> Dict[str, Any]: + state = self._state() + workspace_id = _ensure_default_workspace(state) + path = _abs_user_path(local_path) or repo_url or _ui_repo() + mapping_id = _folder_node_id(path).split(":", 1)[-1] + row = { + "mapping_id": mapping_id, + "path": path, + "local_path": _abs_user_path(local_path) or None, + "repo_url": repo_url, + "label": label or _folder_label(path), + "kind": kind, + "project_id": project_id, + "team_id": team_id, + "workspace_id": workspace_id, + "shared": True, + "linked_at": _now_iso(), + "source": "ui", + } + state.setdefault("folders", {})[path] = row + self._save(state) + return {"mapping": row, "folder": row, "mapping_id": mapping_id} + + def add_team_folder(self, team_id: str, local_path: Optional[str] = None, repo_url: Optional[str] = None, label: Optional[str] = None, kind: str = "folder") -> Dict[str, Any]: + team = self.get_team(team_id) or {} + return self._add_folder(project_id=team.get("project_id"), team_id=team_id, local_path=local_path, repo_url=repo_url, label=label, kind=kind) + + def add_project_folder(self, project_id: str, local_path: Optional[str] = None, repo_url: Optional[str] = None, label: Optional[str] = None, kind: str = "folder") -> Dict[str, Any]: + return self._add_folder(project_id=project_id, team_id=None, local_path=local_path, repo_url=repo_url, label=label, kind=kind) + + def remove_project_folder(self, mapping_id: str) -> Dict[str, Any]: + state = self._state() + folders = state.get("folders") or {} + for path, row in list(folders.items()): + if isinstance(row, dict) and str(row.get("mapping_id") or _folder_node_id(path).split(":", 1)[-1]) == mapping_id: + folders.pop(path, None) + self._save(state) + return {"mapping_id": mapping_id} + raise ValueError(f"folder mapping not found: {mapping_id}") + + def add_team_collaborator(self, team_id: str, target_team_id: str) -> Dict[str, Any]: + return { + "team": self.get_team(team_id) or {"team_id": team_id}, + "target_team": self.get_team(target_team_id) or {"team_id": target_team_id}, + "collaborating_team_ids": [target_team_id], + } + + def run_ast_extraction(self, project_id: str, team_id: Optional[str] = None) -> Dict[str, Any]: + state = self._state() + folders = [ + row for row in (state.get("folders") or {}).values() + if isinstance(row, dict) + and (not project_id or row.get("project_id") in {project_id, None}) + and (not team_id or row.get("team_id") == team_id) + ] + files_seen = 0 + for row in folders: + root = _abs_user_path(row.get("local_path") or row.get("path")) + if not root or not os.path.isdir(root): + continue + for _base, dirs, files in os.walk(root): + dirs[:] = [d for d in dirs if d not in {".git", "node_modules", ".venv", "__pycache__"}] + files_seen += len(files) + if files_seen >= 1000: + break + return { + "project_id": project_id, + "team_id": team_id, + "folders_seen": len(folders), + "files_seen": files_seen, + "files_extracted": 0, + "files_cached": files_seen, + "nodes_upserted": 0, + "edges_upserted": 0, + "errors": [], + } + + def _enterprise_service() -> _LocalContextService: + return _LocalContextService() + + def _resolve_repo_pointer() -> Dict[str, Any]: + cwd = Path(os.environ.get("DHEE_UI_REPO") or os.getcwd()).resolve() + home = Path.home().resolve() + for candidate in [cwd, *cwd.parents]: + if candidate == home: + break + cfg = candidate / ".dhee" / "config.json" + if cfg.is_file(): + try: + data = json.loads(cfg.read_text(encoding="utf-8")) + return {"repo_root": str(candidate), **(data if isinstance(data, dict) else {})} + except Exception: # noqa: BLE001 + return {} + digest = hashlib.sha1(str(candidate).encode("utf-8")).hexdigest()[:16] + private_cfg = Path.home() / ".dhee" / "repo_orgs" / f"{digest}.json" + if private_cfg.is_file(): + try: + data = json.loads(private_cfg.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: # noqa: BLE001 + return {} + return {} + + @app.get("/api/me") + def api_me() -> Dict[str, Any]: + pointer = _resolve_repo_pointer() + org_id = ( + pointer.get("org_id") + or os.environ.get("DHEE_UI_ORG_ID") + or "default" + ) + user_id = _ui_user_id() + team_id = pointer.get("team_id") + team_ids = [team_id] if team_id else [] + # role defaults to developer; manager flag flips when this user is the + # team's assigned context_manager.owner_user_id. + role = pointer.get("default_role") or "developer" + try: + svc = _enterprise_service() + try: + if team_id: + mgr = svc.get_context_manager(team_id) + if mgr and ( + mgr.get("owner_user_id") == user_id + or mgr.get("manager_id") == user_id + ): + role = "manager" + finally: + svc.close() + except Exception: # noqa: BLE001 + pass + return { + "live": True, + "user_id": user_id, + "org_id": org_id, + "project_id": pointer.get("project_id"), + "team_id": team_id, + "team_ids": team_ids, + "role": role, + "repo_root": str(pointer.get("repo_root") or _ui_repo()), + } + + @app.get("/api/continuity") + def api_continuity() -> Dict[str, Any]: + repo = _ui_repo() + pointer = _resolve_repo_pointer() + last_session: Optional[Dict[str, Any]] = None + error = "" + try: + from dhee.core.kernel import get_last_session + + last_session = get_last_session( + agent_id="claude-code", + repo=repo, + fallback_log_recovery=True, + user_id=_ui_user_id(), + requester_agent_id="dhee-ui", + ) + except Exception as exc: # noqa: BLE001 + error = str(exc) + try: + claude_sessions = _find_claude_sessions(repo, limit=5) + except Exception: # noqa: BLE001 + claude_sessions = [] + return { + "live": bool(last_session or claude_sessions), + "repo": repo, + "repo_config": pointer, + "last_session": last_session, + "claude_sessions": claude_sessions, + "error": error or None, + } + + # ── Local-context state file: workspaces + folders ─────────────── + # v1 schema (legacy): {"folders": {path: {...}}} + # v2 schema (now): {"schema_version": 2, + # "workspaces": {ws_id: {"name": ..., "created_at": ...}}, + # "folders": {path: {workspace_id: ws_id, ...}}} + # Reads accept both shapes. Writes always emit v2. Multi-workspace + # is supported in storage today; the create endpoint enforces a + # single-workspace cap so the UI stays simple — lift the cap by + # changing one constant when we're ready. + + LOCAL_CONTEXT_SCHEMA_VERSION = 2 + DEFAULT_WORKSPACE_ID = "default" + MAX_WORKSPACES = 1 # raise this when we ship multi-workspace UX + + def _local_context_state_path() -> Path: + return Path.home() / ".dhee" / "local_context_folders.json" + + def _empty_state() -> Dict[str, Any]: + return { + "schema_version": LOCAL_CONTEXT_SCHEMA_VERSION, + "workspaces": {}, + "folders": {}, + "projects": {}, + "teams": {}, + } + + def _read_local_context_state() -> Dict[str, Any]: + path = _local_context_state_path() + if not path.exists(): + return _empty_state() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + return _empty_state() + if not isinstance(data, dict): + return _empty_state() + + folders_raw = data.get("folders") + folders = folders_raw if isinstance(folders_raw, dict) else {} + + workspaces_raw = data.get("workspaces") + workspaces = workspaces_raw if isinstance(workspaces_raw, dict) else {} + + # v1 → v2 migration: any pre-existing folders implicitly join + # the default workspace. We auto-create that workspace here so + # the rest of the code can assume every folder has one. + if folders and not workspaces: + workspaces = { + DEFAULT_WORKSPACE_ID: { + "id": DEFAULT_WORKSPACE_ID, + "name": "My Workspace", + "created_at": _now_iso(), + } + } + + # Stamp every folder with a workspace_id so reads never have to + # guess. Doesn't persist until the next write — that's fine. + for fpath, frow in list(folders.items()): + if not isinstance(frow, dict): + continue + if not frow.get("workspace_id"): + frow["workspace_id"] = ( + next(iter(workspaces)) if workspaces else DEFAULT_WORKSPACE_ID + ) + + out = { + "schema_version": LOCAL_CONTEXT_SCHEMA_VERSION, + "workspaces": workspaces, + "folders": folders, + } + for key in ("projects", "teams", "collaborators"): + if isinstance(data.get(key), dict): + out[key] = data[key] + return out + + def _ensure_default_workspace(state: Dict[str, Any]) -> str: + """Make sure the state has at least one workspace; return its id. + + Single-workspace mode means: if no workspace exists yet, the + first folder add creates the default one. The cap is enforced + on explicit ``/api/workspace/create`` calls only; auto-creating + the implicit default never hits the cap. + """ + workspaces = state.get("workspaces") or {} + if not isinstance(workspaces, dict): + workspaces = {} + state["workspaces"] = workspaces + if workspaces: + return next(iter(workspaces)) + ws_id = DEFAULT_WORKSPACE_ID + workspaces[ws_id] = { + "id": ws_id, + "name": "My Workspace", + "created_at": _now_iso(), + } + return ws_id + + def _write_local_context_state(state: Dict[str, Any]) -> None: + path = _local_context_state_path() + path.parent.mkdir(parents=True, exist_ok=True) + # Always normalise on write so the disk format is exactly v2. + out = { + "schema_version": LOCAL_CONTEXT_SCHEMA_VERSION, + "workspaces": state.get("workspaces") or {}, + "folders": state.get("folders") or {}, + } + for key in ("projects", "teams", "collaborators"): + if isinstance(state.get(key), dict): + out[key] = state[key] + path.write_text(json.dumps(out, indent=2, sort_keys=True), encoding="utf-8") + + def _folder_node_id(path: str) -> str: + digest = hashlib.sha1(path.encode("utf-8")).hexdigest()[:14] + return f"folder:{digest}" + + def _session_node_id(session: Dict[str, Any]) -> str: + return str(session.get("id") or session.get("nativeSessionId") or hashlib.sha1( + json.dumps(session, sort_keys=True, default=str).encode("utf-8") + ).hexdigest()[:14]) + + def _folder_label(path: str) -> str: + return os.path.basename(path.rstrip(os.sep)) or path + + def _path_is_within(path: str, root: str) -> bool: + try: + return os.path.commonpath([path, root]) == root + except ValueError: + return False + + git_root_cache: Dict[str, str] = {} + + def _git_root_for_path(path: str) -> str: + resolved = _abs_user_path(path) + if not resolved: + return "" + probe = resolved if os.path.isdir(resolved) else os.path.dirname(resolved) + if not probe: + return "" + cached = git_root_cache.get(probe) + if cached is not None: + return cached + try: + result = subprocess.run( + ["git", "-C", probe, "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + timeout=0.8, + ) + except Exception: # noqa: BLE001 + git_root_cache[probe] = "" + return "" + if result.returncode != 0: + git_root_cache[probe] = "" + return "" + git_root_cache[probe] = _abs_user_path((result.stdout or "").strip()) or "" + return git_root_cache[probe] + + def _best_configured_root(path: str, configured_paths: List[str]) -> str: + best = "" + best_len = -1 + for folder in configured_paths: + try: + common = os.path.commonpath([path, folder]) + except ValueError: + continue + if common == folder and len(folder) > best_len: + best = folder + best_len = len(folder) + return best + + def _root_folder_for_path(path: Optional[str], configured_paths: Optional[List[str]] = None) -> str: + resolved = _abs_user_path(path) + if not resolved: + return "" + git_root = _git_root_for_path(resolved) + if git_root: + return git_root + ui_repo = _abs_user_path(_ui_repo()) + if ui_repo and _path_is_within(resolved, ui_repo): + return ui_repo + configured_root = _best_configured_root(resolved, configured_paths or []) + return configured_root or resolved + + def _folder_context_manager(path: str) -> Dict[str, Any]: + label = _folder_label(path) + return { + "manager_id": f"context-manager:{_folder_node_id(path).split(':', 1)[-1]}", + "display_name": f"{label} Context Manager", + "folder_path": path, + "charter": "Own context quality, freshness, and sharing for this local folder.", + "status": "active", + } + + ACTIVE_GRACE_SECONDS = 30 * 60 # survives quiet turns without reviving old rows + + def _is_session_active(session: Dict[str, Any]) -> bool: + """Return whether a session should be shown in live-only surfaces. + + Older mirrored rows were sometimes left with ``state=active`` forever. + Treat the stored state as a hint, then require either a fresh update, + a current mirror marker, or a live native process/pid. This keeps + FOLDERS and ROUTER from showing stale historical sessions as live. + """ + state_value = str(session.get("state") or "").lower() + if state_value in ("paused", "stale", "ended", "completed", "killed"): + return False + + metadata = dict(session.get("metadata") or {}) + runtime = str(session.get("runtime_id") or session.get("runtime") or "").lower() + updated_at = session.get("updated_at") or session.get("updatedAt") + + if _recent_enough(updated_at, seconds=ACTIVE_GRACE_SECONDS): + return True + + # Claude's local registry gives us the real process id. Prefer that + # over cwd-level process checks, because one Claude process in a repo + # should not resurrect every old session that once used that folder. + if runtime in {"claude-code", "claude"} and _pid_alive(metadata.get("pid")): + return True + + return False + + def _local_context_graph_payload(*, active_only: bool = False) -> Dict[str, Any]: + db = _get_db() + state = _read_local_context_state() + configured = state.get("folders") or {} + workspaces = state.get("workspaces") or {} + configured_paths = [ + _abs_user_path(path) + for path in configured.keys() + if _abs_user_path(path) + ] + process_paths = [ + _abs_user_path(proc.get("cwd")) + for proc in _runtime_processes() + if str(proc.get("runtime_id") or "") in {"codex", "claude-code"} + ] + extra_paths = [path for path in [*configured_paths, *process_paths] if path] + sync = _mirror_runtime_sessions(db, extra_paths=extra_paths) + raw_sessions = [ + session + for session in list(sync.get("sessions") or []) + if str(session.get("state") or "") != "stale" + ] + if not raw_sessions: + raw_sessions = [ + session + for session in db.list_agent_sessions(user_id=_ui_user_id(), limit=40) + if str(session.get("state") or "") == "active" + or _recent_enough(session.get("updated_at"), seconds=86_400) + ] + if active_only: + raw_sessions = [s for s in raw_sessions if _is_session_active(s)] + sessions = [_session_summary(db, session) for session in raw_sessions] + folder_paths: List[str] = [] + seen_paths: set[str] = set() + + def add_folder(path: Optional[str]) -> None: + resolved = _abs_user_path(path) + if not resolved or resolved in seen_paths: + return + seen_paths.add(resolved) + folder_paths.append(resolved) + + # Only the UI repo and configured paths seed the folder set. Sessions + # whose cwd lives in a subfolder are clustered into their root folder + # (git root → ui repo → nearest configured root), so the canvas shows + # one node per project and all its sessions share a single context. + add_folder(_root_folder_for_path(_ui_repo(), configured_paths) or _ui_repo()) + for path in configured_paths: + add_folder(_root_folder_for_path(path, configured_paths) or path) + + nodes: List[Dict[str, Any]] = [] + edges: List[Dict[str, Any]] = [] + folders: Dict[str, Dict[str, Any]] = {} + session_counts: Dict[str, int] = Counter() + active_counts: Dict[str, int] = Counter() + latest_by_folder: Dict[str, str] = {} + folder_for_session: Dict[str, str] = {} + + for session in sessions: + cwd = _abs_user_path(session.get("cwd")) + if not cwd: + continue + folder = _root_folder_for_path(cwd, configured_paths) + if not folder: + folder = cwd + add_folder(folder) + folder_for_session[_session_node_id(session)] = folder + session_counts[folder] += 1 + if str(session.get("state") or "") == "active" or session.get("isCurrent"): + active_counts[folder] += 1 + updated = str(session.get("updatedAt") or "") + if updated and updated > latest_by_folder.get(folder, ""): + latest_by_folder[folder] = updated + + def saved_rows_for_root(root: str) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + for saved_path, row in configured.items(): + resolved = _abs_user_path(saved_path) + if not resolved or not isinstance(row, dict): + continue + row_root = _root_folder_for_path(resolved, configured_paths) or resolved + if row_root == root: + rows.append(row) + return rows + + for path in folder_paths: + saved_rows = saved_rows_for_root(path) + if active_only and session_counts.get(path, 0) == 0 and not saved_rows: + continue + saved = saved_rows[0] if saved_rows else {} + shared = any(bool(row.get("shared")) for row in saved_rows) + linked = any(bool(row.get("linked")) for row in saved_rows) + folders[path] = { + "id": _folder_node_id(path), + "type": "folder", + "label": _folder_label(path), + "health": "healthy" if (shared or linked) else "watch", + "meta": { + "path": path, + "shared": shared, + "linked": linked, + "linked_at": saved.get("linked_at") if isinstance(saved, dict) else None, + "session_count": session_counts.get(path, 0), + "active_session_count": active_counts.get(path, 0), + "updated_at": latest_by_folder.get(path), + "source": "manual" if saved_rows else "session", + "context_manager": _folder_context_manager(path), + }, + } + + for path in sorted(folders): + nodes.append(folders[path]) + + for session in sessions: + session_id = _session_node_id(session) + folder = folder_for_session.get(session_id) + if not folder or folder not in folders: + continue + nodes.append({ + "id": session_id, + "type": "session", + "label": _session_title( + session.get("title"), + preview=session.get("preview"), + runtime=session.get("runtime"), + cwd=session.get("cwd"), + session_id=session.get("nativeSessionId") or session_id, + ), + "health": "healthy" if session.get("isCurrent") or session.get("state") == "active" else "watch", + "meta": { + "folder_path": folder, + "cwd": session.get("cwd"), + "runtime": session.get("runtime"), + "state": session.get("state"), + "model": session.get("model"), + "updated_at": session.get("updatedAt"), + "task_id": session.get("taskId"), + "preview": session.get("preview"), + "permission_mode": session.get("permissionMode"), + }, + }) + edges.append({"source": folders[folder]["id"], "target": session_id, "kind": "contains"}) + + shared_folder_ids = [row["id"] for row in folders.values() if (row.get("meta") or {}).get("shared")] + for idx in range(1, len(shared_folder_ids)): + edges.append({ + "source": shared_folder_ids[idx - 1], + "target": shared_folder_ids[idx], + "kind": "shares", + }) + + # Workspace summary — list every known workspace so the UI can + # render the workspace switcher (single-cap today, multi later). + workspace_list = [] + for ws_id, ws_row in (workspaces or {}).items(): + if not isinstance(ws_row, dict): + continue + ws_folders = [ + p for p, f in (configured or {}).items() + if isinstance(f, dict) and (f.get("workspace_id") or DEFAULT_WORKSPACE_ID) == ws_id + ] + workspace_list.append({ + "id": ws_id, + "name": ws_row.get("name") or "Workspace", + "created_at": ws_row.get("created_at"), + "folder_count": len(ws_folders), + }) + + return { + "live": True, + "org_id": "local", + "active_only": bool(active_only), + "workspaces": workspace_list, + "active_workspace_id": (workspace_list[0]["id"] if workspace_list else None), + "nodes": nodes, + "edges": edges, + "totals": { + "projects": 0, + "teams": len(sessions), + "repos": len(folders), + "context_items": 0, + "pending_proposals": 0, + "folders": len(folders), + "sessions": len(sessions), + "shared_folders": len(shared_folder_ids), + }, + "raw": { + "mode": "local_context", + "folders": list(folders.values()), + "sessions": sessions, + "shared_folder_paths": [ + str((row.get("meta") or {}).get("path") or "") + for row in folders.values() + if (row.get("meta") or {}).get("shared") + ], + "context_index": [], + "pending_proposals": [], + "context_managers_by_team": {}, + }, + } + + @app.get("/api/local-context") + def api_local_context() -> Dict[str, Any]: + return _local_context_graph_payload() + + @app.post("/api/local-context/folders") + def api_local_context_folder_add(payload: LocalContextFolderPayload) -> Dict[str, Any]: + """Add a folder for shared context. + + Two-step flow that matches the CLI: + + 1. If the path is inside a git repo, call ``repo_link.link()``. + That creates ``C||125ee?(C.sortIndex=W,t(c,C),n(d)===null&&C===n(c)&&(T?(h(z),z=-1):T=!0,ye(p,W-ee))):(C.sortIndex=ie,t(d,C),E||S||(E=!0,V(j))),C},e.unstable_shouldYield=J,e.unstable_wrapCallback=function(C){var A=x;return function(){var W=x;x=A;try{return C.apply(this,arguments)}finally{x=W}}}})(ac);sc.exports=ac;var Hp=sc.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Vp=v,vt=Hp;function L(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n "u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Ml=Object.prototype.hasOwnProperty,Kp=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,hd={},gd={};function Qp(e){return Ml.call(gd,e)?!0:Ml.call(hd,e)?!1:Kp.test(e)?gd[e]=!0:(hd[e]=!0,!1)}function Yp(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Jp(e,t,n,r){if(t===null||typeof t>"u"||Yp(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function lt(e,t,n,r,o,l,s){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=o,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=l,this.removeEmptyString=s}var Xe={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Xe[e]=new lt(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];Xe[t]=new lt(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){Xe[e]=new lt(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Xe[e]=new lt(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Xe[e]=new lt(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){Xe[e]=new lt(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){Xe[e]=new lt(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){Xe[e]=new lt(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){Xe[e]=new lt(e,5,!1,e.toLowerCase(),null,!1,!1)});var Os=/[\-:]([a-z])/g;function $s(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Os,$s);Xe[t]=new lt(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Os,$s);Xe[t]=new lt(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Os,$s);Xe[t]=new lt(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){Xe[e]=new lt(e,1,!1,e.toLowerCase(),null,!1,!1)});Xe.xlinkHref=new lt("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){Xe[e]=new lt(e,1,!1,e.toLowerCase(),null,!0,!0)});function Ds(e,t,n,r){var o=Xe.hasOwnProperty(t)?Xe[t]:null;(o!==null?o.type!==0:r||!(2 a||o[s]!==l[a]){var d=` +`+o[s].replace(" at new "," at ");return e.displayName&&d.includes(" ")&&(d=d.replace(" ",e.displayName)),d}while(1<=s&&0<=a);break}}}finally{il=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Ir(e):""}function Xp(e){switch(e.tag){case 5:return Ir(e.type);case 16:return Ir("Lazy");case 13:return Ir("Suspense");case 19:return Ir("SuspenseList");case 0:case 2:case 15:return e=ol(e.type,!1),e;case 11:return e=ol(e.type.render,!1),e;case 1:return e=ol(e.type,!0),e;default:return""}}function Ul(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Zn:return"Fragment";case qn:return"Portal";case Il:return"Profiler";case Ms:return"StrictMode";case Bl:return"Suspense";case Al:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case cc:return(e.displayName||"Context")+".Consumer";case uc:return(e._context.displayName||"Context")+".Provider";case Is:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Bs:return t=e.displayName||null,t!==null?t:Ul(e.type)||"Memo";case en:t=e._payload,e=e._init;try{return Ul(e(t))}catch{}}return null}function qp(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Ul(t);case 8:return t===Ms?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function yn(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function pc(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Zp(e){var t=pc(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var o=n.get,l=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(s){r=""+s,l.call(this,s)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(s){r=""+s},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function zi(e){e._valueTracker||(e._valueTracker=Zp(e))}function mc(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=pc(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function fo(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Wl(e,t){var n=t.checked;return Ne({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function yd(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=yn(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function hc(e,t){t=t.checked,t!=null&&Ds(e,"checked",t,!1)}function Hl(e,t){hc(e,t);var n=yn(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Vl(e,t.type,n):t.hasOwnProperty("defaultValue")&&Vl(e,t.type,yn(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function xd(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Vl(e,t,n){(t!=="number"||fo(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Br=Array.isArray;function dr(e,t,n,r){if(e=e.options,t){t={};for(var o=0;o "+t.valueOf().toString()+"",t=Ri.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function ri(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Kr={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Gp=["Webkit","ms","Moz","O"];Object.keys(Kr).forEach(function(e){Gp.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Kr[t]=Kr[e]})});function xc(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Kr.hasOwnProperty(e)&&Kr[e]?(""+t).trim():t+"px"}function Sc(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,o=xc(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,o):e[n]=o}}var em=Ne({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Yl(e,t){if(t){if(em[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(L(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(L(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(L(61))}if(t.style!=null&&typeof t.style!="object")throw Error(L(62))}}function Jl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Xl=null;function As(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ql=null,ur=null,cr=null;function jd(e){if(e=ji(e)){if(typeof ql!="function")throw Error(L(280));var t=e.stateNode;t&&(t=Wo(t),ql(e.stateNode,e.type,t))}}function kc(e){ur?cr?cr.push(e):cr=[e]:ur=e}function jc(){if(ur){var e=ur,t=cr;if(cr=ur=null,jd(e),t)for(e=0;e >>=0,e===0?32:31-(cm(e)/fm|0)|0}var Ni=64,Pi=4194304;function Ar(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function go(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,o=e.suspendedLanes,l=e.pingedLanes,s=n&268435455;if(s!==0){var a=s&~o;a!==0?r=Ar(a):(l&=s,l!==0&&(r=Ar(l)))}else s=n&~o,s!==0?r=Ar(s):l!==0&&(r=Ar(l));if(r===0)return 0;if(t!==0&&t!==r&&!(t&o)&&(o=r&-r,l=t&-t,o>=l||o===16&&(l&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0 n;n++)t.push(e);return t}function Si(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Rt(t),e[t]=n}function gm(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0 =Yr),Nd=" ",Pd=!1;function Uc(e,t){switch(e){case"keyup":return Hm.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Wc(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Gn=!1;function Km(e,t){switch(e){case"compositionend":return Wc(t);case"keypress":return t.which!==32?null:(Pd=!0,Nd);case"textInput":return e=t.data,e===Nd&&Pd?null:e;default:return null}}function Qm(e,t){if(Gn)return e==="compositionend"||!Js&&Uc(e,t)?(e=Bc(),Gi=Ks=ln=null,Gn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1 =t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=$d(n)}}function Qc(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Qc(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Yc(){for(var e=window,t=fo();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=fo(e.document)}return t}function Xs(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function nh(e){var t=Yc(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Qc(n.ownerDocument.documentElement,n)){if(r!==null&&Xs(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var o=n.textContent.length,l=Math.min(r.start,o);r=r.end===void 0?l:Math.min(r.end,o),!e.extend&&l>r&&(o=r,r=l,l=o),o=Dd(n,l);var s=Dd(n,r);o&&s&&(e.rangeCount!==1||e.anchorNode!==o.node||e.anchorOffset!==o.offset||e.focusNode!==s.node||e.focusOffset!==s.offset)&&(t=t.createRange(),t.setStart(o.node,o.offset),e.removeAllRanges(),l>r?(e.addRange(t),e.extend(s.node,s.offset)):(t.setEnd(s.node,s.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n =document.documentMode,er=null,rs=null,Xr=null,is=!1;function Md(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;is||er==null||er!==fo(r)||(r=er,"selectionStart"in r&&Xs(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Xr&&di(Xr,r)||(Xr=r,r=xo(rs,"onSelect"),0 rr||(e.current=us[rr],us[rr]=null,rr--)}function ke(e,t){rr++,us[rr]=e.current,e.current=t}var xn={},nt=kn(xn),ut=kn(!1),In=xn;function gr(e,t){var n=e.type.contextTypes;if(!n)return xn;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var o={},l;for(l in n)o[l]=t[l];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=o),o}function ct(e){return e=e.childContextTypes,e!=null}function ko(){Ce(ut),Ce(nt)}function Vd(e,t,n){if(nt.current!==xn)throw Error(L(168));ke(nt,t),ke(ut,n)}function rf(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var o in r)if(!(o in t))throw Error(L(108,qp(e)||"Unknown",o));return Ne({},n,r)}function jo(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||xn,In=nt.current,ke(nt,e),ke(ut,ut.current),!0}function Kd(e,t,n){var r=e.stateNode;if(!r)throw Error(L(169));n?(e=rf(e,t,In),r.__reactInternalMemoizedMergedChildContext=e,Ce(ut),Ce(nt),ke(nt,e)):Ce(ut),ke(ut,n)}var Ht=null,Ho=!1,xl=!1;function of(e){Ht===null?Ht=[e]:Ht.push(e)}function mh(e){Ho=!0,of(e)}function jn(){if(!xl&&Ht!==null){xl=!0;var e=0,t=ge;try{var n=Ht;for(ge=1;e >=s,o-=s,Vt=1<<32-Rt(t)+o|n< z?(Q=P,P=null):Q=P.sibling;var R=x(h,P,f[z],p);if(R===null){P===null&&(P=Q);break}e&&P&&R.alternate===null&&t(h,P),u=l(R,u,z),_===null?j=R:_.sibling=R,_=R,P=Q}if(z===f.length)return n(h,P),be&&zn(h,z),j;if(P===null){for(;z z?(Q=P,P=null):Q=P.sibling;var J=x(h,P,R.value,p);if(J===null){P===null&&(P=Q);break}e&&P&&J.alternate===null&&t(h,P),u=l(J,u,z),_===null?j=J:_.sibling=J,_=J,P=Q}if(R.done)return n(h,P),be&&zn(h,z),j;if(P===null){for(;!R.done;z++,R=f.next())R=m(h,R.value,p),R!==null&&(u=l(R,u,z),_===null?j=R:_.sibling=R,_=R);return be&&zn(h,z),j}for(P=r(h,P);!R.done;z++,R=f.next())R=S(P,h,z,R.value,p),R!==null&&(e&&R.alternate!==null&&P.delete(R.key===null?z:R.key),u=l(R,u,z),_===null?j=R:_.sibling=R,_=R);return e&&P.forEach(function(X){return t(h,X)}),be&&zn(h,z),j}function H(h,u,f,p){if(typeof f=="object"&&f!==null&&f.type===Zn&&f.key===null&&(f=f.props.children),typeof f=="object"&&f!==null){switch(f.$$typeof){case Ti:e:{for(var j=f.key,_=u;_!==null;){if(_.key===j){if(j=f.type,j===Zn){if(_.tag===7){n(h,_.sibling),u=o(_,f.props.children),u.return=h,h=u;break e}}else if(_.elementType===j||typeof j=="object"&&j!==null&&j.$$typeof===en&&Jd(j)===_.type){n(h,_.sibling),u=o(_,f.props),u.ref=Nr(h,_,f),u.return=h,h=u;break e}n(h,_);break}else t(h,_);_=_.sibling}f.type===Zn?(u=Dn(f.props.children,h.mode,p,f.key),u.return=h,h=u):(p=so(f.type,f.key,f.props,null,h.mode,p),p.ref=Nr(h,u,f),p.return=h,h=p)}return s(h);case qn:e:{for(_=f.key;u!==null;){if(u.key===_)if(u.tag===4&&u.stateNode.containerInfo===f.containerInfo&&u.stateNode.implementation===f.implementation){n(h,u.sibling),u=o(u,f.children||[]),u.return=h,h=u;break e}else{n(h,u);break}else t(h,u);u=u.sibling}u=El(f,h.mode,p),u.return=h,h=u}return s(h);case en:return _=f._init,H(h,u,_(f._payload),p)}if(Br(f))return E(h,u,f,p);if(_r(f))return T(h,u,f,p);Ii(h,f)}return typeof f=="string"&&f!==""||typeof f=="number"?(f=""+f,u!==null&&u.tag===6?(n(h,u.sibling),u=o(u,f),u.return=h,h=u):(n(h,u),u=_l(f,h.mode,p),u.return=h,h=u),s(h)):n(h,u)}return H}var yr=df(!0),uf=df(!1),bo=kn(null),_o=null,lr=null,ea=null;function ta(){ea=lr=_o=null}function na(e){var t=bo.current;Ce(bo),e._currentValue=t}function ps(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function pr(e,t){_o=e,ea=lr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(dt=!0),e.firstContext=null)}function Ct(e){var t=e._currentValue;if(ea!==e)if(e={context:e,memoizedValue:t,next:null},lr===null){if(_o===null)throw Error(L(308));lr=e,_o.dependencies={lanes:0,firstContext:e}}else lr=lr.next=e;return t}var Fn=null;function ra(e){Fn===null?Fn=[e]:Fn.push(e)}function cf(e,t,n,r){var o=t.interleaved;return o===null?(n.next=n,ra(t)):(n.next=o.next,o.next=n),t.interleaved=n,Xt(e,r)}function Xt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var tn=!1;function ia(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ff(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Qt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function pn(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,pe&2){var o=r.pending;return o===null?t.next=t:(t.next=o.next,o.next=t),r.pending=t,Xt(e,n)}return o=r.interleaved,o===null?(t.next=t,ra(r)):(t.next=o.next,o.next=t),r.interleaved=t,Xt(e,n)}function to(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Ws(e,n)}}function Xd(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var o=null,l=null;if(n=n.firstBaseUpdate,n!==null){do{var s={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};l===null?o=l=s:l=l.next=s,n=n.next}while(n!==null);l===null?o=l=t:l=l.next=t}else o=l=t;n={baseState:r.baseState,firstBaseUpdate:o,lastBaseUpdate:l,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Eo(e,t,n,r){var o=e.updateQueue;tn=!1;var l=o.firstBaseUpdate,s=o.lastBaseUpdate,a=o.shared.pending;if(a!==null){o.shared.pending=null;var d=a,c=d.next;d.next=null,s===null?l=c:s.next=c,s=d;var g=e.alternate;g!==null&&(g=g.updateQueue,a=g.lastBaseUpdate,a!==s&&(a===null?g.firstBaseUpdate=c:a.next=c,g.lastBaseUpdate=d))}if(l!==null){var m=o.baseState;s=0,g=c=d=null,a=l;do{var x=a.lane,S=a.eventTime;if((r&x)===x){g!==null&&(g=g.next={eventTime:S,lane:0,tag:a.tag,payload:a.payload,callback:a.callback,next:null});e:{var E=e,T=a;switch(x=t,S=n,T.tag){case 1:if(E=T.payload,typeof E=="function"){m=E.call(S,m,x);break e}m=E;break e;case 3:E.flags=E.flags&-65537|128;case 0:if(E=T.payload,x=typeof E=="function"?E.call(S,m,x):E,x==null)break e;m=Ne({},m,x);break e;case 2:tn=!0}}a.callback!==null&&a.lane!==0&&(e.flags|=64,x=o.effects,x===null?o.effects=[a]:x.push(a))}else S={eventTime:S,lane:x,tag:a.tag,payload:a.payload,callback:a.callback,next:null},g===null?(c=g=S,d=m):g=g.next=S,s|=x;if(a=a.next,a===null){if(a=o.shared.pending,a===null)break;x=a,a=x.next,x.next=null,o.lastBaseUpdate=x,o.shared.pending=null}}while(!0);if(g===null&&(d=m),o.baseState=d,o.firstBaseUpdate=c,o.lastBaseUpdate=g,t=o.shared.interleaved,t!==null){o=t;do s|=o.lane,o=o.next;while(o!==t)}else l===null&&(o.shared.lanes=0);Un|=s,e.lanes=s,e.memoizedState=m}}function qd(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;t n?n:4,e(!0);var r=kl.transition;kl.transition={};try{e(!1),t()}finally{ge=n,kl.transition=r}}function zf(){return bt().memoizedState}function yh(e,t,n){var r=hn(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Rf(e))Nf(t,n);else if(n=cf(e,t,n,r),n!==null){var o=it();Nt(n,e,r,o),Pf(n,t,r)}}function xh(e,t,n){var r=hn(e),o={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Rf(e))Nf(t,o);else{var l=e.alternate;if(e.lanes===0&&(l===null||l.lanes===0)&&(l=t.lastRenderedReducer,l!==null))try{var s=t.lastRenderedState,a=l(s,n);if(o.hasEagerState=!0,o.eagerState=a,Pt(a,s)){var d=t.interleaved;d===null?(o.next=o,ra(t)):(o.next=d.next,d.next=o),t.interleaved=o;return}}catch{}finally{}n=cf(e,t,o,r),n!==null&&(o=it(),Nt(n,e,r,o),Pf(n,t,r))}}function Rf(e){var t=e.alternate;return e===Re||t!==null&&t===Re}function Nf(e,t){qr=zo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Pf(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Ws(e,n)}}var Ro={readContext:Ct,useCallback:qe,useContext:qe,useEffect:qe,useImperativeHandle:qe,useInsertionEffect:qe,useLayoutEffect:qe,useMemo:qe,useReducer:qe,useRef:qe,useState:qe,useDebugValue:qe,useDeferredValue:qe,useTransition:qe,useMutableSource:qe,useSyncExternalStore:qe,useId:qe,unstable_isNewReconciler:!1},Sh={readContext:Ct,useCallback:function(e,t){return $t().memoizedState=[e,t===void 0?null:t],e},useContext:Ct,useEffect:Gd,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,ro(4194308,4,Cf.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ro(4194308,4,e,t)},useInsertionEffect:function(e,t){return ro(4,2,e,t)},useMemo:function(e,t){var n=$t();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=$t();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=yh.bind(null,Re,e),[r.memoizedState,e]},useRef:function(e){var t=$t();return e={current:e},t.memoizedState=e},useState:Zd,useDebugValue:fa,useDeferredValue:function(e){return $t().memoizedState=e},useTransition:function(){var e=Zd(!1),t=e[0];return e=vh.bind(null,e[1]),$t().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=Re,o=$t();if(be){if(n===void 0)throw Error(L(407));n=n()}else{if(n=t(),Ve===null)throw Error(L(349));An&30||gf(r,t,n)}o.memoizedState=n;var l={value:n,getSnapshot:t};return o.queue=l,Gd(yf.bind(null,r,l,e),[e]),r.flags|=2048,vi(9,vf.bind(null,r,l,n,t),void 0,null),n},useId:function(){var e=$t(),t=Ve.identifierPrefix;if(be){var n=Kt,r=Vt;n=(r&~(1<<32-Rt(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=hi++,0 <\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=s.createElement(n,{is:r.is}):(e=s.createElement(n),n==="select"&&(s=e,r.multiple?s.multiple=!0:r.size&&(s.size=r.size))):e=s.createElementNS(e,n),e[Dt]=t,e[fi]=r,Uf(e,t,!1,!1),t.stateNode=e;e:{switch(s=Jl(n,r),n){case"dialog":we("cancel",e),we("close",e),o=r;break;case"iframe":case"object":case"embed":we("load",e),o=r;break;case"video":case"audio":for(o=0;o kr&&(t.flags|=128,r=!0,Pr(l,!1),t.lanes=4194304)}else{if(!r)if(e=To(s),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Pr(l,!0),l.tail===null&&l.tailMode==="hidden"&&!s.alternate&&!be)return Ze(t),null}else 2*Oe()-l.renderingStartTime>kr&&n!==1073741824&&(t.flags|=128,r=!0,Pr(l,!1),t.lanes=4194304);l.isBackwards?(s.sibling=t.child,t.child=s):(n=l.last,n!==null?n.sibling=s:t.child=s,l.last=s)}return l.tail!==null?(t=l.tail,l.rendering=t,l.tail=t.sibling,l.renderingStartTime=Oe(),t.sibling=null,n=Te.current,ke(Te,r?n&1|2:n&1),t):(Ze(t),null);case 22:case 23:return ya(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?mt&1073741824&&(Ze(t),t.subtreeFlags&6&&(t.flags|=8192)):Ze(t),null;case 24:return null;case 25:return null}throw Error(L(156,t.tag))}function Th(e,t){switch(Zs(t),t.tag){case 1:return ct(t.type)&&ko(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return xr(),Ce(ut),Ce(nt),sa(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return la(t),null;case 13:if(Ce(Te),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(L(340));vr()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Ce(Te),null;case 4:return xr(),null;case 10:return na(t.type._context),null;case 22:case 23:return ya(),null;case 24:return null;default:return null}}var Ai=!1,et=!1,zh=typeof WeakSet=="function"?WeakSet:Set,U=null;function sr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Pe(e,t,r)}else n.current=null}function js(e,t,n){try{n()}catch(r){Pe(e,t,r)}}var uu=!1;function Rh(e,t){if(os=vo,e=Yc(),Xs(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var o=r.anchorOffset,l=r.focusNode;r=r.focusOffset;try{n.nodeType,l.nodeType}catch{n=null;break e}var s=0,a=-1,d=-1,c=0,g=0,m=e,x=null;t:for(;;){for(var S;m!==n||o!==0&&m.nodeType!==3||(a=s+o),m!==l||r!==0&&m.nodeType!==3||(d=s+r),m.nodeType===3&&(s+=m.nodeValue.length),(S=m.firstChild)!==null;)x=m,m=S;for(;;){if(m===e)break t;if(x===n&&++c===o&&(a=s),x===l&&++g===r&&(d=s),(S=m.nextSibling)!==null)break;m=x,x=m.parentNode}m=S}n=a===-1||d===-1?null:{start:a,end:d}}else n=null}n=n||{start:0,end:0}}else n=null;for(ls={focusedElem:e,selectionRange:n},vo=!1,U=t;U!==null;)if(t=U,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,U=e;else for(;U!==null;){t=U;try{var E=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(E!==null){var T=E.memoizedProps,H=E.memoizedState,h=t.stateNode,u=h.getSnapshotBeforeUpdate(t.elementType===t.type?T:Et(t.type,T),H);h.__reactInternalSnapshotBeforeUpdate=u}break;case 3:var f=t.stateNode.containerInfo;f.nodeType===1?f.textContent="":f.nodeType===9&&f.documentElement&&f.removeChild(f.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(L(163))}}catch(p){Pe(t,t.return,p)}if(e=t.sibling,e!==null){e.return=t.return,U=e;break}U=t.return}return E=uu,uu=!1,E}function Zr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var o=r=r.next;do{if((o.tag&e)===e){var l=o.destroy;o.destroy=void 0,l!==void 0&&js(t,n,l)}o=o.next}while(o!==r)}}function Qo(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function ws(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Vf(e){var t=e.alternate;t!==null&&(e.alternate=null,Vf(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Dt],delete t[fi],delete t[ds],delete t[fh],delete t[ph])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Kf(e){return e.tag===5||e.tag===3||e.tag===4}function cu(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Kf(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Cs(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=So));else if(r!==4&&(e=e.child,e!==null))for(Cs(e,t,n),e=e.sibling;e!==null;)Cs(e,t,n),e=e.sibling}function bs(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(bs(e,t,n),e=e.sibling;e!==null;)bs(e,t,n),e=e.sibling}var Qe=null,Tt=!1;function Gt(e,t,n){for(n=n.child;n!==null;)Qf(e,t,n),n=n.sibling}function Qf(e,t,n){if(It&&typeof It.onCommitFiberUnmount=="function")try{It.onCommitFiberUnmount(Io,n)}catch{}switch(n.tag){case 5:et||sr(n,t);case 6:var r=Qe,o=Tt;Qe=null,Gt(e,t,n),Qe=r,Tt=o,Qe!==null&&(Tt?(e=Qe,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Qe.removeChild(n.stateNode));break;case 18:Qe!==null&&(Tt?(e=Qe,n=n.stateNode,e.nodeType===8?yl(e.parentNode,n):e.nodeType===1&&yl(e,n),si(e)):yl(Qe,n.stateNode));break;case 4:r=Qe,o=Tt,Qe=n.stateNode.containerInfo,Tt=!0,Gt(e,t,n),Qe=r,Tt=o;break;case 0:case 11:case 14:case 15:if(!et&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){o=r=r.next;do{var l=o,s=l.destroy;l=l.tag,s!==void 0&&(l&2||l&4)&&js(n,t,s),o=o.next}while(o!==r)}Gt(e,t,n);break;case 1:if(!et&&(sr(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(a){Pe(n,t,a)}Gt(e,t,n);break;case 21:Gt(e,t,n);break;case 22:n.mode&1?(et=(r=et)||n.memoizedState!==null,Gt(e,t,n),et=r):Gt(e,t,n);break;default:Gt(e,t,n)}}function fu(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new zh),t.forEach(function(r){var o=Ih.bind(null,e,r);n.has(r)||(n.add(r),r.then(o,o))})}}function _t(e,t){var n=t.deletions;if(n!==null)for(var r=0;r o&&(o=s),r&=~l}if(r=o,r=Oe()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Ph(r/1960))-r,10 e?16:e,sn===null)var r=!1;else{if(e=sn,sn=null,Fo=0,pe&6)throw Error(L(331));var o=pe;for(pe|=4,U=e.current;U!==null;){var l=U,s=l.child;if(U.flags&16){var a=l.deletions;if(a!==null){for(var d=0;d Oe()-ga?$n(e,0):ha|=n),ft(e,t)}function tp(e,t){t===0&&(e.mode&1?(t=Pi,Pi<<=1,!(Pi&130023424)&&(Pi=4194304)):t=1);var n=it();e=Xt(e,t),e!==null&&(Si(e,t,n),ft(e,n))}function Mh(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),tp(e,n)}function Ih(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,o=e.memoizedState;o!==null&&(n=o.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(L(314))}r!==null&&r.delete(t),tp(e,n)}var np;np=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ut.current)dt=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return dt=!1,_h(e,t,n);dt=!!(e.flags&131072)}else dt=!1,be&&t.flags&1048576&&lf(t,Co,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;io(e,t),e=t.pendingProps;var o=gr(t,nt.current);pr(t,n),o=da(null,t,r,e,o,n);var l=ua();return t.flags|=1,typeof o=="object"&&o!==null&&typeof o.render=="function"&&o.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,ct(r)?(l=!0,jo(t)):l=!1,t.memoizedState=o.state!==null&&o.state!==void 0?o.state:null,ia(t),o.updater=Ko,t.stateNode=o,o._reactInternals=t,hs(t,r,e,n),t=ys(null,t,r,!0,l,n)):(t.tag=0,be&&l&&qs(t),rt(null,t,o,n),t=t.child),t;case 16:r=t.elementType;e:{switch(io(e,t),e=t.pendingProps,o=r._init,r=o(r._payload),t.type=r,o=t.tag=Ah(r),e=Et(r,e),o){case 0:t=vs(null,t,r,e,n);break e;case 1:t=su(null,t,r,e,n);break e;case 11:t=ou(null,t,r,e,n);break e;case 14:t=lu(null,t,r,Et(r.type,e),n);break e}throw Error(L(306,r,""))}return t;case 0:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:Et(r,o),vs(e,t,r,o,n);case 1:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:Et(r,o),su(e,t,r,o,n);case 3:e:{if(If(t),e===null)throw Error(L(387));r=t.pendingProps,l=t.memoizedState,o=l.element,ff(e,t),Eo(t,r,null,n);var s=t.memoizedState;if(r=s.element,l.isDehydrated)if(l={element:r,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=l,t.memoizedState=l,t.flags&256){o=Sr(Error(L(423)),t),t=au(e,t,r,n,o);break e}else if(r!==o){o=Sr(Error(L(424)),t),t=au(e,t,r,n,o);break e}else for(ht=fn(t.stateNode.containerInfo.firstChild),gt=t,be=!0,zt=null,n=uf(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(vr(),r===o){t=qt(e,t,n);break e}rt(e,t,r,n)}t=t.child}return t;case 5:return pf(t),e===null&&fs(t),r=t.type,o=t.pendingProps,l=e!==null?e.memoizedProps:null,s=o.children,ss(r,o)?s=null:l!==null&&ss(r,l)&&(t.flags|=32),Mf(e,t),rt(e,t,s,n),t.child;case 6:return e===null&&fs(t),null;case 13:return Bf(e,t,n);case 4:return oa(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=yr(t,null,r,n):rt(e,t,r,n),t.child;case 11:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:Et(r,o),ou(e,t,r,o,n);case 7:return rt(e,t,t.pendingProps,n),t.child;case 8:return rt(e,t,t.pendingProps.children,n),t.child;case 12:return rt(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,o=t.pendingProps,l=t.memoizedProps,s=o.value,ke(bo,r._currentValue),r._currentValue=s,l!==null)if(Pt(l.value,s)){if(l.children===o.children&&!ut.current){t=qt(e,t,n);break e}}else for(l=t.child,l!==null&&(l.return=t);l!==null;){var a=l.dependencies;if(a!==null){s=l.child;for(var d=a.firstContext;d!==null;){if(d.context===r){if(l.tag===1){d=Qt(-1,n&-n),d.tag=2;var c=l.updateQueue;if(c!==null){c=c.shared;var g=c.pending;g===null?d.next=d:(d.next=g.next,g.next=d),c.pending=d}}l.lanes|=n,d=l.alternate,d!==null&&(d.lanes|=n),ps(l.return,n,t),a.lanes|=n;break}d=d.next}}else if(l.tag===10)s=l.type===t.type?null:l.child;else if(l.tag===18){if(s=l.return,s===null)throw Error(L(341));s.lanes|=n,a=s.alternate,a!==null&&(a.lanes|=n),ps(s,n,t),s=l.sibling}else s=l.child;if(s!==null)s.return=l;else for(s=l;s!==null;){if(s===t){s=null;break}if(l=s.sibling,l!==null){l.return=s.return,s=l;break}s=s.return}l=s}rt(e,t,o.children,n),t=t.child}return t;case 9:return o=t.type,r=t.pendingProps.children,pr(t,n),o=Ct(o),r=r(o),t.flags|=1,rt(e,t,r,n),t.child;case 14:return r=t.type,o=Et(r,t.pendingProps),o=Et(r.type,o),lu(e,t,r,o,n);case 15:return $f(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:Et(r,o),io(e,t),t.tag=1,ct(r)?(e=!0,jo(t)):e=!1,pr(t,n),Ff(t,r,o),hs(t,r,o,n),ys(null,t,r,!0,e,n);case 19:return Af(e,t,n);case 22:return Df(e,t,n)}throw Error(L(156,t.tag))};function rp(e,t){return zc(e,t)}function Bh(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function jt(e,t,n,r){return new Bh(e,t,n,r)}function Sa(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Ah(e){if(typeof e=="function")return Sa(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Is)return 11;if(e===Bs)return 14}return 2}function gn(e,t){var n=e.alternate;return n===null?(n=jt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function so(e,t,n,r,o,l){var s=2;if(r=e,typeof e=="function")Sa(e)&&(s=1);else if(typeof e=="string")s=5;else e:switch(e){case Zn:return Dn(n.children,o,l,t);case Ms:s=8,o|=8;break;case Il:return e=jt(12,n,t,o|2),e.elementType=Il,e.lanes=l,e;case Bl:return e=jt(13,n,t,o),e.elementType=Bl,e.lanes=l,e;case Al:return e=jt(19,n,t,o),e.elementType=Al,e.lanes=l,e;case fc:return Jo(n,o,l,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case uc:s=10;break e;case cc:s=9;break e;case Is:s=11;break e;case Bs:s=14;break e;case en:s=16,r=null;break e}throw Error(L(130,e==null?e:typeof e,""))}return t=jt(s,n,t,o),t.elementType=e,t.type=r,t.lanes=l,t}function Dn(e,t,n,r){return e=jt(7,e,r,t),e.lanes=n,e}function Jo(e,t,n,r){return e=jt(22,e,r,t),e.elementType=fc,e.lanes=n,e.stateNode={isHidden:!1},e}function _l(e,t,n){return e=jt(6,e,null,t),e.lanes=n,e}function El(e,t,n){return t=jt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Uh(e,t,n,r,o){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=sl(0),this.expirationTimes=sl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=sl(0),this.identifierPrefix=r,this.onRecoverableError=o,this.mutableSourceEagerHydrationData=null}function ka(e,t,n,r,o,l,s,a,d){return e=new Uh(e,t,n,a,d),t===1?(t=1,l===!0&&(t|=8)):t=0,l=jt(3,null,null,t),e.current=l,l.stateNode=e,l.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},ia(l),e}function Wh(e,t,n){var r=3 "u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(sp)}catch(e){console.error(e)}}sp(),lc.exports=yt;var Yh=lc.exports,Su=Yh;Dl.createRoot=Su.createRoot,Dl.hydrateRoot=Su.hydrateRoot;const Jh="modulepreload",Xh=function(e){return"/"+e},ku={},qh=function(t,n,r){let o=Promise.resolve();if(n&&n.length>0){document.getElementsByTagName("link");const s=document.querySelector("meta[property=csp-nonce]"),a=(s==null?void 0:s.nonce)||(s==null?void 0:s.getAttribute("nonce"));o=Promise.allSettled(n.map(d=>{if(d=Xh(d),d in ku)return;ku[d]=!0;const c=d.endsWith(".css"),g=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${d}"]${g}`))return;const m=document.createElement("link");if(m.rel=c?"stylesheet":Jh,c||(m.as="script"),m.crossOrigin="",m.href=d,a&&m.setAttribute("nonce",a),document.head.appendChild(m),c)return new Promise((x,S)=>{m.addEventListener("load",x),m.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${d}`)))})}))}function l(s){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=s,window.dispatchEvent(a),!a.defaultPrevented)throw s}return o.then(s=>{for(const a of s||[])a.status==="rejected"&&l(a.reason);return t().catch(l)})},ao="/api";async function F(e,t){const n=await fetch(ao+e,{...t,headers:{"Content-Type":"application/json",...(t==null?void 0:t.headers)||{}}});if(!n.ok)throw new Error(`${n.status} ${n.statusText}`);return await n.json()}const B={listMemories:()=>F("/memories"),remember:(e,t,n)=>F("/memories",{method:"POST",body:JSON.stringify({content:e,tier:t,tags:n})}),archiveMemory:e=>F(`/memories/${encodeURIComponent(e)}`,{method:"DELETE"}),routerStats:e=>F(`/router/stats${e?`?agent_id=${encodeURIComponent(e)}`:""}`),routerPolicy:()=>F("/router/policy"),routerTune:()=>F("/router/tune",{method:"POST"}),metaBuddhi:()=>F("/meta-buddhi"),evolution:()=>F("/evolution"),conflicts:()=>F("/conflicts"),resolveConflict:(e,t)=>F(`/conflicts/${encodeURIComponent(e)}/resolve`,{method:"POST",body:JSON.stringify({action:t})}),resolveConflictDetailed:(e,t)=>F(`/conflicts/${encodeURIComponent(e)}/resolve`,{method:"POST",body:JSON.stringify(t)}),tasks:()=>F("/tasks"),createTask:(e,t)=>F("/tasks",{method:"POST",body:JSON.stringify({title:e,harness:t})}),taskDetail:(e,t=24)=>F(`/tasks/${encodeURIComponent(e)}?limit=${encodeURIComponent(String(t))}`),updateTaskStatus:(e,t)=>F(`/tasks/${encodeURIComponent(e)}/status`,{method:"POST",body:JSON.stringify({status:t})}),addTaskNote:(e,t)=>F(`/tasks/${encodeURIComponent(e)}/notes`,{method:"POST",body:JSON.stringify({content:t})}),workspaceGraph:(e,t)=>F(`/workspace/graph${e?`?workspace_id=${encodeURIComponent(e)}${t?`&project_id=${encodeURIComponent(t)}`:""}`:""}`),projects:()=>F("/workspaces"),workspaces:()=>F("/workspaces"),createWorkspaceRoot:(e,t)=>F("/workspaces",{method:"POST",body:JSON.stringify({name:e,description:t})}),createProject:(e,t)=>F(`/workspaces/${encodeURIComponent(e)}/projects`,{method:"POST",body:JSON.stringify(t)}),updateProject:(e,t)=>F(`/projects/${encodeURIComponent(e)}`,{method:"PATCH",body:JSON.stringify(t)}),projectSessions:e=>F(`/projects/${encodeURIComponent(e)}/sessions`),projectCanvas:e=>F(`/projects/${encodeURIComponent(e)}/canvas`),workspaceCanvas:e=>F(`/workspaces/${encodeURIComponent(e)}/canvas`),pickFolder:e=>F("/folders/pick",{method:"POST",body:JSON.stringify({prompt:e})}),addWorkspaceFolder:(e,t,n)=>F(`/workspaces/${encodeURIComponent(e)}/folders`,{method:"POST",body:JSON.stringify({path:t,label:n})}),removeWorkspaceFolder:(e,t)=>F(`/workspaces/${encodeURIComponent(e)}/mounts?path=${encodeURIComponent(t)}`,{method:"DELETE"}),updateWorkspace:(e,t)=>F(`/workspaces/${encodeURIComponent(e)}`,{method:"PATCH",body:JSON.stringify(t)}),deleteWorkspace:e=>F(`/workspaces/${encodeURIComponent(e)}`,{method:"DELETE"}),deleteProject:e=>F(`/projects/${encodeURIComponent(e)}`,{method:"DELETE"}),workspaceDetail:e=>F(`/workspaces/${encodeURIComponent(e)}`),workspaceSessions:e=>F(`/workspaces/${encodeURIComponent(e)}/sessions`),sessionDetail:e=>F(`/sessions/${encodeURIComponent(e)}`),launchWorkspaceSession:(e,t,n,r,o,l)=>F(`/workspaces/${encodeURIComponent(e)}/sessions/launch`,{method:"POST",body:JSON.stringify({runtime:t,title:n,permission_mode:r,task_id:o,project_id:l})}),workspaceLineMessages:(e,t)=>F(`/workspaces/${encodeURIComponent(e)}/line/messages?${new URLSearchParams(Object.entries({project_id:t==null?void 0:t.project_id,channel:t==null?void 0:t.channel,cursor:t==null?void 0:t.cursor,limit:t!=null&&t.limit?String(t.limit):void 0}).filter(n=>!!n[1])).toString()}`),publishWorkspaceLineMessage:(e,t)=>F(`/workspaces/${encodeURIComponent(e)}/line/messages`,{method:"POST",body:JSON.stringify(t)}),uploadSessionAsset:async(e,t,n)=>{const r=new FormData;r.append("file",t),n&&r.append("label",n);const o=await fetch(`${ao}/sessions/${encodeURIComponent(e)}/assets`,{method:"POST",body:r});if(!o.ok)throw new Error(`${o.status} ${o.statusText}`);return await o.json()},listProjectAssets:e=>F(`/projects/${encodeURIComponent(e)}/assets`),listWorkspaceAssets:(e,t=!0)=>F(`/workspaces/${encodeURIComponent(e)}/assets?include_project_assets=${t?"true":"false"}`),uploadProjectAsset:async(e,t,n)=>{const r=new FormData;r.append("file",t),n!=null&&n.label&&r.append("label",n.label),n!=null&&n.folder&&r.append("folder",n.folder);const o=await fetch(`${ao}/projects/${encodeURIComponent(e)}/assets`,{method:"POST",body:r});if(!o.ok)throw new Error(`${o.status} ${o.statusText}`);return await o.json()},uploadWorkspaceAsset:async(e,t,n)=>{const r=new FormData;r.append("file",t),n!=null&&n.label&&r.append("label",n.label),n!=null&&n.folder&&r.append("folder",n.folder),n!=null&&n.project_id&&r.append("project_id",n.project_id);const o=await fetch(`${ao}/workspaces/${encodeURIComponent(e)}/assets`,{method:"POST",body:r});if(!o.ok)throw new Error(`${o.status} ${o.statusText}`);return await o.json()},deleteProjectAsset:e=>F(`/project-assets/${encodeURIComponent(e)}`,{method:"DELETE"}),fileContext:(e,t)=>F(`/files/${encodeURIComponent(e)}/context${t?`?workspace_id=${encodeURIComponent(t)}`:""}`),assetContext:e=>F(`/assets/${encodeURIComponent(e)}/context`),askAsset:(e,t)=>F(`/assets/${encodeURIComponent(e)}/ask`,{method:"POST",body:JSON.stringify({question:t})}),runtimeStatus:()=>F("/runtime-status"),status:()=>F("/status"),memoryNow:()=>F("/memory/now"),captureTimeline:(e=16)=>F(`/capture/timeline?limit=${encodeURIComponent(String(e))}`),launch:(e,t,n)=>F("/launch",{method:"POST",body:JSON.stringify({runtime:e,taskId:t,title:n})}),apiKeys:()=>F("/security/api-keys"),storeApiKey:(e,t,n)=>F("/security/api-keys",{method:"POST",body:JSON.stringify({provider:e,apiKey:t,label:n})}),rotateApiKey:(e,t,n)=>F(`/security/api-keys/${encodeURIComponent(e)}/rotate`,{method:"POST",body:JSON.stringify({apiKey:t,label:n})}),me:()=>F("/me"),continuity:()=>F("/continuity"),orgGraph:(e,t)=>{const n=new URLSearchParams;e&&n.set("org",e),t!=null&&t.active&&n.set("active","true");const r=n.toString();return F(`/org/graph${r?`?${r}`:""}`)},routerSessions:e=>{const t=new URLSearchParams;(e==null?void 0:e.active)!=null&&t.set("active",e.active?"true":"false"),e!=null&&e.cursor&&t.set("cursor",e.cursor),e!=null&&e.limit&&t.set("limit",String(e.limit)),e!=null&&e.agent&&t.set("agent",e.agent);const n=t.toString();return F(`/router/sessions${n?`?${n}`:""}`)},contextEntries:(e,t=200)=>{const n=new URLSearchParams;return e&&n.set("repo",e),n.set("limit",String(t)),F(`/context/entries?${n.toString()}`)},contextPromote:e=>F("/context/promote",{method:"POST",body:JSON.stringify(e)}),contextDemote:e=>F("/context/demote",{method:"POST",body:JSON.stringify(e)}),localWorkspaces:()=>F("/local/workspaces"),localWorkspaceCreate:e=>F("/local/workspaces",{method:"POST",body:JSON.stringify(e)}),localContextLinkFolder:e=>F("/local-context/folders/link",{method:"POST",body:JSON.stringify({path:e})}),localContextUnlinkFolder:e=>F("/local-context/folders/unlink",{method:"POST",body:JSON.stringify({path:e})}),contextItems:(e={})=>{const t=new URLSearchParams;e.team&&t.set("team",e.team),e.project&&t.set("project",e.project),e.scope&&t.set("scope",e.scope),e.kind&&t.set("kind",e.kind),e.limit&&t.set("limit",String(e.limit));const n=t.toString();return F(`/context/items${n?`?${n}`:""}`)},contextUsage:(e={})=>{const t=new URLSearchParams;e.team&&t.set("team",e.team),e.project&&t.set("project",e.project),e.scope&&t.set("scope",e.scope),e.kind&&t.set("kind",e.kind),e.limit&&t.set("limit",String(e.limit));const n=t.toString();return F(`/context/usage${n?`?${n}`:""}`)},commandCenter:()=>F("/ui/command-center"),proofReplay:(e=80)=>F(`/ui/proof-replay?limit=${encodeURIComponent(String(e))}`),handoffUi:()=>F("/ui/handoff"),learningsUi:(e=120)=>F(`/ui/learnings?limit=${encodeURIComponent(String(e))}`),promoteLearning:(e,t)=>F(`/ui/learnings/${encodeURIComponent(e)}/promote`,{method:"POST",body:JSON.stringify(t||{approved_by:"dhee-ui"})}),rejectLearning:(e,t)=>F(`/ui/learnings/${encodeURIComponent(e)}/reject`,{method:"POST",body:JSON.stringify(t||{reason:"rejected in Dhee UI"})}),portabilityUi:()=>F("/ui/portability"),exportPackUi:e=>F("/ui/portability/export",{method:"POST",body:JSON.stringify(e||{})}),importPackDryRunUi:e=>F("/ui/portability/import-dry-run",{method:"POST",body:JSON.stringify(e)}),upsertContext:e=>F("/context",{method:"POST",body:JSON.stringify(e)}),proposeContext:e=>F("/proposals",{method:"POST",body:JSON.stringify(e)}),approveProposal:(e,t)=>F(`/proposals/${encodeURIComponent(e)}/approve`,{method:"POST",body:JSON.stringify({reviewer_user_id:t})}),rejectProposal:(e,t)=>F(`/proposals/${encodeURIComponent(e)}/reject`,{method:"POST",body:JSON.stringify({reviewer_user_id:t})}),inbox:(e={})=>{const t=new URLSearchParams;e.team&&t.set("team",e.team),e.user&&t.set("user",e.user);const n=t.toString();return F(`/inbox${n?`?${n}`:""}`)},resolveFinding:(e,t)=>F(`/findings/${encodeURIComponent(e)}/resolve`,{method:"POST",body:JSON.stringify({resolved_by:t})}),backlinks:(e,t=50)=>F(`/backlinks?context_id=${encodeURIComponent(e)}&limit=${encodeURIComponent(String(t))}`),setIntegration:e=>F("/integrations",{method:"POST",body:JSON.stringify(e)}),teamJoin:e=>F("/team-join",{method:"POST",body:JSON.stringify(e)}),localContextAddFolder:e=>F("/local-context/folders",{method:"POST",body:JSON.stringify(e)}),localContextShareFolder:e=>F("/local-context/folders/share",{method:"POST",body:JSON.stringify(e)}),enterpriseSetWorkspace:e=>F("/workspace",{method:"POST",body:JSON.stringify(e)}),enterpriseResetWorkspace:()=>F("/workspace/reset",{method:"POST",body:"{}"}),enterpriseCreateProject:e=>F("/projects",{method:"POST",body:JSON.stringify(e)}),enterpriseDeleteProject:e=>F(`/projects/${encodeURIComponent(e)}`,{method:"DELETE"}),enterpriseCreateProjectTeam:(e,t)=>F(`/projects/${encodeURIComponent(e)}/teams`,{method:"POST",body:JSON.stringify(t)}),enterpriseAddProjectFolder:(e,t)=>F(`/projects/${encodeURIComponent(e)}/folders`,{method:"POST",body:JSON.stringify(t)}),enterpriseAddTeamFolder:(e,t)=>F(`/teams/${encodeURIComponent(e)}/folders`,{method:"POST",body:JSON.stringify(t)}),enterpriseRemoveFolder:e=>F(`/folders/${encodeURIComponent(e)}`,{method:"DELETE"}),enterpriseAddTeamCollaborator:(e,t)=>F(`/teams/${encodeURIComponent(e)}/collaborators`,{method:"POST",body:JSON.stringify({target_team_id:t})}),enterpriseExtractProject:e=>F(`/projects/${encodeURIComponent(e)}/extract`,{method:"POST",body:"{}"}),enterpriseExtractTeam:e=>F(`/teams/${encodeURIComponent(e)}/extract`,{method:"POST",body:"{}"}),pickFolderPath:e=>F("/folders/pick",{method:"POST",body:JSON.stringify({prompt:e})})};function Zh({view:e,setView:t,conflictCount:n}){const r=[{id:"command",icon:"⌂",label:"HOME",tip:"Command center · current truth · next action"},{id:"router",icon:"⇌",label:"FIREWALL",tip:"Context firewall · routing · expansions · tokens saved"},{id:"canvas",icon:"⊞",label:"BRAIN",tip:"Repo Brain · linked folders · active sessions"},{id:"handoff",icon:"↗",label:"HANDOFF",tip:"Resume state across agents"},{id:"replay",icon:"◌",label:"REPLAY",tip:"Context decision replay"},{id:"learnings",icon:"↑",label:"LEARN",tip:"Evidence-backed learning review"},{id:"context",icon:"◐",label:"CONTEXT",tip:"Context vault · personal and shared memory"},{id:"portability",icon:"□",label:"PACKS",tip:".dheemem export · import dry-run · portability"},{id:"conflicts",icon:"⟷",label:"INBOX",tip:"Proposals · findings · conflicts",badge:n}];return i.jsxs("div",{style:{width:"var(--nav)",borderRight:"1px solid var(--border)",display:"flex",flexDirection:"column",flexShrink:0,background:"var(--bg)",zIndex:20},children:[i.jsx("div",{style:{height:48,borderBottom:"1px solid var(--border)",display:"flex",alignItems:"center",justifyContent:"center"},children:i.jsx("img",{src:"/dhee-logo.png",alt:"Dhee",style:{width:22,height:22,objectFit:"contain"}})}),i.jsx("div",{style:{flex:1,display:"flex",flexDirection:"column",padding:"6px 0",gap:0},children:r.map(o=>{const l=o.id==="router"?e==="router"||e.startsWith("router/"):e===o.id;return i.jsxs("div",{title:o.tip,onClick:()=>t(o.id),style:{position:"relative",height:44,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",cursor:"pointer",background:l?"var(--surface)":"transparent",borderLeft:`2px solid ${l?"var(--accent)":"transparent"}`,gap:2,transition:"all 0.1s"},onMouseEnter:s=>{l||(s.currentTarget.style.background="var(--surface)")},onMouseLeave:s=>{l||(s.currentTarget.style.background="transparent")},children:[i.jsx("span",{style:{fontSize:14,color:l?"var(--accent)":"var(--ink3)",lineHeight:1},children:o.icon}),i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:7,color:l?"var(--accent)":"var(--ink3)",letterSpacing:"0.04em"},children:o.label}),o.badge&&o.badge>0?i.jsx("div",{style:{position:"absolute",top:6,right:6,width:14,height:14,borderRadius:"50%",background:"var(--rose)",display:"flex",alignItems:"center",justifyContent:"center"},children:i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:8,color:"white",fontWeight:700},children:o.badge})}):null]},o.id)})}),i.jsx("div",{style:{borderTop:"1px solid var(--border)",height:44,display:"flex",alignItems:"center",justifyContent:"center"},children:i.jsx("div",{title:"Dhee active",style:{width:5,height:5,borderRadius:"50%",background:"var(--green)"}})})]})}function Lr(e){return!e||e<=0?"0":new Intl.NumberFormat("en",{notation:"compact",maximumFractionDigits:1}).format(e)}function Gh(e){if(!e)return 0;const t=Number(e.sessionTokensSaved||0),n=Number(e.enterpriseSavedTokens||0);return t+n}function eg(e){return e?Number(e.enterpriseSavedPct||0):0}function tg({viewer:e,routerStats:t,onRefresh:n,onOpenTweaks:r,onResetWorkspace:o}){const[l,s]=v.useState(!1),[a,d]=v.useState(null),c=v.useRef(null);v.useEffect(()=>{if(!l)return;const u=f=>{c.current&&(c.current.contains(f.target)||s(!1))};return window.addEventListener("mousedown",u),()=>window.removeEventListener("mousedown",u)},[l]),v.useEffect(()=>{if(t){d(null);return}let u=!1;const f=async()=>{try{const j=await B.routerStats();u||d(j)}catch{}};f();const p=window.setInterval(f,5e3);return()=>{u=!0,window.clearInterval(p)}},[t]);const g=(e==null?void 0:e.org_id)||"default",m=(e==null?void 0:e.project_id)||null,x=(e==null?void 0:e.team_id)||null,S=[g,m,x].filter(Boolean).join(" · "),E=t||a,T=Gh(E),H=eg(E),h=(()=>{if(!E)return"loading";const u=Number(E.sessionTokensSaved||0),f=Number(E.enterpriseSavedTokens||0),p=Number(E.enterpriseRawTokens||0),j=Number(E.enterpriseSummaryTokens||0),_=Number(E.enterpriseRawFallbacks||0),P=Number(E.enterpriseGateSuggestions||0);return`Session: ${Lr(u)} · Repo index: ${Lr(f)} · Raw avoided: ${Lr(p)} -> ${Lr(j)} · Fallbacks: ${_} · Gates: ${P}`})();return i.jsxs("div",{style:{height:32,borderBottom:"1px solid var(--border)",background:"var(--bg)",display:"flex",alignItems:"center",padding:"0 12px",gap:10,flexShrink:0,zIndex:15},children:[i.jsxs("div",{className:"workspace-pill",title:S,style:{display:"inline-flex",alignItems:"center",gap:6,padding:"3px 9px",borderRadius:4,background:"var(--surface)",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:10,color:"var(--ink2)",letterSpacing:"0.04em"},children:[i.jsx("span",{style:{width:5,height:5,borderRadius:"50%",background:e!=null&&e.live?"var(--green)":"var(--ink3)"}}),i.jsx("span",{children:S||"no workspace"}),e!=null&&e.role?i.jsx("span",{style:{marginLeft:6,padding:"1px 5px",borderRadius:3,background:"var(--surface2)",color:"var(--ink2)",fontSize:9},children:String(e.role).toUpperCase()}):null]}),i.jsx("div",{style:{flex:1}}),i.jsxs("div",{className:"tokens-chip",title:h,style:{display:"inline-flex",alignItems:"center",gap:6,padding:"3px 9px",borderRadius:4,background:"var(--accent-dim)",border:"1px solid var(--accent)",color:"var(--accent)",fontFamily:"var(--mono)",fontSize:10,letterSpacing:"0.04em"},children:[i.jsx("span",{style:{fontSize:11},children:"↯"}),i.jsxs("span",{children:[Lr(T)," saved"]}),H>0?i.jsxs("span",{style:{color:"var(--ink3)"},children:["· ",H.toFixed(0),"%"]}):null]}),i.jsxs("div",{ref:c,style:{position:"relative",display:"inline-block"},children:[i.jsx("button",{"aria-label":"Menu",onClick:()=>s(u=>!u),style:{width:22,height:22,borderRadius:4,background:l?"var(--surface2)":"var(--surface)",border:"1px solid var(--border)",color:"var(--ink2)",fontSize:12,lineHeight:1,display:"inline-flex",alignItems:"center",justifyContent:"center"},children:"⋮"}),l?i.jsxs("div",{style:{position:"absolute",top:"calc(100% + 4px)",right:0,minWidth:180,background:"var(--bg)",border:"1px solid var(--border)",borderRadius:4,boxShadow:"0 6px 18px rgba(20,16,10,0.08)",zIndex:30,padding:4,fontFamily:"var(--mono)",fontSize:10,letterSpacing:"0.04em"},children:[i.jsx(Hi,{label:"REFRESH",onClick:()=>{s(!1),n()}}),i.jsx(Hi,{label:"TWEAKS",hint:"⌘K",onClick:()=>{s(!1),r()}}),o?i.jsxs(i.Fragment,{children:[i.jsx("div",{style:{height:1,background:"var(--border)",margin:"3px 0"}}),i.jsx(Hi,{label:"RESET WORKSPACE",onClick:()=>{s(!1),o()},danger:!0})]}):null,i.jsx("div",{style:{height:1,background:"var(--border)",margin:"3px 0"}}),i.jsx(Hi,{label:"USER ID",hint:(e==null?void 0:e.user_id)||"—",onClick:()=>s(!1),dim:!0})]}):null]})]})}function Hi({label:e,hint:t,onClick:n,dim:r,danger:o}){return i.jsxs("button",{onClick:n,style:{width:"100%",textAlign:"left",padding:"5px 8px",borderRadius:3,background:"transparent",color:o?"var(--rose)":r?"var(--ink3)":"var(--ink2)",display:"flex",justifyContent:"space-between",alignItems:"center",gap:8},onMouseEnter:l=>{l.currentTarget.style.background=o?"var(--rose-dim)":"var(--surface)"},onMouseLeave:l=>{l.currentTarget.style.background="transparent"},children:[i.jsx("span",{children:e}),t?i.jsx("span",{style:{color:"var(--ink3)",fontSize:9},children:t}):null]})}function ng({tweaks:e,setTweaks:t,visible:n}){if(!n)return null;const r=(o,l)=>{const s={...e,[o]:l};t(s)};return i.jsxs("div",{style:{position:"fixed",bottom:20,right:20,width:236,border:"1px solid var(--border)",background:"white",zIndex:1e3,boxShadow:"0 8px 32px rgba(0,0,0,0.1)"},children:[i.jsx("div",{style:{padding:"9px 14px",borderBottom:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:10,fontWeight:700,letterSpacing:"0.06em"},children:"TWEAKS"}),i.jsxs("div",{style:{padding:"14px"},children:[i.jsxs("div",{style:{marginBottom:14},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:6,textTransform:"uppercase"},children:"Accent hue"}),i.jsx("input",{type:"range",min:"0",max:"360",value:e.accentHue,onChange:o=>{const l=o.target.value;r("accentHue",l),document.documentElement.style.setProperty("--accent",`oklch(0.64 0.18 ${l})`),document.documentElement.style.setProperty("--accent-dim",`oklch(0.97 0.04 ${l})`)},style:{width:"100%"}}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginTop:2},children:["hue ",e.accentHue,"°"]})]}),i.jsxs("div",{style:{marginBottom:14},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:6,textTransform:"uppercase"},children:"Compact nav"}),i.jsx("button",{onClick:()=>r("compactNav",!e.compactNav),style:{padding:"4px 10px",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:10,background:e.compactNav?"var(--ink)":"transparent",color:e.compactNav?"var(--bg)":"var(--ink)",cursor:"pointer"},children:e.compactNav?"ON":"OFF"})]}),i.jsxs("div",{style:{marginBottom:14},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:6,textTransform:"uppercase"},children:"Canvas style"}),i.jsx("div",{style:{display:"flex",gap:5},children:["dots","grid"].map(o=>i.jsx("button",{onClick:()=>r("canvasStyle",o),style:{padding:"4px 10px",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,background:e.canvasStyle===o?"var(--ink)":"transparent",color:e.canvasStyle===o?"var(--bg)":"var(--ink)",cursor:"pointer"},children:o},o))})]}),i.jsxs("div",{children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:6,textTransform:"uppercase"},children:"Timestamps"}),i.jsx("button",{onClick:()=>r("showTimestamps",!e.showTimestamps),style:{padding:"4px 10px",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:10,background:e.showTimestamps?"var(--ink)":"transparent",color:e.showTimestamps?"var(--bg)":"var(--ink)",cursor:"pointer"},children:e.showTimestamps?"ON":"OFF"})]})]})]})}const rg={position:"fixed",inset:0,background:"rgba(20,16,10,0.28)",backdropFilter:"blur(4px)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:60},ig={width:640,maxWidth:"calc(100vw - 32px)",maxHeight:"calc(100vh - 60px)",background:"white",border:"1px solid var(--border)",borderRadius:10,boxShadow:"0 20px 60px rgba(20,16,10,0.20)",display:"flex",flexDirection:"column",overflow:"hidden"},pt={width:"100%",border:"1px solid var(--border)",padding:"9px 11px",background:"var(--bg)",fontSize:13,lineHeight:1.4},Nn={fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:.5,textTransform:"uppercase",marginBottom:4,display:"block"},Vi={padding:"8px 14px",border:"1px solid var(--ink)",background:"var(--ink)",color:"white",fontFamily:"var(--mono)",fontSize:10,letterSpacing:.4,cursor:"pointer"},Wr={padding:"8px 14px",border:"1px solid var(--border)",background:"white",color:"var(--ink2)",fontFamily:"var(--mono)",fontSize:10,letterSpacing:.4,cursor:"pointer"},ju={padding:"8px 14px",border:"1px solid var(--rose)",background:"white",color:"var(--rose)",fontFamily:"var(--mono)",fontSize:10,letterSpacing:.4,cursor:"pointer"},wu=["codex","claude-code"];function og({open:e,onClose:t,projectIndex:n,initialWorkspaceId:r,initialTab:o="workspaces",onChanged:l}){var he,_e;const s=(n==null?void 0:n.workspaces)||[],[a,d]=v.useState(o),[c,g]=v.useState(r||((he=s[0])==null?void 0:he.id)||""),m=s.find(N=>N.id===c)||null,[x,S]=v.useState(""),[E,T]=v.useState(""),[H,h]=v.useState(""),[u,f]=v.useState(""),[p,j]=v.useState(""),[_,P]=v.useState(""),[z,Q]=v.useState(""),[R,J]=v.useState("codex"),[X,K]=v.useState([]),[G,M]=v.useState({}),[V,ye]=v.useState(!1),[C,A]=v.useState(null),[W,ee]=v.useState(null);if(v.useEffect(()=>{var N;e&&(d(o),g(r||((N=s[0])==null?void 0:N.id)||""),A(null),ee(null),S(""),T(""),P(""),Q(""),J("codex"),K([]),j(""))},[e]),v.useEffect(()=>{if(!m){h(""),f("");return}h(String(m.label||m.name||"")),f(String(m.description||"")),j("")},[m==null?void 0:m.id,m==null?void 0:m.label,m==null?void 0:m.description]),v.useEffect(()=>{m&&M(N=>{const oe={};for(const q of m.projects||[]){const te=(q.scopeRules||[]).map(Ae=>Ae.pathPrefix);oe[q.id]=N[q.id]||{name:q.name,description:q.description||"",defaultRuntime:q.defaultRuntime||"codex",folders:te}}return oe})},[m==null?void 0:m.id,(_e=m==null?void 0:m.projects)==null?void 0:_e.length]),!e)return null;const ie=async(N,oe)=>{A(null);try{const q=await B.pickFolder(oe||"Choose a folder");q.ok&&q.path&&N(q.path)}catch(q){A(String(q))}},D=async(N,oe)=>{ye(!0),A(null),ee(null);try{await oe(),ee(N),await l()}catch(q){A(String(q))}finally{ye(!1)}},fe=()=>D("Workspace created.",async()=>{const N=x.trim();if(!N)throw new Error("Name is required.");await B.createWorkspaceRoot(N,E.trim()||void 0),S(""),T("")}),se=()=>D("Workspace updated.",async()=>{if(!m)return;const N={};if(H.trim()&&H!==(m.label||m.name)&&(N.label=H.trim()),u!==(m.description||"")&&(N.description=u),!Object.keys(N).length)throw new Error("Nothing to save.");await B.updateWorkspace(m.id,N)}),me=()=>D("Workspace deleted.",async()=>{if(m){if(p.trim()!==(m.label||m.name))throw new Error("Type the workspace name exactly to confirm.");await B.deleteWorkspace(m.id),g(""),j("")}}),$=()=>D("Project created.",async()=>{if(!m)throw new Error("Pick a workspace first.");const N=_.trim();if(!N)throw new Error("Project name is required.");const oe=X.map(q=>q.trim()).filter(Boolean).map(q=>({path_prefix:q}));await B.createProject(m.id,{name:N,description:z.trim()||void 0,default_runtime:R,scope_rules:oe}),P(""),Q(""),J("codex"),K([])}),de=N=>()=>D("Project updated.",async()=>{const oe=G[N.id];if(!oe)return;const q={};oe.name.trim()&&oe.name.trim()!==N.name&&(q.name=oe.name.trim()),oe.description!==(N.description||"")&&(q.description=oe.description),oe.defaultRuntime&&oe.defaultRuntime!==(N.defaultRuntime||"codex")&&(q.default_runtime=oe.defaultRuntime);const te=(N.scopeRules||[]).map(xe=>xe.pathPrefix),Ae=oe.folders.map(xe=>xe.trim()).filter(Boolean);if((te.length!==Ae.length||te.some((xe,Me)=>xe!==Ae[Me]))&&(q.scope_rules=Ae.map(xe=>({path_prefix:xe}))),!Object.keys(q).length)throw new Error("Nothing to save.");await B.updateProject(N.id,q)}),ce=N=>()=>D("Project deleted.",async()=>{if(!window.confirm(`Delete project "${N.name}"? This removes its assets. The workspace stays.`))throw new Error("cancelled");await B.deleteProject(N.id)});return i.jsx("div",{style:rg,onClick:t,children:i.jsxs("div",{style:ig,onClick:N=>N.stopPropagation(),children:[i.jsxs("div",{style:{padding:"14px 20px",borderBottom:"1px solid var(--border)",display:"flex",alignItems:"center",justifyContent:"space-between"},children:[i.jsxs("div",{children:[i.jsx("div",{style:{fontSize:15,fontWeight:700},children:"Workspaces & projects"}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginTop:2},children:"Organise every agent under a shared brain."})]}),i.jsx("button",{onClick:t,style:{width:28,height:28,border:"1px solid var(--border)",borderRadius:4,background:"white",cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center",color:"var(--ink3)"},children:i.jsx("svg",{width:12,height:12,viewBox:"0 0 24 24",fill:"none",children:i.jsx("path",{d:"M6 6l12 12M18 6L6 18",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round"})})})]}),i.jsx("div",{style:{padding:"10px 20px",borderBottom:"1px solid var(--border)",display:"flex",gap:6},children:["workspaces","projects"].map(N=>i.jsx("button",{onClick:()=>d(N),style:{padding:"6px 12px",border:`1px solid ${a===N?"var(--ink)":"var(--border)"}`,background:a===N?"var(--ink)":"white",color:a===N?"white":"var(--ink2)",fontFamily:"var(--mono)",fontSize:10,letterSpacing:.4,textTransform:"uppercase",cursor:"pointer"},children:N},N))}),i.jsxs("div",{style:{padding:20,overflowY:"auto",display:"flex",flexDirection:"column",gap:16},children:[a==="workspaces"&&i.jsxs(i.Fragment,{children:[i.jsxs("section",{style:{display:"flex",flexDirection:"column",gap:8},children:[i.jsx("div",{style:Nn,children:"New workspace"}),i.jsx("input",{placeholder:"Name (e.g. Office, Personal, Sankhya AI Labs)",value:x,onChange:N=>S(N.target.value),style:pt}),i.jsx("textarea",{placeholder:"Description (optional)",value:E,onChange:N=>T(N.target.value),rows:2,style:{...pt,resize:"vertical"}}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",lineHeight:1.5},children:"A workspace is a collection of projects. Folders attach to projects, not workspaces."}),i.jsx("div",{style:{display:"flex",justifyContent:"flex-end"},children:i.jsx("button",{onClick:()=>void fe(),disabled:V||!x.trim(),style:{...Vi,opacity:V||!x.trim()?.5:1},children:"create workspace"})})]}),i.jsxs("section",{style:{display:"flex",flexDirection:"column",gap:8},children:[i.jsxs("div",{style:Nn,children:["Existing · ",s.length]}),s.length===0?i.jsx("div",{style:{padding:14,border:"1px dashed var(--border)",fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",lineHeight:1.55},children:"No workspaces yet. Create one above."}):i.jsx("div",{style:{display:"grid",gap:6},children:s.map(N=>{var te;const oe=N.id===c,q=((te=N.projects)==null?void 0:te.length)||0;return i.jsxs("button",{onClick:()=>g(N.id),style:{textAlign:"left",padding:"10px 12px",border:`1px solid ${oe?"var(--accent)":"var(--border)"}`,background:oe?"var(--surface)":"white",display:"flex",justifyContent:"space-between",gap:12,cursor:"pointer"},children:[i.jsxs("div",{style:{minWidth:0},children:[i.jsx("div",{style:{fontSize:13,fontWeight:600,lineHeight:1.3},children:N.label||N.name}),N.description&&i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginTop:2},children:N.description})]}),i.jsxs("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[q," project",q===1?"":"s"]})]},N.id)})})]}),m&&i.jsxs("section",{style:{display:"flex",flexDirection:"column",gap:8,borderTop:"1px solid var(--border)",paddingTop:14},children:[i.jsxs("div",{style:Nn,children:["Edit · ",m.label||m.name]}),i.jsx("input",{value:H,onChange:N=>h(N.target.value),placeholder:"Name",style:pt}),i.jsx("textarea",{value:u,onChange:N=>f(N.target.value),placeholder:"Description",rows:2,style:{...pt,resize:"vertical"}}),i.jsx("div",{style:{display:"flex",justifyContent:"flex-end",gap:8},children:i.jsx("button",{onClick:()=>void se(),disabled:V,style:{...Vi,opacity:V?.5:1},children:"save changes"})}),i.jsxs("div",{style:{marginTop:8,padding:12,border:"1px solid rgba(203,63,78,0.3)",borderRadius:4,background:"rgba(203,63,78,0.04)"},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--rose)",letterSpacing:.5,textTransform:"uppercase",marginBottom:6},children:"Danger zone"}),i.jsxs("div",{style:{fontSize:11,color:"var(--ink2)",lineHeight:1.5,marginBottom:8},children:["Deleting ",i.jsx("strong",{children:m.label||m.name})," removes every project, asset, and line message. Sessions remain but detach. Type the workspace name to confirm."]}),i.jsxs("div",{style:{display:"flex",gap:8},children:[i.jsx("input",{value:p,onChange:N=>j(N.target.value),placeholder:m.label||m.name,style:{...pt,flex:1}}),i.jsx("button",{onClick:()=>void me(),disabled:V||p.trim()!==(m.label||m.name),style:{...ju,opacity:V||p.trim()!==(m.label||m.name)?.5:1},children:"delete workspace"})]})]})]})]}),a==="projects"&&i.jsxs(i.Fragment,{children:[i.jsxs("section",{style:{display:"flex",flexDirection:"column",gap:8},children:[i.jsx("div",{style:Nn,children:"Workspace"}),i.jsxs("select",{value:c,onChange:N=>g(N.target.value),style:pt,children:[i.jsx("option",{value:"",children:"— pick a workspace —"}),s.map(N=>i.jsx("option",{value:N.id,children:N.label||N.name},N.id))]})]}),m&&i.jsxs(i.Fragment,{children:[i.jsxs("section",{style:{display:"flex",flexDirection:"column",gap:8},children:[i.jsxs("div",{style:Nn,children:["Add project to ",m.label||m.name]}),i.jsx("input",{placeholder:"Project name (e.g. frontend, backend, design)",value:_,onChange:N=>P(N.target.value),style:pt}),i.jsx("textarea",{placeholder:"Description (optional)",value:z,onChange:N=>Q(N.target.value),rows:2,style:{...pt,resize:"vertical"}}),i.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center"},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"default runtime"}),i.jsx("select",{value:R,onChange:N=>J(N.target.value),style:{...pt,flex:1},children:wu.map(N=>i.jsx("option",{value:N,children:N},N))})]}),i.jsx(Cu,{folders:X,onChange:K,onPick:N=>void ie(N,"Choose a project folder"),disabled:V}),i.jsx("div",{style:{display:"flex",justifyContent:"flex-end"},children:i.jsx("button",{onClick:()=>void $(),disabled:V||!_.trim(),style:{...Vi,opacity:V||!_.trim()?.5:1},children:"add project"})})]}),i.jsxs("section",{style:{display:"flex",flexDirection:"column",gap:8},children:[i.jsxs("div",{style:Nn,children:["Projects · ",(m.projects||[]).length]}),(m.projects||[]).length===0?i.jsx("div",{style:{padding:14,border:"1px dashed var(--border)",fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",lineHeight:1.55},children:"No projects yet — add the first one above."}):i.jsx("div",{style:{display:"grid",gap:10},children:(m.projects||[]).map(N=>{const oe=G[N.id]||{name:N.name,description:N.description||"",defaultRuntime:N.defaultRuntime||"codex",folders:(N.scopeRules||[]).map(te=>te.pathPrefix)},q=te=>M(Ae=>({...Ae,[N.id]:{...oe,...te}}));return i.jsxs("div",{style:{border:"1px solid var(--border)",borderRadius:6,padding:10,display:"flex",flexDirection:"column",gap:8},children:[i.jsx("input",{value:oe.name,onChange:te=>q({name:te.target.value}),style:pt}),i.jsx("textarea",{value:oe.description,onChange:te=>q({description:te.target.value}),rows:2,style:{...pt,resize:"vertical"}}),i.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center"},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"runtime"}),i.jsx("select",{value:oe.defaultRuntime,onChange:te=>q({defaultRuntime:te.target.value}),style:{...pt,flex:1},children:wu.map(te=>i.jsx("option",{value:te,children:te},te))})]}),i.jsx(Cu,{folders:oe.folders,onChange:te=>q({folders:te}),onPick:te=>void ie(te,`Add a folder to ${N.name}`),disabled:V}),i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",gap:8},children:[i.jsx("button",{onClick:()=>void ce(N)(),disabled:V,style:{...ju,opacity:V?.5:1},children:"delete"}),i.jsx("button",{onClick:()=>void de(N)(),disabled:V,style:{...Vi,opacity:V?.5:1},children:"save"})]})]},N.id)})})]})]})]})]}),i.jsxs("div",{style:{padding:"10px 20px",borderTop:"1px solid var(--border)",background:"var(--bg)",display:"flex",alignItems:"center",justifyContent:"space-between",minHeight:44},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,lineHeight:1.4},children:C?i.jsx("span",{style:{color:"var(--rose)"},children:C}):W?i.jsx("span",{style:{color:"var(--green)"},children:W}):i.jsx("span",{style:{color:"var(--ink3)"},children:"Changes save immediately."})}),i.jsx("button",{onClick:t,style:Wr,children:"close"})]})]})})}function Cu({folders:e,onChange:t,onPick:n,disabled:r}){const o=(d,c)=>{const g=e.slice();g[d]=c,t(g)},l=d=>{const c=e.slice();c.splice(d,1),t(c)},s=()=>t([...e,""]),a=()=>n(d=>t([...e,d]));return i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsxs("div",{style:Nn,children:["Folders · ",e.length]}),e.length===0&&i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",lineHeight:1.5},children:"No folders yet. A project can have one or many."}),e.map((d,c)=>i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsx("input",{value:d,onChange:g=>o(c,g.target.value),placeholder:"/absolute/path",style:{...pt,flex:1}}),i.jsx("button",{onClick:()=>n(g=>o(c,g)),style:Wr,disabled:r,children:"browse…"}),i.jsx("button",{onClick:()=>l(c),style:Wr,disabled:r,title:"Remove folder",children:"✕"})]},c)),i.jsxs("div",{style:{display:"flex",gap:6},children:[i.jsx("button",{onClick:a,style:Wr,disabled:r,children:"+ pick folder"}),i.jsx("button",{onClick:s,style:Wr,disabled:r,children:"+ type path"})]})]})}function $e({label:e,tone:t="var(--ink3)"}){return i.jsx("span",{style:{display:"inline-flex",alignItems:"center",gap:5,padding:"2px 7px",border:`1px solid ${t}`,color:t,fontFamily:"var(--mono)",fontSize:9,lineHeight:1.2,whiteSpace:"nowrap"},children:e})}const bu=6e4,_u=36e5,Eu=864e5;function ap(e){if(!e)return"";const t=Date.parse(e);if(Number.isNaN(t))return String(e);const n=Date.now()-t;return n void t(e),disabled:n,title:"Remove asset","aria-label":"Remove asset",style:{width:22,height:22,display:"flex",alignItems:"center",justifyContent:"center",border:"1px solid transparent",borderRadius:3,background:"transparent",color:"var(--ink3)",cursor:n?"not-allowed":"pointer",opacity:n?.5:1,padding:0,flexShrink:0},onMouseEnter:a=>{n||(a.currentTarget.style.background="rgba(203,63,78,0.08)",a.currentTarget.style.color="var(--rose)")},onMouseLeave:a=>{a.currentTarget.style.background="transparent",a.currentTarget.style.color="var(--ink3)"},children:i.jsx("svg",{width:12,height:12,viewBox:"0 0 24 24",fill:"none",children:i.jsx("path",{d:"M6 6l12 12M18 6L6 18",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round"})})})]}),i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap",alignItems:"center"},children:[s.size>0?i.jsx($e,{label:`${l.length} processed`,tone:"var(--green)"}):i.jsx($e,{label:"not yet processed"}),Array.from(s).slice(0,3).map(a=>i.jsx($e,{label:a,tone:Rs(a)},a))]}),l.length>0&&i.jsxs(i.Fragment,{children:[i.jsx("button",{onClick:()=>o(a=>!a),style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",background:"transparent",border:0,padding:0,textAlign:"left",cursor:"pointer",letterSpacing:.4},children:r?"▾ hide processing feed":`▸ show processing feed (${l.length})`}),r&&i.jsx("div",{style:{display:"flex",flexDirection:"column",borderTop:"1px dashed var(--border)",paddingTop:6},children:l.slice(0,8).map(a=>i.jsx(dg,{result:a},a.id))})]})]})}function cg({workspace:e,project:t,onActivity:n}){const[r,o]=v.useState([]),[l,s]=v.useState(!1),[a,d]=v.useState(null),[c,g]=v.useState("idle"),[m,x]=v.useState(""),[S,E]=v.useState(!1),[T,H]=v.useState(null),h=v.useRef(null),u=v.useMemo(()=>t?t.name:e?`${e.label||e.name} (workspace)`:"—",[t,e]),f=v.useCallback(async()=>{if(!e){o([]);return}s(!0),d(null);try{const R=t?await B.listProjectAssets(t.id):await B.listWorkspaceAssets(e.id,!1);o(R.assets||[])}catch(R){d(String(R))}finally{s(!1)}},[t,e]);v.useEffect(()=>{f()},[f]),v.useEffect(()=>{if(!e)return;const R=window.setInterval(()=>void f(),5e3);return()=>window.clearInterval(R)},[f,e]);const p=v.useCallback(async R=>{if(!e)return;const J=Array.from(R);if(J.length!==0){g("uploading"),x(`uploading ${J.length} file${J.length===1?"":"s"}…`),d(null);try{for(const X of J)t?await B.uploadProjectAsset(t.id,X):await B.uploadWorkspaceAsset(e.id,X);g("success"),x(J.length===1?`uploaded ${J[0].name}`:`uploaded ${J.length} files`),await f(),n==null||n()}catch(X){g("error"),x(String(X))}finally{window.setTimeout(()=>{g("idle"),x("")},2200)}}},[t,e,f,n]),j=R=>{var X;R.preventDefault(),R.stopPropagation(),E(!1);const J=R.dataTransfer;(X=J==null?void 0:J.files)!=null&&X.length&&p(J.files)},_=R=>{var J;e&&(R.preventDefault(),R.stopPropagation(),(J=R.dataTransfer.types)!=null&&J.includes("Files")&&!S&&E(!0))},P=R=>{R.currentTarget===R.target&&E(!1)},z=R=>{const J=R.target.files;J!=null&&J.length&&p(J),R.target.value=""},Q=async R=>{if(window.confirm(`Remove "${R.name}"?`)){H(R.id);try{await B.deleteProjectAsset(R.id),o(J=>J.filter(X=>X.id!==R.id)),n==null||n()}catch(J){d(String(J))}finally{H(null)}}};return i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:10},onDrop:j,onDragOver:_,onDragLeave:P,children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",justifyContent:"space-between",fontFamily:"var(--mono)",fontSize:9,letterSpacing:.5,color:"var(--ink3)",textTransform:"uppercase"},children:[i.jsxs("span",{children:["Assets · ",u]}),i.jsx("span",{children:r.length})]}),i.jsxs("div",{onClick:()=>{var R;return e&&((R=h.current)==null?void 0:R.click())},style:{padding:"16px 14px",border:`1px dashed ${S?"var(--accent)":"var(--border)"}`,background:S?"rgba(224,107,63,0.06)":"white",borderRadius:6,textAlign:"center",cursor:e?"pointer":"not-allowed",opacity:e?1:.55,transition:"background 0.18s ease, border-color 0.18s ease"},children:[i.jsx("input",{ref:h,type:"file",multiple:!0,style:{display:"none"},onChange:z}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:11,color:S?"var(--accent)":"var(--ink2)",fontWeight:500,marginBottom:4},children:c==="uploading"?m:S?"release to upload":"drop files here or click to upload"}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:.4},children:t?"visible to every agent working on this project":e?"workspace-wide — every project sees it":"select a workspace first"})]}),c==="success"&&i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--green)",lineHeight:1.4},children:m}),c==="error"&&i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--rose)",lineHeight:1.4},children:m}),a&&c!=="error"?i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--rose)",lineHeight:1.4},children:a}):null,l&&r.length===0?i.jsx("div",{style:{display:"grid",gap:8},children:[0,1,2].map(R=>i.jsx("div",{style:{height:66,borderRadius:6,background:"linear-gradient(90deg, rgba(20,16,10,0.04) 0%, rgba(20,16,10,0.08) 50%, rgba(20,16,10,0.04) 100%)",backgroundSize:"200% 100%",animation:`dhee-shimmer 1.4s linear ${R*140}ms infinite`,border:"1px solid rgba(20,16,10,0.06)"}},R))}):r.length===0?i.jsxs("div",{style:{padding:14,border:"1px dashed var(--border)",borderRadius:6,fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",lineHeight:1.55,background:"white"},children:["No assets yet. Drop a spec PDF, design export, or schema doc here — every agent in this",t?" project":" workspace"," will see it."]}):i.jsx("div",{style:{display:"grid",gap:8},children:r.map(R=>i.jsx(ug,{asset:R,onDelete:Q,busyDelete:T===R.id},R.id))})]})}const Tu=6e4,zu=36e5,Ru=864e5;function dp(e){if(!e)return"";const t=Date.parse(e);if(Number.isNaN(t))return String(e);const n=Date.now()-t;return n {const l=String(o.runtime||"unknown").toLowerCase(),s=n.get(l)||{count:0,latestUpdate:null,isLive:!1};s.count+=1,o.updatedAt&&(!s.latestUpdate||o.updatedAt>s.latestUpdate)&&(s.latestUpdate=o.updatedAt),(o.isCurrent||o.state==="active"||o.state==="recent")&&(s.isLive=!0),n.set(l,s)};for(const o of t||[])r(o);for(const o of e||[])for(const l of o.sessions||[])r(l);return Array.from(n.entries()).map(([o,l])=>({runtime:o,...l})).sort((o,l)=>l.count-o.count)}function gg({workspace:e,projects:t,workspaceSessions:n}){const r=v.useMemo(()=>hg(t,n),[t,n]),o=r.reduce((l,s)=>l+s.count,0);return i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:14},children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"baseline",marginBottom:10},children:[i.jsxs("span",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:.6,color:"var(--ink3)",textTransform:"uppercase"},children:["Connected agents · ",o]}),e?i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:e.label||e.name}):null]}),r.length===0?i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",lineHeight:1.55},children:"No agent sessions yet. Launch claude-code or codex in this workspace — they will register here and start publishing to the line."}):i.jsx("div",{style:{display:"grid",gap:6},children:r.map(l=>i.jsxs("div",{style:{display:"flex",alignItems:"center",justifyContent:"space-between",gap:10},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,minWidth:0},children:[i.jsx("span",{style:{width:7,height:7,borderRadius:"50%",background:l.isLive?"var(--green)":"var(--ink3)",boxShadow:l.isLive?"0 0 0 3px rgba(31,169,113,0.18)":"none",flexShrink:0}}),i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:11,color:up(l.runtime),whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"},children:l.runtime}),i.jsxs("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:["· ",l.count]})]}),l.latestUpdate?i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:dp(l.latestUpdate)}):null]},l.runtime))})]})}function cp({message:e,workspace:t,onOpenTask:n}){var m,x;const r=((m=t==null?void 0:t.projects.find(S=>S.id===e.project_id))==null?void 0:m.name)||"workspace",o=((x=t==null?void 0:t.projects.find(S=>S.id===e.target_project_id))==null?void 0:x.name)||"",l=e.metadata||{},s=String(l.harness||l.runtime||""),a=String(l.tool_name||l.toolName||""),d=String(l.ptr||""),c=e.body||"",g=pg(e.message_kind);return i.jsxs("div",{style:{border:"1px solid var(--border)",borderLeft:`3px solid ${g}`,background:"white",padding:"11px 13px"},children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"baseline",gap:10,marginBottom:6},children:[i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:up(s),letterSpacing:.4},children:[fg(e.created_at),s?` · ${s}`:"",r!=="workspace"?` · ${r}`:""]}),i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:dp(e.created_at)})]}),e.title?i.jsx("div",{style:{fontSize:13,fontWeight:600,marginBottom:5,lineHeight:1.35},children:e.title}):null,c?i.jsx("div",{style:{fontSize:12,color:"var(--ink2)",lineHeight:1.55,whiteSpace:"pre-wrap",wordBreak:"break-word"},children:c}):null,i.jsxs("div",{style:{display:"flex",gap:5,flexWrap:"wrap",marginTop:8},children:[i.jsx($e,{label:mg(e.message_kind),tone:g}),a?i.jsx($e,{label:a.toLowerCase()}):null,o?i.jsx($e,{label:`→ ${o}`,tone:"#4d6cff"}):null,d?i.jsx($e,{label:d}):null,e.task_id&&n?i.jsx("button",{onClick:()=>n(String(e.task_id)),style:{padding:"2px 8px",border:"1px solid var(--ink)",background:"var(--ink)",color:"white",fontFamily:"var(--mono)",fontSize:9,letterSpacing:.4,textTransform:"uppercase",cursor:"pointer"},children:"open task"}):null]})]})}function fp({workspace:e,activeProjectId:t,sessionId:n,taskId:r,onPublished:o}){var P;const[l,s]=v.useState(""),[a,d]=v.useState(""),[c,g]=v.useState(""),[m,x]=v.useState(!1),[S,E]=v.useState(null),[T,H]=v.useState(null),h=v.useRef(null);v.useEffect(()=>{var z;(z=h.current)==null||z.focus()},[e==null?void 0:e.id]);const u=v.useMemo(()=>((e==null?void 0:e.projects)||[]).filter(z=>!t||z.id!==t),[e==null?void 0:e.projects,t]),f=async()=>{var z;if(!(!(e!=null&&e.id)||!a.trim()||m)){x(!0),H(null),E(null);try{const Q=await B.publishWorkspaceLineMessage(e.id,{project_id:t||void 0,target_project_id:c||void 0,channel:t?"project":"workspace",session_id:n||void 0,task_id:r||void 0,message_kind:c?"broadcast":"note",title:l.trim()||void 0,body:a.trim(),metadata:{sourceProject:(z=e.projects.find(R=>R.id===t))==null?void 0:z.name}});if(Q.suggestedTask){const R=e.projects.find(J=>J.id===c);E(R?`Broadcast sent · suggested task created in ${R.name}.`:"Broadcast sent · suggested task created.")}else E("Published to the workspace line.");s(""),d(""),g(""),o==null||o(Q.message,Q.suggestedTask)}catch(Q){H(String(Q))}finally{x(!1)}}};if(!e)return i.jsx("div",{style:{padding:16,border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",background:"white"},children:"No workspace selected."});const p=!a.trim()||m,j=(P=u.find(z=>z.id===c))==null?void 0:P.name,_=m?"publishing…":c?`broadcast → ${j||"project"}`:"publish update";return i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:14,display:"flex",flexDirection:"column",gap:8},children:[i.jsx("input",{value:l,onChange:z=>s(z.target.value),placeholder:"headline (optional) — e.g. user.plan field added",style:{border:"1px solid var(--border)",padding:"9px 11px",background:"var(--bg)",fontSize:13}}),i.jsxs("select",{value:c,onChange:z=>g(z.target.value),style:{border:"1px solid var(--border)",padding:"9px 11px",background:"var(--bg)",fontFamily:"var(--mono)",fontSize:10},children:[i.jsxs("option",{value:"",children:["Publish to current ",t?"project":"workspace"," only"]}),u.map(z=>i.jsxs("option",{value:z.id,children:["Broadcast into ",z.name," (creates task)"]},z.id))]}),i.jsx("textarea",{ref:h,value:a,onChange:z=>d(z.target.value),placeholder:c?"What should the target project's agent know? It will spawn a task with this context.":"Broadcast a dependency change, a tool result, or a follow-up signal to the workspace line…",rows:4,onKeyDown:z=>{(z.metaKey||z.ctrlKey)&&z.key==="Enter"&&(z.preventDefault(),f())},style:{border:"1px solid var(--border)",padding:"10px 12px",background:"var(--bg)",fontSize:13,lineHeight:1.55,resize:"vertical"}}),i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:10},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:.3},children:"⌘/Ctrl + Enter to publish"}),i.jsx("button",{onClick:()=>void f(),disabled:p,style:{padding:"8px 14px",border:"1px solid var(--ink)",background:p?"var(--ink3)":"var(--ink)",color:"white",fontFamily:"var(--mono)",fontSize:10,letterSpacing:.4,opacity:p?.7:1,cursor:p?"not-allowed":"pointer"},children:_})]}),S?i.jsx("div",{style:{fontSize:11,color:"var(--green)",lineHeight:1.5},children:S}):null,T?i.jsx("div",{style:{fontSize:11,color:"var(--rose)",lineHeight:1.5},children:T}):null]})}function pp(e,t){const[n,r]=v.useState([]),[o,l]=v.useState(!1),[s,a]=v.useState(null),d=g=>{r(m=>{const x=new Map;return[...g,...m].forEach(S=>{S!=null&&S.id&&x.set(S.id,S)}),Array.from(x.values()).sort((S,E)=>String(E.created_at||"").localeCompare(String(S.created_at||"")))})},c=async()=>{if(!e){r([]);return}try{const g=await B.workspaceLineMessages(e,{project_id:t||void 0,limit:100});r(g.messages||[])}catch(g){a(String(g))}};return v.useEffect(()=>{c()},[e,t]),v.useEffect(()=>{if(!e){l(!1);return}const g=new URLSearchParams;t&&g.set("project_id",t);const m=new EventSource(`/api/workspaces/${encodeURIComponent(e)}/line/stream${g.toString()?`?${g.toString()}`:""}`);return m.onopen=()=>l(!0),m.onmessage=x=>{try{const S=JSON.parse(x.data);d([S])}catch{}},m.onerror=()=>{l(!1),m.close()},()=>{m.close(),l(!1)}},[e,t]),{messages:n,live:o,error:s,merge:d,refresh:c}}function vg(e,t){if(!e)return[];const n=new Set(e.projects.map(r=>r.id));return t.filter(r=>{const o=String(r.source||"").toLowerCase();if(o!=="broadcast"&&!o.includes("suggested"))return!1;const l=r.project_id;return!l||n.has(String(l))})}function yg({projectIndex:e,workspaceGraph:t,tasks:n,viewer:r,orgGraph:o,selectedWorkspaceId:l,selectedProjectId:s,onSelectWorkspace:a,onSelectProject:d,onSelectTask:c,onTasksRefresh:g,onOpenCanvas:m,onLaunchSession:x,onOpenManager:S}){var A,W,ee,ie,D,fe,se,me;const E=(e==null?void 0:e.workspaces)||[],[T,H]=v.useState(null),h=v.useMemo(()=>{var he;const $=((he=o==null?void 0:o.raw)==null?void 0:he.context_index)||[],de=(r==null?void 0:r.team_id)||"",ce=(r==null?void 0:r.project_id)||"";return $.filter(_e=>!!(_e.scope==="company"||de&&_e.team_id===de||ce&&_e.project_id===ce||_e.scope==="user"&&_e.user_id===(r==null?void 0:r.user_id)))},[(A=o==null?void 0:o.raw)==null?void 0:A.context_index,r==null?void 0:r.project_id,r==null?void 0:r.team_id,r==null?void 0:r.user_id]),u=v.useMemo(()=>{var ce;const $=((ce=o==null?void 0:o.raw)==null?void 0:ce.pending_proposals)||[],de=(r==null?void 0:r.team_id)||"";return de?$.filter(he=>!he.team_id||he.team_id===de):$},[(W=o==null?void 0:o.raw)==null?void 0:W.pending_proposals,r==null?void 0:r.team_id]),[f,p]=v.useState("all"),j=v.useMemo(()=>E.find($=>$.id===l)||E.find($=>$.id===(e==null?void 0:e.currentWorkspaceId))||E[0]||(t==null?void 0:t.workspace)||null,[E,l,e==null?void 0:e.currentWorkspaceId,t]),_=v.useMemo(()=>j&&(j.projects.find($=>$.id===s)||j.projects.find($=>$.id===(e==null?void 0:e.currentProjectId)))||null,[j,s,e==null?void 0:e.currentProjectId]),P=(j==null?void 0:j.sessions)||[],z=v.useMemo(()=>{var ce,he;const $=e==null?void 0:e.currentSessionId;if(!j)return null;const de=((ce=_==null?void 0:_.sessions)==null?void 0:ce.find(_e=>_e.id===$))||((he=_==null?void 0:_.sessions)==null?void 0:he[0]);return de||P.find(_e=>_e.id===$)||P[0]||null},[j,_,e==null?void 0:e.currentSessionId,P]),{messages:Q,live:R,error:J,refresh:X}=pp(j==null?void 0:j.id,_==null?void 0:_.id);v.useEffect(()=>{let $=!0;return B.continuity().then(de=>{$&&H(de)}).catch(()=>{$&&H(null)}),()=>{$=!1}},[r==null?void 0:r.org_id,r==null?void 0:r.team_id]);const K=v.useMemo(()=>f==="all"?Q:Q.filter($=>{const de=String($.message_kind||"").toLowerCase();return f==="broadcast"?de==="broadcast":f==="tool"?de.startsWith("tool."):f==="note"?de==="note"||de==="update":!0}),[Q,f]),G=v.useMemo(()=>vg(j,n),[j,n]),[M,V]=v.useState(null);v.useEffect(()=>{var de;const $=(de=Q[0])==null?void 0:de.id;$&&$!==M&&V($)},[Q,M]);const ye=$=>({padding:"5px 10px",border:`1px solid ${$?"var(--ink)":"var(--border)"}`,background:$?"var(--ink)":"white",color:$?"white":"var(--ink2)",fontFamily:"var(--mono)",fontSize:9,letterSpacing:.5,textTransform:"uppercase",cursor:"pointer"}),C=$=>({width:"100%",textAlign:"left",padding:"9px 10px",border:`1px solid ${$?"var(--accent)":"var(--border)"}`,background:$?"var(--surface)":"white",fontFamily:$?"var(--sans)":"var(--mono)",fontSize:$?12:11,color:"var(--ink)",cursor:"pointer",display:"flex",justifyContent:"space-between",alignItems:"center",gap:8});return i.jsxs("div",{style:{height:"100%",display:"flex",flexDirection:"column",overflow:"hidden"},children:[i.jsxs("div",{style:{height:48,borderBottom:"1px solid var(--border)",padding:"0 20px",display:"flex",alignItems:"center",justifyContent:"space-between",flexShrink:0},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:10,minWidth:0},children:[i.jsxs("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:[(j==null?void 0:j.label)||(j==null?void 0:j.name)||"channel",_?` / ${_.name}`:""]}),i.jsx($e,{label:R?"live":"offline",tone:R?"var(--green)":"var(--ink3)"}),i.jsxs("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:[K.length," events · ",G.length," suggested tasks"]}),i.jsx($e,{label:`${(r==null?void 0:r.role)||"developer"} context`,tone:(r==null?void 0:r.role)==="manager"||(r==null?void 0:r.role)==="admin"?"var(--accent)":"var(--indigo)"})]}),i.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center"},children:[i.jsx("button",{onClick:m,style:{padding:"6px 12px",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink2)",background:"white",cursor:"pointer",letterSpacing:.4},children:"open canvas"}),i.jsx("button",{onClick:()=>void X(),style:{padding:"6px 12px",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink2)",background:"white",cursor:"pointer",letterSpacing:.4},children:"refresh"})]})]}),i.jsxs("div",{style:{flex:1,display:"grid",gridTemplateColumns:"260px minmax(0, 1fr) 360px",overflow:"hidden"},children:[i.jsxs("div",{style:{borderRight:"1px solid var(--border)",padding:16,overflowY:"auto",display:"flex",flexDirection:"column",gap:14},children:[i.jsxs("div",{style:{padding:12,border:"1px solid var(--border)",background:"var(--surface)",display:"grid",gap:8},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:.6,color:"var(--ink3)",textTransform:"uppercase"},children:"Context scope"}),i.jsx("div",{style:{fontSize:13,color:"var(--ink)"},children:(r==null?void 0:r.team_id)||(r==null?void 0:r.project_id)||(r==null?void 0:r.org_id)||"local dhee"}),i.jsxs("div",{style:{display:"flex",flexWrap:"wrap",gap:6,fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[i.jsxs("span",{children:[h.length," context items"]}),i.jsx("span",{children:"·"}),i.jsxs("span",{children:[u.length," pending"]})]}),i.jsx("button",{onClick:m,style:{padding:"6px 8px",border:"1px solid var(--accent)",background:"var(--accent-dim)",color:"var(--accent)",fontFamily:"var(--mono)",fontSize:9,letterSpacing:.5,cursor:"pointer"},children:"OPEN ORG MAP"})]}),i.jsxs("div",{style:{padding:12,border:"1px solid var(--border)",background:"white",display:"grid",gap:8},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:.6,color:"var(--ink3)",textTransform:"uppercase"},children:"Continue context"}),i.jsx("div",{style:{fontSize:12,color:"var(--ink2)",lineHeight:1.45},children:((ee=T==null?void 0:T.last_session)==null?void 0:ee.task_summary)||((D=(ie=T==null?void 0:T.claude_sessions)==null?void 0:ie[0])==null?void 0:D.preview)||"No Claude session recovered yet for this repo."}),i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[i.jsx("span",{children:((fe=T==null?void 0:T.last_session)==null?void 0:fe.source)||"waiting"}),i.jsx("span",{children:"·"}),i.jsxs("span",{children:[((se=T==null?void 0:T.claude_sessions)==null?void 0:se.length)||0," claude sessions"]})]})]}),i.jsxs("div",{children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:8,gap:8},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:.6,color:"var(--ink3)",textTransform:"uppercase"},children:"Workspace"}),S?i.jsx("button",{onClick:()=>S("workspaces"),title:"Manage workspaces",style:{padding:"3px 7px",border:"1px solid var(--border)",background:"white",fontFamily:"var(--mono)",fontSize:9,letterSpacing:.4,color:"var(--ink3)",cursor:"pointer"},children:"+ new / manage"}):null]}),E.length===0?i.jsx("button",{onClick:()=>S==null?void 0:S("workspaces"),style:{width:"100%",padding:"10px 12px",border:"1px dashed var(--border)",background:"white",fontFamily:"var(--mono)",fontSize:10,color:"var(--ink2)",textAlign:"left",cursor:"pointer",lineHeight:1.5},children:"Create your first workspace → e.g. Office, Personal, Sankhya AI Labs."}):i.jsx("select",{value:(j==null?void 0:j.id)||"",onChange:$=>a($.target.value),style:{width:"100%",padding:"9px 10px",border:"1px solid var(--border)",background:"white",fontSize:12},children:E.map($=>i.jsx("option",{value:$.id,children:$.label||$.name},$.id))})]}),i.jsxs("div",{children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:8,gap:8},children:[i.jsxs("span",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:.6,color:"var(--ink3)",textTransform:"uppercase"},children:["Projects · ",((me=j==null?void 0:j.projects)==null?void 0:me.length)||0]}),S&&j?i.jsx("button",{onClick:()=>S("projects"),title:"Add or edit projects",style:{padding:"3px 7px",border:"1px solid var(--border)",background:"white",fontFamily:"var(--mono)",fontSize:9,letterSpacing:.4,color:"var(--ink3)",cursor:"pointer"},children:"+ project"}):null]}),i.jsxs("div",{style:{display:"grid",gap:6},children:[i.jsxs("button",{onClick:()=>d("",j==null?void 0:j.id),style:C(!_),children:[i.jsx("span",{children:"All projects (workspace line)"}),i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:P.length})]}),((j==null?void 0:j.projects)||[]).map($=>{var he;const de=$.id===(_==null?void 0:_.id),ce=((he=$.sessions)==null?void 0:he.length)||0;return i.jsxs("button",{onClick:()=>d($.id,j==null?void 0:j.id),style:C(de),children:[i.jsx("span",{children:$.name}),i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:ce})]},$.id)})]})]}),i.jsx(gg,{workspace:j,projects:(j==null?void 0:j.projects)||[],workspaceSessions:P}),j&&i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:.6,color:"var(--ink3)",textTransform:"uppercase"},children:"Launch"}),i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap"},children:[i.jsx("button",{onClick:()=>void x("channel session","codex",j.id,void 0,_==null?void 0:_.id),style:ye(!1),children:"+ codex"}),i.jsx("button",{onClick:()=>void x("channel session","claude-code",j.id,"standard",_==null?void 0:_.id),style:ye(!1),children:"+ claude"})]})]})]}),i.jsxs("div",{style:{display:"flex",flexDirection:"column",overflow:"hidden",background:"var(--bg)"},children:[i.jsxs("div",{style:{padding:"12px 20px",borderBottom:"1px solid var(--border)",display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:.6,color:"var(--ink3)",textTransform:"uppercase"},children:"Shared line"}),i.jsx("button",{onClick:()=>p("all"),style:ye(f==="all"),children:"all"}),i.jsx("button",{onClick:()=>p("broadcast"),style:ye(f==="broadcast"),children:"broadcasts"}),i.jsx("button",{onClick:()=>p("tool"),style:ye(f==="tool"),children:"tool events"}),i.jsx("button",{onClick:()=>p("note"),style:ye(f==="note"),children:"notes"}),i.jsx("span",{style:{flex:1}}),J?i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--rose)"},children:J}):null]}),i.jsx("div",{style:{flex:1,overflowY:"auto",padding:20,display:"flex",flexDirection:"column",gap:10},children:K.length===0?i.jsxs("div",{style:{padding:24,border:"1px dashed var(--border)",background:"white",textAlign:"center"},children:[i.jsx("div",{style:{fontSize:13,fontWeight:600,marginBottom:6},children:"The line is quiet."}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",lineHeight:1.55},children:"Every agent tool-call in this workspace will appear here. Launch a session from the left rail, or broadcast a note from the composer to get started."})]}):K.map($=>i.jsx(cp,{message:$,workspace:j,onOpenTask:c},$.id))})]}),i.jsxs("div",{style:{borderLeft:"1px solid var(--border)",padding:16,overflowY:"auto",display:"flex",flexDirection:"column",gap:14},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:.6,color:"var(--ink3)",textTransform:"uppercase"},children:"Broadcast"}),i.jsx(fp,{workspace:j,activeProjectId:_==null?void 0:_.id,sessionId:z==null?void 0:z.id,onPublished:async()=>{await g(),X()}}),i.jsx(cg,{workspace:j,project:_,onActivity:()=>void X()}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:.6,color:"var(--ink3)",textTransform:"uppercase",marginTop:2},children:["Suggested tasks · ",G.length]}),i.jsx("div",{style:{display:"grid",gap:8},children:G.length===0?i.jsx("div",{style:{padding:12,border:"1px dashed var(--border)",fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",lineHeight:1.55,background:"white"},children:"When an agent broadcasts to another project, a task is auto-created there. It will show up here."}):G.slice(0,10).map($=>i.jsxs("button",{onClick:()=>c($.id),style:{textAlign:"left",padding:"10px 12px",border:"1px solid var(--border)",background:"white",cursor:"pointer",display:"flex",flexDirection:"column",gap:6},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,lineHeight:1.35},children:$.title}),i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap"},children:[i.jsx($e,{label:$.status||"active",tone:"var(--accent)"}),$.harness?i.jsx($e,{label:String($.harness)}):null]})]},$.id))})]})]})]})}const xg={live:!1,proposals:[],findings:[],conflicts:[],totals:{proposals:0,findings:0,conflicts:0}};function Pu(e){return e==="high"?"var(--rose)":e==="medium"?"var(--accent)":"var(--indigo)"}function Sg(e){const t=e.summary||e.content||"";return t.length>260?`${t.slice(0,260)}...`:t}function kg({viewer:e,onChanged:t}){var T,H,h;const[n,r]=v.useState(xg),[o,l]=v.useState(null),[s,a]=v.useState(null),[d,c]=v.useState(null),g=async()=>{try{const u=await B.inbox(e!=null&&e.team_id?{team:e.team_id,user:e.user_id}:{user:e==null?void 0:e.user_id});r(u),l(f=>{var p,j,_,P,z,Q;return f||((j=(p=u.proposals)==null?void 0:p[0])==null?void 0:j.context_id)||((P=(_=u.findings)==null?void 0:_[0])==null?void 0:P.finding_id)||String(((Q=(z=u.conflicts)==null?void 0:z[0])==null?void 0:Q.id)||"")||null})}catch(u){c(u instanceof Error?u.message:String(u))}};v.useEffect(()=>{g();const u=window.setInterval(()=>void g(),6e3);return()=>window.clearInterval(u)},[e==null?void 0:e.team_id,e==null?void 0:e.user_id]);const m=v.useMemo(()=>{var u,f,p;return(((u=n.totals)==null?void 0:u.proposals)||0)+(((f=n.totals)==null?void 0:f.findings)||0)+(((p=n.totals)==null?void 0:p.conflicts)||0)},[n.totals]),x=async(u,f)=>{a(`${f}:${u.context_id}`),c(null);try{f==="approve"?await B.approveProposal(u.context_id,(e==null?void 0:e.user_id)||"manager"):await B.rejectProposal(u.context_id,(e==null?void 0:e.user_id)||"manager"),await g(),await(t==null?void 0:t())}catch(p){c(p instanceof Error?p.message:String(p))}finally{a(null)}},S=async u=>{a(`finding:${u.finding_id}`),c(null);try{await B.resolveFinding(u.finding_id,(e==null?void 0:e.user_id)||"manager"),await g(),await(t==null?void 0:t())}catch(f){c(f instanceof Error?f.message:String(f))}finally{a(null)}},E=async(u,f)=>{const p=String(u.id||"");if(p){a(`conflict:${p}:${f}`),c(null);try{await B.resolveConflictDetailed(p,{action:f}),await g(),await(t==null?void 0:t())}catch(j){c(j instanceof Error?j.message:String(j))}finally{a(null)}}};return i.jsxs("div",{style:{display:"flex",height:"100%",minHeight:0},children:[i.jsxs("aside",{style:{width:300,borderRight:"1px solid var(--border)",background:"white",padding:16,overflowY:"auto",flexShrink:0},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",letterSpacing:"0.08em",textTransform:"uppercase"},children:"Inbox"}),i.jsxs("div",{style:{fontSize:22,fontWeight:650,marginTop:6},children:[m," open"]}),i.jsxs("div",{style:{marginTop:10,display:"grid",gap:8,fontFamily:"var(--mono)",fontSize:10},children:[i.jsx(Tl,{label:"Proposals",value:((T=n.totals)==null?void 0:T.proposals)||0}),i.jsx(Tl,{label:"Findings",value:((H=n.totals)==null?void 0:H.findings)||0}),i.jsx(Tl,{label:"Conflicts",value:((h=n.totals)==null?void 0:h.conflicts)||0})]}),i.jsx("div",{style:{marginTop:18,padding:12,border:"1px solid var(--border)",background:"var(--surface)",fontSize:12,lineHeight:1.5,color:"var(--ink2)"},children:"Review context changes, stale-context findings, and memory conflicts from one queue. Approvals activate context used by routing."}),d?i.jsx("div",{style:{marginTop:12,padding:10,border:"1px solid var(--rose)",background:"var(--rose-dim)",color:"var(--rose)",fontFamily:"var(--mono)",fontSize:10,lineHeight:1.5},children:d}):null]}),i.jsxs("main",{style:{flex:1,minWidth:0,overflowY:"auto",background:"var(--bg)",padding:18,display:"grid",gap:16,alignContent:"start"},children:[i.jsx(zl,{title:"Pending Proposals",count:n.proposals.length,empty:"No context edits are waiting for approval.",children:n.proposals.map(u=>i.jsxs("article",{onClick:()=>l(u.context_id),style:Fl(o===u.context_id),children:[i.jsxs("div",{style:Rl,children:[i.jsxs("div",{children:[i.jsx("div",{style:Nl,children:u.title}),i.jsxs("div",{style:Pl,children:[u.proposed_by_user_id||"developer"," · ",u.team_id||u.project_id||u.scope]})]}),i.jsx(uo,{color:"var(--accent)",children:"pending"})]}),i.jsx("p",{style:Lu,children:Sg(u)||"No preview available."}),i.jsxs("div",{style:{display:"flex",gap:8,justifyContent:"flex-end"},children:[i.jsx(Or,{label:"Open in Context",onClick:f=>{f.stopPropagation(),window.location.hash=`#vault/item/${u.context_id}`,window.history.replaceState(null,"",`?view=context${window.location.hash}`),window.dispatchEvent(new PopStateEvent("popstate"))}}),i.jsx(Or,{label:"Reject",color:"var(--rose)",busy:s===`reject:${u.context_id}`,onClick:f=>{f.stopPropagation(),x(u,"reject")}}),i.jsx(Or,{label:"Approve",color:"var(--green)",busy:s===`approve:${u.context_id}`,onClick:f=>{f.stopPropagation(),x(u,"approve")}})]})]},u.context_id))}),i.jsx(zl,{title:"Manager Findings",count:n.findings.length,empty:"No stale, low-quality, or duplicate context findings.",children:n.findings.map(u=>i.jsxs("article",{onClick:()=>l(u.finding_id),style:Fl(o===u.finding_id),children:[i.jsxs("div",{style:Rl,children:[i.jsxs("div",{children:[i.jsx("div",{style:Nl,children:u.title}),i.jsxs("div",{style:Pl,children:[u.team_id," · ",u.finding_type]})]}),i.jsx(uo,{color:Pu(u.severity),children:u.severity})]}),i.jsx("p",{style:Lu,children:u.detail}),i.jsx("div",{style:{display:"flex",justifyContent:"flex-end"},children:i.jsx(Or,{label:"Resolve",color:"var(--green)",busy:s===`finding:${u.finding_id}`,onClick:f=>{f.stopPropagation(),S(u)}})})]},u.finding_id))}),i.jsx(zl,{title:"Memory Conflicts",count:n.conflicts.length,empty:"No memory contradictions detected.",children:n.conflicts.map(u=>{var j,_;const f=u,p=String(f.id||Math.random());return i.jsxs("article",{onClick:()=>l(p),style:Fl(o===p),children:[i.jsxs("div",{style:Rl,children:[i.jsxs("div",{children:[i.jsx("div",{style:Nl,children:"Memory conflict"}),i.jsx("div",{style:Pl,children:f.reason||"Contradiction"})]}),i.jsx(uo,{color:Pu(f.severity),children:f.severity||"open"})]}),i.jsxs("div",{style:{display:"grid",gap:6,marginTop:10},children:[i.jsx(Fu,{label:"A",text:(j=f.belief_a)==null?void 0:j.content}),i.jsx(Fu,{label:"B",text:(_=f.belief_b)==null?void 0:_.content})]}),i.jsx("div",{style:{display:"flex",gap:8,justifyContent:"flex-end",marginTop:10},children:["KEEP A","KEEP B","MERGE"].map(P=>i.jsx(Or,{label:P,busy:s===`conflict:${p}:${P}`,onClick:z=>{z.stopPropagation(),E(u,P)}},P))})]},p)})})]})]})}function Tl({label:e,value:t}){return i.jsxs("div",{style:{display:"flex",justifyContent:"space-between"},children:[i.jsx("span",{style:{color:"var(--ink3)"},children:e}),i.jsx("span",{style:{color:t?"var(--accent)":"var(--ink2)"},children:t})]})}function zl({title:e,count:t,empty:n,children:r}){return i.jsxs("section",{style:{display:"grid",gap:10},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,fontFamily:"var(--mono)",fontSize:10,letterSpacing:"0.08em",color:"var(--ink3)",textTransform:"uppercase"},children:[i.jsx("span",{children:e}),i.jsx(uo,{children:t})]}),t===0?i.jsx("div",{style:{border:"1px dashed var(--border)",background:"white",color:"var(--ink3)",padding:16,fontSize:12},children:n}):r]})}function uo({children:e,color:t="var(--ink3)"}){return i.jsx("span",{style:{display:"inline-flex",alignItems:"center",padding:"2px 7px",border:`1px solid ${t}`,color:t,background:"white",borderRadius:3,fontFamily:"var(--mono)",fontSize:9},children:e})}function Or({label:e,onClick:t,color:n="var(--ink2)",busy:r}){return i.jsx("button",{onClick:t,disabled:r,style:{padding:"6px 9px",border:`1px solid ${n}`,color:n,background:"white",fontFamily:"var(--mono)",fontSize:9,borderRadius:3,cursor:r?"wait":"pointer"},children:r?"...":e})}function Fu({label:e,text:t}){return i.jsxs("div",{style:{border:"1px solid var(--border)",background:"var(--surface)",padding:10,display:"grid",gridTemplateColumns:"20px minmax(0, 1fr)",gap:8},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",color:"var(--ink3)"},children:e}),i.jsx("span",{style:{color:"var(--ink2)",fontSize:12,lineHeight:1.5},children:t||"No content"})]})}const Rl={display:"flex",justifyContent:"space-between",alignItems:"flex-start",gap:12},Nl={fontSize:15,fontWeight:650,color:"var(--ink)"},Pl={fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginTop:3},Lu={margin:"10px 0",color:"var(--ink2)",fontSize:12,lineHeight:1.55};function Fl(e){return{border:`1px solid ${e?"var(--accent)":"var(--border)"}`,background:"white",padding:14,boxShadow:e?"0 10px 24px rgba(20,16,10,0.06)":"none",cursor:"pointer"}}const jg={"&":"&","<":"<",">":">",'"':""","'":"'"};function ti(e){return e.replace(/[&<>"']/g,t=>jg[t]||t)}function wg(e){const t=e.trim();return/^(https?:|mailto:|#|\/)/i.test(t)?t:"#"}function Cn(e,t){let n=ti(e);return n=n.replace(/`([^`]+)`/g,(r,o)=>` ${o}`),n=n.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,(r,o,l)=>{const s=(l||o).trim(),a=t?t(o.trim()):null;return a?`${s}`:`${s}`}),n=n.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(r,o,l)=>`${o}`),n=n.replace(/\*\*([^*]+)\*\*/g,"$1"),n=n.replace(/(^|[^*])\*([^*]+)\*/g,"$1$2"),n}function Cg({source:e,wikiResolve:t}){const n=(e||"").replace(/\r\n/g,` +`).split(` +`),r=[];let o=0;for(;o${ti(d.join(` +`))}`);continue}if(/^### /.test(l)){r.push(`${Cn(l.slice(4),t)}
`),o+=1;continue}if(/^## /.test(l)){r.push(`${Cn(l.slice(3),t)}
`),o+=1;continue}if(/^# /.test(l)){r.push(`${Cn(l.slice(2),t)}
`),o+=1;continue}if(/^>\s?/.test(l)){const a=[];for(;o\s?/.test(n[o]);)a.push(n[o].replace(/^>\s?/,"")),o+=1;r.push(` ${Cn(a.join(" "),t)}`);continue}if(/^\s*[-*]\s/.test(l)){const a=[];for(;o${Cn(n[o].replace(/^\s*[-*]\s/,""),t)}`),o+=1;r.push(` ${a.join("")}
`);continue}if(/^\s*\d+\.\s/.test(l)){const a=[];for(;o${Cn(n[o].replace(/^\s*\d+\.\s/,""),t)}`),o+=1;r.push(` ${a.join("")}
`);continue}if(l.trim()===""){o+=1;continue}const s=[l];for(o+=1;o\s?)/.test(n[o]);)s.push(n[o]),o+=1;r.push(` ${Cn(s.join(` +`),t)}
`)}return i.jsx("div",{className:"md-root",style:{fontFamily:"var(--font)",fontSize:13.5,lineHeight:1.55,color:"var(--ink)"},dangerouslySetInnerHTML:{__html:r.join("")}})}const Ki={title:"",content:"",scope:"team",kind:"note",project_id:"",team_id:"",tags:""},bg=["user","company","global_team","project","team","agent"],Ou={items:[],next_cursor:null,active_only:!1,totals:{tokens_saved:0,estimated_cost_saved_usd:0,router_calls:0,sessions:0}},$u={live:!1,items:[],totals:{contexts:0,used_contexts:0,usage_count:0,tokens_served:0,proven_tokens_saved:0,theoretical_api_value_usd:0,realized_cost_saved_usd:0}};function _g(e){return{...e,tags:Array.isArray(e.tags)?e.tags:[]}}function Du(e){return(e==null?void 0:e.role)==="manager"||(e==null?void 0:e.role)==="admin"?e.team_id?{...Ki,scope:"team",team_id:e.team_id,project_id:e.project_id||""}:e.project_id?{...Ki,scope:"project",project_id:e.project_id}:{...Ki,scope:"company"}:{...Ki,scope:"user",user_id:e==null?void 0:e.user_id}}function Eg(e){return{title:e.title||"Untitled context",content:e.content||e.summary||"",scope:e.scope||"team",kind:e.kind||"note",project_id:e.project_id||"",team_id:e.team_id||"",tags:(e.tags||[]).join(", ")}}function Tg(e){return{title:ba(e),content:e.content||"",scope:"memory",kind:e.tier,project_id:"",team_id:"",tags:(e.tags||[]).join(", ")}}function Mu(e,t,n){return(e==null?void 0:e.role)==="admin"||(e==null?void 0:e.role)==="manager"?!0:((t==null?void 0:t.scope)||(n==null?void 0:n.scope))==="user"?!(t!=null&&t.user_id)||t.user_id===(e==null?void 0:e.user_id):!1}function Iu(e){return e==="pending_review"?"var(--accent)":e==="rejected"||e==="inactive"?"var(--rose)":"var(--green)"}function Ll(e){return e.split(",").map(t=>t.trim()).filter(Boolean)}function zg(e){return e.team_id?e.team_id:e.project_id?e.project_id:e.user_id?"mine":e.scope}function Rg(e){return e==="user"?"Mine":e==="company"?"Company":e==="global_team"?"Global Teams":e==="project"?"Projects":e==="team"?"Teams":e==="agent"?"Agents":e}function Bu(e){return e.replace(/[-_]+/g," ").replace(/\s+/g," ").trim().replace(/\b\w/g,t=>t.toUpperCase())}function ba(e){var n;return(e.content||"").split(/\r?\n/).map(r=>r.trim()).find(Boolean)||""||((n=e.tags)==null?void 0:n[0])||`${e.tier} memory`}function Ng(e){var l;const t=ba(e),n=t.match(/^([^>]{3,72})\s*>\s*.+$/);if(n!=null&&n[1])return n[1].trim();const r=[t,e.source,...e.tags||[]].join(" ").toLowerCase();if(r.includes("repository guideline")||r.includes("agents.md"))return"Repository Guidelines";if(r.startsWith("edited ")||r.includes("/users/")||r.includes("/tmp/"))return"File Edits";if(r.includes("session")||r.includes("continuity"))return"Session Continuity";const o=(l=e.tags)==null?void 0:l.find(s=>s&&s!==e.tier);return o?Bu(o):`${Bu(e.tier)} Memory`}function Pg(e){const t=ba(e),n=t.split(/\s*>\s*/);return(n.length>1?n.slice(1).join(" > "):t).slice(0,72)||"Memory"}function on(e){return!e||e<=0?"0":new Intl.NumberFormat("en",{notation:"compact",maximumFractionDigits:1}).format(e)}function On(e){const t=Math.max(0,Number(e||0));return t>0&&t<.01?"<$0.01":new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",maximumFractionDigits:t>=10?0:2}).format(t)}function Fg(e){const t=e.pricing;if(!t)return"PAYG input-token estimate";const n=t.input_cost_per_million,r=t.model_family||e.model||"model",o=t.provider||e.runtime||e.agent||"provider",l=typeof n=="number"?`$${n}/1M input tokens`:"input-token rate";return`${o} · ${r} · ${l}`}function Lg(e,t){const n=(t==null?void 0:t.usage_count)??e.usage_count??0,r=(t==null?void 0:t.proven_tokens_saved)??0,o=[`${e.kind} · ${zg(e)}`,`${n} use${n===1?"":"s"}`];return o.push(r>0?`${on(r)} proven saved`:"no proven $"),o.join(" · ")}function Og(e){const t=String(e.runtime||e.agent||"").toLowerCase();return t==="codex"?"var(--indigo)":t==="claude-code"||t==="claude"?"var(--accent)":"var(--green)"}function $g(e){const t=(e.title||"").trim();return t||(e.cwd||e.repo_root||"").split("/").filter(Boolean).pop()||e.session_id||"Session"}function Dg({onMemoryCountChange:e,viewer:t,orgGraph:n,onInboxChanged:r}){var wn,Ut,Z,ae,Se,le,Ee,Lt,Qn;const[o,l]=v.useState([]),[s,a]=v.useState([]),[d,c]=v.useState(null),[g,m]=v.useState(()=>Du(t||null)),[x,S]=v.useState(!1),[E,T]=v.useState(""),[H,h]=v.useState(null),[u,f]=v.useState(null),[p,j]=v.useState(!0),[_,P]=v.useState(null),[z,Q]=v.useState(Ou),[R,J]=v.useState($u),[X,K]=v.useState({}),[G,M]=v.useState(""),V=v.useRef({}),ye=async()=>{var w,b,k;try{const[O,I,ne,ve]=await Promise.all([B.listMemories().catch(Ue=>({live:!1,engrams:[],count:0,error:String(Ue)})),B.contextItems({limit:500}).catch(Ue=>({live:!1,items:[],error:String(Ue)})),B.routerSessions({active:!1,limit:50}).catch(()=>Ou),B.contextUsage({limit:500}).catch(()=>$u)]),Ke=(w=I.items)!=null&&w.length||!((b=n==null?void 0:n.raw)!=null&&b.context_index)?I.items||[]:n.raw.context_index,st=new Map((ve.items||[]).map(Ue=>[Ue.context_id,Ue]));l(Ke.map(_g).map(Ue=>{const Yn=st.get(Ue.context_id);return Yn?{...Ue,usage_count:Yn.usage_count,last_used_at:Yn.last_used_at,token_cost:Yn.token_cost}:Ue})),a(O.engrams||[]),Q(ne),J(ve),j(!!(O.live||I.live||n!=null&&n.live)),f(I.error||O.error||null),e==null||e(((k=O.engrams)==null?void 0:k.length)||0)}catch(O){f(O instanceof Error?O.message:String(O)),j(!1)}};v.useEffect(()=>{ye();const w=window.setInterval(()=>void ye(),6e3);return()=>window.clearInterval(w)},[]),v.useEffect(()=>{if(d)return;const w=decodeURIComponent(window.location.hash||""),b=w.startsWith("#vault/")?w.replace("#vault/","").trim():"",k=b?o.find(O=>O.team_id===b):null;if(k){c({kind:"context",id:k.context_id});return}if(o[0]){c({kind:"context",id:o[0].context_id});return}s[0]&&c({kind:"memory",id:s[0].id})},[o,s,d]);const C=(d==null?void 0:d.kind)==="context"&&o.find(w=>w.context_id===d.id)||null,A=(d==null?void 0:d.kind)==="memory"&&s.find(w=>w.id===d.id)||null;v.useEffect(()=>{if(C){m(Eg(C)),S(!1);return}if(A){m(Tg(A)),S(!1);return}(d==null?void 0:d.kind)==="new"&&(m(Du(t||null)),S(!0))},[C,A,d==null?void 0:d.kind,t]),v.useEffect(()=>{if(!C){P(null);return}let w=!0;return B.backlinks(C.context_id).then(b=>{w&&P(b)}).catch(()=>{w&&P(null)}),()=>{w=!1}},[C==null?void 0:C.context_id]);const W=v.useMemo(()=>{const w=E.trim().toLowerCase();return w?o.filter(b=>[b.title,b.summary,b.content,b.kind,b.scope,b.team_id,b.project_id,...b.tags||[]].filter(Boolean).join(" ").toLowerCase().includes(w)):o},[o,E]),ee=v.useMemo(()=>{const w=new Map;for(const b of bg)w.set(b,[]);for(const b of W){const k=b.scope||"team";w.set(k,[...w.get(k)||[],b])}return w},[W]),ie=v.useMemo(()=>{const w=E.trim().toLowerCase();return w?s.filter(b=>[b.content,b.source,b.tier,...b.tags||[]].filter(Boolean).join(" ").toLowerCase().includes(w)):s},[s,E]),D=v.useMemo(()=>{const w=new Map;for(const b of ie){const k=Ng(b);w.set(k,[...w.get(k)||[],b])}return new Map([...w.entries()].sort(([b],[k])=>b.localeCompare(k)))},[ie]),fe=v.useMemo(()=>{const w=[];for(const[b,k]of ee.entries())k.length!==0&&w.push({id:`context:${b}`,kind:"context",label:Rg(b),count:k.length,rows:k});for(const[b,k]of D.entries())k.length!==0&&w.push({id:`memory:${b}`,kind:"memory",label:b,count:k.length,rows:k});return w},[ee,D]),se=v.useMemo(()=>{const w=[];for(const b of fe)if(w.push({id:`section:${b.id}`,type:"section",sectionId:b.id}),!!X[b.id])if(b.kind==="context")for(const k of b.rows)w.push({id:`context-item:${k.context_id}`,type:"context",sectionId:b.id,item:k});else for(const k of b.rows)w.push({id:`memory-item:${k.id}`,type:"memory",sectionId:b.id,memory:k});return w},[X,fe]);v.useEffect(()=>{E.trim()&&K({})},[E]),v.useEffect(()=>{if(!se.length){G&&M("");return}(!G||!se.some(w=>w.id===G))&&M(se[0].id)},[G,se]),v.useEffect(()=>{var w;G&&((w=V.current[G])==null||w.focus())},[G]);const me=(w,b)=>{K(k=>({...k,[w]:typeof b=="boolean"?b:!k[w]}))},$=w=>{c({kind:"context",id:w.context_id}),window.location.hash=`#vault/item/${w.context_id}`},de=w=>{c({kind:"memory",id:w.id})},ce=w=>{M(w)},he=(w,b)=>{const k=se.findIndex(I=>I.id===w);if(k<0)return;const O=se[Math.max(0,Math.min(se.length-1,k+b))];O&&ce(O.id)},_e=(w,b)=>{if(w.key==="ArrowDown"){w.preventDefault(),he(b.id,1);return}if(w.key==="ArrowUp"){w.preventDefault(),he(b.id,-1);return}if(w.key==="Home"){w.preventDefault(),se[0]&&ce(se[0].id);return}if(w.key==="End"){w.preventDefault();const k=se[se.length-1];k&&ce(k.id);return}if(w.key==="ArrowRight"&&b.type==="section"){if(w.preventDefault(),!X[b.sectionId])me(b.sectionId,!0);else{const k=se.find(O=>O.sectionId===b.sectionId&&O.type!=="section");k&&ce(k.id)}return}if(w.key==="ArrowLeft"){w.preventDefault(),b.type==="section"?X[b.sectionId]&&me(b.sectionId,!1):ce(`section:${b.sectionId}`);return}(w.key==="Enter"||w.key===" ")&&b.type==="section"&&(w.preventDefault(),me(b.sectionId))},N=w=>{const b=o.find(k=>k.title.toLowerCase()===w.toLowerCase());return b?`#vault/item/${b.context_id}`:null},oe=()=>{c({kind:"new",id:"new"})},q=async()=>{if(!g.title.trim()||!g.content.trim()){f("Title and content are required.");return}h("save"),f(null);const w={title:g.title.trim(),content:g.content,scope:g.scope,kind:g.kind||"note",project_id:g.project_id||(t==null?void 0:t.project_id)||void 0,team_id:g.team_id||(t==null?void 0:t.team_id)||void 0,tags:Ll(g.tags),metadata:{source:"sankhya-vault"}};try{if(Mu(t||null,C,g)){const b=await B.upsertContext({...w,context_id:C==null?void 0:C.context_id,user_id:g.scope==="user"?t==null?void 0:t.user_id:void 0});c({kind:"context",id:b.item.context_id})}else{const b=await B.proposeContext({...w,proposed_by_user_id:(t==null?void 0:t.user_id)||"developer",supersedes_id:C==null?void 0:C.context_id});c({kind:"context",id:b.proposal.context_id}),await(r==null?void 0:r())}await ye(),S(!1)}catch(b){f(b instanceof Error?b.message:String(b))}finally{h(null)}},te=async w=>{if(C){h(w);try{w==="approve"?await B.approveProposal(C.context_id,(t==null?void 0:t.user_id)||"manager"):await B.rejectProposal(C.context_id,(t==null?void 0:t.user_id)||"manager"),await ye(),await(r==null?void 0:r())}catch(b){f(b instanceof Error?b.message:String(b))}finally{h(null)}}},Ae=(d==null?void 0:d.kind)==="memory",Ft=Mu(t||null,C,g),xe=v.useMemo(()=>[...z.items||[]].sort((b,k)=>{const O=(k.tokens_saved||0)-(b.tokens_saved||0);return O!==0?O:String(k.updated_at||"").localeCompare(String(b.updated_at||""))}).filter(b=>b.tokens_saved>0).slice(0,4),[z.items]),Me=v.useMemo(()=>new Map((R.items||[]).map(w=>[w.context_id,w])),[R.items]),je=C&&Me.get(C.context_id)||null,Fe=v.useMemo(()=>[...R.items||[]].filter(w=>(w.usage_count||0)>0).sort((w,b)=>{const k=(b.usage_count||0)-(w.usage_count||0);return k!==0?k:(b.proven_tokens_saved||0)-(w.proven_tokens_saved||0)}).slice(0,5),[R.items]);return i.jsxs("div",{style:{display:"flex",height:"100%",minHeight:0},children:[i.jsxs("aside",{style:{width:282,borderRight:"1px solid var(--border)",background:"white",display:"flex",flexDirection:"column",flexShrink:0},children:[i.jsxs("div",{style:{padding:14,borderBottom:"1px solid var(--border)"},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,marginBottom:10},children:[i.jsxs("div",{children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",letterSpacing:"0.08em",textTransform:"uppercase"},children:"Context"}),i.jsx("div",{style:{fontSize:14,fontWeight:600,color:"var(--ink)"},children:"Manager"})]}),i.jsx("button",{onClick:oe,style:{padding:"5px 8px",border:"1px solid var(--accent)",background:"var(--accent-dim)",color:"var(--accent)",fontFamily:"var(--mono)",fontSize:10,borderRadius:3},children:"NEW"})]}),i.jsx("input",{value:E,onChange:w=>T(w.target.value),placeholder:"search context or memory",style:{width:"100%",boxSizing:"border-box",border:"1px solid var(--border)",background:"var(--surface)",color:"var(--ink)",padding:"8px 9px",fontSize:12}}),i.jsxs("div",{style:{marginTop:8},children:[i.jsx(Bg,{rows:Fe,totals:R.totals,onSelect:w=>{c({kind:"context",id:w}),window.location.hash=`#vault/item/${w}`}}),i.jsx(Ag,{rows:xe,totalTokens:((wn=z.totals)==null?void 0:wn.tokens_saved)||0,totalCost:((Ut=z.totals)==null?void 0:Ut.realized_cost_saved_usd)??((Z=z.totals)==null?void 0:Z.estimated_cost_saved_usd)??0,apiValue:((ae=z.totals)==null?void 0:ae.theoretical_api_value_usd)??((Se=z.totals)==null?void 0:Se.estimated_cost_saved_usd)??0,totalSessions:((le=z.totals)==null?void 0:le.sessions)||0,budget:z.budget})]})]}),i.jsx("div",{style:{flex:1,overflowY:"auto",padding:10},role:"tree","aria-label":"Context categories",children:fe.map(w=>{const b={id:`section:${w.id}`,type:"section",sectionId:w.id},k=!!X[w.id],O=w.kind==="context"?w.rows.some(I=>(d==null?void 0:d.kind)==="context"&&d.id===I.context_id):w.rows.some(I=>(d==null?void 0:d.kind)==="memory"&&d.id===I.id);return i.jsx(Ig,{label:w.label,count:w.count,open:k,active:O,focused:G===b.id,buttonRef:I=>{V.current[b.id]=I},onFocus:()=>M(b.id),onToggle:()=>me(w.id),onKeyDown:I=>_e(I,b),children:w.kind==="context"?w.rows.map(I=>{const ne={id:`context-item:${I.context_id}`,type:"context",sectionId:w.id};return i.jsx(Au,{treeId:ne.id,buttonRef:ve=>{V.current[ne.id]=ve},focused:G===ne.id,active:(d==null?void 0:d.kind)==="context"&&d.id===I.context_id,dot:Iu(I.proposal_status||I.status),title:I.title,meta:Lg(I,Me.get(I.context_id)),onFocus:()=>M(ne.id),onKeyDown:ve=>_e(ve,ne),onClick:()=>$(I)},I.context_id)}):w.rows.map(I=>{const ne={id:`memory-item:${I.id}`,type:"memory",sectionId:w.id};return i.jsx(Au,{treeId:ne.id,buttonRef:ve=>{V.current[ne.id]=ve},focused:G===ne.id,active:(d==null?void 0:d.kind)==="memory"&&d.id===I.id,dot:"var(--indigo)",title:Pg(I),meta:`${I.tier} · ${I.tokens||0} tok`,onFocus:()=>M(ne.id),onKeyDown:ve=>_e(ve,ne),onClick:()=>de(I)},I.id)})},w.id)})})]}),i.jsxs("main",{style:{flex:1,minWidth:0,display:"grid",gridTemplateColumns:"minmax(0, 1fr) 320px",background:"var(--bg)"},children:[i.jsxs("section",{style:{minWidth:0,display:"flex",flexDirection:"column"},children:[i.jsxs("header",{style:{height:52,borderBottom:"1px solid var(--border)",display:"flex",alignItems:"center",justifyContent:"space-between",padding:"0 18px",background:"white",gap:12},children:[i.jsxs("div",{style:{minWidth:0},children:[i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:"0.08em",color:"var(--ink3)",textTransform:"uppercase"},children:[A?"Legacy memory":g.scope||"context",p?"":" · offline"]}),i.jsx("div",{style:{fontSize:16,fontWeight:600,color:"var(--ink)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:g.title||"New context item"})]}),i.jsxs("div",{style:{display:"flex",gap:8,flexShrink:0},children:[(C==null?void 0:C.proposal_status)==="pending_review"&&((t==null?void 0:t.role)==="manager"||(t==null?void 0:t.role)==="admin")?i.jsxs(i.Fragment,{children:[i.jsx(Qi,{label:"APPROVE",tone:"green",busy:H==="approve",onClick:()=>void te("approve")}),i.jsx(Qi,{label:"REJECT",tone:"rose",busy:H==="reject",onClick:()=>void te("reject")})]}):null,Ae?null:i.jsxs(i.Fragment,{children:[i.jsx(Qi,{label:x?"PREVIEW":"EDIT",onClick:()=>S(w=>!w)}),i.jsx(Qi,{label:Ft?"SAVE":"REQUEST",tone:Ft?"accent":"indigo",busy:H==="save",onClick:()=>void q()})]})]})]}),i.jsxs("div",{style:{flex:1,minHeight:0,overflow:"auto",padding:18,display:"grid",gridTemplateColumns:x?"minmax(0, 1fr) minmax(0, 1fr)":"minmax(0, 760px)",gap:16,alignContent:"start"},children:[x&&!Ae?i.jsxs("div",{style:{display:"grid",gap:10,alignContent:"start"},children:[i.jsx("input",{value:g.title,onChange:w=>m(b=>({...b,title:w.target.value})),placeholder:"Context title",style:bn}),i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:8},children:[i.jsxs("select",{value:g.scope,onChange:w=>m(b=>({...b,scope:w.target.value})),style:bn,children:[i.jsx("option",{value:"company",children:"company"}),i.jsx("option",{value:"project",children:"project"}),i.jsx("option",{value:"global_team",children:"global_team"}),i.jsx("option",{value:"team",children:"team"}),i.jsx("option",{value:"user",children:"user"}),i.jsx("option",{value:"agent",children:"agent"})]}),i.jsx("input",{value:g.kind,onChange:w=>m(b=>({...b,kind:w.target.value})),placeholder:"kind: runbook / policy / decision",style:bn})]}),i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:8},children:[i.jsx("input",{value:g.project_id,onChange:w=>m(b=>({...b,project_id:w.target.value})),placeholder:"project id",style:bn}),i.jsx("input",{value:g.team_id,onChange:w=>m(b=>({...b,team_id:w.target.value})),placeholder:"team id",style:bn})]}),i.jsx("input",{value:g.tags,onChange:w=>m(b=>({...b,tags:w.target.value})),placeholder:"tags, comma separated",style:bn}),i.jsx("textarea",{value:g.content,onChange:w=>m(b=>({...b,content:w.target.value})),rows:20,placeholder:"Write markdown context here...",style:{...bn,minHeight:420,resize:"vertical",lineHeight:1.55,fontFamily:"var(--mono)"}})]}):null,i.jsx("article",{style:{border:"1px solid var(--border)",background:"white",padding:18,minHeight:420,boxShadow:"0 10px 28px rgba(20,16,10,0.04)"},children:i.jsx(Cg,{source:g.content||"_No context selected._",wikiResolve:N})})]})]}),i.jsxs("aside",{style:{borderLeft:"1px solid var(--border)",background:"white",padding:16,overflowY:"auto",display:"flex",flexDirection:"column",gap:14},children:[i.jsxs(_n,{label:"Scope",children:[i.jsx($r,{children:g.scope}),g.kind?i.jsx($r,{children:g.kind}):null,C!=null&&C.proposal_status?i.jsx($r,{color:Iu(C.proposal_status),children:C.proposal_status}):null]}),i.jsxs(_n,{label:"Ownership",children:[i.jsx(Ge,{k:"org",v:t==null?void 0:t.org_id}),i.jsx(Ge,{k:"project",v:g.project_id||(t==null?void 0:t.project_id)}),i.jsx(Ge,{k:"team",v:g.team_id||(t==null?void 0:t.team_id)}),i.jsx(Ge,{k:"viewer",v:t==null?void 0:t.user_id}),i.jsx(Ge,{k:"role",v:(t==null?void 0:t.role)||"developer"})]}),i.jsxs(_n,{label:"Health",children:[i.jsx(Ge,{k:"quality",v:C==null?void 0:C.quality_score}),i.jsx(Ge,{k:"freshness",v:C==null?void 0:C.freshness_score}),i.jsx(Ge,{k:"confidence",v:C==null?void 0:C.confidence}),i.jsx(Ge,{k:"token cost",v:C==null?void 0:C.token_cost}),i.jsx(Ge,{k:"updated",v:(C==null?void 0:C.updated_at)||(A==null?void 0:A.created)})]}),C?i.jsxs(_n,{label:"Usage",children:[i.jsx(Ge,{k:"uses",v:(je==null?void 0:je.usage_count)??C.usage_count??0}),i.jsx(Ge,{k:"last used",v:(je==null?void 0:je.last_used_at)??C.last_used_at}),i.jsx(Ge,{k:"tokens served",v:(je==null?void 0:je.tokens_served)??0}),i.jsx(Ge,{k:"proven saved",v:(je==null?void 0:je.proven_tokens_saved)??0}),i.jsx(Ge,{k:"proven $",v:On(je==null?void 0:je.realized_cost_saved_usd)}),i.jsx(Ge,{k:"evidence",v:(Ee=je==null?void 0:je.evidence)!=null&&Ee.has_direct_savings_evidence?"direct attribution":"usage only"})]}):null,i.jsx(_n,{label:"Tags",children:Ll(g.tags).length?Ll(g.tags).map(w=>i.jsx($r,{children:w},w)):i.jsx("span",{style:{color:"var(--ink3)",fontSize:12},children:"none"})}),i.jsx(_n,{label:"Backlinks",children:(Lt=_==null?void 0:_.backlinks)!=null&&Lt.length?_.backlinks.map(w=>i.jsx("button",{onClick:()=>c({kind:"context",id:w.src}),style:Mg,children:w.src_title||w.src},`${w.src}:${w.edge_type}`)):i.jsx("span",{style:{color:"var(--ink3)",fontSize:12},children:"none"})}),i.jsx(_n,{label:"Shares",children:(Qn=_==null?void 0:_.shares)!=null&&Qn.length?_.shares.map((w,b)=>i.jsx($r,{children:String(w.scope||w.team_id||"share")},b)):i.jsx("span",{style:{color:"var(--ink3)",fontSize:12},children:"private"})}),u?i.jsx("div",{style:{border:"1px solid var(--rose)",color:"var(--rose)",background:"var(--rose-dim)",padding:10,fontFamily:"var(--mono)",fontSize:10,lineHeight:1.5},children:u}):null]})]})]})}const bn={width:"100%",boxSizing:"border-box",border:"1px solid var(--border)",background:"white",color:"var(--ink)",padding:"8px 9px",fontSize:12},Mg={display:"block",width:"100%",textAlign:"left",border:"1px solid var(--border)",background:"var(--surface)",color:"var(--ink2)",padding:"7px 8px",fontSize:12,cursor:"pointer"};function Ig({label:e,count:t,open:n,active:r,focused:o,buttonRef:l,onFocus:s,onToggle:a,onKeyDown:d,children:c}){return i.jsxs("div",{style:{marginBottom:6},children:[i.jsxs("button",{ref:l,type:"button",role:"treeitem","aria-expanded":n,"aria-selected":r,tabIndex:o?0:-1,onFocus:s,onClick:a,onKeyDown:d,style:{width:"100%",border:`1px solid ${r?"var(--accent)":"var(--border)"}`,background:r?"var(--accent-dim)":"var(--surface)",color:"var(--ink)",borderRadius:4,padding:"7px 8px",cursor:"pointer",display:"grid",gridTemplateColumns:"12px minmax(0, 1fr) auto",alignItems:"center",gap:8,textAlign:"left",fontFamily:"var(--mono)",fontSize:10,letterSpacing:"0.04em",textTransform:"uppercase",outline:o?"2px solid var(--accent-dim)":"none"},children:[i.jsx("span",{style:{color:"var(--ink3)"},children:n?"v":">"}),i.jsx("span",{style:{overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:e}),i.jsx("span",{style:{color:"var(--ink3)"},children:t})]}),n?i.jsx("div",{role:"group",style:{display:"grid",gap:4,margin:"4px 0 8px 14px"},children:c}):null]})}function Bg({rows:e,totals:t,onSelect:n}){return i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",borderRadius:4,padding:8,marginBottom:8},children:[i.jsxs("div",{style:{display:"flex",alignItems:"baseline",justifyContent:"space-between",gap:8},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:"0.04em",textTransform:"uppercase"},children:"Frequent context"}),i.jsxs("span",{style:{fontSize:13,fontWeight:700,color:"var(--ink)"},children:[on((t==null?void 0:t.usage_count)||0)," uses"]})]}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginTop:2,lineHeight:1.35},children:[on((t==null?void 0:t.tokens_served)||0)," served ·"," ",on((t==null?void 0:t.proven_tokens_saved)||0)," proven saved ·"," ",On(t==null?void 0:t.realized_cost_saved_usd)]}),i.jsx("div",{style:{display:"grid",gap:5,marginTop:8},children:e.length?e.map(r=>i.jsxs("button",{type:"button",onClick:()=>n(r.context_id),style:{border:"0",borderTop:"1px solid var(--border)",background:"transparent",padding:"6px 0 0",textAlign:"left",cursor:"pointer",display:"grid",gridTemplateColumns:"minmax(0, 1fr) auto",gap:8},children:[i.jsxs("span",{style:{minWidth:0},children:[i.jsx("span",{style:{display:"block",color:"var(--ink)",fontSize:11,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:r.title,children:r.title}),i.jsxs("span",{style:{display:"block",fontFamily:"var(--mono)",color:"var(--ink3)",fontSize:9,marginTop:2},children:[r.usage_count," uses · ",on(r.tokens_served)," served"]})]}),i.jsxs("span",{style:{textAlign:"right",fontFamily:"var(--mono)"},children:[i.jsx("span",{style:{display:"block",color:"var(--green)",fontSize:11},children:on(r.proven_tokens_saved)}),i.jsx("span",{style:{display:"block",color:"var(--ink3)",fontSize:9},children:On(r.realized_cost_saved_usd)})]})]},r.context_id)):i.jsx("div",{style:{borderTop:"1px solid var(--border)",paddingTop:7,fontSize:11,color:"var(--ink3)",lineHeight:1.4},children:"No context has been injected yet."})}),i.jsx("div",{style:{borderTop:"1px solid var(--border)",marginTop:8,paddingTop:6,color:"var(--ink3)",fontSize:10,lineHeight:1.35},children:"Per-context dollars are shown only when Dhee has direct attribution."})]})}function Ag({rows:e,totalTokens:t,totalCost:n,apiValue:r,totalSessions:o,budget:l}){return i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",borderRadius:4,padding:8},children:[i.jsxs("div",{style:{display:"flex",alignItems:"baseline",justifyContent:"space-between",gap:8},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:"0.04em",textTransform:"uppercase"},children:"Budget-capped savings"}),i.jsx("span",{style:{fontSize:14,fontWeight:700,color:"var(--green)"},children:On(n)})]}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginTop:2},children:[on(t)," input tokens avoided across ",o," sessions"]}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginTop:2,lineHeight:1.35},children:["API value ",On(r),l!=null&&l.monthly_budget_usd?`; monthly cap ${On(l.monthly_budget_usd)}`:""]}),i.jsx("div",{style:{display:"grid",gap:5,marginTop:8},children:e.length?e.map(s=>{const a=Og(s);return i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"minmax(0, 1fr) auto",gap:8,borderTop:"1px solid var(--border)",paddingTop:6},children:[i.jsxs("div",{style:{minWidth:0},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,minWidth:0},children:[i.jsx("span",{style:{width:6,height:6,borderRadius:99,background:a,flexShrink:0}}),i.jsx("span",{style:{fontSize:11,color:"var(--ink)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:$g(s)})]}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:a,marginTop:2},children:[s.runtime||s.agent||"agent"," · ",s.router_calls," calls"]})]}),i.jsxs("div",{style:{textAlign:"right"},title:Fg(s),children:[i.jsx("div",{style:{fontSize:12,fontWeight:700,color:"var(--ink)"},children:On(s.estimated_cost_saved_usd)}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[on(s.tokens_saved)," tok"]})]})]},s.session_id)}):i.jsx("div",{style:{borderTop:"1px solid var(--border)",paddingTop:7,fontSize:11,color:"var(--ink3)",lineHeight:1.4},children:"No session-level savings recorded yet."})})]})}function Au({treeId:e,buttonRef:t,focused:n,active:r,dot:o,title:l,meta:s,onFocus:a,onKeyDown:d,onClick:c}){return i.jsxs("button",{ref:t,type:"button","data-tree-id":e,role:"treeitem","aria-selected":r,tabIndex:n?0:-1,onFocus:a,onKeyDown:d,onClick:c,style:{textAlign:"left",border:`1px solid ${r?"var(--accent)":"transparent"}`,background:r?"var(--accent-dim)":"transparent",color:"var(--ink)",padding:"7px 8px",borderRadius:4,cursor:"pointer",display:"grid",gridTemplateColumns:"8px minmax(0, 1fr)",gap:8,alignItems:"start",outline:n?"2px solid var(--accent-dim)":"none"},children:[i.jsx("span",{style:{width:6,height:6,borderRadius:99,background:o,marginTop:5}}),i.jsxs("span",{style:{minWidth:0},children:[i.jsx("span",{style:{display:"block",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",fontSize:12},children:l}),i.jsx("span",{style:{display:"block",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginTop:2},children:s})]})]})}function Qi({label:e,onClick:t,tone:n="default",busy:r}){const o=n==="accent"?"var(--accent)":n==="indigo"?"var(--indigo)":n==="green"?"var(--green)":n==="rose"?"var(--rose)":"var(--ink2)";return i.jsx("button",{onClick:t,disabled:r,style:{padding:"7px 10px",border:`1px solid ${o}`,background:n==="default"?"white":"var(--surface)",color:o,fontFamily:"var(--mono)",fontSize:10,letterSpacing:"0.06em",borderRadius:3,cursor:r?"wait":"pointer"},children:r?"...":e})}function _n({label:e,children:t}){return i.jsxs("section",{style:{display:"grid",gap:8},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,letterSpacing:"0.08em",color:"var(--ink3)",textTransform:"uppercase"},children:e}),i.jsx("div",{style:{display:"flex",flexWrap:"wrap",gap:6},children:t})]})}function $r({children:e,color:t="var(--ink2)"}){return i.jsx("span",{style:{display:"inline-flex",border:"1px solid var(--border)",background:"var(--surface)",color:t,padding:"3px 7px",borderRadius:3,fontFamily:"var(--mono)",fontSize:10},children:e})}function Ge({k:e,v:t}){return t==null||t===""?null:i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"76px minmax(0, 1fr)",gap:6,width:"100%",fontSize:12},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:e}),i.jsx("span",{style:{color:"var(--ink2)",wordBreak:"break-word"},children:String(t)})]})}function Ug({projectIndex:e,memories:t,tokensSaved:n,onAddMemory:r,onSelectSession:o,onCreateWorkspace:l,onLaunchSession:s,onOpenWorkspace:a,onOpenTasks:d}){var ie;const[c,g]=v.useState(""),[m,x]=v.useState("task"),[S,E]=v.useState("codex"),[T,H]=v.useState("standard"),[h,u]=v.useState(""),[f,p]=v.useState(""),[j,_]=v.useState(!1),[P,z]=v.useState(""),[Q,R]=v.useState(!1),[J,X]=v.useState(null),K=v.useRef(null);v.useEffect(()=>{var D;(D=K.current)==null||D.focus()},[]);const G=(e==null?void 0:e.workspaces)||[],M=v.useMemo(()=>G.find(D=>D.id===(h||(e==null?void 0:e.currentWorkspaceId)))||G[0]||null,[e==null?void 0:e.currentWorkspaceId,h,G]),V=v.useMemo(()=>{var D,fe;return((D=M==null?void 0:M.projects)==null?void 0:D.find(se=>se.id===(f||(e==null?void 0:e.currentProjectId))))||((fe=M==null?void 0:M.projects)==null?void 0:fe[0])||null},[M,e==null?void 0:e.currentProjectId,f]),ye=v.useMemo(()=>{var D,fe,se,me;return((D=V==null?void 0:V.sessions)==null?void 0:D.find($=>$.id===(e==null?void 0:e.currentSessionId)))||((fe=V==null?void 0:V.sessions)==null?void 0:fe[0])||((se=M==null?void 0:M.sessions)==null?void 0:se.find($=>$.id===(e==null?void 0:e.currentSessionId)))||((me=M==null?void 0:M.sessions)==null?void 0:me[0])||null},[V,M,e==null?void 0:e.currentSessionId]);v.useEffect(()=>{!h&&(e!=null&&e.currentWorkspaceId)&&u(e.currentWorkspaceId)},[e==null?void 0:e.currentWorkspaceId,h]),v.useEffect(()=>{!f&&(e!=null&&e.currentProjectId)&&p(e.currentProjectId)},[e==null?void 0:e.currentProjectId,f]),v.useEffect(()=>{M&&V&&!M.projects.some(D=>D.id===f)&&p(V.id)},[M,V,f]);const C=c.split(` +`).map(D=>D.trim()).filter(Boolean),A=async()=>{if(!(!C.length||Q)){R(!0),X(null);try{if(m==="memory")for(const D of C)await r(D);else for(const D of C)await s(D,S,M==null?void 0:M.id,S==="claude-code"?T:void 0,V==null?void 0:V.id);g("")}catch(D){X(String(D))}finally{R(!1)}}},W=async()=>{if(!(!P.trim()||Q)){R(!0),X(null);try{await l(P.trim()),z(""),_(!1)}catch(D){X(String(D))}finally{R(!1)}}},ee=D=>({padding:"6px 10px",border:"1px solid var(--border)",background:D?"var(--ink)":"white",color:D?"white":"var(--ink2)",fontFamily:"var(--mono)",fontSize:9});return i.jsxs("div",{style:{height:"100%",display:"flex",flexDirection:"column",overflow:"hidden"},children:[i.jsxs("div",{style:{height:48,borderBottom:"1px solid var(--border)",padding:"0 24px",display:"flex",alignItems:"center",justifyContent:"space-between",flexShrink:0},children:[i.jsx("div",{style:{display:"flex",gap:10,alignItems:"center",minWidth:0},children:i.jsxs("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:[(M==null?void 0:M.label)||(M==null?void 0:M.name)||"workspace",V?` / ${V.name}`:""]})}),i.jsxs("div",{style:{display:"flex",gap:18,alignItems:"center",fontFamily:"var(--mono)",fontSize:10},children:[i.jsx("button",{onClick:d,style:{color:"var(--ink3)"},children:"tasks"}),i.jsxs("span",{style:{color:"var(--ink3)"},children:[t," engrams"]})]})]}),i.jsxs("div",{style:{flex:1,overflow:"auto",padding:28,display:"grid",gridTemplateColumns:"minmax(0, 1fr) 280px",gap:24},children:[i.jsxs("div",{style:{border:"1px solid var(--border)",background:"transparent",padding:24,display:"flex",flexDirection:"column",minHeight:0},children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",gap:12,alignItems:"center",marginBottom:16,flexWrap:"wrap"},children:[i.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"},children:[i.jsx("button",{onClick:()=>x("task"),style:ee(m==="task"),children:"create task"}),i.jsx("button",{onClick:()=>x("memory"),style:ee(m==="memory"),children:"save memory"}),m==="task"&&i.jsxs(i.Fragment,{children:[i.jsx("button",{onClick:()=>E("codex"),style:ee(S==="codex"),children:"codex"}),i.jsx("button",{onClick:()=>E("claude-code"),style:ee(S==="claude-code"),children:"claude-code"}),S==="claude-code"&&i.jsxs(i.Fragment,{children:[i.jsx("button",{onClick:()=>H("standard"),style:ee(T==="standard"),children:"standard"}),i.jsx("button",{onClick:()=>H("full-access"),style:ee(T==="full-access"),children:"full access"})]})]})]}),i.jsx("button",{onClick:a,style:{padding:"7px 10px",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:"open workspace"})]}),m==="task"&&i.jsxs("div",{style:{display:"grid",gap:10,marginBottom:14},children:[i.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:"workspace"}),i.jsx("select",{value:(M==null?void 0:M.id)||"",onChange:D=>u(D.target.value),style:{border:"1px solid var(--border)",padding:"7px 10px",background:"white",minWidth:220},children:G.map(D=>i.jsx("option",{value:D.id,children:D.label||D.name},D.id))})]}),(ie=M==null?void 0:M.projects)!=null&&ie.length?i.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:"project"}),M.projects.map(D=>i.jsx("button",{onClick:()=>p(D.id),style:{...ee(D.id===(V==null?void 0:V.id)),background:D.id===(V==null?void 0:V.id)?"var(--accent)":"white"},children:D.name},D.id))]}):null]}),i.jsx("textarea",{ref:K,value:c,onChange:D=>g(D.target.value),onKeyDown:D=>{(D.metaKey||D.ctrlKey)&&D.key==="Enter"&&(D.preventDefault(),A())},placeholder:m==="task"?`- broadcast backend contract changes +- compare project scope rules +- create follow-up task for frontend stream`:`- backend project now emits model version updates +- paper asset is chunked and queryable +- avoid reprocessing shared results`,style:{width:"100%",flex:1,border:"1px solid var(--border)",padding:"18px 20px",fontSize:22,lineHeight:1.55,background:"white",resize:"none",minHeight:420}}),i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginTop:14},children:[i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:[C.length," point",C.length===1?"":"s"]}),i.jsx("button",{onClick:()=>void A(),style:{padding:"10px 16px",border:"1px solid var(--ink)",background:"var(--ink)",color:"white",fontFamily:"var(--mono)",fontSize:10},children:Q?"saving…":m==="task"?"create task":"save memory"})]}),J&&i.jsx("div",{style:{marginTop:12,fontSize:11,color:"var(--rose)"},children:J})]}),i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:12},children:[i.jsxs("div",{onClick:a,style:{border:"1px solid var(--border)",background:"white",padding:16,cursor:"pointer"},children:[i.jsx("div",{style:{marginBottom:8,fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"CURRENT WORKSPACE"}),i.jsx("div",{style:{fontSize:16,fontWeight:600},children:(M==null?void 0:M.label)||(M==null?void 0:M.name)||"No workspace"}),i.jsx("div",{style:{marginTop:6,fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:(M==null?void 0:M.workspacePath)||"Select or create a workspace"}),V&&i.jsxs("div",{style:{marginTop:10,fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[V.name," · default ",V.defaultRuntime||"codex"]}),ye&&i.jsx("button",{onClick:D=>{D.stopPropagation(),o(ye.id,ye.taskId||null)},style:{marginTop:12,padding:"7px 10px",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:"open current session"})]}),i.jsx("button",{onClick:()=>_(!0),style:{padding:"12px 14px",border:"1px solid var(--border)",background:"white",fontFamily:"var(--mono)",fontSize:10,textAlign:"left"},children:"+ add workspace"})]})]}),j&&i.jsx("div",{style:{position:"fixed",inset:0,background:"rgba(0,0,0,0.18)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:40},onClick:()=>_(!1),children:i.jsxs("div",{onClick:D=>D.stopPropagation(),style:{width:460,maxWidth:"calc(100vw - 32px)",border:"1px solid var(--border)",background:"white",padding:20},children:[i.jsx("div",{style:{marginBottom:14,fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"ADD WORKSPACE"}),i.jsxs("div",{style:{display:"grid",gap:10},children:[i.jsx("input",{value:P,onChange:D=>z(D.target.value),placeholder:"Workspace name",style:{border:"1px solid var(--border)",padding:"11px 12px",background:"white"}}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",lineHeight:1.5},children:"A workspace is a collection of projects. Add projects and their folders after creating it."}),i.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:8,marginTop:4},children:[i.jsx("button",{onClick:()=>_(!1),style:{padding:"10px 12px",border:"1px solid var(--border)",background:"white",fontFamily:"var(--mono)",fontSize:10},children:"cancel"}),i.jsx("button",{onClick:()=>void W(),style:{padding:"10px 12px",border:"1px solid var(--ink)",background:"var(--ink)",color:"white",fontFamily:"var(--mono)",fontSize:10},children:Q?"creating…":"create workspace"})]})]})]})})]})}const el={fontFamily:"var(--mono)",fontSize:10,letterSpacing:"0.04em",textTransform:"uppercase"};function Mt(e){return Array.isArray(e)?e.filter(Boolean):[]}function Y(e,t,n=void 0){return!e||typeof e!="object"?n:e[t]??n}function ze(e){const t=Number(e||0);return new Intl.NumberFormat("en",{notation:Math.abs(t)>=1e4?"compact":"standard",maximumFractionDigits:Math.abs(t)>=1e4?1:0}).format(t)}function Wg(e){const t=String(e||"");if(!t)return"not linked";const n=t.split("/").filter(Boolean);return n.length>3?`.../${n.slice(-3).join("/")}`:t}function _a(e){if(!e)return"no timestamp";const t=new Date(e).getTime();if(Number.isNaN(t))return String(e);const n=Date.now()-t;return n<6e4?"just now":n<36e5?`${Math.floor(n/6e4)}m ago`:n<864e5?`${Math.floor(n/36e5)}h ago`:`${Math.floor(n/864e5)}d ago`}function $o(e){const t=String(e||"").toLowerCase();return t.includes("reject")||t.includes("fail")||t.includes("stale")?"var(--rose)":t.includes("pending")||t.includes("candidate")||t.includes("derived")?"var(--accent)":t.includes("promoted")||t.includes("active")||t.includes("ok")?"var(--green)":t.includes("evidence")||t.includes("digest")?"var(--indigo)":"var(--ink3)"}function Ci(e){const[t,n]=v.useState(null),[r,o]=v.useState(""),[l,s]=v.useState(!0),a=async()=>{s(!0),o("");try{n(await e())}catch(d){o(String(d))}finally{s(!1)}};return v.useEffect(()=>{a()},[]),{data:t,error:r,loading:l,refresh:a}}function bi({eyebrow:e,title:t,subtitle:n,children:r,action:o}){return i.jsx("div",{style:{height:"100%",overflowY:"auto",background:"var(--surface)"},children:i.jsxs("div",{style:{minHeight:"100%",padding:24,display:"flex",flexDirection:"column",gap:18},children:[i.jsxs("div",{style:{display:"flex",alignItems:"flex-start",justifyContent:"space-between",gap:20,borderBottom:"1px solid var(--border)",paddingBottom:18},children:[i.jsxs("div",{style:{maxWidth:760},children:[i.jsx("div",{style:{...el,color:"var(--accent)",marginBottom:8},children:e}),i.jsx("h1",{style:{fontSize:30,lineHeight:1.08,fontWeight:700,letterSpacing:0},children:t}),i.jsx("p",{style:{marginTop:8,color:"var(--ink2)",fontSize:14,lineHeight:1.55},children:n})]}),o]}),r]})})}function He({label:e,children:t,style:n}){return i.jsxs("section",{style:{background:"white",border:"1px solid var(--border)",padding:16,minWidth:0,...n},children:[e?i.jsx("div",{style:{...el,color:"var(--ink3)",marginBottom:12},children:e}):null,t]})}function Ye({label:e,value:t,tone:n}){return i.jsxs(He,{style:{minHeight:94},children:[i.jsx("div",{style:{fontSize:26,lineHeight:1,fontWeight:700,color:n||"var(--ink)"},children:t}),i.jsx("div",{style:{marginTop:8,color:"var(--ink3)",fontSize:12},children:e})]})}function tt({children:e,tone:t}){return i.jsx("span",{style:{...el,display:"inline-flex",alignItems:"center",minHeight:22,padding:"3px 8px",color:t||"var(--ink2)",background:"var(--surface2)",border:"1px solid var(--border)"},children:e})}function tl({rows:e,empty:t,render:n}){return e.length?i.jsx("div",{style:{display:"grid",gap:10},children:e.map(n)}):i.jsx("div",{style:{color:"var(--ink3)",fontSize:13},children:t})}function _i({loading:e,error:t}){return e?i.jsx(He,{children:"Loading Dhee state..."}):t?i.jsx(He,{children:i.jsx("span",{style:{color:"var(--rose)"},children:t})}):null}function Hg({onNavigate:e}){const{data:t,error:n,loading:r,refresh:o}=Ci(B.commandCenter),l=Y(t,"router",{}),s=Y(t,"context",{}),a=Y(t,"learnings",{}),d=Y(t,"inbox",{}),c=Y(t,"active_task",null),g=Mt(Y(t,"router_sessions",[])),m=Y(a,"totals",{}),x=Y(d,"totals",{});return i.jsxs(bi,{eyebrow:"COMMAND CENTER",title:"The current truth before the agent sees anything.",subtitle:"Start here to see task continuity, context health, routed savings, review queues, and the next best action for this repo.",action:i.jsx("button",{onClick:o,style:At,children:"refresh"}),children:[i.jsx(_i,{loading:r,error:n}),t?i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"repeat(4, minmax(0, 1fr))",gap:12},children:[i.jsx(Ye,{label:"tokens avoided",value:ze(Y(l,"sessionTokensSaved",0)),tone:"var(--green)"}),i.jsx(Ye,{label:"router calls",value:ze(Y(l,"totalCalls",0)),tone:"var(--accent)"}),i.jsx(Ye,{label:"repo context",value:ze(Y(Y(s,"totals",{}),"repo_entries",0)),tone:"var(--indigo)"}),i.jsx(Ye,{label:"learning candidates",value:ze(Y(m,"candidate",0)),tone:"var(--accent)"})]}),i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1.1fr 0.9fr",gap:14},children:[i.jsx(He,{label:"ACTIVE WORK",children:c?i.jsxs("div",{children:[i.jsx("div",{style:{fontSize:22,lineHeight:1.2,fontWeight:700},children:String(Y(c,"title","Active task"))}),i.jsxs("div",{style:{marginTop:8,display:"flex",gap:8,flexWrap:"wrap"},children:[i.jsx(tt,{tone:$o(Y(c,"status")),children:String(Y(c,"status","active"))}),i.jsx(tt,{children:String(Y(c,"harness","agent"))})]})]}):i.jsx("div",{style:{color:"var(--ink3)"},children:"No active task yet. Start from a linked repo to let Dhee compile state."})}),i.jsxs(He,{label:"NEXT ACTION",children:[i.jsx("div",{style:{fontSize:18,fontWeight:650,lineHeight:1.35},children:String(Y(t,"next_action","Start a routed agent task"))}),i.jsxs("div",{style:{marginTop:14,display:"flex",gap:8,flexWrap:"wrap"},children:[i.jsx("button",{onClick:()=>e("handoff"),style:At,children:"handoff"}),i.jsx("button",{onClick:()=>e("router"),style:Do,children:"firewall"}),i.jsx("button",{onClick:()=>e("learnings"),style:Do,children:"learnings"})]})]})]}),i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:14},children:[i.jsx(He,{label:"LIVE SESSIONS",children:i.jsx(tl,{rows:g.slice(0,5),empty:"No routed sessions yet.",render:S=>i.jsx(Hr,{title:String(S.title||S.session_id||"session"),meta:`${S.agent||S.runtime||"agent"} - ${ze(S.tokens_saved)} tokens`,tone:$o(S.state)},String(S.session_id))})}),i.jsxs(He,{label:"REVIEW QUEUE",children:[i.jsx(Hr,{title:"proposals",meta:ze(Y(x,"proposals",0)),tone:"var(--accent)"}),i.jsx(Hr,{title:"findings",meta:ze(Y(x,"findings",0)),tone:"var(--rose)"}),i.jsx(Hr,{title:"conflicts",meta:ze(Y(x,"conflicts",0)),tone:"var(--indigo)"})]}),i.jsxs(He,{label:"ADDRESSABLE CONTEXT",children:[(Mt(Y(t,"dhee_aliases",[])).length,null),(Y(t,"dhee_aliases",[])||[]).map(S=>i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:11,padding:"5px 0",color:"var(--ink2)"},children:S},S))]})]})]}):null]})}function Vg(){const{data:e,error:t,loading:n,refresh:r}=Ci(B.handoffUi),o=Y(e,"continuity",{}),l=Y(o,"last_session",{})||{},s=Mt(Y(e,"tasks",[])),a=Mt(Y(e,"sessions",[])),d=Mt(Y(l,"files_touched",Y(l,"filesTouched",[]))),c=Mt(Y(l,"decisions",[])),g=Mt(Y(l,"todos",[]));return i.jsxs(bi,{eyebrow:"HANDOFF HUB",title:"Resume without replaying the transcript.",subtitle:"Dhee turns the latest work into task state: decisions, files, blockers, commands, tests, resume confidence, and the next step.",action:i.jsx("button",{onClick:r,style:At,children:"refresh"}),children:[i.jsx(_i,{loading:n,error:t}),e?i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1.1fr 0.9fr",gap:14},children:[i.jsxs(He,{label:"LATEST HANDOFF",children:[i.jsx("div",{style:{fontSize:24,lineHeight:1.15,fontWeight:700},children:String(Y(l,"task_summary","No handoff saved yet"))}),i.jsxs("div",{style:{marginTop:12,display:"flex",gap:8,flexWrap:"wrap"},children:[i.jsxs(tt,{tone:"var(--green)",children:["confidence ",Math.round(Number(Y(e,"resume_confidence",0))*100),"%"]}),i.jsx(tt,{children:_a(Y(l,"updated")||Y(l,"ended_at"))}),i.jsx(tt,{children:String(Y(l,"agent_id",Y(l,"source","dhee")))})]}),i.jsx("pre",{style:mp,children:String(Y(e,"command",""))})]}),i.jsx(He,{label:"RESUME INVENTORY",children:i.jsx(Jg,{rows:[["tasks",s.length],["sessions",a.length],["files",d.length],["decisions",c.length],["todos",g.length]]})}),i.jsx(He,{label:"DECISIONS",style:{gridColumn:"span 1"},children:i.jsx(Uu,{rows:c,empty:"No decisions captured yet."})}),i.jsx(He,{label:"FILES TOUCHED",children:i.jsx(Uu,{rows:d.map(m=>Wg(String(m))),empty:"No files in the latest handoff."})})]}):null]})}function Kg(){const{data:e,error:t,loading:n,refresh:r}=Ci(()=>B.proofReplay(120)),o=Mt(Y(e,"items",[])),l=Y(e,"totals",{});return i.jsxs(bi,{eyebrow:"PROOF REPLAY",title:"Replay the context decisions, not just the chat.",subtitle:"See the expansion trace: what Dhee digested, hid, expanded, injected, promoted, rejected, or derived from local records.",action:i.jsx("button",{onClick:r,style:At,children:"refresh"}),children:[i.jsx(_i,{loading:n,error:t}),i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"repeat(4, minmax(0, 1fr))",gap:12},children:[i.jsx(Ye,{label:"events",value:ze(Y(l,"events",o.length))}),i.jsx(Ye,{label:"digests",value:ze(Y(l,"digests",0)),tone:"var(--green)"}),i.jsx(Ye,{label:"expansion trace",value:ze(Y(l,"expansions",0)),tone:"var(--accent)"}),i.jsx(Ye,{label:"evidence",value:ze(Y(l,"evidence",0)),tone:"var(--indigo)"}),i.jsx(Ye,{label:"derived rows",value:ze(Y(l,"derived",0))})]}),i.jsx(He,{label:"DECISION TIMELINE",children:i.jsx(tl,{rows:o,empty:"No context decisions recorded yet.",render:(s,a)=>i.jsx(Xg,{index:a,title:String(s.title||"Decision"),meta:`${s.source||"dhee"} - ${_a(s.time)}`,detail:String(s.detail||""),kind:String(s.kind||"event"),derived:!!s.derived},String(s.id||a))})})]})}function Qg(){const{data:e,error:t,loading:n,refresh:r}=Ci(()=>B.learningsUi(160)),[o,l]=v.useState(""),s=Mt(Y(e,"items",[])),a=Y(e,"totals",{}),d=async(c,g)=>{l(c);try{g==="promote"?await B.promoteLearning(c,{approved_by:"dhee-ui"}):await B.rejectLearning(c,{reason:"rejected in Dhee UI"}),await r()}finally{l("")}};return i.jsxs(bi,{eyebrow:"LEARNING INBOX",title:"Only evidence-backed learnings get promoted.",subtitle:"Clear pending review candidates from agent work. Dhee should learn from success, avoided failure, repeated utility, or explicit approval.",action:i.jsx("button",{onClick:r,style:At,children:"refresh"}),children:[i.jsx(_i,{loading:n,error:t}),i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"repeat(4, minmax(0, 1fr))",gap:12},children:[i.jsx(Ye,{label:"candidates",value:ze(Y(a,"candidate",0)),tone:"var(--accent)"}),i.jsx(Ye,{label:"promoted",value:ze(Y(a,"promoted",0)),tone:"var(--green)"}),i.jsx(Ye,{label:"rejected",value:ze(Y(a,"rejected",0)),tone:"var(--rose)"}),i.jsx(Ye,{label:"all learnings",value:ze(Y(a,"all",s.length))})]}),i.jsx(He,{label:"LEARNING REVIEW",children:i.jsx(tl,{rows:s,empty:"No learning candidates yet.",render:c=>{const g=String(c.id||""),m=String(c.status||"candidate");return i.jsxs("div",{style:Zg,children:[i.jsxs("div",{style:{minWidth:0},children:[i.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginBottom:8},children:[i.jsx(tt,{tone:$o(m),children:m}),i.jsx(tt,{children:String(c.evidence_gate||"needs approval")}),i.jsx(tt,{children:String(c.source_harness||c.source_agent_id||"agent")})]}),i.jsx("div",{style:{fontSize:17,fontWeight:700,lineHeight:1.25},children:String(c.title||g)}),i.jsx("div",{style:{marginTop:6,color:"var(--ink2)",lineHeight:1.55},children:String(c.body||"")})]}),i.jsxs("div",{style:{display:"flex",gap:8,flexShrink:0},children:[i.jsx("button",{disabled:!g||o===g||m==="promoted",onClick:()=>d(g,"promote"),style:At,children:"promote"}),i.jsx("button",{disabled:!g||o===g||m==="rejected",onClick:()=>d(g,"reject"),style:Do,children:"reject"})]})]},g)}})})]})}function Yg(){const{data:e,error:t,loading:n,refresh:r}=Ci(B.portabilityUi),[o,l]=v.useState(!1),[s,a]=v.useState(""),[d,c]=v.useState(null),[g,m]=v.useState(""),x=Y(e,"counts",{}),S=Mt(Y(e,"packs",[])),E=(Y(e,"contract",[])||[]).filter(Boolean),T=async()=>{l(!0),m("");try{await B.exportPackUi({}),await r()}catch(h){m(String(h))}finally{l(!1)}},H=async()=>{m(""),c(null);try{c(await B.importPackDryRunUi({input_path:s}))}catch(h){m(String(h))}};return i.jsxs(bi,{eyebrow:"PORTABILITY & TRUST",title:"Local memory should be inspectable, signed, and movable.",subtitle:"Dhee keeps export/import as a product surface, not an afterthought. No lock-in tricks, no hidden hosted dependency.",action:i.jsx("button",{onClick:r,style:At,children:"refresh"}),children:[i.jsx(_i,{loading:n,error:t}),i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"repeat(4, minmax(0, 1fr))",gap:12},children:[i.jsx(Ye,{label:"memories",value:ze(Y(x,"memories",0))}),i.jsx(Ye,{label:"artifacts",value:ze(Y(x,"artifacts",0)),tone:"var(--indigo)"}),i.jsx(Ye,{label:"repo context",value:ze(Y(x,"repo_context_entries",0)),tone:"var(--green)"}),i.jsx(Ye,{label:"packs found",value:ze(S.length),tone:"var(--accent)"})]}),g?i.jsx(He,{children:i.jsx("span",{style:{color:"var(--rose)"},children:g})}):null,i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"0.9fr 1.1fr",gap:14},children:[i.jsxs(He,{label:"PORTABLE SUBSTRATE",children:[i.jsx("div",{style:{display:"flex",gap:8,flexWrap:"wrap"},children:E.map(h=>i.jsx(tt,{tone:"var(--green)",children:h},h))}),i.jsx("button",{disabled:o,onClick:T,style:{...At,marginTop:16},children:o?"exporting...":"export .dheemem"})]}),i.jsxs(He,{label:"IMPORT DRY RUN",children:[i.jsxs("div",{style:{display:"flex",gap:10},children:[i.jsx("input",{value:s,onChange:h=>a(h.target.value),placeholder:"/path/to/backup.dheemem",style:qg}),i.jsx("button",{disabled:!s.trim(),onClick:H,style:At,children:"dry run"})]}),d?i.jsx("pre",{style:mp,children:JSON.stringify(Y(d,"result",d),null,2)}):null]})]}),i.jsx(He,{label:"RECENT PACKS",children:i.jsx(tl,{rows:S,empty:"No .dheemem packs found yet.",render:h=>i.jsx(Hr,{title:String(h.name||h.path),meta:`${h.verified?"verified":"unverified"} - ${ze(Number(h.size_bytes||0))} bytes - ${_a(h.updated_at)}`,tone:h.verified?"var(--green)":"var(--accent)"},String(h.path))})})]})}function Ev({onOpenContext:e}){return i.jsxs("div",{style:{position:"absolute",left:68,top:14,zIndex:8,display:"flex",gap:10,alignItems:"center",pointerEvents:"auto"},children:[i.jsx(tt,{tone:"var(--green)",children:"REPO BRAIN"}),i.jsx(tt,{children:"dhee://state/current"}),i.jsx(tt,{children:"dhee://handoff/latest"}),e?i.jsx("button",{onClick:e,style:Do,children:"context vault"}):null]})}function Uu({rows:e,empty:t}){return e.length?i.jsx("div",{style:{display:"grid",gap:8},children:e.map((n,r)=>i.jsx("div",{style:{padding:"8px 0",borderBottom:"1px solid var(--border)",color:"var(--ink2)"},children:String(n)},r))}):i.jsx("div",{style:{color:"var(--ink3)"},children:t})}function Jg({rows:e}){return i.jsx("div",{style:{display:"grid",gap:8},children:e.map(([t,n])=>i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",gap:20},children:[i.jsx("span",{style:{color:"var(--ink3)"},children:t}),i.jsx("strong",{children:ze(n)})]},t))})}function Hr({title:e,meta:t,tone:n}){return i.jsxs("div",{style:{display:"flex",gap:10,alignItems:"flex-start",padding:"7px 0",borderBottom:"1px solid var(--border)"},children:[i.jsx("span",{style:{width:8,height:8,marginTop:6,background:n||"var(--ink3)",flexShrink:0}}),i.jsxs("div",{style:{minWidth:0},children:[i.jsx("div",{style:{fontSize:13,fontWeight:650,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:e}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginTop:2},children:t})]})]})}function Xg({index:e,title:t,meta:n,detail:r,kind:o,derived:l}){return i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"44px 1fr",gap:14,padding:"12px 0",borderBottom:"1px solid var(--border)"},children:[i.jsx("div",{style:{...el,color:"var(--ink3)"},children:String(e+1).padStart(2,"0")}),i.jsxs("div",{children:[i.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginBottom:6},children:[i.jsx(tt,{tone:$o(o),children:o}),l?i.jsx(tt,{tone:"var(--accent)",children:"derived"}):i.jsx(tt,{tone:"var(--green)",children:"recorded"}),i.jsx(tt,{children:n})]}),i.jsx("div",{style:{fontSize:16,fontWeight:700},children:t}),r?i.jsx("div",{style:{marginTop:4,color:"var(--ink2)",lineHeight:1.55},children:r}):null]})]})}const At={border:"1px solid var(--ink)",background:"var(--ink)",color:"white",padding:"8px 12px",fontFamily:"var(--mono)",fontSize:10,letterSpacing:"0.04em",textTransform:"uppercase"},Do={...At,color:"var(--ink)",background:"white",borderColor:"var(--border2)"},qg={minHeight:36,flex:1,border:"1px solid var(--border2)",background:"white",padding:"0 10px",fontFamily:"var(--mono)",fontSize:11},mp={marginTop:14,border:"1px solid var(--border)",background:"var(--surface2)",padding:12,fontFamily:"var(--mono)",fontSize:11,whiteSpace:"pre-wrap",overflowX:"auto"},Zg={display:"grid",gridTemplateColumns:"1fr auto",gap:18,padding:"14px 0",borderBottom:"1px solid var(--border)",alignItems:"start"},Gg={border:"1px solid var(--border)",background:"var(--bg)",borderRadius:8,padding:18,width:"min(760px, 100%)",boxSizing:"border-box",display:"flex",flexWrap:"wrap",gap:18,boxShadow:"0 10px 28px rgba(20,16,10,0.06)"},Wu={fontFamily:"var(--mono)",fontSize:9,letterSpacing:"0.08em",textTransform:"uppercase",color:"var(--ink3)"},ev={borderRadius:5,cursor:"pointer",fontFamily:"var(--mono)",fontSize:10,padding:"8px 11px",whiteSpace:"nowrap"};function tv(e="secondary",t){const n=e==="primary";return{...ev,border:`1px solid ${n?"var(--ink)":"var(--border)"}`,background:n?"var(--ink)":"white",color:n?"white":"var(--accent)",opacity:t?.55:1,cursor:t?"not-allowed":"pointer"}}function hp({title:e="Set up a developer workspace",eyebrow:t="First run",body:n="Connect a repo folder, then start Codex or Claude Code from that folder so Dhee can mirror sessions and context.",actions:r=[],commands:o=["dhee onboard --root .","dhee doctor"],aside:l}){return i.jsxs("section",{style:Gg,children:[i.jsxs("div",{style:{flex:"1 1 300px",minWidth:0},children:[i.jsx("div",{style:Wu,children:t}),i.jsx("h2",{style:{margin:"5px 0 7px",fontSize:22,lineHeight:1.15,color:"var(--ink)",letterSpacing:0},children:e}),i.jsx("div",{style:{color:"var(--ink2)",fontSize:12.5,lineHeight:1.55,maxWidth:680},children:n}),r.length?i.jsx("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:14},children:r.map(s=>i.jsx("button",{type:"button",onClick:s.onClick,disabled:s.disabled,style:tv(s.tone,s.disabled),children:s.label},s.label))}):null]}),i.jsxs("div",{style:{flex:"1 1 260px",border:"1px solid var(--border)",background:"var(--surface)",borderRadius:6,padding:12,minWidth:0},children:[i.jsx("div",{style:{...Wu,marginBottom:8},children:"Terminal path"}),i.jsx("div",{style:{display:"grid",gap:7},children:o.map(s=>i.jsx("code",{style:{display:"block",border:"1px solid var(--border)",background:"white",borderRadius:4,padding:"8px 9px",fontFamily:"var(--mono)",fontSize:10,color:"var(--ink)",lineHeight:1.45,overflowWrap:"anywhere"},children:s},s))}),l?i.jsx("div",{style:{marginTop:10},children:l}):null]})]})}const Dr=[{key:"day",label:"Daily",short:"24h",ms:24*60*60*1e3},{key:"week",label:"Weekly",short:"7d",ms:7*24*60*60*1e3},{key:"month",label:"Monthly",short:"30d",ms:30*24*60*60*1e3},{key:"year",label:"Yearly",short:"365d",ms:365*24*60*60*1e3}],Hu={items:[],next_cursor:null,active_only:!1,totals:{tokens_saved:0,estimated_cost_saved_usd:0,router_calls:0,sessions:0}};function vn(e){return e==null?"0":new Intl.NumberFormat("en",{notation:Math.abs(e)>=1e4?"compact":"standard",maximumFractionDigits:Math.abs(e)>=1e4?1:0}).format(e)}function Mn(e){return new Intl.NumberFormat("en-US").format(Math.max(0,Number(e||0)))}function Vr(e){const t=Math.max(0,Number(e||0));return t>0&&t<.01?"<$0.01":new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",maximumFractionDigits:t>=100?0:2}).format(t)}function Xn(e){if(!e)return 0;const t=new Date(e).getTime();return Number.isNaN(t)?0:t}function Ea(e){const t=Xn(e);if(!t)return"n/a";const n=Date.now()-t;return n<6e4?"just now":n<36e5?`${Math.floor(n/6e4)}m ago`:n<864e5?`${Math.floor(n/36e5)}h ago`:`${Math.floor(n/864e5)}d ago`}function Ta(e){if(!e)return"n/a";const t=e.split("/").filter(Boolean);return t.length<=2?e:".../"+t.slice(-2).join("/")}function gp(e){var r;const t=typeof e=="string"?e:e.agent||e.runtime||((r=e.agents)==null?void 0:r[0])||"unknown",n=String(t||"").toLowerCase();return n.includes("codex")?"codex":n.includes("claude")?"claude-code":n||"unknown"}function nv(e){const t=gp(e||"");return t==="codex"?"Codex":t==="claude-code"?"Claude Code":e||"Unknown"}function za(e){const t=gp(e||"");return t==="codex"?"var(--indigo)":t==="claude-code"?"var(--accent)":"var(--ink3)"}function Ra(e){var t;return Number(((t=e.pricing)==null?void 0:t.input_cost_per_million)||0)>0}function Na(e){return e.tokens_saved>0&&!Ra(e)?"unpriced":Vr(e.estimated_cost_saved_usd)}function rv(e){const t=String(e.state||"").toLowerCase();return!!e.active&&(t==="active"||t==="running"||t==="live")}function vp(e){const t=e.pricing;if(!t||!Ra(e))return(t==null?void 0:t.note)||"No official provider/model rate mapped yet.";const n=t.provider||e.runtime||e.agent||"provider",r=t.model_family||e.model||"model";return`${n} ${r}: $${t.input_cost_per_million}/1M input tokens`}function yp(e,t){if(!e)return Number.POSITIVE_INFINITY;const r=Number(e[t==="day"?"daily_budget_usd":t==="week"?"weekly_budget_usd":t==="year"?"yearly_budget_usd":"monthly_budget_usd"]||0);return r>0?r:Number.POSITIVE_INFINITY}function Vu(e,t,n){return e.reduce((r,o)=>(r.tokens+=o.tokens_saved||0,r.apiValue+=Number(o.estimated_cost_saved_usd||0),r.calls+=o.router_calls||0,r.sessions+=1,r.cost=Math.min(r.apiValue,yp(t,n)),r),{tokens:0,apiValue:0,cost:0,calls:0,sessions:0})}function Ku(){const e=new URLSearchParams(window.location.search),t=String(e.get("view")||"").toLowerCase(),n=window.location.pathname.replace(/^\/+|\/+$/g,"").toLowerCase();return t==="router/sessionshistory"||t==="router/session-history"||t==="router/history"||n==="router/sessionshistory"?"history":"live"}function iv(e){const t=new URLSearchParams(window.location.search);t.set("view",e==="history"?"router/sessionshistory":"router");const n=t.toString(),r=`${window.location.pathname}${n?`?${n}`:""}${window.location.hash||""}`;window.history.pushState({},"",r),window.dispatchEvent(new Event("popstate"))}function ov({onOpenFolders:e,onOpenSetup:t}){return i.jsx("div",{style:{height:"100%",overflowY:"auto",background:"var(--surface)"},children:i.jsx(lv,{onOpenFolders:e,onOpenSetup:t})})}function lv({onOpenFolders:e,onOpenSetup:t}){const n=v.useRef(null),[r,o]=v.useState(Hu),[l,s]=v.useState(Hu),[a,d]=v.useState("week"),[c,g]=v.useState(()=>Ku()),[m,x]=v.useState(""),[S,E]=v.useState(!1),[T,H]=v.useState(null),h=async(K=!1)=>{K||E(!0),H(null);try{const[G,M]=await Promise.all([B.routerSessions({active:!1,limit:100}),B.routerSessions({active:!0,limit:50})]);o(G),s(M)}catch(G){H(String(G))}finally{K||E(!1)}};v.useEffect(()=>{h(!1);const K=window.setInterval(()=>void h(!0),15e3);return()=>window.clearInterval(K)},[]),v.useEffect(()=>{const K=()=>g(Ku());return window.addEventListener("popstate",K),()=>window.removeEventListener("popstate",K)},[]),v.useEffect(()=>{var K,G;(G=(K=n.current)==null?void 0:K.parentElement)==null||G.scrollTo({top:0,behavior:"auto"})},[c]);const u=v.useMemo(()=>[...l.items].filter(rv).sort((K,G)=>Xn(G.updated_at)-Xn(K.updated_at)),[l.items]),f=v.useMemo(()=>{const K=Dr.find(M=>M.key===a)||Dr[1],G=Date.now()-K.ms;return[...r.items].filter(M=>Xn(M.updated_at||M.started_at)>=G).sort((M,V)=>Xn(V.updated_at)-Xn(M.updated_at))},[r.items,a]),p=c==="history"?f.find(K=>K.session_id===m)||null:u.find(K=>K.session_id===m)||null,j=r.budget||l.budget,_=Vu(f,j,a),P=Vu(u,j,"day"),z=l.items.length>0||r.items.length>0,Q=Dr.find(K=>K.key===a)||Dr[1],R=yp(j,a),J=Number.isFinite(R)&&_.apiValue>R,X=K=>{g(K),iv(K)};return i.jsxs("div",{ref:n,style:{padding:"clamp(10px, 3vw, 18px)",display:"grid",gap:14,minWidth:0,width:"100%",boxSizing:"border-box"},children:[i.jsxs("section",{style:{border:"1px solid var(--border)",background:"var(--bg)",borderRadius:8,padding:16,minWidth:0,boxSizing:"border-box"},children:[i.jsxs("div",{style:{display:"flex",flexWrap:"wrap",gap:14,alignItems:"start",justifyContent:"space-between",marginBottom:14},children:[i.jsxs("div",{style:{flex:"1 1 240px",minWidth:0},children:[i.jsx(Hn,{children:"Context Firewall"}),i.jsx("h1",{style:{margin:"2px 0 0",fontSize:26,lineHeight:1.1,color:"var(--ink)",letterSpacing:0},children:c==="history"?"Firewall session history":"Live context firewall"}),i.jsx("p",{style:{margin:"6px 0 0",color:"var(--ink3)",fontSize:12,maxWidth:780},children:c==="history"?"Every completed and recent local agent session, with pointer-backed evidence and avoided raw context.":"Running Claude Code and Codex sessions, with raw output kept behind digests until the agent asks to expand."})]}),i.jsxs("div",{style:{display:"flex",gap:8,justifyContent:"flex-start",flexWrap:"wrap",flex:"0 1 auto"},children:[i.jsx("button",{onClick:()=>X(c==="history"?"live":"history"),style:{border:"1px solid var(--accent)",background:c==="history"?"white":"var(--accent-dim)",borderRadius:5,color:"var(--accent)",cursor:"pointer",fontFamily:"var(--mono)",fontSize:10,padding:"8px 11px"},children:c==="history"?"LIVE FIREWALL":"SESSION HISTORY"}),i.jsx("button",{onClick:()=>h(!1),disabled:S,style:{border:"1px solid var(--border)",background:"white",borderRadius:5,color:S?"var(--ink3)":"var(--accent)",cursor:S?"wait":"pointer",fontFamily:"var(--mono)",fontSize:10,padding:"8px 11px"},children:S?"SYNCING":"REFRESH"})]})]}),i.jsx("div",{style:{display:"flex",gap:7,flexWrap:"wrap",marginBottom:12},children:Dr.map(K=>{const G=K.key===a;return i.jsxs("button",{onClick:()=>d(K.key),style:{border:`1px solid ${G?"var(--accent)":"var(--border)"}`,background:G?"var(--accent-dim)":"white",color:G?"var(--accent)":"var(--ink2)",borderRadius:5,padding:"6px 10px",fontFamily:"var(--mono)",fontSize:10,cursor:"pointer"},children:[K.label,i.jsx("span",{style:{color:"var(--ink3)",marginLeft:6},children:K.short})]},K.key)})}),i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"repeat(auto-fit, minmax(118px, 1fr))",gap:10},children:[i.jsx(an,{label:`${Q.label} API value`,value:Vr(_.apiValue),sub:"official input-rate estimate",accent:"var(--green)"}),i.jsx(an,{label:"Budget-capped savings",value:Vr(_.cost),sub:J?`capped at ${Vr(R)}`:"same as API value for this range",accent:"var(--green)"}),i.jsx(an,{label:`${Q.label} raw tokens avoided`,value:vn(_.tokens),sub:`${Mn(_.tokens)} avoided input tokens`,accent:"var(--green)"}),i.jsx(an,{label:"Live governed sessions",value:Mn(P.sessions),sub:`${vn(P.tokens)} active-session savings`,accent:"var(--accent)"})]})]}),T?i.jsxs("div",{style:{border:"1px solid var(--rose)",background:"white",color:"var(--rose)",padding:"10px 12px",borderRadius:6,fontFamily:"var(--mono)",fontSize:11},children:["context firewall data unavailable: ",T]}):null,!S&&!T&&!z?i.jsx(hp,{body:"Point Dhee at a repo folder, then start an agent task from that folder. The context firewall will fill with digests, evidence pointers, and expansions after the first mirrored Codex or Claude Code run.",actions:[...e?[{label:"ADD REPO FOLDER",onClick:e,tone:"primary"}]:[],...t?[{label:"START TASK",onClick:t}]:[]]}):null,c==="history"?i.jsx(Qu,{title:"Session history",sub:`${f.length} sessions in the last ${Q.short} · ${vn(_.tokens)} tokens · ${Vr(_.apiValue)} API value`,action:i.jsx("button",{onClick:()=>X("live"),style:{border:"1px solid var(--border)",background:"white",borderRadius:5,color:"var(--accent)",cursor:"pointer",fontFamily:"var(--mono)",fontSize:10,padding:"7px 10px",whiteSpace:"nowrap"},children:"LIVE FIREWALL"}),children:i.jsx(av,{rows:f,selectedId:(p==null?void 0:p.session_id)||"",onSelect:x,loading:S})}):i.jsx(Qu,{title:"Live governed sessions",sub:`${u.length} active local agent session${u.length===1?"":"s"} · click a session to inspect routing, evidence, and savings`,action:i.jsx("button",{onClick:()=>X("history"),style:{border:"1px solid var(--border)",background:"white",borderRadius:5,color:"var(--accent)",cursor:"pointer",fontFamily:"var(--mono)",fontSize:10,padding:"7px 10px",whiteSpace:"nowrap"},children:"HISTORY"}),children:u.length===0?i.jsx(xp,{children:"No active Claude Code or Codex sessions detected."}):i.jsx("div",{style:{display:"grid",gap:8},children:u.map(K=>i.jsx(sv,{row:K,selected:(p==null?void 0:p.session_id)===K.session_id,onSelect:()=>x(G=>G===K.session_id?"":K.session_id)},K.session_id))})})]})}function sv({row:e,selected:t,onSelect:n}){const r=za(e.agent||e.runtime),o=e.live_usage;return i.jsxs("div",{style:{width:"100%",border:`1px solid ${t?r:"var(--border)"}`,background:t?"var(--surface)":"white",borderRadius:6,overflow:"hidden"},children:[i.jsx("button",{type:"button","aria-expanded":t,onClick:n,style:{width:"100%",textAlign:"left",background:"transparent",border:0,padding:12,cursor:"pointer"},children:i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"minmax(0, 1fr) repeat(3, minmax(86px, max-content)) 22px",gap:16,alignItems:"center"},children:[i.jsxs("div",{style:{minWidth:0},children:[i.jsx(Pa,{agent:e.agent||e.runtime||"unknown"}),i.jsx("div",{style:{fontSize:15,fontWeight:600,color:"var(--ink)",whiteSpace:"nowrap",textOverflow:"ellipsis",overflow:"hidden",marginTop:4},title:e.title,children:e.title||e.session_id}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginTop:3},title:e.cwd||e.repo_root,children:[Ta(e.repo_root||e.cwd)," - updated ",Ea(e.updated_at)]})]}),i.jsx(co,{label:"saved",value:vn(e.tokens_saved)}),i.jsx(co,{label:"API value",value:Na(e)}),i.jsx(co,{label:"live tokens",value:o!=null&&o.available?vn(o.total_tokens):"n/a"}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:18,lineHeight:1,color:t?r:"var(--ink3)",textAlign:"right"},"aria-hidden":"true",children:t?"-":"+"})]})}),t?i.jsx("div",{style:{borderTop:"1px solid var(--border)",padding:"12px 12px 14px",background:"white"},children:i.jsx(dv,{row:e,showHeader:!1})}):null]})}function av({rows:e,selectedId:t,onSelect:n,loading:r}){return e.length===0?i.jsx(xp,{children:r?"Loading sessions...":"No sessions in this range."}):i.jsx("div",{style:{border:"1px solid var(--border)",borderRadius:6,overflowX:"auto",background:"white"},children:i.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontFamily:"var(--mono)",fontSize:11},children:[i.jsx("thead",{children:i.jsxs("tr",{style:{background:"var(--surface)"},children:[i.jsx(En,{children:"Session"}),i.jsx(En,{children:"Agent"}),i.jsx(En,{children:"State"}),i.jsx(En,{children:"Updated"}),i.jsx(En,{align:"right",children:"Tokens saved"}),i.jsx(En,{align:"right",children:"API value"}),i.jsx(En,{align:"right",children:"Calls"})]})}),i.jsx("tbody",{children:e.map(o=>{const l=t===o.session_id;return i.jsxs("tr",{onClick:()=>n(o.session_id),style:{borderTop:"1px solid var(--border)",background:l?"oklch(0.98 0.02 262)":"white",cursor:"pointer"},children:[i.jsxs(Tn,{title:o.title||o.session_id,children:[i.jsx("div",{style:{color:"var(--ink)",fontWeight:l?700:500,maxWidth:420,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:o.title||o.session_id}),i.jsx("div",{style:{color:"var(--ink3)",marginTop:2,maxWidth:420,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:o.cwd||o.repo_root,children:Ta(o.repo_root||o.cwd)})]}),i.jsx(Tn,{children:i.jsx(Pa,{agent:o.agent||o.runtime||"unknown"})}),i.jsx(Tn,{children:i.jsx(cv,{state:o.state,active:o.active})}),i.jsx(Tn,{children:Ea(o.updated_at)}),i.jsx(Tn,{align:"right",children:Mn(o.tokens_saved)}),i.jsx(Tn,{align:"right",title:vp(o),children:Na(o)}),i.jsx(Tn,{align:"right",children:Mn(o.router_calls)})]},o.session_id)})})]})})}function dv({row:e,showHeader:t=!0}){var o;const n=e.live_usage,r=Object.entries(e.tool_breakdown||{}).sort((l,s)=>s[1]-l[1]);return i.jsxs("div",{style:{display:"grid",gap:10},children:[t?i.jsxs("div",{children:[i.jsx(Pa,{agent:e.agent||e.runtime||"unknown"}),i.jsx("h2",{style:{margin:"6px 0 4px",fontSize:18,lineHeight:1.25,color:"var(--ink)",letterSpacing:0,display:"-webkit-box",WebkitLineClamp:3,WebkitBoxOrient:"vertical",overflow:"hidden"},title:e.title||e.session_id,children:e.title||e.session_id}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e.cwd||e.repo_root||void 0,children:[e.model||"model unavailable"," · ",Ta(e.cwd||e.repo_root)]})]}):null,i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"repeat(auto-fit, minmax(150px, 1fr))",gap:8},children:[i.jsx(an,{label:"tokens saved",value:vn(e.tokens_saved),sub:`${Mn(e.tokens_saved)} avoided`,accent:"var(--green)"}),i.jsx(an,{label:"API value",value:Na(e),sub:Ra(e)?"API value":"model unpriced",accent:"var(--green)"}),i.jsx(an,{label:"router calls",value:Mn(e.router_calls),sub:"cached reads",accent:"var(--ink2)"}),i.jsx(an,{label:"live tokens",value:n!=null&&n.available?vn(n.total_tokens):"n/a",sub:n!=null&&n.available?"native telemetry":"not captured",accent:za(e.agent||e.runtime)})]}),i.jsxs("div",{style:{border:"1px solid var(--border)",borderRadius:6,padding:10,background:"white"},children:[i.jsx(Hn,{children:"Pricing"}),i.jsx("div",{style:{fontSize:12,color:"var(--ink)",marginTop:5},children:vp(e)}),(o=e.pricing)!=null&&o.source?i.jsx("a",{href:e.pricing.source,target:"_blank",rel:"noreferrer",style:{display:"inline-block",marginTop:7,fontFamily:"var(--mono)",fontSize:10,color:"var(--accent)"},children:"official pricing source"}):null]}),n!=null&&n.available?i.jsx(uv,{row:e}):null,i.jsxs("div",{style:{border:"1px solid var(--border)",borderRadius:6,padding:10,background:"white"},children:[i.jsx(Hn,{children:"Read savings by tool"}),r.length===0?i.jsx("div",{style:{color:"var(--ink3)",fontSize:12,marginTop:7},children:"No cached reads yet."}):i.jsx("div",{style:{display:"grid",gap:6,marginTop:8},children:r.map(([l,s])=>i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",gap:10,fontFamily:"var(--mono)",fontSize:11},children:[i.jsx("span",{style:{color:"var(--ink2)"},children:l}),i.jsxs("span",{style:{color:"var(--ink)"},children:[Mn(s)," calls"]})]},l))})]})]})}function uv({row:e}){const t=e.live_usage;if(!(t!=null&&t.available))return i.jsxs("div",{style:{border:"1px solid var(--border)",borderRadius:6,padding:12,background:"white"},children:[i.jsx(Hn,{children:"Live token usage"}),i.jsx("div",{style:{color:"var(--ink3)",fontSize:12,marginTop:8},children:"No exact live token report captured for this session yet."})]});const n=[["Input",t.input_tokens],["Cached input",t.cached_input_tokens],["Output",t.output_tokens],["Reasoning",t.reasoning_output_tokens],["Last turn",t.last_turn_tokens],["Context",t.context_window]];return i.jsxs("div",{style:{border:"1px solid var(--border)",borderRadius:6,padding:12,background:"white"},children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",gap:12},children:[i.jsx(Hn,{children:"Live token usage"}),i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--green)",whiteSpace:"nowrap"},children:"exact"})]}),i.jsx("div",{style:{display:"grid",gridTemplateColumns:"repeat(2, minmax(0, 1fr))",gap:9,marginTop:10},children:n.map(([r,o])=>i.jsx(co,{label:r,value:vn(o)},r))}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginTop:10},children:[t.source||"native telemetry"," - updated ",Ea(t.updated_at||e.updated_at)]})]})}function Qu({title:e,sub:t,action:n,children:r}){return i.jsxs("section",{style:{border:"1px solid var(--border)",background:"var(--bg)",borderRadius:8,padding:16,minWidth:0,boxSizing:"border-box"},children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",gap:12,alignItems:"baseline",marginBottom:12},children:[i.jsxs("div",{style:{minWidth:0},children:[i.jsx(Hn,{children:e}),t?i.jsx("div",{style:{marginTop:4,color:"var(--ink3)",fontSize:12,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:t,children:t}):null]}),n?i.jsx("div",{style:{flexShrink:0},children:n}):null]}),r]})}function an({label:e,value:t,sub:n,accent:r}){return i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",borderRadius:6,padding:11,minWidth:0},children:[i.jsx(Hn,{children:e}),i.jsx("div",{style:{marginTop:7,fontFamily:"var(--mono)",fontSize:22,lineHeight:1.05,fontWeight:700,color:r,overflow:"hidden",textOverflow:"ellipsis"},children:t}),n?i.jsx("div",{style:{color:"var(--ink3)",fontSize:11,marginTop:4},children:n}):null]})}function co({label:e,value:t}){return i.jsxs("div",{style:{minWidth:80},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",textTransform:"uppercase",letterSpacing:"0.06em",marginBottom:3},children:e}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:14,fontWeight:700,color:"var(--ink)",whiteSpace:"nowrap"},children:t})]})}function Pa({agent:e}){const t=za(e);return i.jsxs("span",{style:{display:"inline-flex",alignItems:"center",gap:6,fontFamily:"var(--mono)",fontSize:10,color:t,textTransform:"uppercase",letterSpacing:"0.06em"},children:[i.jsx("span",{style:{width:7,height:7,borderRadius:999,background:t,flexShrink:0}}),nv(e)]})}function cv({state:e,active:t}){const n=t?"var(--green)":"var(--ink3)";return i.jsx("span",{style:{border:`1px solid ${n}`,color:n,borderRadius:4,padding:"1px 6px",fontSize:10},children:t?"active":e||"n/a"})}function xp({children:e}){return i.jsx("div",{style:{border:"1px dashed var(--border)",color:"var(--ink3)",background:"white",borderRadius:6,padding:18,textAlign:"center",fontSize:12},children:e})}function Hn({children:e}){return i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",letterSpacing:"0.08em",textTransform:"uppercase",fontWeight:700},children:e})}function En({children:e,align:t}){return i.jsx("th",{style:{padding:"8px 10px",textAlign:t||"left",color:"var(--ink2)",fontWeight:700,letterSpacing:"0.04em",borderBottom:"1px solid var(--border)",whiteSpace:"nowrap"},children:e})}function Tn({children:e,align:t,title:n}){return i.jsx("td",{title:n,style:{padding:"8px 10px",textAlign:t||"left",color:"var(--ink2)",verticalAlign:"middle"},children:e})}function fv({tasks:e,projectIndex:t,onSelectTask:n,onSelectSession:r,tweaks:o}){var x,S,E,T,H,h,u,f;const l={green:"var(--green)",indigo:"var(--indigo)",orange:"var(--accent)",rose:"var(--rose)"},s=((x=t==null?void 0:t.workspaces)==null?void 0:x.find(p=>p.id===(t==null?void 0:t.currentWorkspaceId)))||((S=t==null?void 0:t.workspaces)==null?void 0:S[0])||null,a=((E=s==null?void 0:s.projects)==null?void 0:E.find(p=>p.id===(t==null?void 0:t.currentProjectId)))||((T=s==null?void 0:s.projects)==null?void 0:T[0])||null,d=((H=a==null?void 0:a.sessions)==null?void 0:H.find(p=>p.id===(t==null?void 0:t.currentSessionId)))||((h=a==null?void 0:a.sessions)==null?void 0:h[0])||((u=s==null?void 0:s.sessions)==null?void 0:u.find(p=>p.id===(t==null?void 0:t.currentSessionId)))||((f=s==null?void 0:s.sessions)==null?void 0:f[0])||null,c=e.find(p=>p.id===(d==null?void 0:d.taskId))||e[0]||null,g=e.filter(p=>p.id!==(c==null?void 0:c.id)),m=p=>i.jsxs("button",{onClick:()=>n(p.id),style:{display:"grid",gridTemplateColumns:"12px minmax(0, 1fr) 18px",alignItems:"center",gap:14,padding:"14px 0",borderBottom:"1px solid var(--border)",textAlign:"left",background:"transparent"},children:[i.jsx("span",{style:{width:10,height:10,background:l[p.color]||"var(--accent)",display:"inline-block"}}),i.jsxs("span",{style:{minWidth:0},children:[i.jsx("div",{style:{fontSize:16,fontWeight:560,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:p.title}),i.jsxs("div",{style:{marginTop:4,fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",display:"flex",gap:10,flexWrap:"wrap"},children:[o.showTimestamps&&i.jsx("span",{children:p.created}),i.jsxs("span",{children:[p.messages.length," msgs"]}),p.harness&&i.jsx("span",{children:p.harness})]})]}),i.jsx("span",{style:{color:"var(--ink3)",fontSize:18},children:"→"})]},p.id);return i.jsxs("div",{style:{height:"100%",display:"flex",flexDirection:"column",overflow:"hidden"},children:[i.jsxs("div",{style:{height:48,borderBottom:"1px solid var(--border)",padding:"0 24px",display:"flex",alignItems:"center",justifyContent:"space-between",flexShrink:0},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:11,color:"var(--ink3)",letterSpacing:"0.08em"},children:"TASKS"}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:[e.length," tracked tasks"]})]}),i.jsxs("div",{style:{flex:1,overflow:"auto",padding:24,display:"grid",gap:20},children:[d&&i.jsxs("div",{onClick:()=>r(d.id,d.taskId||null),style:{border:"1px solid var(--green)",background:"white",padding:18,cursor:"pointer"},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,marginBottom:10,flexWrap:"wrap"},children:[i.jsx("span",{style:{width:9,height:9,background:"var(--green)",display:"inline-block"}}),i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"LIVE TASK"}),i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--accent)"},children:d.runtime})]}),i.jsx("div",{style:{fontSize:24,fontWeight:650,lineHeight:1.2},children:(c==null?void 0:c.title)||d.title}),i.jsx("div",{style:{marginTop:8,fontSize:13,color:"var(--ink2)",lineHeight:1.5},children:d.preview||"Current mirrored session is ready to continue."})]}),i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:18},children:[i.jsx("div",{style:{marginBottom:12,fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"TASK HISTORY"}),i.jsxs("div",{style:{display:"grid",gap:0},children:[g.length===0&&i.jsx("div",{style:{padding:"36px 0",textAlign:"center",fontFamily:"var(--mono)",fontSize:11,color:"var(--ink3)"},children:"No task history yet."}),g.map(m)]})]})]})]})}function pv({url:e,title:t,lines:n}){const[r,o]=v.useState(!0);return i.jsxs("div",{style:{border:"1px solid var(--border)",marginTop:8,background:"white"},children:[i.jsxs("div",{onClick:()=>o(l=>!l),style:{borderBottom:r?"1px solid var(--border)":"none",padding:"6px 10px",display:"flex",alignItems:"center",gap:8,background:"var(--surface)",cursor:"pointer"},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:1},children:"BROWSER"}),i.jsx("span",{style:{flex:1,fontFamily:"var(--mono)",fontSize:10,color:"var(--ink2)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:e}),i.jsx("span",{style:{fontSize:10,color:"var(--ink3)"},children:r?"▲":"▼"})]}),r&&i.jsxs("div",{style:{padding:"10px 12px"},children:[i.jsx("div",{style:{fontWeight:600,fontSize:13,marginBottom:8},children:t}),n.map((l,s)=>i.jsxs("div",{style:{display:"flex",gap:7,marginBottom:3},children:[i.jsx("span",{style:{color:"var(--accent)",fontFamily:"var(--mono)",fontSize:9,marginTop:2,flexShrink:0},children:"→"}),i.jsx("span",{style:{color:"var(--ink2)",fontSize:12.5,lineHeight:1.4},children:l})]},s))]})]})}function mv({query:e,files:t}){return i.jsxs("div",{style:{border:"1px solid var(--border)",marginTop:8,background:"white"},children:[i.jsxs("div",{style:{borderBottom:"1px solid var(--border)",padding:"6px 10px",display:"flex",gap:8,alignItems:"center",background:"var(--surface)"},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:1},children:"GREP"}),i.jsxs("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--accent)"},children:['"',e,'"']}),i.jsxs("span",{style:{marginLeft:"auto",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[t.length," matches"]})]}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:11.5},children:t.map((n,r)=>i.jsxs("div",{style:{padding:"8px 12px",borderBottom:ri.jsx("div",{style:{color:n.c==="comment"?"oklch(0.5 0.01 260)":n.c==="bad"?"var(--rose)":n.c==="good"?"var(--green-mid)":"oklch(0.88 0.01 260)"},children:n.t||" "},r))})]})}function gv({title:e,lines:t}){return i.jsxs("div",{style:{border:"1px solid var(--border)",marginTop:8,background:"white"},children:[i.jsxs("div",{style:{borderBottom:"1px solid var(--border)",padding:"6px 12px",display:"flex",gap:8,alignItems:"center",background:"var(--surface)"},children:[i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:1},children:"DOCUMENT"}),i.jsx("span",{style:{fontSize:11,fontWeight:500,fontFamily:"var(--mono)",color:"var(--ink2)"},children:e})]}),i.jsx("div",{style:{padding:"12px 16px",fontSize:13,lineHeight:1.65},children:t.map((n,r)=>typeof n=="object"&&"h"in n?i.jsx("div",{style:{fontWeight:700,marginTop:r>0?12:0,marginBottom:3,fontSize:10.5,textTransform:"uppercase",letterSpacing:"0.06em",color:"var(--ink2)"},children:n.h},r):i.jsx("div",{style:{color:"var(--ink)"},children:n},r))})]})}function vv({linkedTask:e,preview:t,tasks:n,onSelectTask:r}){const o=n.find(a=>a.id===e);if(!o)return null;const s={green:"var(--green)",indigo:"var(--indigo)",orange:"var(--accent)",rose:"var(--rose)"}[o.color]||"var(--accent)";return i.jsxs("div",{onClick:()=>r(o.id),style:{border:`1px solid ${s}`,marginTop:8,cursor:"pointer",display:"flex",gap:12,padding:"9px 12px",background:"white",transition:"background 0.12s"},onMouseEnter:a=>a.currentTarget.style.background="var(--surface)",onMouseLeave:a=>a.currentTarget.style.background="white",children:[i.jsx("div",{style:{width:8,height:8,background:s,flexShrink:0,marginTop:3}}),i.jsxs("div",{style:{flex:1},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:1,marginBottom:2},children:"LINKED TASK"}),i.jsx("div",{style:{fontWeight:500,fontSize:13},children:o.title}),i.jsx("div",{style:{fontSize:11,color:"var(--ink2)",marginTop:2},children:t})]}),i.jsx("div",{style:{color:s,fontSize:15,alignSelf:"center"},children:"→"})]})}function Yu({msg:e,tasks:t,onSelectTask:n}){if(e.role==="component"){const o=e;return i.jsxs("div",{style:{marginBottom:14,paddingLeft:14},children:[o.type==="browser"&&i.jsx(pv,{url:o.url,title:o.title,lines:o.lines}),o.type==="grep"&&i.jsx(mv,{query:o.query,files:o.files}),o.type==="code"&&i.jsx(hv,{lang:o.lang,lines:o.lines}),o.type==="document"&&i.jsx(gv,{title:o.title,lines:o.lines}),o.type==="link"&&i.jsx(vv,{linkedTask:o.linkedTask,preview:o.preview,tasks:t,onSelectTask:n})]})}const r=e.role==="user";return i.jsxs("div",{style:{marginBottom:13,display:"flex",flexDirection:"column",alignItems:r?"flex-end":"flex-start"},children:[!r&&i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:5,marginBottom:4},children:[i.jsx("div",{style:{width:5,height:5,background:"var(--green)",borderRadius:"50%"}}),i.jsx("span",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",letterSpacing:1},children:"AGENT"})]}),i.jsx("div",{style:{maxWidth:"88%",padding:"9px 13px",background:r?"var(--ink)":"white",color:r?"var(--bg)":"var(--ink)",border:r?"none":"1px solid var(--border)",fontSize:13.5,lineHeight:1.6,whiteSpace:"pre-wrap"},children:e.content})]})}function yv(e){if(!e)return"—";const t=new Date(e);return Number.isNaN(t.getTime())?"—":t.toLocaleString()}function xv(e){var t;return e?((t=e.currentSession)==null?void 0:t.state)==="active"?"session live":e.installed?"installed":"not attached":"No runtime selected"}function Sv(e){const t=(e==null?void 0:e.mounts)||(e==null?void 0:e.folders)||[],n=t.find(r=>r.primary)||t[0];return(n==null?void 0:n.path)||(e==null?void 0:e.rootPath)||(e==null?void 0:e.workspacePath)||""}function kv({tasks:e,activeTaskId:t,selectedProjectId:n,selectedWorkspaceId:r,selectedSessionId:o,projectIndex:l,workspaceGraph:s,onSelectTask:a,onSelectSession:d,onSelectProject:c,onCanvasOpen:g,onNotepadOpen:m,onAddTaskNote:x,onUpdateWorkspace:S,onAddWorkspaceFolder:E,onRemoveWorkspaceFolder:T,onCreateProject:H,onUpdateProject:h,onTasksRefresh:u,tweaks:f}){var Fa,La,Oa,$a,Da,Ma,Ia,Ba,Aa,Ua,Wa,Ha,Va,Ka,Qa,Ya,Ja,Xa,qa,Za,Ga,ed,td,nd,rd,id,od,ld,sd,ad,dd,ud;const[p,j]=v.useState(null),[_,P]=v.useState([]),[z,Q]=v.useState(""),[R,J]=v.useState(!1),[X,K]=v.useState(!1),[G,M]=v.useState(null),[V,ye]=v.useState(null),[C,A]=v.useState(""),[W,ee]=v.useState(!1),[ie,D]=v.useState(""),[fe,se]=v.useState(""),[me,$]=v.useState(""),[de,ce]=v.useState(""),[he,_e]=v.useState("codex"),[N,oe]=v.useState(""),[q,te]=v.useState(""),[Ae,Ft]=v.useState("codex"),[xe,Me]=v.useState(!1),[je,Fe]=v.useState(null),wn=v.useRef(null),Ut=v.useRef(null),Z=((Fa=l==null?void 0:l.workspaces)==null?void 0:Fa.find(y=>y.id===r))||((La=l==null?void 0:l.workspaces)==null?void 0:La.find(y=>y.id===(s==null?void 0:s.currentWorkspaceId)))||((Oa=l==null?void 0:l.workspaces)==null?void 0:Oa[0])||(s==null?void 0:s.workspace)||null,ae=(($a=Z==null?void 0:Z.projects)==null?void 0:$a.find(y=>y.id===n))||((Da=Z==null?void 0:Z.projects)==null?void 0:Da.find(y=>y.id===(s==null?void 0:s.currentProjectId)))||((Ma=Z==null?void 0:Z.projects)==null?void 0:Ma[0])||null,Se=((Ia=ae==null?void 0:ae.sessions)==null?void 0:Ia.find(y=>y.id===o))||((Ba=ae==null?void 0:ae.sessions)==null?void 0:Ba.find(y=>y.id===(s==null?void 0:s.currentSessionId)))||((Aa=ae==null?void 0:ae.sessions)==null?void 0:Aa[0])||((Ua=Z==null?void 0:Z.sessions)==null?void 0:Ua.find(y=>y.id===o))||((Wa=Z==null?void 0:Z.sessions)==null?void 0:Wa.find(y=>y.id===(s==null?void 0:s.currentSessionId)))||((Ha=Z==null?void 0:Z.sessions)==null?void 0:Ha[0])||null,le=((Va=l==null?void 0:l.workspaces)==null?void 0:Va.find(y=>y.id===(ie||(Z==null?void 0:Z.id))))||Z||null,Ee=((Ka=le==null?void 0:le.projects)==null?void 0:Ka.find(y=>y.id===(fe||(ae==null?void 0:ae.id))))||((Qa=le==null?void 0:le.projects)==null?void 0:Qa[0])||null;v.useEffect(()=>{Z&&!ie&&D(Z.id)},[Z,ie]),v.useEffect(()=>{var y;le&&!le.projects.some(re=>re.id===fe)&&se(((y=le.projects[0])==null?void 0:y.id)||"")},[le,fe]),v.useEffect(()=>{le&&$(le.label||le.name)},[le==null?void 0:le.id]),v.useEffect(()=>{ae&&ce(ae.name)},[ae==null?void 0:ae.id]),v.useEffect(()=>{Ee&&(ce(Ee.name),_e(Ee.defaultRuntime==="claude-code"?"claude-code":"codex"),oe((Ee.scopeRules||[]).map(y=>y.pathPrefix).filter(Boolean).join(` +`)))},[Ee==null?void 0:Ee.id]);const Lt=async y=>{var Le,cd;const re=y||(Se==null?void 0:Se.id);if(re)try{const br=await B.sessionDetail(re);j(br),P(((Le=br.runtime)==null?void 0:Le.runtimes)||[]),M(((cd=br.files)==null?void 0:cd[0])||null),ye(null),Fe(null)}catch(br){Fe(String(br))}},{messages:Qn}=pp(Z==null?void 0:Z.id,ae==null?void 0:ae.id);v.useEffect(()=>{Lt(o||(Se==null?void 0:Se.id))},[o,Z==null?void 0:Z.id]),v.useEffect(()=>{const y=window.setInterval(()=>{Lt(o||(Se==null?void 0:Se.id))},5e3);return()=>window.clearInterval(y)},[o,Se==null?void 0:Se.id]),v.useEffect(()=>{var y;(y=wn.current)==null||y.focus()},[(Ya=p==null?void 0:p.session)==null?void 0:Ya.id]);const w=e.find(y=>y.id===t)||null,b=(p==null?void 0:p.task)||w||null,k=v.useMemo(()=>{var y,re,Le;return(re=(y=p==null?void 0:p.session)==null?void 0:y.messages)!=null&&re.length?p.session.messages:(Le=b==null?void 0:b.messages)!=null&&Le.length?b.messages:[]},[(Ja=p==null?void 0:p.session)==null?void 0:Ja.messages,b]),O=v.useMemo(()=>{var y;return(y=p==null?void 0:p.session)!=null&&y.runtime?_.filter(re=>re.id===p.session.runtime):_},[(Xa=p==null?void 0:p.session)==null?void 0:Xa.runtime,_]),I=async()=>{var re;const y=z.trim();if(!(!y||!b||R)){J(!0);try{await x(b.id,y),Q(""),await u(),await Lt((re=p==null?void 0:p.session)==null?void 0:re.id)}catch(Le){Fe(String(Le))}finally{J(!1)}}},ne=async y=>{var re;if(!(!y||!((re=p==null?void 0:p.session)!=null&&re.id))){K(!0);try{await B.uploadSessionAsset(p.session.id,y),await Lt(p.session.id)}catch(Le){Fe(String(Le))}finally{K(!1),Ut.current&&(Ut.current.value="")}}},ve=async y=>{try{const re=await B.assetContext(y);ye(re)}catch(re){Fe(String(re))}},Ke=()=>{var y,re;Z&&(D(Z.id),$(Z.label||Z.name),se((ae==null?void 0:ae.id)||((re=(y=Z.projects)==null?void 0:y[0])==null?void 0:re.id)||"")),ee(!0),Fe(null)},st=async()=>{if(!(!le||!me.trim()||xe)){Me(!0);try{await S(le.id,me.trim()),Fe(null)}catch(y){Fe(String(y))}finally{Me(!1)}}},Ue=async()=>{if(!(!le||xe)){Me(!0),Fe(null);try{const y=await B.pickFolder("Select a folder to mount in this workspace");y.ok&&y.path&&await E(le.id,y.path)}catch(y){Fe(String(y))}finally{Me(!1)}}},Yn=async y=>{if(!(!le||xe)){Me(!0),Fe(null);try{await T(le.id,y)}catch(re){Fe(String(re))}finally{Me(!1)}}},Sp=async()=>{if(!Ee||xe)return;const y=N.split(` +`).map(re=>re.trim()).filter(Boolean).map((re,Le)=>({path_prefix:re,label:Le===0?"primary":`scope-${Le+1}`}));Me(!0),Fe(null);try{await h(Ee.id,{name:de.trim()||Ee.name,default_runtime:he,scope_rules:y})}catch(re){Fe(String(re))}finally{Me(!1)}};return!ae||!Z||!Se?i.jsx("div",{style:{height:"100%",display:"grid",alignItems:"center",justifyContent:"center",padding:28},children:i.jsx(hp,{title:"No mirrored workspace session yet",body:"Add a repo folder or start a task from an existing workspace. Once an agent session is mirrored, this view becomes the working console.",actions:[{label:"ADD REPO FOLDER",onClick:g,tone:"primary"},{label:"START TASK",onClick:m}]})}):i.jsxs("div",{style:{display:"flex",flexDirection:"column",height:"100%"},children:[i.jsxs("div",{style:{borderBottom:"1px solid var(--border)",padding:"0 14px",height:48,display:"flex",alignItems:"center",gap:10,flexShrink:0},children:[i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:10,minWidth:0,flex:1},children:[i.jsxs("span",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:[Z.label||Z.name," / ",ae.name]}),i.jsx("span",{style:{fontSize:14,fontWeight:600,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:((qa=p==null?void 0:p.session)==null?void 0:qa.title)||Se.title}),i.jsx($e,{label:`${((Za=p==null?void 0:p.session)==null?void 0:Za.runtime)||Se.runtime}`.replace("-"," "),tone:"var(--green)"}),i.jsx($e,{label:((Ga=p==null?void 0:p.session)==null?void 0:Ga.permissionMode)||Se.permissionMode||"native"}),i.jsx($e,{label:((ed=p==null?void 0:p.session)==null?void 0:ed.state)||Se.state})]}),i.jsx("button",{onClick:()=>{var y;return void Lt((y=p==null?void 0:p.session)==null?void 0:y.id)},style:{height:48,padding:"0 12px",borderLeft:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink2)"},children:"REFRESH"}),i.jsx("button",{onClick:g,style:{height:48,padding:"0 14px",borderLeft:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink2)"},children:"⊞ CANVAS"}),i.jsx("button",{onClick:Ke,style:{height:48,padding:"0 14px",borderLeft:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,color:"var(--ink2)"},children:"MANAGE"})]}),i.jsxs("div",{style:{flex:1,display:"flex",overflow:"hidden"},children:[i.jsxs("div",{style:{width:f.compactNav?74:260,borderRight:"1px solid var(--border)",display:"flex",flexDirection:"column",overflowY:"auto"},children:[i.jsxs("div",{style:{padding:"12px 12px 10px",borderBottom:"1px solid var(--border)"},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:8},children:"WORKSPACE"}),i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:10,marginBottom:12},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,marginBottom:4},children:Z.label||Z.name}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",lineHeight:1.5},children:[(Z.folders||Z.mounts||[]).length," mounted folders · ",Z.sessionCount||Z.sessions.length," sessions"]}),i.jsx("button",{onClick:Ke,style:{marginTop:8,padding:"6px 10px",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,color:"var(--accent)"},children:"manage workspace"})]}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:8},children:"PROJECTS"}),Z.projects.map(y=>i.jsx("div",{style:{marginBottom:10},children:i.jsxs("button",{onClick:()=>c(y.id,Z.id),style:{width:"100%",textAlign:"left",padding:"8px 9px",border:`1px solid ${y.id===ae.id?"var(--border2)":"var(--border)"}`,background:y.id===ae.id?"var(--surface)":"white",fontSize:12,fontWeight:y.id===ae.id?700:500,color:y.id===ae.id?"var(--ink)":"var(--ink2)"},children:[y.name,i.jsxs("div",{style:{marginTop:4,fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[y.sessions.length," sessions · default ",y.defaultRuntime||"codex"]})]})},y.id))]}),i.jsxs("div",{style:{padding:"12px"},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:8},children:"SESSIONS"}),(ae.sessions||[]).map(y=>{var re,Le;return i.jsxs("button",{onClick:()=>d(y.id,y.taskId),style:{width:"100%",textAlign:"left",padding:"9px 10px",marginBottom:8,border:`1px solid ${((re=p==null?void 0:p.session)==null?void 0:re.id)===y.id?"var(--green)":"var(--border)"}`,background:((Le=p==null?void 0:p.session)==null?void 0:Le.id)===y.id?"oklch(0.98 0.02 145)":"white"},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,marginBottom:4},children:y.title}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[y.runtime," · ",y.model||"unknown model"]}),i.jsx("div",{style:{fontSize:10.5,color:"var(--ink3)",marginTop:6,lineHeight:1.4},children:y.preview||"No preview yet."})]},y.id)})]})]}),i.jsxs("div",{style:{flex:1,display:"flex",flexDirection:"column",minWidth:0},children:[i.jsxs("div",{style:{padding:"12px 16px",borderBottom:"1px solid var(--border)",display:"flex",gap:8,flexWrap:"wrap"},children:[i.jsx($e,{label:((td=p==null?void 0:p.session)==null?void 0:td.runtime)||Se.runtime,tone:"var(--green)"}),i.jsx($e,{label:((nd=p==null?void 0:p.session)==null?void 0:nd.taskStatus)||(b==null?void 0:b.status)||"mirrored"}),i.jsx($e,{label:`${((id=(rd=p==null?void 0:p.session)==null?void 0:rd.touchedFiles)==null?void 0:id.length)||0} files`}),i.jsx($e,{label:`${((od=p==null?void 0:p.assets)==null?void 0:od.length)||0} assets`}),i.jsx($e,{label:`${((ld=p==null?void 0:p.results)==null?void 0:ld.length)||0} shared results`})]}),i.jsxs("div",{style:{flex:1,overflowY:"auto",padding:"20px 18px 8px"},children:[k.length===0&&i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:11,color:"var(--ink3)"},children:"No mirrored messages yet."}),k.map(y=>i.jsx(Yu,{msg:y,tasks:e,onSelectTask:a},y.id)),(sd=p==null?void 0:p.results)==null?void 0:sd.slice(0,8).map(y=>i.jsx(Yu,{msg:{id:`result:${y.id}`,role:"agent",content:`${y.tool_name}: ${y.digest||"No digest recorded."}`},tasks:e,onSelectTask:a},y.id)),i.jsx("div",{style:{height:1}})]}),i.jsxs("div",{style:{borderTop:"1px solid var(--border)",padding:"12px 16px"},children:[i.jsx("textarea",{ref:wn,value:z,onChange:y=>Q(y.target.value),placeholder:b?"Add a Dhee note to this session task…":"This mirrored session has no linked Dhee task yet.",rows:3,disabled:!b,style:{width:"100%",fontFamily:"var(--font)",fontSize:14,lineHeight:1.5,border:"1px solid var(--border)",padding:"12px 14px",background:b?"white":"var(--surface)"}}),i.jsxs("div",{style:{marginTop:10,display:"flex",gap:10,alignItems:"center"},children:[i.jsx("button",{onClick:()=>void I(),disabled:!b||R,style:{padding:"7px 14px",border:"1px solid var(--ink)",background:b?"var(--ink)":"transparent",color:b?"white":"var(--ink3)",fontFamily:"var(--mono)",fontSize:10},children:R?"saving…":"save note"}),je&&i.jsx("span",{style:{fontSize:11,color:"var(--rose)"},children:je})]})]})]}),i.jsxs("div",{style:{width:320,borderLeft:"1px solid var(--border)",overflowY:"auto",padding:"14px 14px 18px"},children:[i.jsxs("div",{style:{marginBottom:18},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:10},children:"RUNTIME"}),O.map(y=>{var re,Le;return i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:12,marginBottom:10},children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,marginBottom:6},children:[i.jsx("div",{style:{fontSize:12.5,fontWeight:600},children:y.label}),i.jsx($e,{label:xv(y),tone:y.installed?"var(--green)":"var(--rose)"})]}),i.jsx("div",{style:{fontSize:11,color:"var(--ink2)"},children:((re=y.currentSession)==null?void 0:re.title)||((Le=y.currentSession)==null?void 0:Le.cwd)||"No active session"}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginTop:6},children:["limit: ",y.limits.state,y.limits.resetAt?` · reset ${yv(y.limits.resetAt)}`:""]})]},y.id)})]}),i.jsxs("div",{style:{marginBottom:18},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:10},children:"COLLABORATION LINE"}),i.jsx(fp,{workspace:Z,activeProjectId:ae==null?void 0:ae.id,sessionId:((ad=p==null?void 0:p.session)==null?void 0:ad.id)||(Se==null?void 0:Se.id),taskId:b==null?void 0:b.id,onPublished:async()=>{await u()}}),i.jsx("div",{style:{display:"grid",gap:8,marginTop:10},children:(Qn||[]).slice(0,8).map(y=>i.jsx(cp,{message:y,workspace:Z},y.id))})]}),i.jsxs("div",{style:{marginBottom:18},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:10},children:"FILE CONTEXT"}),(dd=p==null?void 0:p.files)==null?void 0:dd.map(y=>i.jsxs("button",{onClick:()=>M(y),style:{width:"100%",textAlign:"left",padding:"10px 11px",border:`1px solid ${(G==null?void 0:G.path)===y.path?"var(--indigo)":"var(--border)"}`,background:"white",marginBottom:8},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,marginBottom:4},children:y.path.split("/").pop()}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[y.results.length," results · ",y.memories.length," memories"]})]},y.path)),G&&i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:12},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,marginBottom:6},children:G.path}),i.jsx("div",{style:{fontSize:11,color:"var(--ink2)",lineHeight:1.5,marginBottom:8},children:G.summary||"No stored summary yet."}),G.results.slice(0,3).map(y=>i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:6},children:[y.tool_name,": ",String(y.digest||"").slice(0,120)]},y.id))]})]}),i.jsxs("div",{children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:"SESSION ASSETS"}),i.jsx("button",{onClick:()=>{var y;return(y=Ut.current)==null?void 0:y.click()},style:{padding:"4px 10px",border:"1px solid var(--border)",fontFamily:"var(--mono)",fontSize:9,color:"var(--accent)"},children:X?"uploading…":"upload"})]}),i.jsx("input",{ref:Ut,type:"file",hidden:!0,onChange:y=>{var re;return void ne((re=y.target.files)==null?void 0:re[0])}}),(ud=p==null?void 0:p.assets)==null?void 0:ud.map(y=>i.jsxs("button",{onClick:()=>void ve(y.id),style:{width:"100%",textAlign:"left",border:"1px solid var(--border)",background:"white",padding:12,marginBottom:8},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,marginBottom:4},children:y.name}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[y.mime_type||"file"," · ",(y.size_bytes||0).toLocaleString()," bytes"]})]},y.id)),V&&i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:12,marginTop:10},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,marginBottom:6},children:V.asset.name}),i.jsx("div",{style:{fontSize:11,color:"var(--ink2)",lineHeight:1.5,marginBottom:8},children:V.summary||"No extracted summary yet. Re-upload or parse this asset to make it queryable."}),(V.chunks||[]).slice(0,3).map(y=>i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginBottom:6},children:["chunk ",y.chunk_index,": ",y.content.slice(0,180)]},`${V.asset.id}:${y.chunk_index}`)),i.jsx("textarea",{value:C,onChange:y=>A(y.target.value),placeholder:"Ask Dhee about this asset…",rows:3,style:{width:"100%",border:"1px solid var(--border)",padding:"10px 12px",marginTop:8,background:"var(--bg)"}}),i.jsx("button",{onClick:async()=>{var y;if(C.trim())try{const re=await B.askAsset(V.asset.id,C.trim());A(""),(y=re.launch)!=null&&y.session_id&&d(re.launch.session_id,re.launch.task_id||null)}catch(re){Fe(String(re))}},style:{marginTop:8,padding:"8px 12px",border:"1px solid var(--ink)",background:"var(--ink)",color:"white",fontFamily:"var(--mono)",fontSize:10},children:"ask with default claude code"})]})]})]})]}),W&&ae&&le&&i.jsx("div",{onClick:()=>ee(!1),style:{position:"fixed",inset:0,background:"rgba(12, 12, 12, 0.22)",display:"flex",alignItems:"center",justifyContent:"center",padding:24,zIndex:80},children:i.jsxs("div",{onClick:y=>y.stopPropagation(),style:{width:"min(1040px, calc(100vw - 80px))",maxHeight:"calc(100vh - 80px)",background:"var(--bg)",border:"1px solid var(--border2)",display:"grid",gridTemplateColumns:"280px minmax(0, 1fr)",overflow:"hidden",boxShadow:"0 30px 80px rgba(0,0,0,0.12)"},children:[i.jsxs("div",{style:{borderRight:"1px solid var(--border)",background:"white",overflowY:"auto"},children:[i.jsxs("div",{style:{padding:16,borderBottom:"1px solid var(--border)"},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"WORKSPACES"}),i.jsx("div",{style:{marginTop:6,fontSize:13,fontWeight:600},children:"switch workspace"})]}),i.jsx("div",{style:{padding:12},children:((l==null?void 0:l.workspaces)||[]).map(y=>i.jsxs("button",{onClick:()=>{var re,Le;D(y.id),se(((Le=(re=y.projects)==null?void 0:re[0])==null?void 0:Le.id)||"")},style:{width:"100%",textAlign:"left",padding:"10px 12px",marginBottom:8,border:`1px solid ${y.id===le.id?"var(--accent)":"var(--border)"}`,background:y.id===le.id?"rgba(224, 107, 63, 0.06)":"white"},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,marginBottom:4},children:y.label||y.name}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)"},children:[y.sessionCount||y.sessions.length," sessions · ",(y.folders||y.mounts||[]).length," folders"]})]},y.id))})]}),i.jsxs("div",{style:{display:"flex",flexDirection:"column",overflow:"hidden"},children:[i.jsxs("div",{style:{padding:"16px 18px",borderBottom:"1px solid var(--border)",background:"white",display:"flex",alignItems:"center",justifyContent:"space-between",gap:12},children:[i.jsxs("div",{children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"WORKSPACE SETTINGS"}),i.jsx("div",{style:{marginTop:6,fontSize:18,fontWeight:650},children:le.label||le.name})]}),i.jsx("button",{onClick:()=>ee(!1),style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"close"})]}),i.jsxs("div",{style:{flex:1,overflowY:"auto",padding:18},children:[i.jsxs("div",{style:{marginBottom:24},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginBottom:10},children:"RENAME"}),i.jsxs("div",{style:{display:"flex",gap:10,alignItems:"center"},children:[i.jsx("input",{value:me,onChange:y=>$(y.target.value),placeholder:"Workspace name",style:{flex:1,border:"1px solid var(--border)",padding:"10px 12px",background:"white",fontSize:14}}),i.jsx("button",{onClick:()=>void st(),disabled:!me.trim()||xe,style:{padding:"10px 12px",border:"1px solid var(--ink)",background:"var(--ink)",color:"white",fontFamily:"var(--mono)",fontSize:10,opacity:!me.trim()||xe?.6:1},children:xe?"saving...":"save name"})]})]}),i.jsxs("div",{children:[i.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:12,marginBottom:10},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)"},children:"MOUNTED FOLDERS"}),i.jsx("button",{onClick:()=>void Ue(),disabled:xe,style:{padding:"8px 10px",border:"1px solid var(--border)",background:"white",fontFamily:"var(--mono)",fontSize:9,color:"var(--accent)"},children:xe?"working...":"add folder"})]}),i.jsx("div",{style:{display:"grid",gap:10},children:(le.folders||le.mounts||[]).map(y=>i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:12,display:"flex",justifyContent:"space-between",gap:12,alignItems:"flex-start"},children:[i.jsxs("div",{style:{minWidth:0},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600,marginBottom:4},children:y.primary?"root folder":y.label}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",lineHeight:1.5,wordBreak:"break-all"},children:y.path})]}),i.jsx("button",{onClick:()=>void Yn(y.path),disabled:xe||(le.folders||le.mounts||[]).length<=1,style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--rose)",opacity:xe||(le.folders||le.mounts||[]).length<=1?.5:1},children:"remove"})]},y.path))}),i.jsx("div",{style:{marginTop:10,fontSize:11,color:"var(--ink3)",lineHeight:1.5},children:"Sessions are included in this workspace by matching their working directory against these mounted folders. Removing a folder removes those sessions from this workspace view without deleting the mirrored session record."})]}),i.jsxs("div",{style:{marginTop:24},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginBottom:10},children:"PROJECTS"}),i.jsxs("div",{style:{display:"grid",gridTemplateColumns:"240px minmax(0, 1fr)",gap:14},children:[i.jsx("div",{style:{display:"grid",gap:10,alignContent:"start"},children:(le.projects||[]).map(y=>i.jsxs("button",{onClick:()=>se(y.id),style:{width:"100%",textAlign:"left",border:`1px solid ${y.id===(Ee==null?void 0:Ee.id)?"var(--accent)":"var(--border)"}`,background:y.id===(Ee==null?void 0:Ee.id)?"rgba(224, 107, 63, 0.06)":"white",padding:12},children:[i.jsx("div",{style:{fontSize:12,fontWeight:600},children:y.name}),i.jsxs("div",{style:{fontFamily:"var(--mono)",fontSize:9,color:"var(--ink3)",marginTop:4},children:[y.sessions.length," sessions · default ",y.defaultRuntime||"codex"]})]},y.id))}),i.jsxs("div",{style:{display:"grid",gap:12,alignContent:"start"},children:[Ee?i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:14},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginBottom:10},children:"EDIT PROJECT"}),i.jsxs("div",{style:{display:"grid",gap:10},children:[i.jsx("input",{value:de,onChange:y=>ce(y.target.value),placeholder:"Project name",style:{border:"1px solid var(--border)",padding:"10px 12px",background:"white",fontSize:14}}),i.jsxs("select",{value:he,onChange:y=>_e(y.target.value),style:{border:"1px solid var(--border)",padding:"10px 12px",background:"white",fontSize:14},children:[i.jsx("option",{value:"codex",children:"Codex default runtime"}),i.jsx("option",{value:"claude-code",children:"Claude Code default runtime"})]}),i.jsx("textarea",{value:N,onChange:y=>oe(y.target.value),rows:5,placeholder:"One path scope rule per line",style:{border:"1px solid var(--border)",padding:"10px 12px",background:"white",fontSize:13,lineHeight:1.5}}),i.jsx("div",{style:{fontSize:11,color:"var(--ink3)",lineHeight:1.5},children:"Sessions are assigned to this project by the longest matching scope rule path."}),i.jsx("button",{onClick:()=>void Sp(),disabled:xe||!de.trim(),style:{justifySelf:"start",padding:"10px 12px",border:"1px solid var(--ink)",background:"var(--ink)",color:"white",fontFamily:"var(--mono)",fontSize:10,opacity:xe||!de.trim()?.6:1},children:xe?"saving...":"save project"})]})]}):null,i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:14},children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",marginBottom:10},children:"ADD PROJECT"}),i.jsxs("div",{style:{display:"grid",gap:10},children:[i.jsx("input",{value:q,onChange:y=>te(y.target.value),placeholder:"New project name",style:{border:"1px solid var(--border)",padding:"10px 12px",background:"white",fontSize:14}}),i.jsxs("select",{value:Ae,onChange:y=>Ft(y.target.value),style:{border:"1px solid var(--border)",padding:"10px 12px",background:"white",fontSize:14},children:[i.jsx("option",{value:"codex",children:"Codex default runtime"}),i.jsx("option",{value:"claude-code",children:"Claude Code default runtime"})]}),i.jsx("button",{onClick:async()=>{if(!(!le||!q.trim())){Me(!0);try{await H(le.id,{name:q.trim(),default_runtime:Ae,scope_rules:[{path_prefix:Sv(le),label:"root"}]}),te("")}catch(y){Fe(String(y))}finally{Me(!1)}}},style:{justifySelf:"start",padding:"10px 12px",border:"1px solid var(--ink)",background:"var(--ink)",color:"white",fontFamily:"var(--mono)",fontSize:10},children:"add project"})]})]})]})]})]}),je&&i.jsx("div",{style:{marginTop:18,fontSize:11,color:"var(--rose)"},children:je})]})]})]})})]})}const jv=v.lazy(()=>qh(()=>import("./CanvasView-Dg15id0Q.js"),[]).then(e=>({default:e.CanvasView}))),wv={accentHue:"36",compactNav:!1,showTimestamps:!0,canvasStyle:"force"},Cv=15e3,bv=new Set(["command","handoff","replay","learnings","portability"]);function Yi(e){return bv.has(e)}function Ol(e){return e==="canvas"}function $l(e){return e==="router"||e==="router/sessionshistory"}function Ji(e){const t=String(e||"").toLowerCase();return t==="home"||t==="overview"?"command":t==="memory"?"context":t==="firewall"||t==="context-firewall"?"router":t==="repo"||t==="brain"||t==="repo-brain"||t==="folders"?"canvas":t==="learn"||t==="learning"?"learnings":t==="packs"||t==="portable"||t==="trust"?"portability":t==="router/sessionshistory"||t==="router/session-history"||t==="router/history"?"router/sessionshistory":t||"command"}function Mr(e){return typeof window>"u"?"":new URLSearchParams(window.location.search).get(e)||""}function _v(){const[e,t]=v.useState(()=>Ji(Mr("view"))),[n,r]=v.useState(!1),[o,l]=v.useState("workspaces"),[s,a]=v.useState([]),[d,c]=v.useState(()=>Mr("task")),[g,m]=v.useState(wv),[x,S]=v.useState(!1),[E,T]=v.useState(0),[H,h]=v.useState([]),[u,f]=v.useState(0),[p,j]=v.useState(0),[_,P]=v.useState(!1),[z,Q]=v.useState(null),[R,J]=v.useState(null),[X,K]=v.useState(null),[G,M]=v.useState(null),[V,ye]=v.useState(0),[C,A]=v.useState(null),[W,ee]=v.useState(()=>Mr("project")),[ie,D]=v.useState(()=>Mr("workspace")),[fe,se]=v.useState(()=>Mr("session")),me=v.useRef({}),$=(k,O,I)=>{let ne="";try{ne=JSON.stringify(O)||""}catch{ne=String(Date.now())}me.current[k]!==ne&&(me.current[k]=ne,I(O))};v.useEffect(()=>{const k=new URLSearchParams(window.location.search),O=k.get("view"),I=k.get("task"),ne=k.get("project"),ve=k.get("workspace"),Ke=k.get("session");O&&t(Ji(O)),I&&c(I),ne&&ee(ne),ve&&D(ve),Ke&&se(Ke);const st=()=>{const Ue=new URLSearchParams(window.location.search);t(Ji(Ue.get("view"))),c(Ue.get("task")||""),ee(Ue.get("project")||""),D(Ue.get("workspace")||""),se(Ue.get("session")||"")};return window.addEventListener("popstate",st),()=>window.removeEventListener("popstate",st)},[]);const de=async()=>{try{const k=await B.tasks();$("tasks",k.tasks||[],a),c(O=>{var I,ne;return O||((ne=(I=k.tasks)==null?void 0:I[0])==null?void 0:ne.id)||""})}catch{}},ce=async()=>{try{const k=ie?await B.workspaceGraph(ie,W||void 0):await B.workspaceGraph();$("workspaceGraph",k,Q)}catch{}},he=async()=>{try{const k=await B.orgGraph(void 0,{active:!0});$("orgGraph",k,J)}catch{}},_e=async()=>{try{const k=await B.me();$("viewer",k,K)}catch{}},N=async()=>{try{const k=await B.routerStats();$("routerStats",k,M),f(k.sessionTokensSaved||0)}catch{}},oe=async()=>{var k,O,I,ne;try{const ve=await B.inbox(X!=null&&X.team_id?{team:X.team_id,user:X.user_id}:{user:X==null?void 0:X.user_id}),Ke=(((k=ve.totals)==null?void 0:k.proposals)||0)+(((O=ve.totals)==null?void 0:O.findings)||0)+(((I=ve.totals)==null?void 0:I.conflicts)||0);ye(Ke),j(((ne=ve.totals)==null?void 0:ne.conflicts)||0)}catch{}},q=async()=>{try{const k=await B.workspaces();$("projectIndex",k,A),D(O=>{var I,ne;return O||k.currentWorkspaceId||((ne=(I=k.workspaces)==null?void 0:I[0])==null?void 0:ne.id)||""}),ee(O=>{var I,ne,ve,Ke;return O||k.currentProjectId||((Ke=(ve=(ne=(I=k.workspaces)==null?void 0:I[0])==null?void 0:ne.projects)==null?void 0:ve[0])==null?void 0:Ke.id)||""}),se(O=>O||k.currentSessionId||"")}catch{}};v.useEffect(()=>{if(_e(),oe(),Yi(e)||N(),Ol(e))he();else{if($l(e))return;Yi(e)||(de(),he(),q().then(()=>ce()),(async()=>{var k;try{const O=await B.listMemories();T(((k=O.engrams)==null?void 0:k.length)||0),h(O.engrams||[])}catch{}})())}},[]),v.useEffect(()=>{const k=window.setInterval(()=>{if(Yi(e)){oe();return}if($l(e)){N(),oe();return}if(Ol(e)){he(),oe();return}de(),q(),ce(),he(),N(),oe()},Cv);return()=>window.clearInterval(k)},[e,ie,W,X==null?void 0:X.team_id]),v.useEffect(()=>{if(!Yi(e)){if($l(e)){N(),oe();return}if(Ol(e)){he();return}de(),q().then(()=>ce()),he(),(async()=>{var k;try{const O=await B.listMemories();T(((k=O.engrams)==null?void 0:k.length)||0),h(O.engrams||[])}catch{}})()}},[e]);const te=(k,O)=>{const I=Ji(k);P(!0),setTimeout(()=>{O&&c(O),t(I);const ne=new URLSearchParams(window.location.search);ne.set("view",I),O||d?ne.set("task",O||d):ne.delete("task"),W?ne.set("project",W):ne.delete("project"),ie?ne.set("workspace",ie):ne.delete("workspace"),fe?ne.set("session",fe):ne.delete("session");const ve=ne.toString(),Ke=ve?`?${ve}`:"",st=I==="context"&&window.location.hash.startsWith("#vault")?window.location.hash:"";window.history.pushState({},"",`${window.location.pathname}${Ke}${st}`),P(!1)},140)},Ae=k=>te("workspace",k),Ft=(k,O)=>{k&&se(k),O&&c(O),te("workspace",O||void 0)},xe=k=>{D(k),ee(""),se(""),te("workspace")},Me=(k,O)=>{O&&D(O),ee(k),se(""),te("workspace")};v.useEffect(()=>{const k=new URLSearchParams(window.location.search);k.set("view",e),d?k.set("task",d):k.delete("task"),W?k.set("project",W):k.delete("project"),ie?k.set("workspace",ie):k.delete("workspace"),fe?k.set("session",fe):k.delete("session");const O=k.toString(),I=`${window.location.pathname}${O?"?"+O:""}`;I!==`${window.location.pathname}${window.location.search}`&&window.history.replaceState({},"",I)},[e,d,W,ie,fe]);const je=async k=>{await B.createWorkspaceRoot(k),await q(),await ce()},Fe=async(k,O)=>{await B.createProject(k,O),await q(),await ce()},wn=async(k,O)=>{await B.updateProject(k,O),await q(),await ce()},Ut=async(k,O,I)=>{await B.addWorkspaceFolder(k,O,I),await q(),await ce()},Z=async(k,O)=>{await B.updateWorkspace(k,{label:O}),await q(),await ce()},ae=async(k,O)=>{await B.removeWorkspaceFolder(k,O),await q(),await ce()},Se=async k=>{try{const O=await B.createTask(k);a(I=>[...I,O.task]),await q(),setTimeout(()=>te("workspace",O.task.id),80)}catch(O){console.warn("createTask failed",O)}},le=async k=>{try{await B.remember(k,"short-term",[]),T(I=>I+1);const O=await B.listMemories();h(O.engrams||[])}catch(O){console.warn("remember failed",O)}},Ee=async(k,O,I,ne,ve)=>{const Ke=I||ie||(C==null?void 0:C.currentWorkspaceId);if(!Ke||!k.trim())return;const st=await B.launchWorkspaceSession(Ke,O,k.trim(),O==="claude-code"?ne:void 0,void 0,ve||W||(C==null?void 0:C.currentProjectId));await de(),await q(),await ce(),st.session_id&&se(st.session_id),st.task_id&&c(st.task_id),te("workspace",st.task_id||void 0)},Lt=async(k,O)=>{try{await B.addTaskNote(k,O),await de(),await q()}catch(I){console.warn("addTaskNote failed",I)}},Qn={opacity:_?0:1,transform:_?"translateY(3px)":"translateY(0)",transition:"opacity 0.14s ease, transform 0.14s ease",flex:1,overflow:"hidden",display:"flex",flexDirection:"column"},w=()=>e==="command"?i.jsx(Hg,{onNavigate:k=>te(k)}):e==="channel"?i.jsx(yg,{projectIndex:C,workspaceGraph:z,tasks:s,viewer:X,orgGraph:R,selectedWorkspaceId:ie,selectedProjectId:W,onSelectWorkspace:xe,onSelectProject:Me,onSelectTask:Ae,onTasksRefresh:de,onOpenCanvas:()=>te("canvas"),onLaunchSession:Ee,onOpenManager:k=>{l(k||"workspaces"),r(!0)},tweaks:g}):e==="notepad"?i.jsx(Ug,{projectIndex:C,memories:E,tokensSaved:u,onAddTask:Se,onAddMemory:le,onSelectSession:Ft,onCreateWorkspace:je,onLaunchSession:Ee,onCreateProject:Fe,onOpenWorkspace:()=>te("workspace"),onOpenTasks:()=>te("tasks"),tweaks:g}):e==="tasks"?i.jsx(fv,{tasks:s,projectIndex:C,onSelectTask:Ae,onSelectSession:Ft,tweaks:g}):e==="workspace"?i.jsx(kv,{tasks:s,activeTaskId:d,selectedProjectId:W,selectedWorkspaceId:ie,selectedSessionId:fe,projectIndex:C,workspaceGraph:z,onSelectTask:Ae,onSelectSession:Ft,onSelectProject:Me,onCanvasOpen:()=>te("canvas"),onNotepadOpen:()=>te("context"),onAddTaskNote:Lt,onUpdateWorkspace:Z,onAddWorkspaceFolder:Ut,onRemoveWorkspaceFolder:ae,onCreateProject:Fe,onUpdateProject:wn,onTasksRefresh:de,tweaks:g}):e==="canvas"?i.jsx(v.Suspense,{fallback:i.jsx("div",{style:{height:"100%",display:"grid",alignItems:"start",background:"var(--surface)",padding:20},children:i.jsxs("div",{style:{border:"1px solid var(--border)",background:"white",padding:"16px 18px",display:"flex",justifyContent:"space-between",alignItems:"center",gap:18},children:[i.jsxs("div",{children:[i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--green)",letterSpacing:"0.12em",textTransform:"uppercase"},children:"Repo Brain"}),i.jsx("div",{style:{marginTop:6,fontSize:20,fontWeight:650},children:"Loading folder canvas"})]}),i.jsx("div",{style:{fontFamily:"var(--mono)",fontSize:10,color:"var(--ink3)",textTransform:"uppercase"},children:"Context Vault"})]})}),children:i.jsx(jv,{tasks:s,selectedProjectId:W,workspaceGraph:z,orgGraph:R,viewer:X,onOpenVault:k=>{k&&(window.location.hash=`#vault/${k}`),te("context")},onOrgGraphChanged:()=>{he(),_e(),oe()},onSelectTask:Ae,onSelectSession:Ft,onSelectWorkspace:xe,onSelectProject:Me,onClose:()=>te("workspace"),tweaks:g})}):e==="memory"||e==="context"?i.jsx(Dg,{onMemoryCountChange:T,viewer:X,orgGraph:R,onInboxChanged:oe}):e==="router"||e==="router/sessionshistory"?i.jsx(ov,{onOpenFolders:()=>te("canvas"),onOpenSetup:()=>te("notepad")}):e==="handoff"?i.jsx(Vg,{}):e==="replay"?i.jsx(Kg,{}):e==="learnings"?i.jsx(Qg,{}):e==="portability"?i.jsx(Yg,{}):e==="conflicts"?i.jsx(kg,{viewer:X,onChanged:oe}):null;v.useEffect(()=>{const k=O=>{(O.ctrlKey||O.metaKey)&&O.key.toLowerCase()==="k"&&(O.preventDefault(),S(I=>!I))};return window.addEventListener("keydown",k),()=>window.removeEventListener("keydown",k)},[]);const b=()=>{_e(),de(),q(),ce(),he(),N(),oe()};return i.jsxs("div",{style:{height:"100vh",display:"flex",overflow:"hidden"},children:[i.jsx(Zh,{view:e,setView:k=>te(k),conflictCount:V||p}),i.jsxs("div",{style:{flex:1,display:"flex",flexDirection:"column",overflow:"hidden"},children:[i.jsx(tg,{viewer:X,routerStats:G,onRefresh:b,onOpenTweaks:()=>S(k=>!k),onResetWorkspace:async()=>{if(window.confirm("Reset workspace? Deletes projects, teams, folders, context items, proposals and findings for this org. Memory engrams are unaffected."))try{await B.enterpriseResetWorkspace()}finally{b()}}}),i.jsx("div",{style:Qn,children:w()})]}),i.jsx(ng,{tweaks:g,setTweaks:m,visible:x}),i.jsx(og,{open:n,onClose:()=>r(!1),projectIndex:C,initialWorkspaceId:ie,initialTab:o,onChanged:async()=>{await q(),await ce()}})]})}Dl.createRoot(document.getElementById("root")).render(i.jsx(Dp.StrictMode,{children:i.jsx(_v,{})}));export{Ev as R,B as a,i as j,v as r}; diff --git a/dhee/ui/web/dist/assets/index-DoQLFf8M.css b/dhee/ui/web/dist/assets/index-DoQLFf8M.css new file mode 100644 index 0000000..cf4f2a4 --- /dev/null +++ b/dhee/ui/web/dist/assets/index-DoQLFf8M.css @@ -0,0 +1 @@ +:root{--bg: oklch(.975 .006 80);--ink: oklch(.1 .01 260);--ink2: oklch(.42 .015 260);--ink3: oklch(.64 .01 260);--surface: oklch(.955 .008 80);--surface2: oklch(.93 .01 80);--border: oklch(.87 .012 80);--border2: oklch(.78 .012 80);--accent: oklch(.64 .18 36);--accent-dim: oklch(.97 .04 36);--green: oklch(.52 .22 145);--green-dim: oklch(.94 .06 145);--green-mid: oklch(.72 .14 145);--indigo: oklch(.52 .2 265);--indigo-dim: oklch(.95 .04 265);--rose: oklch(.58 .2 10);--rose-dim: oklch(.96 .05 10);--font: "Space Grotesk", sans-serif;--mono: "JetBrains Mono", monospace;--nav: 52px}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%;width:100%}body{font-family:var(--font);background:var(--bg);color:var(--ink);overflow:hidden;font-size:13.5px;line-height:1.5;-webkit-font-smoothing:antialiased}::selection{background:var(--accent-dim)}::-webkit-scrollbar{width:3px;height:3px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border2)}button{font-family:var(--font);cursor:pointer;border:none;background:none;color:inherit}textarea,input{font-family:var(--font);color:var(--ink);background:transparent;border:none;outline:none;resize:none}@keyframes blink{0%,to{opacity:1}50%{opacity:0}}@keyframes fadein{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes dhee-card-in{0%{opacity:0;transform:translate3d(0,8px,0) scale(.98)}to{opacity:1;transform:translateZ(0) scale(1)}}@keyframes dhee-shimmer{0%{background-position:200% 0}to{background-position:-200% 0}}@keyframes dhee-pulse{0%,to{box-shadow:0 0 #e06b3f59}50%{box-shadow:0 0 0 6px #e06b3f00}}.dhee-node-card:hover{transform:translate3d(0,-2px,0)!important;box-shadow:0 6px 18px #14100a14,0 2px 6px #14100a0f!important}.dhee-canvas-bg{background-color:#faf7ef;background-image:radial-gradient(rgba(20,16,10,.06) 1px,transparent 1px);background-size:28px 28px;background-position:0 0}.dhee-edge-path{fill:none;stroke:#14100a24;stroke-width:1.4;transition:stroke .18s ease,stroke-width .18s ease,opacity .18s ease}.dhee-edge-path--highlight{stroke:#e06b3f8c;stroke-width:2}.dhee-edge-path--dim{opacity:.28}.md-root{word-wrap:break-word;overflow-wrap:break-word}.md-root .md-h1,.md-root .md-h2,.md-root .md-h3{margin:14px 0 8px;line-height:1.3;color:var(--ink)}.md-root .md-h1{font-size:22px;font-weight:600;border-bottom:1px solid var(--border);padding-bottom:5px}.md-root .md-h2{font-size:18px;font-weight:600}.md-root .md-h3{font-size:15px;font-weight:600;color:var(--ink2)}.md-root .md-p{margin:8px 0}.md-root .md-code{background:var(--surface2);border:1px solid var(--border);border-radius:3px;padding:1px 5px;font-family:var(--mono);font-size:12px;color:var(--ink)}.md-root .md-pre{background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:10px 12px;overflow-x:auto;margin:10px 0}.md-root .md-pre .md-code{background:transparent;border:0;padding:0;font-size:12px;line-height:1.5}.md-root .md-link{color:var(--accent);text-decoration:none;border-bottom:1px solid rgba(224,107,63,.3)}.md-root .md-link:hover{border-bottom-color:var(--accent)}.md-root .md-wiki{color:var(--indigo);font-weight:500}.md-root .md-wiki-missing{color:var(--ink3);border-bottom:1px dashed var(--ink3);cursor:not-allowed}.md-root .md-list{margin:8px 0 8px 22px;padding:0}.md-root .md-list li{margin:3px 0}.md-root .md-quote{border-left:3px solid var(--border2);padding:2px 12px;margin:10px 0;color:var(--ink2);font-style:italic}.proposal-status{display:inline-flex;align-items:center;padding:2px 7px;border-radius:3px;font-family:var(--mono);font-size:9px;letter-spacing:.04em;text-transform:uppercase}.proposal-status.active{background:var(--green-dim);color:var(--green)}.proposal-status.pending_review{background:var(--accent-dim);color:var(--accent)}.proposal-status.rejected{background:var(--rose-dim);color:var(--rose)}.inbox-row{border:1px solid var(--border);border-radius:4px;background:var(--surface);padding:10px 12px;display:flex;flex-direction:column;gap:6px;animation:dhee-card-in .18s ease}.inbox-row[data-severity=high]{border-left:3px solid var(--rose)}.inbox-row[data-severity=medium]{border-left:3px solid var(--accent)}.inbox-row[data-severity=low]{border-left:3px solid var(--indigo)}.workspace-pill{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:360px}.tokens-chip{cursor:default}.vault-leaf{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:3px;cursor:pointer;font-size:12px;color:var(--ink2);background:transparent;border:0;text-align:left;width:100%}.vault-leaf:hover{background:var(--surface)}.vault-leaf[aria-selected=true]{background:var(--accent-dim);color:var(--accent)}.vault-leaf[data-status=pending_review]:before{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0}.vault-leaf[data-status=active]:before{content:"";width:6px;height:6px;border-radius:50%;background:var(--green);flex-shrink:0} diff --git a/dhee/ui/web/dist/dhee-logo.png b/dhee/ui/web/dist/dhee-logo.png new file mode 100644 index 0000000..8ab85fd Binary files /dev/null and b/dhee/ui/web/dist/dhee-logo.png differ diff --git a/dhee/ui/web/dist/index.html b/dhee/ui/web/dist/index.html new file mode 100644 index 0000000..3953791 --- /dev/null +++ b/dhee/ui/web/dist/index.html @@ -0,0 +1,18 @@ + + + + + + Sankhya — Dhee + + + + + + + + + diff --git a/dhee/ui/web/index.html b/dhee/ui/web/index.html new file mode 100644 index 0000000..90a70a8 --- /dev/null +++ b/dhee/ui/web/index.html @@ -0,0 +1,17 @@ + + + + + +Sankhya — Dhee + + + + + + + + diff --git a/dhee/ui/web/package-lock.json b/dhee/ui/web/package-lock.json new file mode 100644 index 0000000..0c8c7c4 --- /dev/null +++ b/dhee/ui/web/package-lock.json @@ -0,0 +1,2034 @@ +{ + "name": "sankhya", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sankhya", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "concurrently": "^9.2.1", + "typescript": "^5.5.3", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.343", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz", + "integrity": "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/dhee/ui/web/package.json b/dhee/ui/web/package.json new file mode 100644 index 0000000..76b18b3 --- /dev/null +++ b/dhee/ui/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "sankhya", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:all": "bash ../../../scripts/dev_ui.sh", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "concurrently": "^9.2.1", + "typescript": "^5.5.3", + "vite": "^5.4.0" + } +} diff --git a/dhee/ui/web/public/dhee-logo.png b/dhee/ui/web/public/dhee-logo.png new file mode 100644 index 0000000..8ab85fd Binary files /dev/null and b/dhee/ui/web/public/dhee-logo.png differ diff --git a/dhee/ui/web/src/App.js b/dhee/ui/web/src/App.js new file mode 100644 index 0000000..de139b8 --- /dev/null +++ b/dhee/ui/web/src/App.js @@ -0,0 +1,534 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { Suspense, lazy, useEffect, useRef, useState } from "react"; +import { api } from "./api"; +import { NavRail } from "./components/NavRail"; +import { TopBar } from "./components/TopBar"; +import { TweaksPanel } from "./components/TweaksPanel"; +import { WorkspaceManagerModal } from "./components/WorkspaceManagerModal"; +import { ChannelView } from "./views/ChannelView"; +import { ConflictView } from "./views/ConflictView"; +import { MemoryView } from "./views/MemoryView"; +import { NotepadView } from "./views/NotepadView"; +import { CommandCenterView, HandoffHubView, LearningInboxView, PortabilityTrustView, ProofReplayView, } from "./views/ProductViews"; +import { RouterView } from "./views/RouterView"; +import { TasksView } from "./views/TasksView"; +import { WorkspaceView } from "./views/WorkspaceView"; +const CanvasView = lazy(() => import("./views/CanvasView").then((module) => ({ default: module.CanvasView }))); +const DEFAULT_TWEAKS = { + accentHue: "36", + compactNav: false, + showTimestamps: true, + canvasStyle: "force", +}; +const GLOBAL_REFRESH_MS = 15000; +const PRODUCT_VIEWS = new Set([ + "command", + "handoff", + "replay", + "learnings", + "portability", +]); +function isProductView(view) { + return PRODUCT_VIEWS.has(view); +} +function isGraphOnlyView(view) { + return view === "canvas"; +} +function isSelfHydratedView(view) { + return view === "router" || view === "router/sessionshistory"; +} +function normalizeView(view) { + const raw = String(view || "").toLowerCase(); + if (raw === "home" || raw === "overview") + return "command"; + if (raw === "memory") + return "context"; + if (raw === "firewall" || raw === "context-firewall") + return "router"; + if (raw === "repo" || raw === "brain" || raw === "repo-brain" || raw === "folders") + return "canvas"; + if (raw === "learn" || raw === "learning") + return "learnings"; + if (raw === "packs" || raw === "portable" || raw === "trust") + return "portability"; + if (raw === "router/sessionshistory" || + raw === "router/session-history" || + raw === "router/history") + return "router/sessionshistory"; + return raw || "command"; +} +function initialParam(name) { + if (typeof window === "undefined") + return ""; + return new URLSearchParams(window.location.search).get(name) || ""; +} +export default function App() { + const [view, setView] = useState(() => normalizeView(initialParam("view"))); + const [managerOpen, setManagerOpen] = useState(false); + const [managerTab, setManagerTab] = useState("workspaces"); + const [tasks, setTasks] = useState([]); + const [activeTaskId, setActiveTaskId] = useState(() => initialParam("task")); + const [tweaks, setTweaks] = useState(DEFAULT_TWEAKS); + const [showTweaks, setShowTweaks] = useState(false); + const [memoryCount, setMemoryCount] = useState(0); + const [memoriesCache, setMemoriesCache] = useState([]); + const [tokensSaved, setTokensSaved] = useState(0); + const [conflictCount, setConflictCount] = useState(0); + const [transitioning, setTransitioning] = useState(false); + const [workspaceGraph, setWorkspaceGraph] = useState(null); + const [orgGraph, setOrgGraph] = useState(null); + const [viewer, setViewer] = useState(null); + const [routerStats, setRouterStats] = useState(null); + const [inboxCount, setInboxCount] = useState(0); + const [projectIndex, setProjectIndex] = useState(null); + const [selectedProjectId, setSelectedProjectId] = useState(() => initialParam("project")); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(() => initialParam("workspace")); + const [selectedSessionId, setSelectedSessionId] = useState(() => initialParam("session")); + const snapshotSignatures = useRef({}); + const updateSnapshot = (key, next, apply) => { + let signature = ""; + try { + signature = JSON.stringify(next) || ""; + } + catch { + signature = String(Date.now()); + } + if (snapshotSignatures.current[key] === signature) + return; + snapshotSignatures.current[key] = signature; + apply(next); + }; + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const urlView = params.get("view"); + const urlTask = params.get("task"); + const urlProject = params.get("project"); + const urlWorkspace = params.get("workspace"); + const urlSession = params.get("session"); + if (urlView) + setView(normalizeView(urlView)); + if (urlTask) + setActiveTaskId(urlTask); + if (urlProject) + setSelectedProjectId(urlProject); + if (urlWorkspace) + setSelectedWorkspaceId(urlWorkspace); + if (urlSession) + setSelectedSessionId(urlSession); + const onPop = () => { + const next = new URLSearchParams(window.location.search); + setView(normalizeView(next.get("view"))); + setActiveTaskId(next.get("task") || ""); + setSelectedProjectId(next.get("project") || ""); + setSelectedWorkspaceId(next.get("workspace") || ""); + setSelectedSessionId(next.get("session") || ""); + }; + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + const refreshTasks = async () => { + try { + const t = await api.tasks(); + updateSnapshot("tasks", t.tasks || [], setTasks); + setActiveTaskId((current) => current || t.tasks?.[0]?.id || ""); + } + catch { } + }; + const refreshWorkspaceGraph = async () => { + try { + const graph = selectedWorkspaceId + ? await api.workspaceGraph(selectedWorkspaceId, selectedProjectId || undefined) + : await api.workspaceGraph(); + updateSnapshot("workspaceGraph", graph, setWorkspaceGraph); + } + catch { } + }; + const refreshOrgGraph = async () => { + try { + // Active-only on the canvas — no stale or paused sessions clutter the + // FOLDERS view. Slice-1 server gates on ?active=true. + const g = await api.orgGraph(undefined, { active: true }); + updateSnapshot("orgGraph", g, setOrgGraph); + } + catch { } + }; + const refreshViewer = async () => { + try { + const v = await api.me(); + updateSnapshot("viewer", v, setViewer); + } + catch { } + }; + const refreshRouterStats = async () => { + try { + const s = await api.routerStats(); + updateSnapshot("routerStats", s, setRouterStats); + setTokensSaved(s.sessionTokensSaved || 0); + } + catch { } + }; + const refreshInbox = async () => { + try { + const box = await api.inbox(viewer?.team_id ? { team: viewer.team_id, user: viewer.user_id } : { user: viewer?.user_id }); + const total = (box.totals?.proposals || 0) + + (box.totals?.findings || 0) + + (box.totals?.conflicts || 0); + setInboxCount(total); + setConflictCount(box.totals?.conflicts || 0); + } + catch { } + }; + const refreshProjects = async () => { + try { + const snapshot = await api.workspaces(); + updateSnapshot("projectIndex", snapshot, setProjectIndex); + setSelectedWorkspaceId((current) => current || snapshot.currentWorkspaceId || snapshot.workspaces?.[0]?.id || ""); + setSelectedProjectId((current) => current || snapshot.currentProjectId || snapshot.workspaces?.[0]?.projects?.[0]?.id || ""); + setSelectedSessionId((current) => current || snapshot.currentSessionId || ""); + } + catch { } + }; + useEffect(() => { + void refreshViewer(); + void refreshInbox(); + if (!isProductView(view)) { + void refreshRouterStats(); + } + if (isGraphOnlyView(view)) { + void refreshOrgGraph(); + } + else if (isSelfHydratedView(view)) { + return; + } + else if (!isProductView(view)) { + void refreshTasks(); + void refreshOrgGraph(); + void refreshProjects().then(() => refreshWorkspaceGraph()); + void (async () => { + try { + const m = await api.listMemories(); + setMemoryCount(m.engrams?.length || 0); + setMemoriesCache(m.engrams || []); + } + catch { } + })(); + } + }, []); + useEffect(() => { + const timer = window.setInterval(() => { + if (isProductView(view)) { + void refreshInbox(); + return; + } + if (isSelfHydratedView(view)) { + void refreshRouterStats(); + void refreshInbox(); + return; + } + if (isGraphOnlyView(view)) { + void refreshOrgGraph(); + void refreshInbox(); + return; + } + void refreshTasks(); + void refreshProjects(); + void refreshWorkspaceGraph(); + void refreshOrgGraph(); + void refreshRouterStats(); + void refreshInbox(); + }, GLOBAL_REFRESH_MS); + return () => window.clearInterval(timer); + }, [view, selectedWorkspaceId, selectedProjectId, viewer?.team_id]); + useEffect(() => { + if (isProductView(view)) + return; + if (isSelfHydratedView(view)) { + void refreshRouterStats(); + void refreshInbox(); + return; + } + if (isGraphOnlyView(view)) { + void refreshOrgGraph(); + return; + } + void refreshTasks(); + void refreshProjects().then(() => refreshWorkspaceGraph()); + void refreshOrgGraph(); + void (async () => { + try { + const m = await api.listMemories(); + setMemoryCount(m.engrams?.length || 0); + setMemoriesCache(m.engrams || []); + } + catch { } + })(); + }, [view]); + const go = (v, taskId) => { + const targetView = normalizeView(v); + setTransitioning(true); + setTimeout(() => { + if (taskId) + setActiveTaskId(taskId); + setView(targetView); + const params = new URLSearchParams(window.location.search); + params.set("view", targetView); + if (taskId || activeTaskId) + params.set("task", taskId || activeTaskId); + else + params.delete("task"); + if (selectedProjectId) + params.set("project", selectedProjectId); + else + params.delete("project"); + if (selectedWorkspaceId) + params.set("workspace", selectedWorkspaceId); + else + params.delete("workspace"); + if (selectedSessionId) + params.set("session", selectedSessionId); + else + params.delete("session"); + const qs = params.toString(); + const next = qs ? `?${qs}` : ""; + const hash = targetView === "context" && window.location.hash.startsWith("#vault") + ? window.location.hash + : ""; + window.history.pushState({}, "", `${window.location.pathname}${next}${hash}`); + setTransitioning(false); + }, 140); + }; + const handleSelectTask = (id) => go("workspace", id); + const handleSelectSession = (sessionId, taskId) => { + if (sessionId) + setSelectedSessionId(sessionId); + if (taskId) + setActiveTaskId(taskId); + go("workspace", taskId || undefined); + }; + const handleSelectWorkspace = (workspaceId) => { + setSelectedWorkspaceId(workspaceId); + setSelectedProjectId(""); + setSelectedSessionId(""); + go("workspace"); + }; + const handleSelectProject = (projectId, workspaceId) => { + if (workspaceId) + setSelectedWorkspaceId(workspaceId); + setSelectedProjectId(projectId); + setSelectedSessionId(""); + go("workspace"); + }; + useEffect(() => { + const params = new URLSearchParams(window.location.search); + params.set("view", view); + if (activeTaskId) + params.set("task", activeTaskId); + else + params.delete("task"); + if (selectedProjectId) + params.set("project", selectedProjectId); + else + params.delete("project"); + if (selectedWorkspaceId) + params.set("workspace", selectedWorkspaceId); + else + params.delete("workspace"); + if (selectedSessionId) + params.set("session", selectedSessionId); + else + params.delete("session"); + const qs = params.toString(); + const next = `${window.location.pathname}${qs ? "?" + qs : ""}`; + if (next !== `${window.location.pathname}${window.location.search}`) { + window.history.replaceState({}, "", next); + } + }, [view, activeTaskId, selectedProjectId, selectedWorkspaceId, selectedSessionId]); + const handleCreateWorkspace = async (name) => { + await api.createWorkspaceRoot(name); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + const handleCreateProject = async (workspaceId, payload) => { + await api.createProject(workspaceId, payload); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + const handleUpdateProject = async (projectId, payload) => { + await api.updateProject(projectId, payload); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + const handleAddWorkspaceFolder = async (workspaceId, path, label) => { + await api.addWorkspaceFolder(workspaceId, path, label); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + const handleUpdateWorkspace = async (workspaceId, label) => { + await api.updateWorkspace(workspaceId, { label }); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + const handleRemoveWorkspaceFolder = async (workspaceId, path) => { + await api.removeWorkspaceFolder(workspaceId, path); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + const handleAddTask = async (title) => { + try { + const res = await api.createTask(title); + setTasks((t) => [...t, res.task]); + await refreshProjects(); + setTimeout(() => go("workspace", res.task.id), 80); + } + catch (e) { + console.warn("createTask failed", e); + } + }; + const handleAddMemory = async (text) => { + try { + await api.remember(text, "short-term", []); + setMemoryCount((c) => c + 1); + const m = await api.listMemories(); + setMemoriesCache(m.engrams || []); + } + catch (e) { + console.warn("remember failed", e); + } + }; + const handleLaunchFromNotepad = async (title, runtime, workspaceId, permissionMode, projectId) => { + const targetWorkspace = workspaceId || selectedWorkspaceId || projectIndex?.currentWorkspaceId; + if (!targetWorkspace || !title.trim()) + return; + const res = await api.launchWorkspaceSession(targetWorkspace, runtime, title.trim(), runtime === "claude-code" ? permissionMode : undefined, undefined, projectId || selectedProjectId || projectIndex?.currentProjectId); + await refreshTasks(); + await refreshProjects(); + await refreshWorkspaceGraph(); + if (res.session_id) + setSelectedSessionId(res.session_id); + if (res.task_id) + setActiveTaskId(res.task_id); + go("workspace", res.task_id || undefined); + }; + const handleAddTaskNote = async (taskId, content) => { + try { + await api.addTaskNote(taskId, content); + await refreshTasks(); + await refreshProjects(); + } + catch (e) { + console.warn("addTaskNote failed", e); + } + }; + const viewStyle = { + opacity: transitioning ? 0 : 1, + transform: transitioning ? "translateY(3px)" : "translateY(0)", + transition: "opacity 0.14s ease, transform 0.14s ease", + flex: 1, + overflow: "hidden", + display: "flex", + flexDirection: "column", + }; + const renderView = () => { + if (view === "command") + return _jsx(CommandCenterView, { onNavigate: (target) => go(target) }); + if (view === "channel") + return (_jsx(ChannelView, { projectIndex: projectIndex, workspaceGraph: workspaceGraph, tasks: tasks, viewer: viewer, orgGraph: orgGraph, selectedWorkspaceId: selectedWorkspaceId, selectedProjectId: selectedProjectId, onSelectWorkspace: handleSelectWorkspace, onSelectProject: handleSelectProject, onSelectTask: handleSelectTask, onTasksRefresh: refreshTasks, onOpenCanvas: () => go("canvas"), onLaunchSession: handleLaunchFromNotepad, onOpenManager: (tab) => { + setManagerTab(tab || "workspaces"); + setManagerOpen(true); + }, tweaks: tweaks })); + if (view === "notepad") + return (_jsx(NotepadView, { projectIndex: projectIndex, memories: memoryCount, tokensSaved: tokensSaved, onAddTask: handleAddTask, onAddMemory: handleAddMemory, onSelectSession: handleSelectSession, onCreateWorkspace: handleCreateWorkspace, onLaunchSession: handleLaunchFromNotepad, onCreateProject: handleCreateProject, onOpenWorkspace: () => go("workspace"), onOpenTasks: () => go("tasks"), tweaks: tweaks })); + if (view === "tasks") + return (_jsx(TasksView, { tasks: tasks, projectIndex: projectIndex, onSelectTask: handleSelectTask, onSelectSession: handleSelectSession, tweaks: tweaks })); + if (view === "workspace") + return (_jsx(WorkspaceView, { tasks: tasks, activeTaskId: activeTaskId, selectedProjectId: selectedProjectId, selectedWorkspaceId: selectedWorkspaceId, selectedSessionId: selectedSessionId, projectIndex: projectIndex, workspaceGraph: workspaceGraph, onSelectTask: handleSelectTask, onSelectSession: handleSelectSession, onSelectProject: handleSelectProject, onCanvasOpen: () => go("canvas"), onNotepadOpen: () => go("context"), onAddTaskNote: handleAddTaskNote, onUpdateWorkspace: handleUpdateWorkspace, onAddWorkspaceFolder: handleAddWorkspaceFolder, onRemoveWorkspaceFolder: handleRemoveWorkspaceFolder, onCreateProject: handleCreateProject, onUpdateProject: handleUpdateProject, onTasksRefresh: refreshTasks, tweaks: tweaks })); + if (view === "canvas") + return (_jsx(Suspense, { fallback: _jsx("div", { style: { + height: "100%", + display: "grid", + alignItems: "start", + background: "var(--surface)", + padding: 20, + }, children: _jsxs("div", { style: { + border: "1px solid var(--border)", + background: "white", + padding: "16px 18px", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: 18, + }, children: [_jsxs("div", { children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--green)", + letterSpacing: "0.12em", + textTransform: "uppercase", + }, children: "Repo Brain" }), _jsx("div", { style: { marginTop: 6, fontSize: 20, fontWeight: 650 }, children: "Loading folder canvas" })] }), _jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: "Context Vault" })] }) }), children: _jsx(CanvasView, { tasks: tasks, selectedProjectId: selectedProjectId, workspaceGraph: workspaceGraph, orgGraph: orgGraph, viewer: viewer, onOpenVault: (teamId) => { + if (teamId) + window.location.hash = `#vault/${teamId}`; + go("context"); + }, onOrgGraphChanged: () => { + void refreshOrgGraph(); + void refreshViewer(); + void refreshInbox(); + }, onSelectTask: handleSelectTask, onSelectSession: handleSelectSession, onSelectWorkspace: handleSelectWorkspace, onSelectProject: handleSelectProject, onClose: () => go("workspace"), tweaks: tweaks }) })); + if (view === "memory" || view === "context") + return (_jsx(MemoryView, { onMemoryCountChange: setMemoryCount, viewer: viewer, orgGraph: orgGraph, onInboxChanged: refreshInbox })); + if (view === "router" || view === "router/sessionshistory") + return _jsx(RouterView, { onOpenFolders: () => go("canvas"), onOpenSetup: () => go("notepad") }); + if (view === "handoff") + return _jsx(HandoffHubView, {}); + if (view === "replay") + return _jsx(ProofReplayView, {}); + if (view === "learnings") + return _jsx(LearningInboxView, {}); + if (view === "portability") + return _jsx(PortabilityTrustView, {}); + if (view === "conflicts") + return _jsx(ConflictView, { viewer: viewer, onChanged: refreshInbox }); + return null; + }; + useEffect(() => { + // Keyboard shortcut: Ctrl/Cmd+K shows tweaks panel. + const onKey = (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setShowTweaks((v) => !v); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + const handleRefreshAll = () => { + void refreshViewer(); + void refreshTasks(); + void refreshProjects(); + void refreshWorkspaceGraph(); + void refreshOrgGraph(); + void refreshRouterStats(); + void refreshInbox(); + }; + return (_jsxs("div", { style: { height: "100vh", display: "flex", overflow: "hidden" }, children: [_jsx(NavRail, { view: view, setView: (v) => go(v), conflictCount: inboxCount || conflictCount }), _jsxs("div", { style: { + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }, children: [_jsx(TopBar, { viewer: viewer, routerStats: routerStats, onRefresh: handleRefreshAll, onOpenTweaks: () => setShowTweaks((v) => !v), onResetWorkspace: async () => { + if (!window.confirm("Reset workspace? Deletes projects, teams, folders, context items, proposals and findings for this org. Memory engrams are unaffected.")) + return; + try { + await api.enterpriseResetWorkspace(); + } + finally { + handleRefreshAll(); + } + } }), _jsx("div", { style: viewStyle, children: renderView() })] }), _jsx(TweaksPanel, { tweaks: tweaks, setTweaks: setTweaks, visible: showTweaks }), _jsx(WorkspaceManagerModal, { open: managerOpen, onClose: () => setManagerOpen(false), projectIndex: projectIndex, initialWorkspaceId: selectedWorkspaceId, initialTab: managerTab, onChanged: async () => { + await refreshProjects(); + await refreshWorkspaceGraph(); + } })] })); +} diff --git a/dhee/ui/web/src/App.tsx b/dhee/ui/web/src/App.tsx new file mode 100644 index 0000000..402ac4d --- /dev/null +++ b/dhee/ui/web/src/App.tsx @@ -0,0 +1,714 @@ +import { Suspense, lazy, useEffect, useRef, useState } from "react"; +import { api } from "./api"; +import { NavRail, type View } from "./components/NavRail"; +import { TopBar } from "./components/TopBar"; +import { TweaksPanel } from "./components/TweaksPanel"; +import { WorkspaceManagerModal } from "./components/WorkspaceManagerModal"; +import type { + Engram, + OrgGraphSnapshot, + ProjectIndexSnapshot, + RouterStats, + SankhyaTask, + Tweaks, + Viewer, + WorkspaceGraphSnapshot, +} from "./types"; +import { ChannelView } from "./views/ChannelView"; +import { ConflictView } from "./views/ConflictView"; +import { MemoryView } from "./views/MemoryView"; +import { NotepadView } from "./views/NotepadView"; +import { + CommandCenterView, + HandoffHubView, + LearningInboxView, + PortabilityTrustView, + ProofReplayView, +} from "./views/ProductViews"; +import { RouterView } from "./views/RouterView"; +import { TasksView } from "./views/TasksView"; +import { WorkspaceView } from "./views/WorkspaceView"; + +const CanvasView = lazy(() => + import("./views/CanvasView").then((module) => ({ default: module.CanvasView })) +); + +const DEFAULT_TWEAKS: Tweaks = { + accentHue: "36", + compactNav: false, + showTimestamps: true, + canvasStyle: "force", +}; +const GLOBAL_REFRESH_MS = 15_000; +const PRODUCT_VIEWS = new Set([ + "command", + "handoff", + "replay", + "learnings", + "portability", +]); + +function isProductView(view: View): boolean { + return PRODUCT_VIEWS.has(view); +} + +function isGraphOnlyView(view: View): boolean { + return view === "canvas"; +} + +function isSelfHydratedView(view: View): boolean { + return view === "router" || view === "router/sessionshistory"; +} + +function normalizeView(view: View | string | null): View { + const raw = String(view || "").toLowerCase(); + if (raw === "home" || raw === "overview") return "command"; + if (raw === "memory") return "context"; + if (raw === "firewall" || raw === "context-firewall") return "router"; + if (raw === "repo" || raw === "brain" || raw === "repo-brain" || raw === "folders") return "canvas"; + if (raw === "learn" || raw === "learning") return "learnings"; + if (raw === "packs" || raw === "portable" || raw === "trust") return "portability"; + if ( + raw === "router/sessionshistory" || + raw === "router/session-history" || + raw === "router/history" + ) + return "router/sessionshistory"; + return (raw as View) || "command"; +} + +function initialParam(name: string): string { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get(name) || ""; +} + +export default function App() { + const [view, setView] = useState (() => normalizeView(initialParam("view"))); + const [managerOpen, setManagerOpen] = useState(false); + const [managerTab, setManagerTab] = useState<"workspaces" | "projects">("workspaces"); + const [tasks, setTasks] = useState ([]); + const [activeTaskId, setActiveTaskId] = useState (() => initialParam("task")); + const [tweaks, setTweaks] = useState (DEFAULT_TWEAKS); + const [showTweaks, setShowTweaks] = useState(false); + const [memoryCount, setMemoryCount] = useState(0); + const [memoriesCache, setMemoriesCache] = useState ([]); + const [tokensSaved, setTokensSaved] = useState(0); + const [conflictCount, setConflictCount] = useState(0); + const [transitioning, setTransitioning] = useState(false); + const [workspaceGraph, setWorkspaceGraph] = + useState (null); + const [orgGraph, setOrgGraph] = useState (null); + const [viewer, setViewer] = useState (null); + const [routerStats, setRouterStats] = useState (null); + const [inboxCount, setInboxCount] = useState(0); + const [projectIndex, setProjectIndex] = useState (null); + const [selectedProjectId, setSelectedProjectId] = useState(() => initialParam("project")); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(() => initialParam("workspace")); + const [selectedSessionId, setSelectedSessionId] = useState(() => initialParam("session")); + const snapshotSignatures = useRef >({}); + + const updateSnapshot = ( + key: string, + next: T, + apply: (value: T) => void + ) => { + let signature = ""; + try { + signature = JSON.stringify(next) || ""; + } catch { + signature = String(Date.now()); + } + if (snapshotSignatures.current[key] === signature) return; + snapshotSignatures.current[key] = signature; + apply(next); + }; + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const urlView = params.get("view") as View | null; + const urlTask = params.get("task"); + const urlProject = params.get("project"); + const urlWorkspace = params.get("workspace"); + const urlSession = params.get("session"); + if (urlView) setView(normalizeView(urlView)); + if (urlTask) setActiveTaskId(urlTask); + if (urlProject) setSelectedProjectId(urlProject); + if (urlWorkspace) setSelectedWorkspaceId(urlWorkspace); + if (urlSession) setSelectedSessionId(urlSession); + const onPop = () => { + const next = new URLSearchParams(window.location.search); + setView(normalizeView(next.get("view") as View | null)); + setActiveTaskId(next.get("task") || ""); + setSelectedProjectId(next.get("project") || ""); + setSelectedWorkspaceId(next.get("workspace") || ""); + setSelectedSessionId(next.get("session") || ""); + }; + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + const refreshTasks = async () => { + try { + const t = await api.tasks(); + updateSnapshot("tasks", t.tasks || [], setTasks); + setActiveTaskId((current) => current || t.tasks?.[0]?.id || ""); + } catch {} + }; + + const refreshWorkspaceGraph = async () => { + try { + const graph = selectedWorkspaceId + ? await api.workspaceGraph(selectedWorkspaceId, selectedProjectId || undefined) + : await api.workspaceGraph(); + updateSnapshot("workspaceGraph", graph, setWorkspaceGraph); + } catch {} + }; + + const refreshOrgGraph = async () => { + try { + // Active-only on the canvas — no stale or paused sessions clutter the + // FOLDERS view. Slice-1 server gates on ?active=true. + const g = await api.orgGraph(undefined, { active: true }); + updateSnapshot("orgGraph", g, setOrgGraph); + } catch {} + }; + + const refreshViewer = async () => { + try { + const v = await api.me(); + updateSnapshot("viewer", v, setViewer); + } catch {} + }; + + const refreshRouterStats = async () => { + try { + const s = await api.routerStats(); + updateSnapshot("routerStats", s, setRouterStats); + setTokensSaved(s.sessionTokensSaved || 0); + } catch {} + }; + + const refreshInbox = async () => { + try { + const box = await api.inbox( + viewer?.team_id ? { team: viewer.team_id, user: viewer.user_id } : { user: viewer?.user_id } + ); + const total = + (box.totals?.proposals || 0) + + (box.totals?.findings || 0) + + (box.totals?.conflicts || 0); + setInboxCount(total); + setConflictCount(box.totals?.conflicts || 0); + } catch {} + }; + + const refreshProjects = async () => { + try { + const snapshot = await api.workspaces(); + updateSnapshot("projectIndex", snapshot, setProjectIndex); + setSelectedWorkspaceId((current) => current || snapshot.currentWorkspaceId || snapshot.workspaces?.[0]?.id || ""); + setSelectedProjectId((current) => current || snapshot.currentProjectId || snapshot.workspaces?.[0]?.projects?.[0]?.id || ""); + setSelectedSessionId((current) => current || snapshot.currentSessionId || ""); + } catch {} + }; + + useEffect(() => { + void refreshViewer(); + void refreshInbox(); + if (!isProductView(view)) { + void refreshRouterStats(); + } + if (isGraphOnlyView(view)) { + void refreshOrgGraph(); + } else if (isSelfHydratedView(view)) { + return; + } else if (!isProductView(view)) { + void refreshTasks(); + void refreshOrgGraph(); + void refreshProjects().then(() => refreshWorkspaceGraph()); + void (async () => { + try { + const m = await api.listMemories(); + setMemoryCount(m.engrams?.length || 0); + setMemoriesCache(m.engrams || []); + } catch {} + })(); + } + }, []); + + useEffect(() => { + const timer = window.setInterval(() => { + if (isProductView(view)) { + void refreshInbox(); + return; + } + if (isSelfHydratedView(view)) { + void refreshRouterStats(); + void refreshInbox(); + return; + } + if (isGraphOnlyView(view)) { + void refreshOrgGraph(); + void refreshInbox(); + return; + } + void refreshTasks(); + void refreshProjects(); + void refreshWorkspaceGraph(); + void refreshOrgGraph(); + void refreshRouterStats(); + void refreshInbox(); + }, GLOBAL_REFRESH_MS); + return () => window.clearInterval(timer); + }, [view, selectedWorkspaceId, selectedProjectId, viewer?.team_id]); + + useEffect(() => { + if (isProductView(view)) return; + if (isSelfHydratedView(view)) { + void refreshRouterStats(); + void refreshInbox(); + return; + } + if (isGraphOnlyView(view)) { + void refreshOrgGraph(); + return; + } + void refreshTasks(); + void refreshProjects().then(() => refreshWorkspaceGraph()); + void refreshOrgGraph(); + void (async () => { + try { + const m = await api.listMemories(); + setMemoryCount(m.engrams?.length || 0); + setMemoriesCache(m.engrams || []); + } catch {} + })(); + }, [view]); + + const go = (v: View, taskId?: string) => { + const targetView = normalizeView(v); + setTransitioning(true); + setTimeout(() => { + if (taskId) setActiveTaskId(taskId); + setView(targetView); + const params = new URLSearchParams(window.location.search); + params.set("view", targetView); + if (taskId || activeTaskId) params.set("task", taskId || activeTaskId); + else params.delete("task"); + + if (selectedProjectId) params.set("project", selectedProjectId); + else params.delete("project"); + + if (selectedWorkspaceId) params.set("workspace", selectedWorkspaceId); + else params.delete("workspace"); + + if (selectedSessionId) params.set("session", selectedSessionId); + else params.delete("session"); + + const qs = params.toString(); + const next = qs ? `?${qs}` : ""; + const hash = + targetView === "context" && window.location.hash.startsWith("#vault") + ? window.location.hash + : ""; + window.history.pushState({}, "", `${window.location.pathname}${next}${hash}`); + setTransitioning(false); + }, 140); + }; + + const handleSelectTask = (id: string) => go("workspace", id); + const handleSelectSession = (sessionId: string, taskId?: string | null) => { + if (sessionId) setSelectedSessionId(sessionId); + if (taskId) setActiveTaskId(taskId); + go("workspace", taskId || undefined); + }; + const handleSelectWorkspace = (workspaceId: string) => { + setSelectedWorkspaceId(workspaceId); + setSelectedProjectId(""); + setSelectedSessionId(""); + go("workspace"); + }; + const handleSelectProject = (projectId: string, workspaceId?: string | null) => { + if (workspaceId) setSelectedWorkspaceId(workspaceId); + setSelectedProjectId(projectId); + setSelectedSessionId(""); + go("workspace"); + }; + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + params.set("view", view); + if (activeTaskId) params.set("task", activeTaskId); else params.delete("task"); + if (selectedProjectId) params.set("project", selectedProjectId); else params.delete("project"); + if (selectedWorkspaceId) params.set("workspace", selectedWorkspaceId); else params.delete("workspace"); + if (selectedSessionId) params.set("session", selectedSessionId); else params.delete("session"); + + const qs = params.toString(); + const next = `${window.location.pathname}${qs ? "?" + qs : ""}`; + if (next !== `${window.location.pathname}${window.location.search}`) { + window.history.replaceState({}, "", next); + } + }, [view, activeTaskId, selectedProjectId, selectedWorkspaceId, selectedSessionId]); + + const handleCreateWorkspace = async (name: string) => { + await api.createWorkspaceRoot(name); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + + const handleCreateProject = async ( + workspaceId: string, + payload: { name: string; description?: string; default_runtime?: string; scope_rules?: { path_prefix: string; label?: string }[] } + ) => { + await api.createProject(workspaceId, payload); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + + const handleUpdateProject = async ( + projectId: string, + payload: { name?: string; description?: string; default_runtime?: string; scope_rules?: { path_prefix: string; label?: string }[] } + ) => { + await api.updateProject(projectId, payload); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + + const handleAddWorkspaceFolder = async (workspaceId: string, path: string, label?: string) => { + await api.addWorkspaceFolder(workspaceId, path, label); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + + const handleUpdateWorkspace = async (workspaceId: string, label: string) => { + await api.updateWorkspace(workspaceId, { label }); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + + const handleRemoveWorkspaceFolder = async (workspaceId: string, path: string) => { + await api.removeWorkspaceFolder(workspaceId, path); + await refreshProjects(); + await refreshWorkspaceGraph(); + }; + + const handleAddTask = async (title: string) => { + try { + const res = await api.createTask(title); + setTasks((t) => [...t, res.task]); + await refreshProjects(); + setTimeout(() => go("workspace", res.task.id), 80); + } catch (e) { + console.warn("createTask failed", e); + } + }; + + const handleAddMemory = async (text: string) => { + try { + await api.remember(text, "short-term", []); + setMemoryCount((c) => c + 1); + const m = await api.listMemories(); + setMemoriesCache(m.engrams || []); + } catch (e) { + console.warn("remember failed", e); + } + }; + + const handleLaunchFromNotepad = async ( + title: string, + runtime: "claude-code" | "codex", + workspaceId?: string, + permissionMode?: "standard" | "full-access", + projectId?: string + ) => { + const targetWorkspace = workspaceId || selectedWorkspaceId || projectIndex?.currentWorkspaceId; + if (!targetWorkspace || !title.trim()) return; + const res = await api.launchWorkspaceSession( + targetWorkspace, + runtime, + title.trim(), + runtime === "claude-code" ? permissionMode : undefined, + undefined, + projectId || selectedProjectId || projectIndex?.currentProjectId + ); + await refreshTasks(); + await refreshProjects(); + await refreshWorkspaceGraph(); + if (res.session_id) setSelectedSessionId(res.session_id); + if (res.task_id) setActiveTaskId(res.task_id); + go("workspace", res.task_id || undefined); + }; + + const handleAddTaskNote = async (taskId: string, content: string) => { + try { + await api.addTaskNote(taskId, content); + await refreshTasks(); + await refreshProjects(); + } catch (e) { + console.warn("addTaskNote failed", e); + } + }; + + const viewStyle: React.CSSProperties = { + opacity: transitioning ? 0 : 1, + transform: transitioning ? "translateY(3px)" : "translateY(0)", + transition: "opacity 0.14s ease, transform 0.14s ease", + flex: 1, + overflow: "hidden", + display: "flex", + flexDirection: "column", + }; + + const renderView = () => { + if (view === "command") + return go(target)} />; + if (view === "channel") + return ( + go("canvas")} + onLaunchSession={handleLaunchFromNotepad} + onOpenManager={(tab) => { + setManagerTab(tab || "workspaces"); + setManagerOpen(true); + }} + tweaks={tweaks} + /> + ); + if (view === "notepad") + return ( + go("workspace")} + onOpenTasks={() => go("tasks")} + tweaks={tweaks} + /> + ); + if (view === "tasks") + return ( + + ); + if (view === "workspace") + return ( + go("canvas")} + onNotepadOpen={() => go("context")} + onAddTaskNote={handleAddTaskNote} + onUpdateWorkspace={handleUpdateWorkspace} + onAddWorkspaceFolder={handleAddWorkspaceFolder} + onRemoveWorkspaceFolder={handleRemoveWorkspaceFolder} + onCreateProject={handleCreateProject} + onUpdateProject={handleUpdateProject} + onTasksRefresh={refreshTasks} + tweaks={tweaks} + /> + ); + if (view === "canvas") + return ( + + + ); + if (view === "memory" || view === "context") + return ( +++ + } + > ++++ Repo Brain +++ Loading folder canvas +++ Context Vault ++{ + if (teamId) window.location.hash = `#vault/${teamId}`; + go("context"); + }} + onOrgGraphChanged={() => { + void refreshOrgGraph(); + void refreshViewer(); + void refreshInbox(); + }} + onSelectTask={handleSelectTask} + onSelectSession={handleSelectSession} + onSelectWorkspace={handleSelectWorkspace} + onSelectProject={handleSelectProject} + onClose={() => go("workspace")} + tweaks={tweaks} + /> + + ); + if (view === "router" || view === "router/sessionshistory") + return go("canvas")} onOpenSetup={() => go("notepad")} />; + if (view === "handoff") + return ; + if (view === "replay") + return ; + if (view === "learnings") + return ; + if (view === "portability") + return ; + if (view === "conflicts") + return ; + return null; + }; + + useEffect(() => { + // Keyboard shortcut: Ctrl/Cmd+K shows tweaks panel. + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setShowTweaks((v) => !v); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const handleRefreshAll = () => { + void refreshViewer(); + void refreshTasks(); + void refreshProjects(); + void refreshWorkspaceGraph(); + void refreshOrgGraph(); + void refreshRouterStats(); + void refreshInbox(); + }; + + return ( + ++ ); +} diff --git a/dhee/ui/web/src/api.js b/dhee/ui/web/src/api.js new file mode 100644 index 0000000..cebd047 --- /dev/null +++ b/dhee/ui/web/src/api.js @@ -0,0 +1,356 @@ +const BASE = "/api"; +async function j(path, init) { + const res = await fetch(BASE + path, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers || {}), + }, + }); + if (!res.ok) + throw new Error(`${res.status} ${res.statusText}`); + return (await res.json()); +} +export const api = { + listMemories: () => j("/memories"), + remember: (content, tier, tags) => j("/memories", { + method: "POST", + body: JSON.stringify({ content, tier, tags }), + }), + archiveMemory: (id) => j(`/memories/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + routerStats: (agentId) => j(`/router/stats${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ""}`), + routerPolicy: () => j("/router/policy"), + routerTune: () => j("/router/tune", { method: "POST" }), + metaBuddhi: () => j("/meta-buddhi"), + evolution: () => j("/evolution"), + conflicts: () => j("/conflicts"), + resolveConflict: (id, action) => j(`/conflicts/${encodeURIComponent(id)}/resolve`, { + method: "POST", + body: JSON.stringify({ action }), + }), + resolveConflictDetailed: (id, payload) => j(`/conflicts/${encodeURIComponent(id)}/resolve`, { + method: "POST", + body: JSON.stringify(payload), + }), + tasks: () => j("/tasks"), + createTask: (title, harness) => j("/tasks", { + method: "POST", + body: JSON.stringify({ title, harness }), + }), + taskDetail: (taskId, limit = 24) => j(`/tasks/${encodeURIComponent(taskId)}?limit=${encodeURIComponent(String(limit))}`), + updateTaskStatus: (taskId, status) => j(`/tasks/${encodeURIComponent(taskId)}/status`, { + method: "POST", + body: JSON.stringify({ status }), + }), + addTaskNote: (taskId, content) => j(`/tasks/${encodeURIComponent(taskId)}/notes`, { + method: "POST", + body: JSON.stringify({ content }), + }), + workspaceGraph: (workspaceId, projectId) => j(`/workspace/graph${workspaceId + ? `?workspace_id=${encodeURIComponent(workspaceId)}${projectId ? `&project_id=${encodeURIComponent(projectId)}` : ""}` + : ""}`), + projects: () => j("/workspaces"), + workspaces: () => j("/workspaces"), + createWorkspaceRoot: (name, description) => j("/workspaces", { + method: "POST", + body: JSON.stringify({ name, description }), + }), + createProject: (workspaceId, payload) => j(`/workspaces/${encodeURIComponent(workspaceId)}/projects`, { + method: "POST", + body: JSON.stringify(payload), + }), + updateProject: (projectId, payload) => j(`/projects/${encodeURIComponent(projectId)}`, { + method: "PATCH", + body: JSON.stringify(payload), + }), + projectSessions: (projectId) => j(`/projects/${encodeURIComponent(projectId)}/sessions`), + projectCanvas: (projectId) => j(`/projects/${encodeURIComponent(projectId)}/canvas`), + workspaceCanvas: (workspaceId) => j(`/workspaces/${encodeURIComponent(workspaceId)}/canvas`), + pickFolder: (prompt) => j("/folders/pick", { + method: "POST", + body: JSON.stringify({ prompt }), + }), + addWorkspaceFolder: (workspaceId, path, label) => j(`/workspaces/${encodeURIComponent(workspaceId)}/folders`, { + method: "POST", + body: JSON.stringify({ path, label }), + }), + removeWorkspaceFolder: (workspaceId, path) => j(`/workspaces/${encodeURIComponent(workspaceId)}/mounts?path=${encodeURIComponent(path)}`, { method: "DELETE" }), + updateWorkspace: (workspaceId, payload) => j(`/workspaces/${encodeURIComponent(workspaceId)}`, { + method: "PATCH", + body: JSON.stringify(payload), + }), + deleteWorkspace: (workspaceId) => j(`/workspaces/${encodeURIComponent(workspaceId)}`, { method: "DELETE" }), + deleteProject: (projectId) => j(`/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" }), + workspaceDetail: (workspaceId) => j(`/workspaces/${encodeURIComponent(workspaceId)}`), + workspaceSessions: (workspaceId) => j(`/workspaces/${encodeURIComponent(workspaceId)}/sessions`), + sessionDetail: (sessionId) => j(`/sessions/${encodeURIComponent(sessionId)}`), + launchWorkspaceSession: (workspaceId, runtime, title, permission_mode, task_id, project_id) => j(`/workspaces/${encodeURIComponent(workspaceId)}/sessions/launch`, { + method: "POST", + body: JSON.stringify({ runtime, title, permission_mode, task_id, project_id }), + }), + workspaceLineMessages: (workspaceId, opts) => j(`/workspaces/${encodeURIComponent(workspaceId)}/line/messages?${new URLSearchParams(Object.entries({ + project_id: opts?.project_id, + channel: opts?.channel, + cursor: opts?.cursor, + limit: opts?.limit ? String(opts.limit) : undefined, + }).filter((entry) => Boolean(entry[1]))).toString()}`), + publishWorkspaceLineMessage: (workspaceId, payload) => j(`/workspaces/${encodeURIComponent(workspaceId)}/line/messages`, { + method: "POST", + body: JSON.stringify(payload), + }), + uploadSessionAsset: async (sessionId, file, label) => { + const form = new FormData(); + form.append("file", file); + if (label) + form.append("label", label); + const res = await fetch(`${BASE}/sessions/${encodeURIComponent(sessionId)}/assets`, { + method: "POST", + body: form, + }); + if (!res.ok) + throw new Error(`${res.status} ${res.statusText}`); + return (await res.json()); + }, + listProjectAssets: (projectId) => j(`/projects/${encodeURIComponent(projectId)}/assets`), + listWorkspaceAssets: (workspaceId, includeProjectAssets = true) => j(`/workspaces/${encodeURIComponent(workspaceId)}/assets?include_project_assets=${includeProjectAssets ? "true" : "false"}`), + uploadProjectAsset: async (projectId, file, opts) => { + const form = new FormData(); + form.append("file", file); + if (opts?.label) + form.append("label", opts.label); + if (opts?.folder) + form.append("folder", opts.folder); + const res = await fetch(`${BASE}/projects/${encodeURIComponent(projectId)}/assets`, { + method: "POST", + body: form, + }); + if (!res.ok) + throw new Error(`${res.status} ${res.statusText}`); + return (await res.json()); + }, + uploadWorkspaceAsset: async (workspaceId, file, opts) => { + const form = new FormData(); + form.append("file", file); + if (opts?.label) + form.append("label", opts.label); + if (opts?.folder) + form.append("folder", opts.folder); + if (opts?.project_id) + form.append("project_id", opts.project_id); + const res = await fetch(`${BASE}/workspaces/${encodeURIComponent(workspaceId)}/assets`, { + method: "POST", + body: form, + }); + if (!res.ok) + throw new Error(`${res.status} ${res.statusText}`); + return (await res.json()); + }, + deleteProjectAsset: (assetId) => j(`/project-assets/${encodeURIComponent(assetId)}`, { method: "DELETE" }), + fileContext: (path, workspaceId) => j(`/files/${encodeURIComponent(path)}/context${workspaceId ? `?workspace_id=${encodeURIComponent(workspaceId)}` : ""}`), + assetContext: (assetId) => j(`/assets/${encodeURIComponent(assetId)}/context`), + askAsset: (assetId, question) => j(`/assets/${encodeURIComponent(assetId)}/ask`, { + method: "POST", + body: JSON.stringify({ question }), + }), + runtimeStatus: () => j("/runtime-status"), + status: () => j("/status"), + memoryNow: () => j("/memory/now"), + captureTimeline: (limit = 16) => j(`/capture/timeline?limit=${encodeURIComponent(String(limit))}`), + launch: (runtime, taskId, title) => j("/launch", { + method: "POST", + body: JSON.stringify({ runtime, taskId, title }), + }), + apiKeys: () => j("/security/api-keys"), + storeApiKey: (provider, apiKey, label) => j("/security/api-keys", { + method: "POST", + body: JSON.stringify({ provider, apiKey, label }), + }), + rotateApiKey: (provider, apiKey, label) => j(`/security/api-keys/${encodeURIComponent(provider)}/rotate`, { + method: "POST", + body: JSON.stringify({ apiKey, label }), + }), + me: () => j("/me"), + continuity: () => j("/continuity"), + orgGraph: (org, opts) => { + const qs = new URLSearchParams(); + if (org) + qs.set("org", org); + if (opts?.active) + qs.set("active", "true"); + const q = qs.toString(); + return j(`/org/graph${q ? `?${q}` : ""}`); + }, + routerSessions: (opts) => { + const qs = new URLSearchParams(); + if (opts?.active != null) + qs.set("active", opts.active ? "true" : "false"); + if (opts?.cursor) + qs.set("cursor", opts.cursor); + if (opts?.limit) + qs.set("limit", String(opts.limit)); + if (opts?.agent) + qs.set("agent", opts.agent); + const q = qs.toString(); + return j(`/router/sessions${q ? `?${q}` : ""}`); + }, + contextEntries: (repo, limit = 200) => { + const qs = new URLSearchParams(); + if (repo) + qs.set("repo", repo); + qs.set("limit", String(limit)); + return j(`/context/entries?${qs.toString()}`); + }, + contextPromote: (payload) => j("/context/promote", { method: "POST", body: JSON.stringify(payload) }), + contextDemote: (payload) => j("/context/demote", { method: "POST", body: JSON.stringify(payload) }), + localWorkspaces: () => j("/local/workspaces"), + localWorkspaceCreate: (payload) => j("/local/workspaces", { + method: "POST", + body: JSON.stringify(payload), + }), + localContextLinkFolder: (path) => j("/local-context/folders/link", { method: "POST", body: JSON.stringify({ path }) }), + localContextUnlinkFolder: (path) => j("/local-context/folders/unlink", { + method: "POST", + body: JSON.stringify({ path }), + }), + contextItems: (filters = {}) => { + const qs = new URLSearchParams(); + if (filters.team) + qs.set("team", filters.team); + if (filters.project) + qs.set("project", filters.project); + if (filters.scope) + qs.set("scope", filters.scope); + if (filters.kind) + qs.set("kind", filters.kind); + if (filters.limit) + qs.set("limit", String(filters.limit)); + const q = qs.toString(); + return j(`/context/items${q ? `?${q}` : ""}`); + }, + contextUsage: (filters = {}) => { + const qs = new URLSearchParams(); + if (filters.team) + qs.set("team", filters.team); + if (filters.project) + qs.set("project", filters.project); + if (filters.scope) + qs.set("scope", filters.scope); + if (filters.kind) + qs.set("kind", filters.kind); + if (filters.limit) + qs.set("limit", String(filters.limit)); + const q = qs.toString(); + return j(`/context/usage${q ? `?${q}` : ""}`); + }, + commandCenter: () => j("/ui/command-center"), + proofReplay: (limit = 80) => j(`/ui/proof-replay?limit=${encodeURIComponent(String(limit))}`), + handoffUi: () => j("/ui/handoff"), + learningsUi: (limit = 120) => j(`/ui/learnings?limit=${encodeURIComponent(String(limit))}`), + promoteLearning: (id, payload) => j(`/ui/learnings/${encodeURIComponent(id)}/promote`, { + method: "POST", + body: JSON.stringify(payload || { approved_by: "dhee-ui" }), + }), + rejectLearning: (id, payload) => j(`/ui/learnings/${encodeURIComponent(id)}/reject`, { + method: "POST", + body: JSON.stringify(payload || { reason: "rejected in Dhee UI" }), + }), + portabilityUi: () => j("/ui/portability"), + exportPackUi: (payload) => j("/ui/portability/export", { + method: "POST", + body: JSON.stringify(payload || {}), + }), + importPackDryRunUi: (payload) => j("/ui/portability/import-dry-run", { + method: "POST", + body: JSON.stringify(payload), + }), + upsertContext: (payload) => j("/context", { + method: "POST", + body: JSON.stringify(payload), + }), + proposeContext: (payload) => j("/proposals", { + method: "POST", + body: JSON.stringify(payload), + }), + approveProposal: (contextId, reviewerUserId) => j(`/proposals/${encodeURIComponent(contextId)}/approve`, { + method: "POST", + body: JSON.stringify({ reviewer_user_id: reviewerUserId }), + }), + rejectProposal: (contextId, reviewerUserId) => j(`/proposals/${encodeURIComponent(contextId)}/reject`, { + method: "POST", + body: JSON.stringify({ reviewer_user_id: reviewerUserId }), + }), + inbox: (filter = {}) => { + const qs = new URLSearchParams(); + if (filter.team) + qs.set("team", filter.team); + if (filter.user) + qs.set("user", filter.user); + const q = qs.toString(); + return j(`/inbox${q ? `?${q}` : ""}`); + }, + resolveFinding: (findingId, resolvedBy) => j(`/findings/${encodeURIComponent(findingId)}/resolve`, { + method: "POST", + body: JSON.stringify({ resolved_by: resolvedBy }), + }), + backlinks: (contextId, limit = 50) => j(`/backlinks?context_id=${encodeURIComponent(contextId)}&limit=${encodeURIComponent(String(limit))}`), + setIntegration: (payload) => j("/integrations", { + method: "POST", + body: JSON.stringify(payload), + }), + teamJoin: (payload) => j("/team-join", { + method: "POST", + body: JSON.stringify(payload), + }), + localContextAddFolder: (payload) => j("/local-context/folders", { + method: "POST", + body: JSON.stringify(payload), + }), + localContextShareFolder: (payload) => j("/local-context/folders/share", { + method: "POST", + body: JSON.stringify(payload), + }), + enterpriseSetWorkspace: (payload) => j("/workspace", { + method: "POST", + body: JSON.stringify(payload), + }), + enterpriseResetWorkspace: () => j("/workspace/reset", { + method: "POST", + body: "{}", + }), + enterpriseCreateProject: (payload) => j("/projects", { + method: "POST", + body: JSON.stringify(payload), + }), + enterpriseDeleteProject: (projectId) => j(`/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" }), + enterpriseCreateProjectTeam: (projectId, payload) => j(`/projects/${encodeURIComponent(projectId)}/teams`, { + method: "POST", + body: JSON.stringify(payload), + }), + enterpriseAddProjectFolder: (projectId, payload) => j(`/projects/${encodeURIComponent(projectId)}/folders`, { + method: "POST", + body: JSON.stringify(payload), + }), + enterpriseAddTeamFolder: (teamId, payload) => j(`/teams/${encodeURIComponent(teamId)}/folders`, { + method: "POST", + body: JSON.stringify(payload), + }), + enterpriseRemoveFolder: (mappingId) => j(`/folders/${encodeURIComponent(mappingId)}`, { method: "DELETE" }), + enterpriseAddTeamCollaborator: (teamId, targetTeamId) => j(`/teams/${encodeURIComponent(teamId)}/collaborators`, { + method: "POST", + body: JSON.stringify({ target_team_id: targetTeamId }), + }), + enterpriseExtractProject: (projectId) => j(`/projects/${encodeURIComponent(projectId)}/extract`, { + method: "POST", + body: "{}", + }), + enterpriseExtractTeam: (teamId) => j(`/teams/${encodeURIComponent(teamId)}/extract`, { + method: "POST", + body: "{}", + }), + pickFolderPath: (prompt) => j("/folders/pick", { + method: "POST", + body: JSON.stringify({ prompt }), + }), +}; diff --git a/dhee/ui/web/src/api.ts b/dhee/ui/web/src/api.ts new file mode 100644 index 0000000..2c785d8 --- /dev/null +++ b/dhee/ui/web/src/api.ts @@ -0,0 +1,728 @@ +import type { + CaptureTimelineItem, + ApiKeyProviderStatus, + ConflictSnapshot, + Engram, + EvolutionEvent, + MemoryNowSnapshot, + MetaBuddhiSnapshot, + PolicyRow, + ProjectAsset, + RuntimeStatusCard, + RouterStats, + ProjectIndexSnapshot, + ProjectSummary, + WorkspaceLineMessage, + SessionDetailSnapshot, + AssetContextSummary, + FileContextSummary, + SessionAsset, + TaskDetailSnapshot, + SankhyaTask, + WorkspaceDetailSnapshot, + WorkspaceGraphSnapshot, + Viewer, + OrgGraphSnapshot, + ContextItem, + Proposal, + Finding, + InboxSnapshot, + BacklinksSnapshot, + ContinuitySnapshot, + LocalWorkspace, + RouterSessionsPage, + ContextUsageSnapshot, + ContextEntriesSnapshot, +} from "./types"; + +const BASE = "/api"; + +async function jgo(v)} + conflictCount={inboxCount || conflictCount} + /> + ++setShowTweaks((v) => !v)} + onResetWorkspace={async () => { + if ( + !window.confirm( + "Reset workspace? Deletes projects, teams, folders, context items, proposals and findings for this org. Memory engrams are unaffected." + ) + ) + return; + try { + await api.enterpriseResetWorkspace(); + } finally { + handleRefreshAll(); + } + }} + /> + {renderView()}++ setManagerOpen(false)} + projectIndex={projectIndex} + initialWorkspaceId={selectedWorkspaceId} + initialTab={managerTab} + onChanged={async () => { + await refreshProjects(); + await refreshWorkspaceGraph(); + }} + /> + (path: string, init?: RequestInit): Promise { + const res = await fetch(BASE + path, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers || {}), + }, + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return (await res.json()) as T; +} + +export const api = { + listMemories: () => + j<{ live: boolean; engrams: Engram[]; count: number; error?: string }>( + "/memories" + ), + remember: (content: string, tier?: string, tags?: string[]) => + j<{ ok: boolean }>("/memories", { + method: "POST", + body: JSON.stringify({ content, tier, tags }), + }), + archiveMemory: (id: string) => + j<{ ok: boolean }>(`/memories/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + routerStats: (agentId?: string) => + j ( + `/router/stats${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ""}` + ), + routerPolicy: () => + j<{ live: boolean; policies: PolicyRow[]; error?: string }>( + "/router/policy" + ), + routerTune: () => + j<{ + ok: boolean; + applied: number; + human: string; + suggestions: { + tool: string; + intent: string; + from: string; + to: string; + reason: string; + }[]; + }>("/router/tune", { method: "POST" }), + metaBuddhi: () => j ("/meta-buddhi"), + evolution: () => + j<{ live: boolean; events: EvolutionEvent[] }>("/evolution"), + conflicts: () => j ("/conflicts"), + resolveConflict: (id: string, action: string) => + j<{ ok: boolean }>(`/conflicts/${encodeURIComponent(id)}/resolve`, { + method: "POST", + body: JSON.stringify({ action }), + }), + resolveConflictDetailed: ( + id: string, + payload: { action: string; merged_content?: string; reason?: string } + ) => + j<{ ok: boolean; result?: Record }>( + `/conflicts/${encodeURIComponent(id)}/resolve`, + { + method: "POST", + body: JSON.stringify(payload), + } + ), + tasks: () => j<{ live: boolean; tasks: SankhyaTask[] }>("/tasks"), + createTask: (title: string, harness?: string | null) => + j<{ ok: boolean; task: SankhyaTask }>("/tasks", { + method: "POST", + body: JSON.stringify({ title, harness }), + }), + taskDetail: (taskId: string, limit = 24) => + j ( + `/tasks/${encodeURIComponent(taskId)}?limit=${encodeURIComponent(String(limit))}` + ), + updateTaskStatus: (taskId: string, status: string) => + j<{ ok: boolean; task: SankhyaTask }>( + `/tasks/${encodeURIComponent(taskId)}/status`, + { + method: "POST", + body: JSON.stringify({ status }), + } + ), + addTaskNote: (taskId: string, content: string) => + j<{ ok: boolean; task: SankhyaTask; result: Record }>( + `/tasks/${encodeURIComponent(taskId)}/notes`, + { + method: "POST", + body: JSON.stringify({ content }), + } + ), + workspaceGraph: (workspaceId?: string, projectId?: string) => + j ( + `/workspace/graph${ + workspaceId + ? `?workspace_id=${encodeURIComponent(workspaceId)}${ + projectId ? `&project_id=${encodeURIComponent(projectId)}` : "" + }` + : "" + }` + ), + projects: () => j ("/workspaces"), + workspaces: () => j ("/workspaces"), + createWorkspaceRoot: (name: string, description?: string) => + j<{ ok: boolean; workspace: WorkspaceDetailSnapshot["workspace"] }>("/workspaces", { + method: "POST", + body: JSON.stringify({ name, description }), + }), + createProject: (workspaceId: string, payload: { name: string; description?: string; default_runtime?: string; color?: string; icon?: string; scope_rules?: { path_prefix: string; label?: string }[] }) => + j<{ ok: boolean; project: ProjectSummary }>( + `/workspaces/${encodeURIComponent(workspaceId)}/projects`, + { + method: "POST", + body: JSON.stringify(payload), + } + ), + updateProject: (projectId: string, payload: { name?: string; description?: string; default_runtime?: string; color?: string; icon?: string; scope_rules?: { path_prefix: string; label?: string }[] }) => + j<{ ok: boolean; project: ProjectSummary }>( + `/projects/${encodeURIComponent(projectId)}`, + { + method: "PATCH", + body: JSON.stringify(payload), + } + ), + projectSessions: (projectId: string) => + j<{ live: boolean; sessions: SessionDetailSnapshot["session"][] }>( + `/projects/${encodeURIComponent(projectId)}/sessions` + ), + projectCanvas: (projectId: string) => + j (`/projects/${encodeURIComponent(projectId)}/canvas`), + workspaceCanvas: (workspaceId: string) => + j (`/workspaces/${encodeURIComponent(workspaceId)}/canvas`), + pickFolder: (prompt?: string) => + j<{ ok: boolean; cancelled?: boolean; path?: string }>("/folders/pick", { + method: "POST", + body: JSON.stringify({ prompt }), + }), + addWorkspaceFolder: (workspaceId: string, path: string, label?: string) => + j<{ ok: boolean; workspace: WorkspaceDetailSnapshot["workspace"] }>( + `/workspaces/${encodeURIComponent(workspaceId)}/folders`, + { + method: "POST", + body: JSON.stringify({ path, label }), + } + ), + removeWorkspaceFolder: (workspaceId: string, path: string) => + j<{ ok: boolean; workspace: WorkspaceDetailSnapshot["workspace"] }>( + `/workspaces/${encodeURIComponent(workspaceId)}/mounts?path=${encodeURIComponent(path)}`, + { method: "DELETE" } + ), + updateWorkspace: ( + workspaceId: string, + payload: { label?: string; description?: string }, + ) => + j<{ ok: boolean; workspace: WorkspaceDetailSnapshot["workspace"] }>( + `/workspaces/${encodeURIComponent(workspaceId)}`, + { + method: "PATCH", + body: JSON.stringify(payload), + } + ), + deleteWorkspace: (workspaceId: string) => + j<{ ok: boolean; id: string }>( + `/workspaces/${encodeURIComponent(workspaceId)}`, + { method: "DELETE" }, + ), + deleteProject: (projectId: string) => + j<{ ok: boolean; id: string; workspace_id?: string }>( + `/projects/${encodeURIComponent(projectId)}`, + { method: "DELETE" }, + ), + workspaceDetail: (workspaceId: string) => + j (`/workspaces/${encodeURIComponent(workspaceId)}`), + workspaceSessions: (workspaceId: string) => + j<{ live: boolean; sessions: WorkspaceDetailSnapshot["sessions"] }>( + `/workspaces/${encodeURIComponent(workspaceId)}/sessions` + ), + sessionDetail: (sessionId: string) => + j (`/sessions/${encodeURIComponent(sessionId)}`), + launchWorkspaceSession: ( + workspaceId: string, + runtime: string, + title?: string, + permission_mode?: string, + task_id?: string, + project_id?: string + ) => + j<{ + ok: boolean; + project_id?: string; + workspace_id?: string; + session_id?: string; + task_id?: string; + runtime: string; + permission_mode?: string; + launch_command: string; + control_state: string; + }>(`/workspaces/${encodeURIComponent(workspaceId)}/sessions/launch`, { + method: "POST", + body: JSON.stringify({ runtime, title, permission_mode, task_id, project_id }), + }), + workspaceLineMessages: ( + workspaceId: string, + opts?: { project_id?: string; channel?: string; cursor?: string; limit?: number } + ) => + j<{ live: boolean; messages: WorkspaceLineMessage[]; nextCursor?: string }>( + `/workspaces/${encodeURIComponent(workspaceId)}/line/messages?${new URLSearchParams( + Object.entries({ + project_id: opts?.project_id, + channel: opts?.channel, + cursor: opts?.cursor, + limit: opts?.limit ? String(opts.limit) : undefined, + }).filter((entry): entry is [string, string] => Boolean(entry[1])) + ).toString()}` + ), + publishWorkspaceLineMessage: ( + workspaceId: string, + payload: { + project_id?: string; + target_project_id?: string; + channel?: string; + session_id?: string; + task_id?: string; + message_kind?: string; + title?: string; + body: string; + metadata?: Record ; + } + ) => + j<{ ok: boolean; message: WorkspaceLineMessage; suggestedTask?: SankhyaTask | null }>( + `/workspaces/${encodeURIComponent(workspaceId)}/line/messages`, + { + method: "POST", + body: JSON.stringify(payload), + } + ), + uploadSessionAsset: async (sessionId: string, file: File, label?: string) => { + const form = new FormData(); + form.append("file", file); + if (label) form.append("label", label); + const res = await fetch(`${BASE}/sessions/${encodeURIComponent(sessionId)}/assets`, { + method: "POST", + body: form, + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return (await res.json()) as { ok: boolean; asset: SessionAsset }; + }, + listProjectAssets: (projectId: string) => + j<{ live: boolean; project_id: string; workspace_id: string; assets: ProjectAsset[] }>( + `/projects/${encodeURIComponent(projectId)}/assets`, + ), + listWorkspaceAssets: (workspaceId: string, includeProjectAssets = true) => + j<{ live: boolean; workspace_id: string; assets: ProjectAsset[] }>( + `/workspaces/${encodeURIComponent(workspaceId)}/assets?include_project_assets=${ + includeProjectAssets ? "true" : "false" + }`, + ), + uploadProjectAsset: async (projectId: string, file: File, opts?: { label?: string; folder?: string }) => { + const form = new FormData(); + form.append("file", file); + if (opts?.label) form.append("label", opts.label); + if (opts?.folder) form.append("folder", opts.folder); + const res = await fetch(`${BASE}/projects/${encodeURIComponent(projectId)}/assets`, { + method: "POST", + body: form, + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return (await res.json()) as { ok: boolean; asset: ProjectAsset }; + }, + uploadWorkspaceAsset: async ( + workspaceId: string, + file: File, + opts?: { label?: string; folder?: string; project_id?: string }, + ) => { + const form = new FormData(); + form.append("file", file); + if (opts?.label) form.append("label", opts.label); + if (opts?.folder) form.append("folder", opts.folder); + if (opts?.project_id) form.append("project_id", opts.project_id); + const res = await fetch(`${BASE}/workspaces/${encodeURIComponent(workspaceId)}/assets`, { + method: "POST", + body: form, + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return (await res.json()) as { ok: boolean; asset: ProjectAsset }; + }, + deleteProjectAsset: (assetId: string) => + j<{ ok: boolean }>(`/project-assets/${encodeURIComponent(assetId)}`, { method: "DELETE" }), + fileContext: (path: string, workspaceId?: string) => + j<{ live: boolean } & FileContextSummary>( + `/files/${encodeURIComponent(path)}/context${workspaceId ? `?workspace_id=${encodeURIComponent(workspaceId)}` : ""}` + ), + assetContext: (assetId: string) => + j<{ live: boolean } & AssetContextSummary>(`/assets/${encodeURIComponent(assetId)}/context`), + askAsset: (assetId: string, question: string) => + j<{ + ok: boolean; + launch: { + task_id?: string; + session_id?: string; + launch_command?: string; + }; + question: string; + }>(`/assets/${encodeURIComponent(assetId)}/ask`, { + method: "POST", + body: JSON.stringify({ question }), + }), + runtimeStatus: () => + j<{ live: boolean; repo: string; runtimes: RuntimeStatusCard[]; error?: string }>( + "/runtime-status" + ), + status: () => + j<{ + ok: boolean; + router?: { sessions: number; calls: number; tokensSaved: number }; + dhee_data_dir?: string; + error?: string; + }>("/status"), + memoryNow: () => j ("/memory/now"), + captureTimeline: (limit = 16) => + j<{ items: CaptureTimelineItem[] }>( + `/capture/timeline?limit=${encodeURIComponent(String(limit))}` + ), + launch: (runtime: string, taskId?: string, title?: string) => + j<{ ok: boolean; command: string; message: string; task?: SankhyaTask; taskId?: string }>( + "/launch", + { + method: "POST", + body: JSON.stringify({ runtime, taskId, title }), + } + ), + apiKeys: () => + j<{ live: boolean; providers: ApiKeyProviderStatus[]; error?: string }>( + "/security/api-keys" + ), + storeApiKey: (provider: string, apiKey: string, label?: string) => + j<{ ok: boolean; provider: ApiKeyProviderStatus }>("/security/api-keys", { + method: "POST", + body: JSON.stringify({ provider, apiKey, label }), + }), + rotateApiKey: (provider: string, apiKey: string, label?: string) => + j<{ ok: boolean; provider: ApiKeyProviderStatus }>( + `/security/api-keys/${encodeURIComponent(provider)}/rotate`, + { + method: "POST", + body: JSON.stringify({ apiKey, label }), + } + ), + me: () => j ("/me"), + continuity: () => j ("/continuity"), + orgGraph: (org?: string, opts?: { active?: boolean }) => { + const qs = new URLSearchParams(); + if (org) qs.set("org", org); + if (opts?.active) qs.set("active", "true"); + const q = qs.toString(); + return j (`/org/graph${q ? `?${q}` : ""}`); + }, + routerSessions: (opts?: { + active?: boolean; + cursor?: string; + limit?: number; + agent?: string; + }) => { + const qs = new URLSearchParams(); + if (opts?.active != null) qs.set("active", opts.active ? "true" : "false"); + if (opts?.cursor) qs.set("cursor", opts.cursor); + if (opts?.limit) qs.set("limit", String(opts.limit)); + if (opts?.agent) qs.set("agent", opts.agent); + const q = qs.toString(); + return j (`/router/sessions${q ? `?${q}` : ""}`); + }, + contextEntries: (repo?: string, limit = 200) => { + const qs = new URLSearchParams(); + if (repo) qs.set("repo", repo); + qs.set("limit", String(limit)); + return j (`/context/entries?${qs.toString()}`); + }, + contextPromote: (payload: { + memory_id: string; + repo?: string; + kind?: string; + title?: string; + }) => + j<{ ok: boolean; entry: Record ; repo_root: string }>( + "/context/promote", + { method: "POST", body: JSON.stringify(payload) }, + ), + contextDemote: (payload: { entry_id: string; repo?: string }) => + j<{ ok: boolean; memory_id: string; entry: Record }>( + "/context/demote", + { method: "POST", body: JSON.stringify(payload) }, + ), + localWorkspaces: () => + j<{ workspaces: LocalWorkspace[]; max_workspaces: number }>( + "/local/workspaces", + ), + localWorkspaceCreate: (payload: { id?: string; name?: string }) => + j<{ ok: boolean; workspace: LocalWorkspace }>("/local/workspaces", { + method: "POST", + body: JSON.stringify(payload), + }), + localContextLinkFolder: (path: string) => + j<{ ok: boolean; folder: Record ; link: { linked: boolean } }>( + "/local-context/folders/link", + { method: "POST", body: JSON.stringify({ path }) }, + ), + localContextUnlinkFolder: (path: string) => + j<{ ok: boolean }>("/local-context/folders/unlink", { + method: "POST", + body: JSON.stringify({ path }), + }), + contextItems: (filters: { team?: string; project?: string; scope?: string; kind?: string; limit?: number } = {}) => { + const qs = new URLSearchParams(); + if (filters.team) qs.set("team", filters.team); + if (filters.project) qs.set("project", filters.project); + if (filters.scope) qs.set("scope", filters.scope); + if (filters.kind) qs.set("kind", filters.kind); + if (filters.limit) qs.set("limit", String(filters.limit)); + const q = qs.toString(); + return j<{ live: boolean; items: ContextItem[]; error?: string }>( + `/context/items${q ? `?${q}` : ""}` + ); + }, + contextUsage: (filters: { team?: string; project?: string; scope?: string; kind?: string; limit?: number } = {}) => { + const qs = new URLSearchParams(); + if (filters.team) qs.set("team", filters.team); + if (filters.project) qs.set("project", filters.project); + if (filters.scope) qs.set("scope", filters.scope); + if (filters.kind) qs.set("kind", filters.kind); + if (filters.limit) qs.set("limit", String(filters.limit)); + const q = qs.toString(); + return j (`/context/usage${q ? `?${q}` : ""}`); + }, + commandCenter: () => j >("/ui/command-center"), + proofReplay: (limit = 80) => + j >(`/ui/proof-replay?limit=${encodeURIComponent(String(limit))}`), + handoffUi: () => j >("/ui/handoff"), + learningsUi: (limit = 120) => + j >(`/ui/learnings?limit=${encodeURIComponent(String(limit))}`), + promoteLearning: (id: string, payload?: { scope?: string; repo?: string; approved_by?: string }) => + j >(`/ui/learnings/${encodeURIComponent(id)}/promote`, { + method: "POST", + body: JSON.stringify(payload || { approved_by: "dhee-ui" }), + }), + rejectLearning: (id: string, payload?: { reason?: string }) => + j >(`/ui/learnings/${encodeURIComponent(id)}/reject`, { + method: "POST", + body: JSON.stringify(payload || { reason: "rejected in Dhee UI" }), + }), + portabilityUi: () => j >("/ui/portability"), + exportPackUi: (payload?: { output_path?: string; user_id?: string; repo?: string }) => + j >("/ui/portability/export", { + method: "POST", + body: JSON.stringify(payload || {}), + }), + importPackDryRunUi: (payload: { input_path: string; user_id?: string; repo?: string }) => + j >("/ui/portability/import-dry-run", { + method: "POST", + body: JSON.stringify(payload), + }), + upsertContext: (payload: { + context_id?: string; + title: string; + content: string; + scope: string; + kind?: string; + project_id?: string; + team_id?: string; + user_id?: string; + tags?: string[]; + summary?: string; + metadata?: Record ; + }) => + j<{ ok: boolean; item: ContextItem }>("/context", { + method: "POST", + body: JSON.stringify(payload), + }), + proposeContext: (payload: { + title: string; + content: string; + scope: string; + kind: string; + proposed_by_user_id: string; + project_id?: string; + team_id?: string; + supersedes_id?: string; + tags?: string[]; + metadata?: Record ; + }) => + j<{ ok: boolean; proposal: Proposal }>("/proposals", { + method: "POST", + body: JSON.stringify(payload), + }), + approveProposal: (contextId: string, reviewerUserId: string) => + j<{ ok: boolean; proposal: Proposal }>( + `/proposals/${encodeURIComponent(contextId)}/approve`, + { + method: "POST", + body: JSON.stringify({ reviewer_user_id: reviewerUserId }), + } + ), + rejectProposal: (contextId: string, reviewerUserId: string) => + j<{ ok: boolean; proposal: Proposal }>( + `/proposals/${encodeURIComponent(contextId)}/reject`, + { + method: "POST", + body: JSON.stringify({ reviewer_user_id: reviewerUserId }), + } + ), + inbox: (filter: { team?: string; user?: string } = {}) => { + const qs = new URLSearchParams(); + if (filter.team) qs.set("team", filter.team); + if (filter.user) qs.set("user", filter.user); + const q = qs.toString(); + return j (`/inbox${q ? `?${q}` : ""}`); + }, + resolveFinding: (findingId: string, resolvedBy?: string) => + j<{ ok: boolean; finding: Finding }>( + `/findings/${encodeURIComponent(findingId)}/resolve`, + { + method: "POST", + body: JSON.stringify({ resolved_by: resolvedBy }), + } + ), + backlinks: (contextId: string, limit = 50) => + j ( + `/backlinks?context_id=${encodeURIComponent(contextId)}&limit=${encodeURIComponent(String(limit))}` + ), + setIntegration: (payload: { + scope: string; + target_id: string; + type: string; + value: unknown; + metadata?: Record ; + }) => + j<{ ok: boolean; node: Record }>("/integrations", { + method: "POST", + body: JSON.stringify(payload), + }), + teamJoin: (payload: { + org_id: string; + project_id?: string; + team_id?: string; + role?: string; + repo_root?: string; + }) => + j<{ ok: boolean; joined: Record }>("/team-join", { + method: "POST", + body: JSON.stringify(payload), + }), + localContextAddFolder: (payload: { path: string; shared?: boolean }) => + j<{ ok: boolean; folder: Record }>("/local-context/folders", { + method: "POST", + body: JSON.stringify(payload), + }), + localContextShareFolder: (payload: { path: string; shared?: boolean }) => + j<{ ok: boolean; folder: Record }>("/local-context/folders/share", { + method: "POST", + body: JSON.stringify(payload), + }), + enterpriseSetWorkspace: (payload: { + name: string; + root_path?: string; + default_branch?: string; + }) => + j<{ ok: boolean; workspace: Record }>("/workspace", { + method: "POST", + body: JSON.stringify(payload), + }), + enterpriseResetWorkspace: () => + j<{ ok: boolean; deleted: Record }>("/workspace/reset", { + method: "POST", + body: "{}", + }), + enterpriseCreateProject: (payload: { + name: string; + project_id?: string; + description?: string; + }) => + j<{ ok: boolean; project: Record }>("/projects", { + method: "POST", + body: JSON.stringify(payload), + }), + enterpriseDeleteProject: (projectId: string) => + j<{ ok: boolean; project_id: string }>( + `/projects/${encodeURIComponent(projectId)}`, + { method: "DELETE" } + ), + enterpriseCreateProjectTeam: ( + projectId: string, + payload: { + name: string; + team_id?: string; + description?: string; + } + ) => + j<{ ok: boolean; team: Record }>( + `/projects/${encodeURIComponent(projectId)}/teams`, + { + method: "POST", + body: JSON.stringify(payload), + } + ), + enterpriseAddProjectFolder: ( + projectId: string, + payload: { + local_path?: string; + repo_url?: string; + label?: string; + kind?: string; + } + ) => + j<{ ok: boolean; mapping: Record }>( + `/projects/${encodeURIComponent(projectId)}/folders`, + { + method: "POST", + body: JSON.stringify(payload), + } + ), + enterpriseAddTeamFolder: ( + teamId: string, + payload: { + local_path?: string; + repo_url?: string; + label?: string; + kind?: string; + } + ) => + j<{ ok: boolean; mapping: Record }>( + `/teams/${encodeURIComponent(teamId)}/folders`, + { + method: "POST", + body: JSON.stringify(payload), + } + ), + enterpriseRemoveFolder: (mappingId: string) => + j<{ ok: boolean; mapping_id: string }>( + `/folders/${encodeURIComponent(mappingId)}`, + { method: "DELETE" } + ), + enterpriseAddTeamCollaborator: (teamId: string, targetTeamId: string) => + j<{ + ok: boolean; + team: Record ; + target_team: Record ; + collaborating_team_ids: string[]; + }>(`/teams/${encodeURIComponent(teamId)}/collaborators`, { + method: "POST", + body: JSON.stringify({ target_team_id: targetTeamId }), + }), + enterpriseExtractProject: (projectId: string) => + j<{ + ok: boolean; + project_id: string; + folders_seen: number; + files_seen: number; + files_extracted: number; + files_cached: number; + nodes_upserted: number; + edges_upserted: number; + errors: { path: string; error: string }[]; + }>(`/projects/${encodeURIComponent(projectId)}/extract`, { + method: "POST", + body: "{}", + }), + enterpriseExtractTeam: (teamId: string) => + j<{ + ok: boolean; + project_id: string; + team_id?: string | null; + folders_seen: number; + files_seen: number; + files_extracted: number; + files_cached: number; + nodes_upserted: number; + edges_upserted: number; + errors: { path: string; error: string }[]; + }>(`/teams/${encodeURIComponent(teamId)}/extract`, { + method: "POST", + body: "{}", + }), + pickFolderPath: (prompt?: string) => + j<{ ok: boolean; cancelled?: boolean; path?: string }>("/folders/pick", { + method: "POST", + body: JSON.stringify({ prompt }), + }), +}; diff --git a/dhee/ui/web/src/components/AssetDrawer.js b/dhee/ui/web/src/components/AssetDrawer.js new file mode 100644 index 0000000..61a3d8f --- /dev/null +++ b/dhee/ui/web/src/components/AssetDrawer.js @@ -0,0 +1,358 @@ +import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { api } from "../api"; +import { StatPill } from "./ui/StatPill"; +const MS_MINUTE = 60000; +const MS_HOUR = 3600000; +const MS_DAY = 86400000; +function fmtRelative(value) { + if (!value) + return ""; + const when = Date.parse(value); + if (Number.isNaN(when)) + return String(value); + const delta = Date.now() - when; + if (delta < MS_MINUTE) + return "just now"; + if (delta < MS_HOUR) + return `${Math.round(delta / MS_MINUTE)}m ago`; + if (delta < MS_DAY) + return `${Math.round(delta / MS_HOUR)}h ago`; + return `${Math.round(delta / MS_DAY)}d ago`; +} +function fmtSize(bytes) { + if (!bytes || bytes <= 0) + return "—"; + if (bytes < 1024) + return `${bytes} B`; + if (bytes < 1024 * 1024) + return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} +function extensionLabel(asset) { + const name = asset.name || ""; + const dot = name.lastIndexOf("."); + if (dot <= 0 || dot === name.length - 1) + return "file"; + return name.slice(dot + 1).toLowerCase().slice(0, 5); +} +function runtimeTone(runtime) { + const key = runtime.toLowerCase(); + if (key.includes("claude")) + return "#e06b3f"; + if (key.includes("codex")) + return "#1a1a1a"; + if (key.includes("cursor")) + return "#4d6cff"; + if (key.includes("browser")) + return "#1fa971"; + return "var(--ink3)"; +} +function ExtensionBadge({ asset }) { + const ext = extensionLabel(asset); + return (_jsx("div", { style: { + width: 38, + height: 44, + flexShrink: 0, + borderRadius: 4, + background: "rgba(20,16,10,0.04)", + border: "1px solid rgba(20,16,10,0.12)", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontFamily: "var(--mono)", + fontSize: 9, + fontWeight: 600, + color: "var(--ink2)", + letterSpacing: 0.4, + textTransform: "uppercase", + }, children: ext })); +} +function AssetResultRow({ result }) { + const runtime = String(result.harness || "dhee"); + const tool = String(result.tool_name || ""); + const kind = String(result.packet_kind || "").replace(/^routed_/, ""); + return (_jsxs("div", { style: { + display: "flex", + alignItems: "center", + gap: 8, + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink2)", + padding: "4px 0", + }, children: [_jsx("span", { style: { + width: 5, + height: 5, + borderRadius: "50%", + background: runtimeTone(runtime), + flexShrink: 0, + } }), _jsx("span", { style: { color: runtimeTone(runtime), minWidth: 60 }, children: runtime }), _jsx("span", { style: { color: "var(--ink3)" }, children: tool.toLowerCase() || kind }), _jsx("span", { style: { marginLeft: "auto", color: "var(--ink3)" }, children: fmtRelative(result.updated_at || result.created_at) })] })); +} +function AssetCard({ asset, onDelete, busyDelete, }) { + const [showResults, setShowResults] = useState(false); + const results = asset.results || []; + const processors = new Set(); + for (const r of results) + processors.add(String(r.harness || "dhee")); + return (_jsxs("div", { style: { + border: "1px solid var(--border)", + background: "white", + padding: 12, + borderRadius: 6, + display: "flex", + flexDirection: "column", + gap: 8, + }, children: [_jsxs("div", { style: { display: "flex", gap: 10, alignItems: "flex-start" }, children: [_jsx(ExtensionBadge, { asset: asset }), _jsxs("div", { style: { flex: 1, minWidth: 0 }, children: [_jsx("div", { style: { + fontSize: 13, + fontWeight: 600, + lineHeight: 1.3, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }, title: asset.name, children: asset.name }), _jsxs("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 0.3, + marginTop: 4, + display: "flex", + gap: 8, + }, children: [_jsx("span", { children: fmtSize(asset.size_bytes) }), asset.updated_at ? _jsxs("span", { children: ["uploaded ", fmtRelative(asset.updated_at)] }) : null] })] }), _jsx("button", { onClick: () => void onDelete(asset), disabled: busyDelete, title: "Remove asset", "aria-label": "Remove asset", style: { + width: 22, + height: 22, + display: "flex", + alignItems: "center", + justifyContent: "center", + border: "1px solid transparent", + borderRadius: 3, + background: "transparent", + color: "var(--ink3)", + cursor: busyDelete ? "not-allowed" : "pointer", + opacity: busyDelete ? 0.5 : 1, + padding: 0, + flexShrink: 0, + }, onMouseEnter: (e) => { + if (!busyDelete) { + e.currentTarget.style.background = "rgba(203,63,78,0.08)"; + e.currentTarget.style.color = "var(--rose)"; + } + }, onMouseLeave: (e) => { + e.currentTarget.style.background = "transparent"; + e.currentTarget.style.color = "var(--ink3)"; + }, children: _jsx("svg", { width: 12, height: 12, viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M6 6l12 12M18 6L6 18", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" }) }) })] }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }, children: [processors.size > 0 ? (_jsx(StatPill, { label: `${results.length} processed`, tone: "var(--green)" })) : (_jsx(StatPill, { label: "not yet processed" })), Array.from(processors) + .slice(0, 3) + .map((runtime) => (_jsx(StatPill, { label: runtime, tone: runtimeTone(runtime) }, runtime)))] }), results.length > 0 && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => setShowResults((v) => !v), style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + background: "transparent", + border: 0, + padding: 0, + textAlign: "left", + cursor: "pointer", + letterSpacing: 0.4, + }, children: showResults ? "▾ hide processing feed" : `▸ show processing feed (${results.length})` }), showResults && (_jsx("div", { style: { + display: "flex", + flexDirection: "column", + borderTop: "1px dashed var(--border)", + paddingTop: 6, + }, children: results.slice(0, 8).map((result) => (_jsx(AssetResultRow, { result: result }, result.id))) }))] }))] })); +} +export function AssetDrawer({ workspace, project, onActivity, }) { + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState("idle"); + const [statusMessage, setStatusMessage] = useState(""); + const [dragHover, setDragHover] = useState(false); + const [busyDeleteId, setBusyDeleteId] = useState(null); + const fileInputRef = useRef(null); + const scopeLabel = useMemo(() => { + if (project) + return project.name; + if (workspace) + return `${workspace.label || workspace.name} (workspace)`; + return "—"; + }, [project, workspace]); + const refresh = useCallback(async () => { + if (!workspace) { + setAssets([]); + return; + } + setLoading(true); + setError(null); + try { + const res = project + ? await api.listProjectAssets(project.id) + : await api.listWorkspaceAssets(workspace.id, false); + setAssets(res.assets || []); + } + catch (e) { + setError(String(e)); + } + finally { + setLoading(false); + } + }, [project, workspace]); + useEffect(() => { + void refresh(); + }, [refresh]); + // Light polling so the processing feed stays fresh without a full SSE + // implementation (PR 4). + useEffect(() => { + if (!workspace) + return; + const timer = window.setInterval(() => void refresh(), 5000); + return () => window.clearInterval(timer); + }, [refresh, workspace]); + const uploadFiles = useCallback(async (files) => { + if (!workspace) + return; + const list = Array.from(files); + if (list.length === 0) + return; + setStatus("uploading"); + setStatusMessage(`uploading ${list.length} file${list.length === 1 ? "" : "s"}…`); + setError(null); + try { + for (const file of list) { + if (project) { + await api.uploadProjectAsset(project.id, file); + } + else { + await api.uploadWorkspaceAsset(workspace.id, file); + } + } + setStatus("success"); + setStatusMessage(list.length === 1 + ? `uploaded ${list[0].name}` + : `uploaded ${list.length} files`); + await refresh(); + onActivity?.(); + } + catch (e) { + setStatus("error"); + setStatusMessage(String(e)); + } + finally { + window.setTimeout(() => { + setStatus("idle"); + setStatusMessage(""); + }, 2200); + } + }, [project, workspace, refresh, onActivity]); + const onDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragHover(false); + const dt = e.dataTransfer; + if (dt?.files?.length) { + void uploadFiles(dt.files); + } + }; + const onDragOver = (e) => { + if (!workspace) + return; + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types?.includes("Files") && !dragHover) + setDragHover(true); + }; + const onDragLeave = (e) => { + if (e.currentTarget === e.target) + setDragHover(false); + }; + const onFilePick = (e) => { + const files = e.target.files; + if (files?.length) + void uploadFiles(files); + e.target.value = ""; + }; + const deleteAsset = async (asset) => { + if (!window.confirm(`Remove "${asset.name}"?`)) + return; + setBusyDeleteId(asset.id); + try { + await api.deleteProjectAsset(asset.id); + setAssets((current) => current.filter((a) => a.id !== asset.id)); + onActivity?.(); + } + catch (e) { + setError(String(e)); + } + finally { + setBusyDeleteId(null); + } + }; + return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, onDrop: onDrop, onDragOver: onDragOver, onDragLeave: onDragLeave, children: [_jsxs("div", { style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.5, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: [_jsxs("span", { children: ["Assets \u00B7 ", scopeLabel] }), _jsx("span", { children: assets.length })] }), _jsxs("div", { onClick: () => workspace && fileInputRef.current?.click(), style: { + padding: "16px 14px", + border: `1px dashed ${dragHover ? "var(--accent)" : "var(--border)"}`, + background: dragHover ? "rgba(224,107,63,0.06)" : "white", + borderRadius: 6, + textAlign: "center", + cursor: workspace ? "pointer" : "not-allowed", + opacity: workspace ? 1 : 0.55, + transition: "background 0.18s ease, border-color 0.18s ease", + }, children: [_jsx("input", { ref: fileInputRef, type: "file", multiple: true, style: { display: "none" }, onChange: onFilePick }), _jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 11, + color: dragHover ? "var(--accent)" : "var(--ink2)", + fontWeight: 500, + marginBottom: 4, + }, children: status === "uploading" + ? statusMessage + : dragHover + ? "release to upload" + : "drop files here or click to upload" }), _jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 0.4, + }, children: project + ? "visible to every agent working on this project" + : workspace + ? "workspace-wide — every project sees it" + : "select a workspace first" })] }), status === "success" && (_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--green)", + lineHeight: 1.4, + }, children: statusMessage })), status === "error" && (_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--rose)", + lineHeight: 1.4, + }, children: statusMessage })), error && status !== "error" ? (_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--rose)", + lineHeight: 1.4, + }, children: error })) : null, loading && assets.length === 0 ? (_jsx("div", { style: { display: "grid", gap: 8 }, children: [0, 1, 2].map((i) => (_jsx("div", { style: { + height: 66, + borderRadius: 6, + background: "linear-gradient(90deg, rgba(20,16,10,0.04) 0%, rgba(20,16,10,0.08) 50%, rgba(20,16,10,0.04) 100%)", + backgroundSize: "200% 100%", + animation: `dhee-shimmer 1.4s linear ${i * 140}ms infinite`, + border: "1px solid rgba(20,16,10,0.06)", + } }, i))) })) : assets.length === 0 ? (_jsxs("div", { style: { + padding: 14, + border: "1px dashed var(--border)", + borderRadius: 6, + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + lineHeight: 1.55, + background: "white", + }, children: ["No assets yet. Drop a spec PDF, design export, or schema doc here \u2014 every agent in this", project ? " project" : " workspace", " will see it."] })) : (_jsx("div", { style: { display: "grid", gap: 8 }, children: assets.map((asset) => (_jsx(AssetCard, { asset: asset, onDelete: deleteAsset, busyDelete: busyDeleteId === asset.id }, asset.id))) }))] })); +} diff --git a/dhee/ui/web/src/components/AssetDrawer.tsx b/dhee/ui/web/src/components/AssetDrawer.tsx new file mode 100644 index 0000000..129d083 --- /dev/null +++ b/dhee/ui/web/src/components/AssetDrawer.tsx @@ -0,0 +1,559 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { api } from "../api"; +import type { + ProjectAsset, + ProjectSummary, + SharedTaskResult, + WorkspaceSummary, +} from "../types"; +import { StatPill } from "./ui/StatPill"; + +// --------------------------------------------------------------------------- +// AssetDrawer — project/workspace-scoped asset list + drag-drop uploader. +// +// The killer feature is the per-asset "processed by …" feed underneath +// each card: whenever a connected agent reads / greps / edits the file, +// the shared-task result shows up here, attributed with runtime + time. +// That's the pitch deck's "one agent read the 40-page contract, every +// other agent benefits" made tangible. +// --------------------------------------------------------------------------- + +type UploadStatus = "idle" | "uploading" | "success" | "error"; + +const MS_MINUTE = 60_000; +const MS_HOUR = 3_600_000; +const MS_DAY = 86_400_000; + +function fmtRelative(value?: string | null): string { + if (!value) return ""; + const when = Date.parse(value); + if (Number.isNaN(when)) return String(value); + const delta = Date.now() - when; + if (delta < MS_MINUTE) return "just now"; + if (delta < MS_HOUR) return `${Math.round(delta / MS_MINUTE)}m ago`; + if (delta < MS_DAY) return `${Math.round(delta / MS_HOUR)}h ago`; + return `${Math.round(delta / MS_DAY)}d ago`; +} + +function fmtSize(bytes?: number | null): string { + if (!bytes || bytes <= 0) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function extensionLabel(asset: ProjectAsset): string { + const name = asset.name || ""; + const dot = name.lastIndexOf("."); + if (dot <= 0 || dot === name.length - 1) return "file"; + return name.slice(dot + 1).toLowerCase().slice(0, 5); +} + +function runtimeTone(runtime: string): string { + const key = runtime.toLowerCase(); + if (key.includes("claude")) return "#e06b3f"; + if (key.includes("codex")) return "#1a1a1a"; + if (key.includes("cursor")) return "#4d6cff"; + if (key.includes("browser")) return "#1fa971"; + return "var(--ink3)"; +} + +function ExtensionBadge({ asset }: { asset: ProjectAsset }) { + const ext = extensionLabel(asset); + return ( + + {ext} ++ ); +} + +function AssetResultRow({ result }: { result: SharedTaskResult }) { + const runtime = String(result.harness || "dhee"); + const tool = String(result.tool_name || ""); + const kind = String(result.packet_kind || "").replace(/^routed_/, ""); + return ( ++ + {runtime} + {tool.toLowerCase() || kind} + + {fmtRelative(result.updated_at || result.created_at)} + ++ ); +} + +function AssetCard({ + asset, + onDelete, + busyDelete, +}: { + asset: ProjectAsset; + onDelete: (asset: ProjectAsset) => Promise| void; + busyDelete: boolean; +}) { + const [showResults, setShowResults] = useState(false); + const results = asset.results || []; + const processors = new Set (); + for (const r of results) processors.add(String(r.harness || "dhee")); + return ( + ++ ); +} + +export function AssetDrawer({ + workspace, + project, + onActivity, +}: { + workspace: WorkspaceSummary | null; + project: ProjectSummary | null; + onActivity?: () => void; +}) { + const [assets, setAssets] = useState++ ++ ++ ++ {asset.name} +++ {fmtSize(asset.size_bytes)} + {asset.updated_at ? uploaded {fmtRelative(asset.updated_at)} : null} +++ {processors.size > 0 ? ( ++ + {results.length > 0 && ( + <> + + {showResults && ( ++ ) : ( + + )} + {Array.from(processors) + .slice(0, 3) + .map((runtime) => ( + + ))} + + {results.slice(0, 8).map((result) => ( ++ )} + > + )} ++ ))} + ([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState (null); + const [status, setStatus] = useState ("idle"); + const [statusMessage, setStatusMessage] = useState (""); + const [dragHover, setDragHover] = useState(false); + const [busyDeleteId, setBusyDeleteId] = useState (null); + const fileInputRef = useRef (null); + + const scopeLabel = useMemo(() => { + if (project) return project.name; + if (workspace) return `${workspace.label || workspace.name} (workspace)`; + return "—"; + }, [project, workspace]); + + const refresh = useCallback(async () => { + if (!workspace) { + setAssets([]); + return; + } + setLoading(true); + setError(null); + try { + const res = project + ? await api.listProjectAssets(project.id) + : await api.listWorkspaceAssets(workspace.id, false); + setAssets(res.assets || []); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, [project, workspace]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // Light polling so the processing feed stays fresh without a full SSE + // implementation (PR 4). + useEffect(() => { + if (!workspace) return; + const timer = window.setInterval(() => void refresh(), 5000); + return () => window.clearInterval(timer); + }, [refresh, workspace]); + + const uploadFiles = useCallback( + async (files: FileList | File[]) => { + if (!workspace) return; + const list = Array.from(files); + if (list.length === 0) return; + setStatus("uploading"); + setStatusMessage(`uploading ${list.length} file${list.length === 1 ? "" : "s"}…`); + setError(null); + try { + for (const file of list) { + if (project) { + await api.uploadProjectAsset(project.id, file); + } else { + await api.uploadWorkspaceAsset(workspace.id, file); + } + } + setStatus("success"); + setStatusMessage( + list.length === 1 + ? `uploaded ${list[0].name}` + : `uploaded ${list.length} files`, + ); + await refresh(); + onActivity?.(); + } catch (e) { + setStatus("error"); + setStatusMessage(String(e)); + } finally { + window.setTimeout(() => { + setStatus("idle"); + setStatusMessage(""); + }, 2200); + } + }, + [project, workspace, refresh, onActivity], + ); + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragHover(false); + const dt = e.dataTransfer; + if (dt?.files?.length) { + void uploadFiles(dt.files); + } + }; + + const onDragOver = (e: React.DragEvent) => { + if (!workspace) return; + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types?.includes("Files") && !dragHover) setDragHover(true); + }; + const onDragLeave = (e: React.DragEvent) => { + if (e.currentTarget === e.target) setDragHover(false); + }; + + const onFilePick = (e: React.ChangeEvent ) => { + const files = e.target.files; + if (files?.length) void uploadFiles(files); + e.target.value = ""; + }; + + const deleteAsset = async (asset: ProjectAsset) => { + if (!window.confirm(`Remove "${asset.name}"?`)) return; + setBusyDeleteId(asset.id); + try { + await api.deleteProjectAsset(asset.id); + setAssets((current) => current.filter((a) => a.id !== asset.id)); + onActivity?.(); + } catch (e) { + setError(String(e)); + } finally { + setBusyDeleteId(null); + } + }; + + return ( + + {/* Scope indicator */} ++ ); +} diff --git a/dhee/ui/web/src/components/ChatMessage.js b/dhee/ui/web/src/components/ChatMessage.js new file mode 100644 index 0000000..cd724e2 --- /dev/null +++ b/dhee/ui/web/src/components/ChatMessage.js @@ -0,0 +1,39 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { BrowserCard, CodeCard, DocumentCard, GrepCard, LinkCard, } from "./cards/Cards"; +export function ChatMessage({ msg, tasks, onSelectTask, }) { + if (msg.role === "component") { + const anyMsg = msg; + return (_jsxs("div", { style: { marginBottom: 14, paddingLeft: 14 }, children: [anyMsg.type === "browser" && (_jsx(BrowserCard, { url: anyMsg.url, title: anyMsg.title, lines: anyMsg.lines })), anyMsg.type === "grep" && (_jsx(GrepCard, { query: anyMsg.query, files: anyMsg.files })), anyMsg.type === "code" && (_jsx(CodeCard, { lang: anyMsg.lang, lines: anyMsg.lines })), anyMsg.type === "document" && (_jsx(DocumentCard, { title: anyMsg.title, lines: anyMsg.lines })), anyMsg.type === "link" && (_jsx(LinkCard, { linkedTask: anyMsg.linkedTask, preview: anyMsg.preview, tasks: tasks, onSelectTask: onSelectTask }))] })); + } + const isUser = msg.role === "user"; + return (_jsxs("div", { style: { + marginBottom: 13, + display: "flex", + flexDirection: "column", + alignItems: isUser ? "flex-end" : "flex-start", + }, children: [!isUser && (_jsxs("div", { style: { + display: "flex", + alignItems: "center", + gap: 5, + marginBottom: 4, + }, children: [_jsx("div", { style: { + width: 5, + height: 5, + background: "var(--green)", + borderRadius: "50%", + } }), _jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 1, + }, children: "AGENT" })] })), _jsx("div", { style: { + maxWidth: "88%", + padding: "9px 13px", + background: isUser ? "var(--ink)" : "white", + color: isUser ? "var(--bg)" : "var(--ink)", + border: isUser ? "none" : "1px solid var(--border)", + fontSize: 13.5, + lineHeight: 1.6, + whiteSpace: "pre-wrap", + }, children: msg.content })] })); +} diff --git a/dhee/ui/web/src/components/ChatMessage.tsx b/dhee/ui/web/src/components/ChatMessage.tsx new file mode 100644 index 0000000..8add042 --- /dev/null +++ b/dhee/ui/web/src/components/ChatMessage.tsx @@ -0,0 +1,101 @@ +import type { SankhyaTask, TaskMessage } from "../types"; +import { + BrowserCard, + CodeCard, + DocumentCard, + GrepCard, + LinkCard, +} from "./cards/Cards"; + +export function ChatMessage({ + msg, + tasks, + onSelectTask, +}: { + msg: TaskMessage; + tasks: SankhyaTask[]; + onSelectTask: (id: string) => void; +}) { + if (msg.role === "component") { + const anyMsg = msg as any; + return ( ++ Assets · {scopeLabel} + {assets.length} ++ + {/* Dropzone / picker */} +workspace && fileInputRef.current?.click()} + style={{ + padding: "16px 14px", + border: `1px dashed ${dragHover ? "var(--accent)" : "var(--border)"}`, + background: dragHover ? "rgba(224,107,63,0.06)" : "white", + borderRadius: 6, + textAlign: "center", + cursor: workspace ? "pointer" : "not-allowed", + opacity: workspace ? 1 : 0.55, + transition: "background 0.18s ease, border-color 0.18s ease", + }} + > + ++ + {status === "success" && ( ++ {status === "uploading" + ? statusMessage + : dragHover + ? "release to upload" + : "drop files here or click to upload"} +++ {project + ? "visible to every agent working on this project" + : workspace + ? "workspace-wide — every project sees it" + : "select a workspace first"} +++ {statusMessage} ++ )} + {status === "error" && ( ++ {statusMessage} ++ )} + {error && status !== "error" ? ( ++ {error} ++ ) : null} + + {loading && assets.length === 0 ? ( ++ {[0, 1, 2].map((i) => ( + + ))} ++ ) : assets.length === 0 ? ( ++ No assets yet. Drop a spec PDF, design export, or schema doc here — every agent in this + {project ? " project" : " workspace"} will see it. ++ ) : ( ++ {assets.map((asset) => ( ++ )} ++ ))} + + {anyMsg.type === "browser" && ( ++ ); + } + const isUser = msg.role === "user"; + return ( ++ )} + {anyMsg.type === "grep" && ( + + )} + {anyMsg.type === "code" && ( + + )} + {anyMsg.type === "document" && ( + + )} + {anyMsg.type === "link" && ( + + )} + + {!isUser && ( ++ ); +} diff --git a/dhee/ui/web/src/components/FirstRunPanel.js b/dhee/ui/web/src/components/FirstRunPanel.js new file mode 100644 index 0000000..be3468f --- /dev/null +++ b/dhee/ui/web/src/components/FirstRunPanel.js @@ -0,0 +1,74 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +const panelStyle = { + border: "1px solid var(--border)", + background: "var(--bg)", + borderRadius: 8, + padding: 18, + width: "min(760px, 100%)", + boxSizing: "border-box", + display: "flex", + flexWrap: "wrap", + gap: 18, + boxShadow: "0 10px 28px rgba(20,16,10,0.06)", +}; +const monoCaps = { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: "0.08em", + textTransform: "uppercase", + color: "var(--ink3)", +}; +const actionBase = { + borderRadius: 5, + cursor: "pointer", + fontFamily: "var(--mono)", + fontSize: 10, + padding: "8px 11px", + whiteSpace: "nowrap", +}; +function actionStyle(tone = "secondary", disabled) { + const primary = tone === "primary"; + return { + ...actionBase, + border: `1px solid ${primary ? "var(--ink)" : "var(--border)"}`, + background: primary ? "var(--ink)" : "white", + color: primary ? "white" : "var(--accent)", + opacity: disabled ? 0.55 : 1, + cursor: disabled ? "not-allowed" : "pointer", + }; +} +export function FirstRunPanel({ title = "Set up a developer workspace", eyebrow = "First run", body = "Connect a repo folder, then start Codex or Claude Code from that folder so Dhee can mirror sessions and context.", actions = [], commands = [ + "dhee onboard --root .", + "dhee doctor", +], aside, }) { + return (_jsxs("section", { style: panelStyle, children: [_jsxs("div", { style: { flex: "1 1 300px", minWidth: 0 }, children: [_jsx("div", { style: monoCaps, children: eyebrow }), _jsx("h2", { style: { + margin: "5px 0 7px", + fontSize: 22, + lineHeight: 1.15, + color: "var(--ink)", + letterSpacing: 0, + }, children: title }), _jsx("div", { style: { + color: "var(--ink2)", + fontSize: 12.5, + lineHeight: 1.55, + maxWidth: 680, + }, children: body }), actions.length ? (_jsx("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", marginTop: 14 }, children: actions.map((action) => (_jsx("button", { type: "button", onClick: action.onClick, disabled: action.disabled, style: actionStyle(action.tone, action.disabled), children: action.label }, action.label))) })) : null] }), _jsxs("div", { style: { + flex: "1 1 260px", + border: "1px solid var(--border)", + background: "var(--surface)", + borderRadius: 6, + padding: 12, + minWidth: 0, + }, children: [_jsx("div", { style: { ...monoCaps, marginBottom: 8 }, children: "Terminal path" }), _jsx("div", { style: { display: "grid", gap: 7 }, children: commands.map((command) => (_jsx("code", { style: { + display: "block", + border: "1px solid var(--border)", + background: "white", + borderRadius: 4, + padding: "8px 9px", + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink)", + lineHeight: 1.45, + overflowWrap: "anywhere", + }, children: command }, command))) }), aside ? _jsx("div", { style: { marginTop: 10 }, children: aside }) : null] })] })); +} diff --git a/dhee/ui/web/src/components/FirstRunPanel.tsx b/dhee/ui/web/src/components/FirstRunPanel.tsx new file mode 100644 index 0000000..43c2cc6 --- /dev/null +++ b/dhee/ui/web/src/components/FirstRunPanel.tsx @@ -0,0 +1,151 @@ +import type { ReactNode } from "react"; + +type ActionTone = "primary" | "secondary"; + +export interface FirstRunAction { + label: string; + onClick: () => void; + tone?: ActionTone; + disabled?: boolean; +} + +interface FirstRunPanelProps { + title?: string; + eyebrow?: string; + body?: string; + actions?: FirstRunAction[]; + commands?: string[]; + aside?: ReactNode; +} + +const panelStyle: React.CSSProperties = { + border: "1px solid var(--border)", + background: "var(--bg)", + borderRadius: 8, + padding: 18, + width: "min(760px, 100%)", + boxSizing: "border-box", + display: "flex", + flexWrap: "wrap", + gap: 18, + boxShadow: "0 10px 28px rgba(20,16,10,0.06)", +}; + +const monoCaps: React.CSSProperties = { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: "0.08em", + textTransform: "uppercase", + color: "var(--ink3)", +}; + +const actionBase: React.CSSProperties = { + borderRadius: 5, + cursor: "pointer", + fontFamily: "var(--mono)", + fontSize: 10, + padding: "8px 11px", + whiteSpace: "nowrap", +}; + +function actionStyle(tone: ActionTone = "secondary", disabled?: boolean): React.CSSProperties { + const primary = tone === "primary"; + return { + ...actionBase, + border: `1px solid ${primary ? "var(--ink)" : "var(--border)"}`, + background: primary ? "var(--ink)" : "white", + color: primary ? "white" : "var(--accent)", + opacity: disabled ? 0.55 : 1, + cursor: disabled ? "not-allowed" : "pointer", + }; +} + +export function FirstRunPanel({ + title = "Set up a developer workspace", + eyebrow = "First run", + body = "Connect a repo folder, then start Codex or Claude Code from that folder so Dhee can mirror sessions and context.", + actions = [], + commands = [ + "dhee onboard --root .", + "dhee doctor", + ], + aside, +}: FirstRunPanelProps) { + return ( ++ + + AGENT + ++ )} ++ {msg.content} +++ + ); +} diff --git a/dhee/ui/web/src/components/LinePanel.js b/dhee/ui/web/src/components/LinePanel.js new file mode 100644 index 0000000..739dfa5 --- /dev/null +++ b/dhee/ui/web/src/components/LinePanel.js @@ -0,0 +1,387 @@ +import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { api } from "../api"; +import { StatPill } from "./ui/StatPill"; +const MS_MINUTE = 60000; +const MS_HOUR = 3600000; +const MS_DAY = 86400000; +function fmtRelative(value) { + if (!value) + return ""; + const when = Date.parse(value); + if (Number.isNaN(when)) + return String(value); + const delta = Date.now() - when; + if (delta < MS_MINUTE) + return "just now"; + if (delta < MS_HOUR) + return `${Math.round(delta / MS_MINUTE)}m ago`; + if (delta < MS_DAY) + return `${Math.round(delta / MS_HOUR)}h ago`; + return `${Math.round(delta / MS_DAY)}d ago`; +} +function fmtClock(value) { + if (!value) + return ""; + const when = new Date(value); + if (Number.isNaN(when.getTime())) + return String(value); + return when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} +// Tool-kind → accent. Matches canvas palette so broadcast linkages feel +// coherent across views. +const KIND_TONE = { + broadcast: "#e06b3f", + "tool.routed_read": "#4d6cff", + "tool.routed_bash": "#0b8b5f", + "tool.routed_grep": "#1fa971", + "tool.routed_agent": "#d74b7b", + "tool.hook_post_tool": "#64748b", + "tool.artifact_parse": "#d74b7b", + note: "#1a1a1a", + update: "#1a1a1a", +}; +function kindTone(kind) { + const key = String(kind || "").toLowerCase(); + if (KIND_TONE[key]) + return KIND_TONE[key]; + if (key.startsWith("tool.")) + return "#4d6cff"; + return "var(--accent)"; +} +function formatKindLabel(kind) { + const raw = String(kind || ""); + if (!raw) + return "note"; + if (raw.startsWith("tool.")) + return raw.slice(5); + return raw; +} +function runtimeTone(runtime) { + const key = String(runtime || "").toLowerCase(); + if (key.includes("claude")) + return "#e06b3f"; + if (key.includes("codex")) + return "#1a1a1a"; + if (key.includes("cursor")) + return "#4d6cff"; + if (key.includes("browser")) + return "#1fa971"; + return "var(--ink3)"; +} +function sessionsForRuntime(projects, workspaceSessions) { + const buckets = new Map(); + const visit = (session) => { + const runtime = String(session.runtime || "unknown").toLowerCase(); + const current = buckets.get(runtime) || { count: 0, latestUpdate: null, isLive: false }; + current.count += 1; + if (session.updatedAt && (!current.latestUpdate || session.updatedAt > current.latestUpdate)) { + current.latestUpdate = session.updatedAt; + } + if (session.isCurrent || session.state === "active" || session.state === "recent") { + current.isLive = true; + } + buckets.set(runtime, current); + }; + for (const session of workspaceSessions || []) + visit(session); + for (const project of projects || []) { + for (const session of project.sessions || []) + visit(session); + } + return Array.from(buckets.entries()) + .map(([runtime, value]) => ({ runtime, ...value })) + .sort((a, b) => b.count - a.count); +} +export function ConnectedAgents({ workspace, projects, workspaceSessions, }) { + const buckets = useMemo(() => sessionsForRuntime(projects, workspaceSessions), [projects, workspaceSessions]); + const total = buckets.reduce((sum, item) => sum + item.count, 0); + return (_jsxs("div", { style: { + border: "1px solid var(--border)", + background: "white", + padding: 14, + }, children: [_jsxs("div", { style: { + display: "flex", + justifyContent: "space-between", + alignItems: "baseline", + marginBottom: 10, + }, children: [_jsxs("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: ["Connected agents \u00B7 ", total] }), workspace ? (_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + }, children: workspace.label || workspace.name })) : null] }), buckets.length === 0 ? (_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + lineHeight: 1.55, + }, children: "No agent sessions yet. Launch claude-code or codex in this workspace \u2014 they will register here and start publishing to the line." })) : (_jsx("div", { style: { display: "grid", gap: 6 }, children: buckets.map((bucket) => (_jsxs("div", { style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { style: { + width: 7, + height: 7, + borderRadius: "50%", + background: bucket.isLive ? "var(--green)" : "var(--ink3)", + boxShadow: bucket.isLive ? "0 0 0 3px rgba(31,169,113,0.18)" : "none", + flexShrink: 0, + } }), _jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 11, + color: runtimeTone(bucket.runtime), + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }, children: bucket.runtime }), _jsxs("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + }, children: ["\u00B7 ", bucket.count] })] }), bucket.latestUpdate ? (_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + }, children: fmtRelative(bucket.latestUpdate) })) : null] }, bucket.runtime))) }))] })); +} +export function LineMessageCard({ message, workspace, onOpenTask, }) { + const sourceProject = workspace?.projects.find((project) => project.id === message.project_id)?.name || "workspace"; + const targetProject = workspace?.projects.find((project) => project.id === message.target_project_id)?.name || ""; + const meta = (message.metadata || {}); + const runtime = String(meta.harness || meta.runtime || ""); + const tool = String(meta.tool_name || meta.toolName || ""); + const ptr = String(meta.ptr || ""); + const body = message.body || ""; + const accent = kindTone(message.message_kind); + return (_jsxs("div", { style: { + border: "1px solid var(--border)", + borderLeft: `3px solid ${accent}`, + background: "white", + padding: "11px 13px", + }, children: [_jsxs("div", { style: { + display: "flex", + justifyContent: "space-between", + alignItems: "baseline", + gap: 10, + marginBottom: 6, + }, children: [_jsxs("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: runtimeTone(runtime), + letterSpacing: 0.4, + }, children: [fmtClock(message.created_at), runtime ? ` · ${runtime}` : "", sourceProject && sourceProject !== "workspace" ? ` · ${sourceProject}` : ""] }), _jsx("span", { style: { fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink3)" }, children: fmtRelative(message.created_at) })] }), message.title ? (_jsx("div", { style: { fontSize: 13, fontWeight: 600, marginBottom: 5, lineHeight: 1.35 }, children: message.title })) : null, body ? (_jsx("div", { style: { + fontSize: 12, + color: "var(--ink2)", + lineHeight: 1.55, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }, children: body })) : null, _jsxs("div", { style: { display: "flex", gap: 5, flexWrap: "wrap", marginTop: 8 }, children: [_jsx(StatPill, { label: formatKindLabel(message.message_kind), tone: accent }), tool ? _jsx(StatPill, { label: tool.toLowerCase() }) : null, targetProject ? _jsx(StatPill, { label: `→ ${targetProject}`, tone: "#4d6cff" }) : null, ptr ? _jsx(StatPill, { label: ptr }) : null, message.task_id && onOpenTask ? (_jsx("button", { onClick: () => onOpenTask(String(message.task_id)), style: { + padding: "2px 8px", + border: "1px solid var(--ink)", + background: "var(--ink)", + color: "white", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.4, + textTransform: "uppercase", + cursor: "pointer", + }, children: "open task" })) : null] })] })); +} +export function LineComposer({ workspace, activeProjectId, sessionId, taskId, onPublished, }) { + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [targetProjectId, setTargetProjectId] = useState(""); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const bodyRef = useRef(null); + useEffect(() => { + bodyRef.current?.focus(); + }, [workspace?.id]); + const targetOptions = useMemo(() => (workspace?.projects || []).filter((project) => !activeProjectId || project.id !== activeProjectId), [workspace?.projects, activeProjectId]); + const publish = async () => { + if (!workspace?.id || !body.trim() || busy) + return; + setBusy(true); + setError(null); + setNotice(null); + try { + const response = await api.publishWorkspaceLineMessage(workspace.id, { + project_id: activeProjectId || undefined, + target_project_id: targetProjectId || undefined, + channel: activeProjectId ? "project" : "workspace", + session_id: sessionId || undefined, + task_id: taskId || undefined, + message_kind: targetProjectId ? "broadcast" : "note", + title: title.trim() || undefined, + body: body.trim(), + metadata: { + sourceProject: workspace.projects.find((p) => p.id === activeProjectId)?.name, + }, + }); + if (response.suggestedTask) { + const target = workspace.projects.find((p) => p.id === targetProjectId); + setNotice(target + ? `Broadcast sent · suggested task created in ${target.name}.` + : "Broadcast sent · suggested task created."); + } + else { + setNotice("Published to the workspace line."); + } + setTitle(""); + setBody(""); + setTargetProjectId(""); + onPublished?.(response.message, response.suggestedTask); + } + catch (e) { + setError(String(e)); + } + finally { + setBusy(false); + } + }; + if (!workspace) { + return (_jsx("div", { style: { + padding: 16, + border: "1px solid var(--border)", + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + background: "white", + }, children: "No workspace selected." })); + } + const disabled = !body.trim() || busy; + const targetName = targetOptions.find((p) => p.id === targetProjectId)?.name; + const ctaLabel = busy + ? "publishing…" + : targetProjectId + ? `broadcast → ${targetName || "project"}` + : "publish update"; + return (_jsxs("div", { style: { + border: "1px solid var(--border)", + background: "white", + padding: 14, + display: "flex", + flexDirection: "column", + gap: 8, + }, children: [_jsx("input", { value: title, onChange: (e) => setTitle(e.target.value), placeholder: "headline (optional) \u2014 e.g. user.plan field added", style: { + border: "1px solid var(--border)", + padding: "9px 11px", + background: "var(--bg)", + fontSize: 13, + } }), _jsxs("select", { value: targetProjectId, onChange: (e) => setTargetProjectId(e.target.value), style: { + border: "1px solid var(--border)", + padding: "9px 11px", + background: "var(--bg)", + fontFamily: "var(--mono)", + fontSize: 10, + }, children: [_jsxs("option", { value: "", children: ["Publish to current ", activeProjectId ? "project" : "workspace", " only"] }), targetOptions.map((project) => (_jsxs("option", { value: project.id, children: ["Broadcast into ", project.name, " (creates task)"] }, project.id)))] }), _jsx("textarea", { ref: bodyRef, value: body, onChange: (e) => setBody(e.target.value), placeholder: targetProjectId + ? "What should the target project's agent know? It will spawn a task with this context." + : "Broadcast a dependency change, a tool result, or a follow-up signal to the workspace line…", rows: 4, onKeyDown: (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + void publish(); + } + }, style: { + border: "1px solid var(--border)", + padding: "10px 12px", + background: "var(--bg)", + fontSize: 13, + lineHeight: 1.55, + resize: "vertical", + } }), _jsxs("div", { style: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: 10, + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 0.3, + }, children: "\u2318/Ctrl + Enter to publish" }), _jsx("button", { onClick: () => void publish(), disabled: disabled, style: { + padding: "8px 14px", + border: "1px solid var(--ink)", + background: disabled ? "var(--ink3)" : "var(--ink)", + color: "white", + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: 0.4, + opacity: disabled ? 0.7 : 1, + cursor: disabled ? "not-allowed" : "pointer", + }, children: ctaLabel })] }), notice ? (_jsx("div", { style: { fontSize: 11, color: "var(--green)", lineHeight: 1.5 }, children: notice })) : null, error ? (_jsx("div", { style: { fontSize: 11, color: "var(--rose)", lineHeight: 1.5 }, children: error })) : null] })); +} +// --------------------------------------------------------------------------- +// Live stream hook — owns the EventSource + initial fetch + dedup merge. +// Exposed so both ChannelView (full page) and WorkspaceView (right rail) +// share one subscription. +// --------------------------------------------------------------------------- +export function useWorkspaceLine(workspaceId, projectId) { + const [messages, setMessages] = useState([]); + const [live, setLive] = useState(false); + const [error, setError] = useState(null); + const merge = (incoming) => { + setMessages((current) => { + const seen = new Map(); + [...incoming, ...current].forEach((message) => { + if (message?.id) + seen.set(message.id, message); + }); + return Array.from(seen.values()).sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || ""))); + }); + }; + const refresh = async () => { + if (!workspaceId) { + setMessages([]); + return; + } + try { + const snapshot = await api.workspaceLineMessages(workspaceId, { + project_id: projectId || undefined, + limit: 100, + }); + setMessages(snapshot.messages || []); + } + catch (e) { + setError(String(e)); + } + }; + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId, projectId]); + useEffect(() => { + if (!workspaceId) { + setLive(false); + return; + } + const qs = new URLSearchParams(); + if (projectId) + qs.set("project_id", projectId); + const source = new EventSource(`/api/workspaces/${encodeURIComponent(workspaceId)}/line/stream${qs.toString() ? `?${qs.toString()}` : ""}`); + source.onopen = () => setLive(true); + source.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + merge([message]); + } + catch { + /* keep-alive frames */ + } + }; + source.onerror = () => { + setLive(false); + source.close(); + }; + return () => { + source.close(); + setLive(false); + }; + }, [workspaceId, projectId]); + return { messages, live, error, merge, refresh }; +} diff --git a/dhee/ui/web/src/components/LinePanel.tsx b/dhee/ui/web/src/components/LinePanel.tsx new file mode 100644 index 0000000..929b2e1 --- /dev/null +++ b/dhee/ui/web/src/components/LinePanel.tsx @@ -0,0 +1,639 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { api } from "../api"; +import type { + AgentSessionSummary, + ProjectSummary, + SankhyaTask, + WorkspaceLineMessage, + WorkspaceSummary, +} from "../types"; +import { StatPill } from "./ui/StatPill"; + +// --------------------------------------------------------------------------- +// LinePanel — the workspace *information line*. The entire product hangs +// off of this component: every agent tool call that lands on the line +// appears here, attributed by runtime + project, and humans can broadcast +// into any project to spawn a suggested task. +// +// Ownership: +// - Holds its own message list + SSE connection (single source of truth +// so embedders don't duplicate fetch/stream state). +// - Emits optional `onPublished` so hosts that care about side-effects +// (suggested task created, etc.) can refresh adjacent panels. +// +// Used by ChannelView (full-width) and WorkspaceView (right rail); both +// share the same control surface. +// --------------------------------------------------------------------------- + +export type LineMessage = WorkspaceLineMessage; + +const MS_MINUTE = 60_000; +const MS_HOUR = 3_600_000; +const MS_DAY = 86_400_000; + +function fmtRelative(value?: string | null): string { + if (!value) return ""; + const when = Date.parse(value); + if (Number.isNaN(when)) return String(value); + const delta = Date.now() - when; + if (delta < MS_MINUTE) return "just now"; + if (delta < MS_HOUR) return `${Math.round(delta / MS_MINUTE)}m ago`; + if (delta < MS_DAY) return `${Math.round(delta / MS_HOUR)}h ago`; + return `${Math.round(delta / MS_DAY)}d ago`; +} + +function fmtClock(value?: string | null): string { + if (!value) return ""; + const when = new Date(value); + if (Number.isNaN(when.getTime())) return String(value); + return when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +// Tool-kind → accent. Matches canvas palette so broadcast linkages feel +// coherent across views. +const KIND_TONE: Record++{eyebrow}++ {title} +
++ {body} ++ {actions.length ? ( ++ {actions.map((action) => ( + + ))} ++ ) : null} +++Terminal path++ {commands.map((command) => ( ++ {aside ?+ {command} ++ ))} +{aside}: null} += { + broadcast: "#e06b3f", + "tool.routed_read": "#4d6cff", + "tool.routed_bash": "#0b8b5f", + "tool.routed_grep": "#1fa971", + "tool.routed_agent": "#d74b7b", + "tool.hook_post_tool": "#64748b", + "tool.artifact_parse": "#d74b7b", + note: "#1a1a1a", + update: "#1a1a1a", +}; + +function kindTone(kind?: string | null): string { + const key = String(kind || "").toLowerCase(); + if (KIND_TONE[key]) return KIND_TONE[key]; + if (key.startsWith("tool.")) return "#4d6cff"; + return "var(--accent)"; +} + +function formatKindLabel(kind?: string | null): string { + const raw = String(kind || ""); + if (!raw) return "note"; + if (raw.startsWith("tool.")) return raw.slice(5); + return raw; +} + +function runtimeTone(runtime?: string | null): string { + const key = String(runtime || "").toLowerCase(); + if (key.includes("claude")) return "#e06b3f"; + if (key.includes("codex")) return "#1a1a1a"; + if (key.includes("cursor")) return "#4d6cff"; + if (key.includes("browser")) return "#1fa971"; + return "var(--ink3)"; +} + +function sessionsForRuntime( + projects: ProjectSummary[], + workspaceSessions: AgentSessionSummary[], +): { runtime: string; count: number; latestUpdate?: string | null; isLive: boolean }[] { + const buckets = new Map (); + const visit = (session: AgentSessionSummary) => { + const runtime = String(session.runtime || "unknown").toLowerCase(); + const current = buckets.get(runtime) || { count: 0, latestUpdate: null, isLive: false }; + current.count += 1; + if (session.updatedAt && (!current.latestUpdate || session.updatedAt > current.latestUpdate)) { + current.latestUpdate = session.updatedAt; + } + if (session.isCurrent || session.state === "active" || session.state === "recent") { + current.isLive = true; + } + buckets.set(runtime, current); + }; + for (const session of workspaceSessions || []) visit(session); + for (const project of projects || []) { + for (const session of project.sessions || []) visit(session); + } + return Array.from(buckets.entries()) + .map(([runtime, value]) => ({ runtime, ...value })) + .sort((a, b) => b.count - a.count); +} + +export function ConnectedAgents({ + workspace, + projects, + workspaceSessions, +}: { + workspace: WorkspaceSummary | null; + projects: ProjectSummary[]; + workspaceSessions: AgentSessionSummary[]; +}) { + const buckets = useMemo( + () => sessionsForRuntime(projects, workspaceSessions), + [projects, workspaceSessions], + ); + const total = buckets.reduce((sum, item) => sum + item.count, 0); + return ( + ++ ); +} + +export function LineMessageCard({ + message, + workspace, + onOpenTask, +}: { + message: LineMessage; + workspace: WorkspaceSummary | null; + onOpenTask?: (taskId: string) => void; +}) { + const sourceProject = + workspace?.projects.find((project) => project.id === message.project_id)?.name || "workspace"; + const targetProject = + workspace?.projects.find((project) => project.id === message.target_project_id)?.name || ""; + const meta = (message.metadata || {}) as Record+ + Connected agents · {total} + + {workspace ? ( + + {workspace.label || workspace.name} + + ) : null} ++ {buckets.length === 0 ? ( ++ No agent sessions yet. Launch claude-code or codex in this workspace — + they will register here and start publishing to the line. ++ ) : ( ++ {buckets.map((bucket) => ( ++ )} +++ ))} ++ + + {bucket.runtime} + + + · {bucket.count} + ++ {bucket.latestUpdate ? ( + + {fmtRelative(bucket.latestUpdate)} + + ) : null} +; + const runtime = String(meta.harness || meta.runtime || ""); + const tool = String(meta.tool_name || meta.toolName || ""); + const ptr = String(meta.ptr || ""); + const body = message.body || ""; + const accent = kindTone(message.message_kind); + return ( + ++ ); +} + +export function LineComposer({ + workspace, + activeProjectId, + sessionId, + taskId, + onPublished, +}: { + workspace: WorkspaceSummary | null; + activeProjectId?: string | null; + sessionId?: string | null; + taskId?: string | null; + onPublished?: (message: LineMessage, suggestedTask?: SankhyaTask | null) => void; +}) { + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [targetProjectId, setTargetProjectId] = useState++ + {message.title ? ( ++ {fmtClock(message.created_at)} + {runtime ? ` · ${runtime}` : ""} + {sourceProject && sourceProject !== "workspace" ? ` · ${sourceProject}` : ""} ++ + {fmtRelative(message.created_at)} + ++ {message.title} ++ ) : null} + + {body ? ( ++ {body} ++ ) : null} + ++++ {tool ? : null} + {targetProject ? : null} + {ptr ? : null} + {message.task_id && onOpenTask ? ( + + ) : null} + (""); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState (null); + const [error, setError] = useState (null); + const bodyRef = useRef (null); + + useEffect(() => { + bodyRef.current?.focus(); + }, [workspace?.id]); + + const targetOptions = useMemo( + () => + (workspace?.projects || []).filter( + (project) => !activeProjectId || project.id !== activeProjectId, + ), + [workspace?.projects, activeProjectId], + ); + + const publish = async () => { + if (!workspace?.id || !body.trim() || busy) return; + setBusy(true); + setError(null); + setNotice(null); + try { + const response = await api.publishWorkspaceLineMessage(workspace.id, { + project_id: activeProjectId || undefined, + target_project_id: targetProjectId || undefined, + channel: activeProjectId ? "project" : "workspace", + session_id: sessionId || undefined, + task_id: taskId || undefined, + message_kind: targetProjectId ? "broadcast" : "note", + title: title.trim() || undefined, + body: body.trim(), + metadata: { + sourceProject: workspace.projects.find((p) => p.id === activeProjectId)?.name, + }, + }); + if (response.suggestedTask) { + const target = workspace.projects.find((p) => p.id === targetProjectId); + setNotice( + target + ? `Broadcast sent · suggested task created in ${target.name}.` + : "Broadcast sent · suggested task created.", + ); + } else { + setNotice("Published to the workspace line."); + } + setTitle(""); + setBody(""); + setTargetProjectId(""); + onPublished?.(response.message, response.suggestedTask); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + if (!workspace) { + return ( + + No workspace selected. ++ ); + } + + const disabled = !body.trim() || busy; + const targetName = targetOptions.find((p) => p.id === targetProjectId)?.name; + const ctaLabel = busy + ? "publishing…" + : targetProjectId + ? `broadcast → ${targetName || "project"}` + : "publish update"; + + return ( ++ setTitle(e.target.value)} + placeholder="headline (optional) — e.g. user.plan field added" + style={{ + border: "1px solid var(--border)", + padding: "9px 11px", + background: "var(--bg)", + fontSize: 13, + }} + /> + ++ ); +} + +// --------------------------------------------------------------------------- +// Live stream hook — owns the EventSource + initial fetch + dedup merge. +// Exposed so both ChannelView (full page) and WorkspaceView (right rail) +// share one subscription. +// --------------------------------------------------------------------------- + +export function useWorkspaceLine(workspaceId?: string | null, projectId?: string | null): { + messages: LineMessage[]; + live: boolean; + error: string | null; + merge: (incoming: LineMessage[]) => void; + refresh: () => Promise; +} { + const [messages, setMessages] = useState ([]); + const [live, setLive] = useState(false); + const [error, setError] = useState (null); + + const merge = (incoming: LineMessage[]) => { + setMessages((current) => { + const seen = new Map (); + [...incoming, ...current].forEach((message) => { + if (message?.id) seen.set(message.id, message); + }); + return Array.from(seen.values()).sort((a, b) => + String(b.created_at || "").localeCompare(String(a.created_at || "")), + ); + }); + }; + + const refresh = async () => { + if (!workspaceId) { + setMessages([]); + return; + } + try { + const snapshot = await api.workspaceLineMessages(workspaceId, { + project_id: projectId || undefined, + limit: 100, + }); + setMessages(snapshot.messages || []); + } catch (e) { + setError(String(e)); + } + }; + + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId, projectId]); + + useEffect(() => { + if (!workspaceId) { + setLive(false); + return; + } + const qs = new URLSearchParams(); + if (projectId) qs.set("project_id", projectId); + const source = new EventSource( + `/api/workspaces/${encodeURIComponent(workspaceId)}/line/stream${ + qs.toString() ? `?${qs.toString()}` : "" + }`, + ); + source.onopen = () => setLive(true); + source.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as LineMessage; + merge([message]); + } catch { + /* keep-alive frames */ + } + }; + source.onerror = () => { + setLive(false); + source.close(); + }; + return () => { + source.close(); + setLive(false); + }; + }, [workspaceId, projectId]); + + return { messages, live, error, merge, refresh }; +} diff --git a/dhee/ui/web/src/components/NavRail.js b/dhee/ui/web/src/components/NavRail.js new file mode 100644 index 0000000..e207d7c --- /dev/null +++ b/dhee/ui/web/src/components/NavRail.js @@ -0,0 +1,100 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +export function NavRail({ view, setView, conflictCount, }) { + const items = [ + { id: "command", icon: "⌂", label: "HOME", tip: "Command center · current truth · next action" }, + { id: "router", icon: "⇌", label: "FIREWALL", tip: "Context firewall · routing · expansions · tokens saved" }, + { id: "canvas", icon: "⊞", label: "BRAIN", tip: "Repo Brain · linked folders · active sessions" }, + { id: "handoff", icon: "↗", label: "HANDOFF", tip: "Resume state across agents" }, + { id: "replay", icon: "◌", label: "REPLAY", tip: "Context decision replay" }, + { id: "learnings", icon: "↑", label: "LEARN", tip: "Evidence-backed learning review" }, + { id: "context", icon: "◐", label: "CONTEXT", tip: "Context vault · personal and shared memory" }, + { id: "portability", icon: "□", label: "PACKS", tip: ".dheemem export · import dry-run · portability" }, + { + id: "conflicts", + icon: "⟷", + label: "INBOX", + tip: "Proposals · findings · conflicts", + badge: conflictCount, + }, + ]; + return (_jsxs("div", { style: { + width: "var(--nav)", + borderRight: "1px solid var(--border)", + display: "flex", + flexDirection: "column", + flexShrink: 0, + background: "var(--bg)", + zIndex: 20, + }, children: [_jsx("div", { style: { + height: 48, + borderBottom: "1px solid var(--border)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, children: _jsx("img", { src: "/dhee-logo.png", alt: "Dhee", style: { width: 22, height: 22, objectFit: "contain" } }) }), _jsx("div", { style: { + flex: 1, + display: "flex", + flexDirection: "column", + padding: "6px 0", + gap: 0, + }, children: items.map((item) => { + const active = item.id === "router" + ? view === "router" || view.startsWith("router/") + : view === item.id; + return (_jsxs("div", { title: item.tip, onClick: () => setView(item.id), style: { + position: "relative", + height: 44, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + background: active ? "var(--surface)" : "transparent", + borderLeft: `2px solid ${active ? "var(--accent)" : "transparent"}`, + gap: 2, + transition: "all 0.1s", + }, onMouseEnter: (e) => { + if (!active) + e.currentTarget.style.background = "var(--surface)"; + }, onMouseLeave: (e) => { + if (!active) + e.currentTarget.style.background = "transparent"; + }, children: [_jsx("span", { style: { + fontSize: 14, + color: active ? "var(--accent)" : "var(--ink3)", + lineHeight: 1, + }, children: item.icon }), _jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 7, + color: active ? "var(--accent)" : "var(--ink3)", + letterSpacing: "0.04em", + }, children: item.label }), item.badge && item.badge > 0 ? (_jsx("div", { style: { + position: "absolute", + top: 6, + right: 6, + width: 14, + height: 14, + borderRadius: "50%", + background: "var(--rose)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, children: _jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 8, + color: "white", + fontWeight: 700, + }, children: item.badge }) })) : null] }, item.id)); + }) }), _jsx("div", { style: { + borderTop: "1px solid var(--border)", + height: 44, + display: "flex", + alignItems: "center", + justifyContent: "center", + }, children: _jsx("div", { title: "Dhee active", style: { + width: 5, + height: 5, + borderRadius: "50%", + background: "var(--green)", + } }) })] })); +} diff --git a/dhee/ui/web/src/components/NavRail.tsx b/dhee/ui/web/src/components/NavRail.tsx new file mode 100644 index 0000000..2b84968 --- /dev/null +++ b/dhee/ui/web/src/components/NavRail.tsx @@ -0,0 +1,192 @@ +type View = + | "command" + | "channel" + | "notepad" + | "tasks" + | "workspace" + | "canvas" + | "context" + | "memory" + | "router" + | "router/sessionshistory" + | "handoff" + | "replay" + | "learnings" + | "portability" + | "conflicts"; + +export function NavRail({ + view, + setView, + conflictCount, +}: { + view: View; + setView: (v: View) => void; + conflictCount: number; +}) { + const items: { + id: View; + icon: string; + label: string; + tip: string; + badge?: number; + }[] = [ + { id: "command", icon: "⌂", label: "HOME", tip: "Command center · current truth · next action" }, + { id: "router", icon: "⇌", label: "FIREWALL", tip: "Context firewall · routing · expansions · tokens saved" }, + { id: "canvas", icon: "⊞", label: "BRAIN", tip: "Repo Brain · linked folders · active sessions" }, + { id: "handoff", icon: "↗", label: "HANDOFF", tip: "Resume state across agents" }, + { id: "replay", icon: "◌", label: "REPLAY", tip: "Context decision replay" }, + { id: "learnings", icon: "↑", label: "LEARN", tip: "Evidence-backed learning review" }, + { id: "context", icon: "◐", label: "CONTEXT", tip: "Context vault · personal and shared memory" }, + { id: "portability", icon: "□", label: "PACKS", tip: ".dheemem export · import dry-run · portability" }, + { + id: "conflicts", + icon: "⟷", + label: "INBOX", + tip: "Proposals · findings · conflicts", + badge: conflictCount, + }, + ]; + return ( + ++ ); +} + +export type { View }; diff --git a/dhee/ui/web/src/components/OrgDrawer.js b/dhee/ui/web/src/components/OrgDrawer.js new file mode 100644 index 0000000..df50c09 --- /dev/null +++ b/dhee/ui/web/src/components/OrgDrawer.js @@ -0,0 +1,655 @@ +import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { useEffect, useState } from "react"; +import { api } from "../api"; +import { SectionHeader } from "./ui/SectionHeader"; +function metaRecord(node) { + return (node?.meta || {}); +} +function sessionTaskId(node) { + const meta = metaRecord(node); + const taskId = String(meta.task_id || meta.taskId || ""); + return taskId || null; +} +function repoMappingsFromNode(node) { + const rows = metaRecord(node).repo_mappings; + return Array.isArray(rows) ? rows : []; +} +function repoMappingLabel(mapping) { + const meta = (mapping.metadata || {}); + const label = typeof meta.label === "string" ? meta.label.trim() : ""; + const raw = label || mapping.local_path || mapping.repo_url || "folder"; + return String(raw).split("/").filter(Boolean).pop() || String(raw); +} +function runtimeColor(runtime) { + const value = String(runtime || "").toLowerCase(); + if (value === "codex") + return "var(--indigo)"; + if (value === "claude-code" || value === "claude") + return "var(--accent)"; + return "var(--ink3)"; +} +function uniqueMappings(rows) { + const seen = new Set(); + const out = []; + for (const row of rows) { + const key = String(row.mapping_id || row.local_path || row.repo_url || ""); + if (!key || seen.has(key)) + continue; + seen.add(key); + out.push(row); + } + return out; +} +export function OrgDrawer({ node, graph, viewer, isManager, onClose, onOpenVault, onOpenSession, onChanged, }) { + const [busy, setBusy] = useState(null); + const [folderPath, setFolderPath] = useState(""); + const [folderLabel, setFolderLabel] = useState(""); + const [projectName, setProjectName] = useState(""); + const [teamName, setTeamName] = useState(""); + const [gitUrl, setGitUrl] = useState(""); + const [collabTeamId, setCollabTeamId] = useState(""); + const [confirmReset, setConfirmReset] = useState(false); + const [confirmDeleteProject, setConfirmDeleteProject] = useState(false); + useEffect(() => { + setFolderPath(""); + setFolderLabel(""); + setProjectName(""); + setTeamName(""); + setGitUrl(""); + setCollabTeamId(""); + setConfirmReset(false); + setConfirmDeleteProject(false); + }, [node?.id]); + if (!node) + return null; + const isWorkspace = node.type === "workspace"; + const isProject = node.type === "project"; + const isTeam = node.type === "team" || node.type === "global_team"; + const isRepo = node.type === "repo"; + const isFolder = node.type === "folder"; + const isSession = node.type === "session"; + // ─── Workspace ───────────────────────────────────────────────────────── + const projects = isWorkspace && graph + ? graph.edges + .filter((e) => e.source === node.id && e.kind === "contains") + .map((e) => graph.nodes.find((n) => n.id === e.target)) + .filter((n) => Boolean(n) && n.type === "project") + : []; + // ─── Project ─────────────────────────────────────────────────────────── + const projectId = isProject + ? String(node.meta?.project_id || "") + : ""; + const projectTeams = isProject && graph + ? graph.edges + .filter((e) => e.kind === "contains" && e.source === node.id) + .map((e) => graph.nodes.find((n) => n.id === e.target)) + .filter((n) => Boolean(n) && (n.type === "team" || n.type === "global_team")) + : []; + // ─── Team / repo body data ───────────────────────────────────────────── + const repoMappings = isTeam ? repoMappingsFromNode(node) : []; + const teamMeta = metaRecord(node); + const teamId = isTeam ? String(teamMeta.team_id || "") : ""; + const developerCount = typeof teamMeta.developer_count === "number" ? teamMeta.developer_count : 0; + const developerJoinEvents = Array.isArray(teamMeta.developer_join_events) + ? teamMeta.developer_join_events + : []; + const collaboratingTeams = Array.isArray(teamMeta.collaborating_teams) + ? teamMeta.collaborating_teams + : []; + const allTeamNodes = graph + ? graph.nodes.filter((n) => (n.type === "team" || n.type === "global_team") && + String(n.meta?.team_id || "") !== teamId) + : []; + const folderMeta = metaRecord(node); + const selectedFolderPath = isFolder ? String(folderMeta.path || "") : ""; + const folderShared = isFolder ? Boolean(folderMeta.shared) : false; + const folderSessions = isFolder && graph + ? graph.edges + .filter((e) => e.source === node.id && e.kind === "contains") + .map((e) => graph.nodes.find((n) => n.id === e.target)) + .filter((n) => Boolean(n) && n.type === "session") + : []; + // ─── Actions ─────────────────────────────────────────────────────────── + const handleResetWorkspace = async () => { + setBusy("reset"); + try { + await api.enterpriseResetWorkspace(); + onChanged(); + } + finally { + setBusy(null); + } + }; + const handleCreateProject = async () => { + if (!projectName.trim()) + return; + setBusy("create-project"); + try { + await api.enterpriseCreateProject({ name: projectName.trim() }); + setProjectName(""); + onChanged(); + } + finally { + setBusy(null); + } + }; + const handleCreateProjectTeam = async () => { + if (!projectId || !teamName.trim()) + return; + setBusy("create-team"); + try { + await api.enterpriseCreateProjectTeam(projectId, { name: teamName.trim() }); + setTeamName(""); + onChanged(); + } + finally { + setBusy(null); + } + }; + const handleAddFolder = async () => { + if (!teamId || !folderPath.trim()) + return; + setBusy("add-folder"); + try { + await api.enterpriseAddTeamFolder(teamId, { + local_path: folderPath.trim(), + label: folderLabel.trim() || undefined, + kind: "folder", + }); + setFolderPath(""); + setFolderLabel(""); + onChanged(); + } + finally { + setBusy(null); + } + }; + const handleAddGitRepo = async () => { + if (!teamId || !gitUrl.trim()) + return; + setBusy("add-git"); + try { + await api.enterpriseAddTeamFolder(teamId, { + repo_url: gitUrl.trim(), + kind: "git", + }); + setGitUrl(""); + onChanged(); + } + finally { + setBusy(null); + } + }; + const handlePickFolder = async () => { + setBusy("pick-folder"); + try { + const r = await api.pickFolderPath("Pick a folder for this team"); + if (r.ok && r.path) + setFolderPath(r.path); + } + finally { + setBusy(null); + } + }; + const handleDeleteProject = async () => { + if (!projectId) + return; + setBusy("delete-project"); + try { + await api.enterpriseDeleteProject(projectId); + onChanged(); + } + finally { + setBusy(null); + } + }; + const handleRemoveFolder = async (mappingId) => { + if (!mappingId) + return; + setBusy("remove-folder"); + try { + await api.enterpriseRemoveFolder(mappingId); + onChanged(); + } + finally { + setBusy(null); + } + }; + const handleAddCollaborator = async () => { + if (!teamId || !collabTeamId.trim()) + return; + setBusy("collaborate"); + try { + await api.enterpriseAddTeamCollaborator(teamId, collabTeamId.trim()); + setCollabTeamId(""); + onChanged(); + } + finally { + setBusy(null); + } + }; + const handleExtractProject = async () => { + if (!projectId) + return; + setBusy("extract"); + try { + const result = await api.enterpriseExtractProject(projectId); + onChanged(); + const summary = `AST extraction · ${result.folders_seen} folder(s) · ` + + `${result.files_seen} files (${result.files_extracted} new, ${result.files_cached} cached) · ` + + `${result.nodes_upserted} nodes · ${result.edges_upserted} edges`; + // eslint-disable-next-line no-alert + window.alert(summary); + } + catch (err) { + // eslint-disable-next-line no-alert + window.alert(`Extraction failed: ${String(err)}`); + } + finally { + setBusy(null); + } + }; + const handleExtractTeam = async () => { + if (!teamId) + return; + setBusy("extract"); + try { + const result = await api.enterpriseExtractTeam(teamId); + onChanged(); + const summary = `AST extraction · ${result.folders_seen} folder(s) · ` + + `${result.files_seen} files (${result.files_extracted} new, ${result.files_cached} cached) · ` + + `${result.nodes_upserted} nodes · ${result.edges_upserted} edges`; + // eslint-disable-next-line no-alert + window.alert(summary); + } + catch (err) { + // eslint-disable-next-line no-alert + window.alert(`Extraction failed: ${String(err)}`); + } + finally { + setBusy(null); + } + }; + const handleToggleFolderShare = async () => { + if (!selectedFolderPath) + return; + setBusy("share-folder"); + try { + await api.localContextShareFolder({ + path: selectedFolderPath, + shared: !folderShared, + }); + onChanged(); + } + finally { + setBusy(null); + } + }; + return (_jsxs("aside", { style: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + width: 440, + background: "var(--bg)", + borderLeft: "1px solid var(--border)", + boxShadow: "-12px 0 30px rgba(20,16,10,0.06)", + display: "flex", + flexDirection: "column", + zIndex: 25, + animation: "fadein 0.18s ease", + }, children: [_jsxs("header", { style: { + padding: "12px 16px", + borderBottom: "1px solid var(--border)", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }, children: [_jsxs("div", { children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: "0.12em", + color: "var(--ink3)", + textTransform: "uppercase", + }, children: nodeKindLabel(node.type) }), _jsx("div", { style: { fontSize: 16, fontWeight: 500, color: "var(--ink)" }, children: node.label })] }), _jsx("button", { onClick: onClose, "aria-label": "Close drawer", style: { + width: 24, + height: 24, + borderRadius: 4, + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--ink2)", + }, children: "\u00D7" })] }), _jsxs("div", { style: { flex: 1, overflowY: "auto", padding: 14 }, children: [isFolder ? (_jsx(FolderBody, { node: node, sessions: folderSessions, shared: folderShared, onToggleShare: handleToggleFolderShare, onOpenVault: () => onOpenVault(), onOpenSession: onOpenSession, busy: busy })) : null, isSession ? (_jsx(SessionBody, { node: node, onOpenSession: () => onOpenSession(node.id, sessionTaskId(node)) })) : null, isWorkspace ? (_jsx(WorkspaceBody, { projects: projects, projectName: projectName, onProjectName: setProjectName, onCreateProject: handleCreateProject, confirmReset: confirmReset, onAskReset: () => setConfirmReset(true), onCancelReset: () => setConfirmReset(false), onConfirmReset: handleResetWorkspace, busy: busy })) : null, isProject ? (_jsx(ProjectBody, { teams: projectTeams, teamName: teamName, onTeamName: setTeamName, onCreateTeam: handleCreateProjectTeam, confirmDelete: confirmDeleteProject, onAskDelete: () => setConfirmDeleteProject(true), onCancelDelete: () => setConfirmDeleteProject(false), onConfirmDelete: handleDeleteProject, busy: busy })) : null, isTeam ? (_jsx(TeamBody, { node: node, repoMappings: repoMappings, developerCount: developerCount, developerJoinEvents: developerJoinEvents, collaboratingTeams: collaboratingTeams, collaboratorOptions: allTeamNodes, collabTeamId: collabTeamId, onCollabTeamId: setCollabTeamId, onAddCollaborator: handleAddCollaborator, folderPath: folderPath, folderLabel: folderLabel, gitUrl: gitUrl, onFolderPath: setFolderPath, onFolderLabel: setFolderLabel, onGitUrl: setGitUrl, onPickFolder: handlePickFolder, onAddFolder: handleAddFolder, onAddGit: handleAddGitRepo, onExtract: handleExtractTeam, onRemoveFolder: handleRemoveFolder, onOpenVault: () => onOpenVault(String(node.meta?.team_id || "")), isManager: isManager, viewer: viewer, busy: busy })) : null, isRepo ? (_jsx(RepoBody, { node: node, onRemove: () => handleRemoveFolder(String(node.meta?.mapping_id || "")), busy: busy })) : null] })] })); +} +function nodeKindLabel(t) { + if (t === "global_team") + return "GLOBAL TEAM"; + if (t === "folder") + return "LOCAL FOLDER"; + if (t === "session") + return "AGENT SESSION"; + return t.toUpperCase(); +} +function FolderBody({ node, sessions, shared, onToggleShare, onOpenVault, onOpenSession, busy, }) { + const meta = metaRecord(node); + const path = String(meta.path || ""); + const activeSessions = Number(meta.active_session_count || 0); + const manager = typeof meta.context_manager === "object" && meta.context_manager + ? meta.context_manager + : null; + return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx("button", { onClick: onOpenVault, style: primaryBtnFilled(false), children: "OPEN CONTEXT \u2192" }), _jsxs("div", { children: [_jsx(SectionHeader, { children: "Folder" }), _jsxs("div", { style: { + marginTop: 6, + padding: "8px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + display: "grid", + gap: 4, + }, children: [_jsx(KeyValue, { label: "path", value: path || node.label, mono: true }), _jsx(KeyValue, { label: "sessions", value: `${sessions.length}` }), _jsx(KeyValue, { label: "active", value: `${activeSessions}` })] })] }), _jsxs("div", { children: [_jsx(SectionHeader, { children: "Context manager" }), _jsxs("div", { style: { + marginTop: 6, + padding: "8px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + display: "grid", + gap: 4, + }, children: [_jsx(KeyValue, { label: "owner", value: String(manager?.display_name || `${node.label} Context Manager`) }), _jsx(KeyValue, { label: "scope", value: String(manager?.folder_path || path || node.label), mono: true })] })] }), _jsxs("div", { children: [_jsx(SectionHeader, { children: "Context sharing" }), _jsx("button", { onClick: onToggleShare, disabled: busy === "share-folder", style: shared ? primaryBtnFilled(busy === "share-folder") : primaryBtn(busy === "share-folder"), children: busy === "share-folder" + ? "UPDATING..." + : shared + ? "SHARING ENABLED" + : "SHARE THIS FOLDER" }), _jsx(Hint, { children: "Shared folders exchange local context with the other folders you enable here." })] }), _jsxs("div", { children: [_jsxs(SectionHeader, { children: ["Agent sessions (", sessions.length, ")"] }), sessions.length === 0 ? (_jsx(Hint, { children: "No Claude Code or Codex sessions detected for this folder yet." })) : (_jsx("div", { style: { display: "grid", gap: 4, marginTop: 6 }, children: sessions.map((session) => { + const smeta = metaRecord(session); + const color = runtimeColor(smeta.runtime); + return (_jsxs("div", { style: { + padding: "7px 10px", + border: "1px solid var(--border)", + borderLeft: `3px solid ${color}`, + borderRadius: 4, + background: "var(--surface)", + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) auto", + gap: 8, + alignItems: "center", + }, children: [_jsxs("div", { style: { minWidth: 0 }, children: [_jsx("div", { style: { + fontSize: 12, + color: "var(--ink)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, title: session.label, children: session.label }), _jsxs("div", { style: { fontFamily: "var(--mono)", fontSize: 10, color }, children: [String(smeta.runtime || "agent"), " \u00B7 ", String(smeta.state || "recent")] })] }), _jsx("button", { onClick: () => onOpenSession(session.id, sessionTaskId(session)), style: smallActionBtn(color), children: "OPEN" })] }, session.id)); + }) }))] })] })); +} +function SessionBody({ node, onOpenSession, }) { + const meta = metaRecord(node); + return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx("button", { onClick: onOpenSession, style: primaryBtnFilled(false), children: "OPEN SESSION TASK \u2192" }), _jsxs("div", { children: [_jsx(SectionHeader, { children: "Session" }), _jsxs("div", { style: { + marginTop: 6, + padding: "8px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + display: "grid", + gap: 4, + }, children: [_jsx(KeyValue, { label: "runtime", value: String(meta.runtime || "agent") }), _jsx(KeyValue, { label: "state", value: String(meta.state || "recent") }), meta.model ? _jsx(KeyValue, { label: "model", value: String(meta.model) }) : null, meta.cwd ? _jsx(KeyValue, { label: "folder", value: String(meta.cwd), mono: true }) : null, meta.updated_at ? _jsx(KeyValue, { label: "updated", value: String(meta.updated_at) }) : null] })] }), meta.preview ? (_jsxs("div", { children: [_jsx(SectionHeader, { children: "Preview" }), _jsx("div", { style: { + marginTop: 6, + padding: "8px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + color: "var(--ink2)", + fontSize: 12, + lineHeight: 1.5, + whiteSpace: "pre-wrap", + }, children: String(meta.preview) })] })) : null] })); +} +// ─── Workspace ───────────────────────────────────────────────────────────── +function WorkspaceBody({ projects, projectName, onProjectName, onCreateProject, confirmReset, onAskReset, onCancelReset, onConfirmReset, busy, }) { + return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 16 }, children: [_jsxs("div", { children: [_jsx(SectionHeader, { children: "Add a project" }), _jsxs("div", { style: { display: "flex", gap: 6, marginTop: 8 }, children: [_jsx("input", { value: projectName, onChange: (e) => onProjectName(e.target.value), placeholder: "e.g. Text_to_Speech", onKeyDown: (e) => { + if (e.key === "Enter") + onCreateProject(); + }, style: inputStyle }), _jsx("button", { onClick: onCreateProject, disabled: busy === "create-project" || !projectName.trim(), style: primaryBtn(busy === "create-project"), children: "CREATE" })] })] }), _jsxs("div", { children: [_jsxs(SectionHeader, { children: ["Projects (", projects.length, ")"] }), projects.length === 0 ? (_jsx(Hint, { children: "No projects yet. Add one above." })) : (_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 4, marginTop: 8 }, children: projects.map((p) => (_jsxs("div", { style: { + padding: "6px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + fontSize: 12, + color: "var(--ink)", + }, children: [_jsx("span", { children: p.label }), _jsx(Pill, { label: "open", tone: "default" })] }, p.id))) }))] }), _jsxs("div", { style: { + marginTop: 8, + paddingTop: 14, + borderTop: "1px solid var(--border)", + }, children: [_jsx(SectionHeader, { children: "Danger zone" }), !confirmReset ? (_jsx("button", { onClick: onAskReset, style: dangerBtn, children: "RESET WORKSPACE" })) : (_jsxs("div", { style: { + marginTop: 8, + padding: 10, + border: "1px solid var(--rose)", + background: "var(--rose-dim)", + borderRadius: 4, + fontSize: 12, + color: "var(--ink)", + }, children: [_jsx("div", { style: { marginBottom: 8 }, children: "This deletes projects, teams, folders, context items, proposals, and findings for this org. Memory engrams in the Dhee tier are not affected. Continue?" }), _jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { onClick: onConfirmReset, disabled: busy === "reset", style: dangerBtn, children: busy === "reset" ? "RESETTING…" : "YES, RESET" }), _jsx("button", { onClick: onCancelReset, style: ghostBtn, children: "CANCEL" })] })] }))] })] })); +} +// ─── Project ─────────────────────────────────────────────────────────────── +function ProjectBody({ teams, teamName, onTeamName, onCreateTeam, confirmDelete, onAskDelete, onCancelDelete, onConfirmDelete, busy, }) { + return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 16 }, children: [_jsxs("div", { children: [_jsx(SectionHeader, { children: "Add a team" }), _jsxs("div", { style: { display: "flex", gap: 6, marginTop: 8 }, children: [_jsx("input", { value: teamName, onChange: (e) => onTeamName(e.target.value), placeholder: "Backend, Frontend, Data, Mobile", onKeyDown: (e) => { + if (e.key === "Enter") + onCreateTeam(); + }, style: inputStyle }), _jsx("button", { onClick: onCreateTeam, disabled: busy === "create-team" || !teamName.trim(), style: primaryBtn(busy === "create-team"), children: "ADD" })] })] }), _jsxs("div", { children: [_jsxs(SectionHeader, { children: ["Teams (", teams.length, ")"] }), teams.length === 0 ? (_jsx(Hint, { children: "No teams yet." })) : (_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 4, marginTop: 8 }, children: teams.map((team) => { + const mappings = uniqueMappings(repoMappingsFromNode(team)); + return (_jsxs("div", { style: { + padding: "6px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + }, children: [_jsx("span", { style: { color: "var(--ink)", fontSize: 12 }, children: team.label }), _jsx(Pill, { label: `${mappings.length} ${mappings.length === 1 ? "repo" : "repos"}`, tone: mappings.length ? "green" : "default" })] }, team.id)); + }) }))] }), _jsx("div", { style: { + marginTop: 8, + paddingTop: 14, + borderTop: "1px solid var(--border)", + }, children: !confirmDelete ? (_jsx("button", { onClick: onAskDelete, style: dangerBtn, children: "DELETE PROJECT" })) : (_jsxs("div", { style: { + marginTop: 4, + padding: 10, + border: "1px solid var(--rose)", + background: "var(--rose-dim)", + borderRadius: 4, + fontSize: 12, + color: "var(--ink)", + }, children: [_jsx("div", { style: { marginBottom: 8 }, children: "Deletes the project and all its teams + context. Continue?" }), _jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { onClick: onConfirmDelete, disabled: busy === "delete-project", style: dangerBtn, children: busy === "delete-project" ? "DELETING…" : "YES, DELETE" }), _jsx("button", { onClick: onCancelDelete, style: ghostBtn, children: "CANCEL" })] })] })) })] })); +} +function TeamBody({ node, repoMappings, developerCount, developerJoinEvents, collaboratingTeams, collaboratorOptions, collabTeamId, onCollabTeamId, onAddCollaborator, folderPath, folderLabel, gitUrl, onFolderPath, onFolderLabel, onGitUrl, onPickFolder, onAddFolder, onAddGit, onExtract, onRemoveFolder, onOpenVault, busy, }) { + const meta = metaRecord(node); + const manager = meta.context_manager; + const teamId = String(meta.team_id || ""); + const projectId = String(meta.project_id || ""); + const mappings = uniqueMappings(repoMappings); + return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx("button", { onClick: onOpenVault, style: primaryBtnFilled(false), children: "OPEN CONTEXT \u2192" }), _jsxs("div", { children: [_jsx(SectionHeader, { children: "Team details" }), _jsxs("div", { style: { + marginTop: 6, + display: "grid", + gap: 4, + padding: "8px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + fontSize: 12, + }, children: [_jsx(KeyValue, { label: "team", value: teamId || node.label }), projectId ? _jsx(KeyValue, { label: "project", value: projectId }) : null, _jsx(KeyValue, { label: "git access", value: `${developerCount} dev${developerCount === 1 ? "" : "s"} joined` })] })] }), _jsxs("div", { children: [_jsx(SectionHeader, { children: "Manager" }), _jsx("div", { style: { + marginTop: 6, + padding: "8px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + fontSize: 12, + }, children: manager?.display_name ? (_jsxs(_Fragment, { children: [_jsx("div", { children: manager.display_name }), _jsx("div", { style: { fontSize: 10, color: "var(--ink3)" }, children: manager.manager_id })] })) : (_jsx("span", { style: { color: "var(--ink3)" }, children: "no manager assigned" })) })] }), _jsxs("div", { children: [_jsx(SectionHeader, { children: "Add a local folder" }), _jsxs("div", { style: { display: "flex", gap: 6, marginTop: 8 }, children: [_jsx("input", { value: folderPath, onChange: (e) => onFolderPath(e.target.value), placeholder: "/Users/me/code/backend", onKeyDown: (e) => { + if (e.key === "Enter") + onAddFolder(); + }, style: inputStyle }), _jsx("button", { onClick: onPickFolder, disabled: busy === "pick-folder", style: ghostBtn, title: "Browse", children: "BROWSE" })] }), _jsxs("div", { style: { display: "flex", gap: 6, marginTop: 6 }, children: [_jsx("input", { value: folderLabel, onChange: (e) => onFolderLabel(e.target.value), placeholder: "Optional label", style: inputStyle }), _jsx("button", { onClick: onAddFolder, disabled: busy === "add-folder" || !folderPath.trim(), style: primaryBtn(busy === "add-folder"), children: "ADD" })] })] }), _jsxs("div", { children: [_jsx(SectionHeader, { children: "Add a git repo" }), _jsxs("div", { style: { display: "flex", gap: 6, marginTop: 8 }, children: [_jsx("input", { value: gitUrl, onChange: (e) => onGitUrl(e.target.value), placeholder: "git@github.com:org/backend.git", onKeyDown: (e) => { + if (e.key === "Enter") + onAddGit(); + }, style: inputStyle }), _jsx("button", { onClick: onAddGit, disabled: busy === "add-git" || !gitUrl.trim(), style: primaryBtn(busy === "add-git"), children: "ADD" })] })] }), _jsxs("div", { children: [_jsxs("div", { style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + }, children: [_jsxs(SectionHeader, { children: ["Git + local folders (", mappings.length, ")"] }), _jsx("button", { onClick: onExtract, disabled: busy === "extract" || mappings.length === 0, title: "Run AST extraction for this team's local folders", style: primaryBtn(busy === "extract"), children: busy === "extract" ? "INDEXING..." : "INDEX TEAM" })] }), mappings.length === 0 ? (_jsx(Hint, { children: "None mapped to this team." })) : (_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 4, marginTop: 6 }, children: mappings.map((mapping) => { + const key = String(mapping.mapping_id || mapping.local_path || mapping.repo_url); + return (_jsxs("div", { style: { + padding: "8px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + display: "grid", + gap: 4, + }, children: [_jsxs("div", { style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + }, children: [_jsx("div", { style: { fontSize: 12, color: "var(--ink)" }, children: repoMappingLabel(mapping) }), _jsx("button", { onClick: () => onRemoveFolder(mapping.mapping_id), style: iconBtn, title: "Remove mapping", "aria-label": "Remove mapping", children: "\u00D7" })] }), mapping.repo_url ? (_jsx(KeyValue, { label: "repo", value: String(mapping.repo_url), mono: true })) : null, mapping.local_path ? (_jsx(KeyValue, { label: "folder", value: String(mapping.local_path), mono: true })) : null] }, key)); + }) }))] }), developerJoinEvents.length ? (_jsxs("div", { children: [_jsx(SectionHeader, { children: "Recent joins" }), _jsx("div", { style: { display: "grid", gap: 4, marginTop: 6 }, children: developerJoinEvents.slice(0, 4).map((event, idx) => (_jsxs("div", { style: { + padding: "6px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + fontSize: 11, + color: "var(--ink2)", + }, children: [_jsx("div", { style: { fontFamily: "var(--mono)", overflow: "hidden", textOverflow: "ellipsis" }, children: event.repo_root || "workspace" }), _jsxs("div", { style: { color: "var(--ink3)", marginTop: 2 }, children: [event.role || "developer", " - ", event.received_at || "recent"] })] }, `${event.repo_root || "join"}-${idx}`))) })] })) : null, _jsxs("div", { children: [_jsx(SectionHeader, { children: "Collaborate teams" }), collaboratingTeams.length === 0 ? (_jsx(Hint, { children: "No team context shares yet." })) : (_jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginTop: 6 }, children: collaboratingTeams.map((team) => (_jsx(Pill, { label: String(team.name || team.team_id), tone: "default" }, String(team.team_id || team.name)))) })), _jsxs("div", { style: { display: "flex", gap: 6, marginTop: 8 }, children: [_jsxs("select", { value: collabTeamId, onChange: (e) => onCollabTeamId(e.target.value), style: inputStyle, children: [_jsx("option", { value: "", children: "Select team" }), collaboratorOptions.map((team) => { + const optionTeamId = String(team.meta?.team_id || ""); + return (_jsx("option", { value: optionTeamId, children: team.label }, team.id)); + })] }), _jsx("button", { onClick: onAddCollaborator, disabled: busy === "collaborate" || !collabTeamId, style: primaryBtn(busy === "collaborate"), children: "ADD" })] })] })] })); +} +function RepoBody({ node, onRemove, busy, }) { + const meta = node.meta || {}; + return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx(SectionHeader, { children: "Folder / path" }), _jsx("div", { style: { + padding: "8px 10px", + border: "1px solid var(--border)", + borderRadius: 4, + background: "var(--surface)", + fontFamily: "var(--mono)", + fontSize: 11, + color: "var(--ink2)", + wordBreak: "break-all", + }, children: meta.local_path || meta.repo_url || node.label }), _jsx("button", { onClick: onRemove, disabled: busy === "remove-folder", style: dangerBtn, children: busy === "remove-folder" ? "REMOVING…" : "REMOVE" })] })); +} +// ─── Atoms ───────────────────────────────────────────────────────────────── +function KeyValue({ label, value, mono = false, }) { + return (_jsxs("div", { style: { + display: "grid", + gridTemplateColumns: "82px minmax(0, 1fr)", + gap: 8, + alignItems: "baseline", + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: label }), _jsx("span", { title: value, style: { + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontFamily: mono ? "var(--mono)" : undefined, + fontSize: mono ? 10 : 12, + color: "var(--ink2)", + }, children: value })] })); +} +function Hint({ children }) { + return (_jsx("div", { style: { fontSize: 11, color: "var(--ink3)", marginTop: 6 }, children: children })); +} +function Pill({ label, tone = "default", }) { + const map = { + default: { bg: "var(--surface)", fg: "var(--ink2)" }, + green: { bg: "var(--green-dim)", fg: "var(--green)" }, + indigo: { bg: "var(--indigo-dim)", fg: "var(--indigo)" }, + rose: { bg: "var(--rose-dim)", fg: "var(--rose)" }, + accent: { bg: "var(--accent-dim)", fg: "var(--accent)" }, + }; + const c = map[tone]; + return (_jsx("span", { style: { + display: "inline-flex", + padding: "2px 7px", + borderRadius: 3, + background: c.bg, + color: c.fg, + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: "0.04em", + }, children: label })); +} +const inputStyle = { + flex: 1, + fontFamily: "var(--mono)", + fontSize: 11, + padding: "6px 8px", + background: "var(--surface)", + border: "1px solid var(--border)", + borderRadius: 3, + color: "var(--ink)", +}; +function primaryBtn(busy) { + return { + fontFamily: "var(--mono)", + fontSize: 10, + padding: "5px 12px", + background: busy ? "var(--surface)" : "var(--accent-dim)", + color: "var(--accent)", + border: "1px solid var(--accent)", + borderRadius: 3, + cursor: busy ? "wait" : "pointer", + }; +} +function primaryBtnFilled(busy) { + return { + fontFamily: "var(--mono)", + fontSize: 11, + padding: "8px 12px", + background: busy ? "var(--surface)" : "var(--accent-dim)", + color: "var(--accent)", + border: "1px solid var(--accent)", + borderRadius: 4, + textAlign: "center", + cursor: busy ? "wait" : "pointer", + }; +} +function smallActionBtn(color) { + return { + fontFamily: "var(--mono)", + fontSize: 9, + padding: "5px 8px", + background: "white", + color, + border: `1px solid ${color}`, + borderRadius: 3, + cursor: "pointer", + whiteSpace: "nowrap", + }; +} +const ghostBtn = { + fontFamily: "var(--mono)", + fontSize: 10, + padding: "5px 10px", + background: "var(--surface)", + color: "var(--ink2)", + border: "1px solid var(--border)", + borderRadius: 3, +}; +const dangerBtn = { + fontFamily: "var(--mono)", + fontSize: 10, + padding: "5px 12px", + background: "var(--rose-dim)", + color: "var(--rose)", + border: "1px solid var(--rose)", + borderRadius: 3, +}; +const iconBtn = { + width: 22, + height: 22, + borderRadius: 3, + background: "var(--surface)", + color: "var(--ink2)", + border: "1px solid var(--border)", + fontSize: 12, + lineHeight: 1, +}; diff --git a/dhee/ui/web/src/components/OrgDrawer.tsx b/dhee/ui/web/src/components/OrgDrawer.tsx new file mode 100644 index 0000000..278f08f --- /dev/null +++ b/dhee/ui/web/src/components/OrgDrawer.tsx @@ -0,0 +1,1458 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import type { OrgGraphSnapshot, OrgNode, Viewer } from "../types"; +import { SectionHeader } from "./ui/SectionHeader"; + +interface Props { + node: OrgNode | null; + graph: OrgGraphSnapshot | null; + viewer: Viewer | null; + isManager: boolean; + onClose: () => void; + onOpenVault: (teamId?: string) => void; + onOpenSession: (sessionId: string, taskId?: string | null) => void; + onChanged: () => void; +} + +interface RepoMapping { + mapping_id?: string; + repo_url?: string | null; + local_path?: string | null; + provider?: string | null; + branch?: string | null; + project_id?: string | null; + team_id?: string | null; + metadata?: Record+++
+ {items.map((item) => { + const active = + item.id === "router" + ? view === "router" || view.startsWith("router/") + : view === item.id; + return ( ++setView(item.id)} + style={{ + position: "relative", + height: 44, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + background: active ? "var(--surface)" : "transparent", + borderLeft: `2px solid ${ + active ? "var(--accent)" : "transparent" + }`, + gap: 2, + transition: "all 0.1s", + }} + onMouseEnter={(e) => { + if (!active) + e.currentTarget.style.background = "var(--surface)"; + }} + onMouseLeave={(e) => { + if (!active) e.currentTarget.style.background = "transparent"; + }} + > + + {item.icon} + + + {item.label} + + {item.badge && item.badge > 0 ? ( ++ ); + })} ++ + {item.badge} + ++ ) : null} ++ ++| null; +} + +interface JoinEvent { + repo_root?: string | null; + role?: string | null; + received_at?: string | null; +} + +interface CollaboratingTeam { + team_id?: string | null; + name?: string | null; + project_id?: string | null; +} + +function metaRecord(node: OrgNode | null): Record { + return (node?.meta || {}) as Record ; +} + +function sessionTaskId(node: OrgNode): string | null { + const meta = metaRecord(node); + const taskId = String(meta.task_id || meta.taskId || ""); + return taskId || null; +} + +function repoMappingsFromNode(node: OrgNode | null): RepoMapping[] { + const rows = metaRecord(node).repo_mappings; + return Array.isArray(rows) ? (rows as RepoMapping[]) : []; +} + +function repoMappingLabel(mapping: RepoMapping): string { + const meta = (mapping.metadata || {}) as Record ; + const label = typeof meta.label === "string" ? meta.label.trim() : ""; + const raw = label || mapping.local_path || mapping.repo_url || "folder"; + return String(raw).split("/").filter(Boolean).pop() || String(raw); +} + +function runtimeColor(runtime: unknown): string { + const value = String(runtime || "").toLowerCase(); + if (value === "codex") return "var(--indigo)"; + if (value === "claude-code" || value === "claude") return "var(--accent)"; + return "var(--ink3)"; +} + +function uniqueMappings(rows: RepoMapping[]): RepoMapping[] { + const seen = new Set (); + const out: RepoMapping[] = []; + for (const row of rows) { + const key = String(row.mapping_id || row.local_path || row.repo_url || ""); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(row); + } + return out; +} + +export function OrgDrawer({ + node, + graph, + viewer, + isManager, + onClose, + onOpenVault, + onOpenSession, + onChanged, +}: Props) { + const [busy, setBusy] = useState (null); + const [folderPath, setFolderPath] = useState(""); + const [folderLabel, setFolderLabel] = useState(""); + const [projectName, setProjectName] = useState(""); + const [teamName, setTeamName] = useState(""); + const [gitUrl, setGitUrl] = useState(""); + const [collabTeamId, setCollabTeamId] = useState(""); + const [confirmReset, setConfirmReset] = useState(false); + const [confirmDeleteProject, setConfirmDeleteProject] = useState(false); + + useEffect(() => { + setFolderPath(""); + setFolderLabel(""); + setProjectName(""); + setTeamName(""); + setGitUrl(""); + setCollabTeamId(""); + setConfirmReset(false); + setConfirmDeleteProject(false); + }, [node?.id]); + + if (!node) return null; + const isWorkspace = node.type === "workspace"; + const isProject = node.type === "project"; + const isTeam = node.type === "team" || node.type === "global_team"; + const isRepo = node.type === "repo"; + const isFolder = node.type === "folder"; + const isSession = node.type === "session"; + + // ─── Workspace ───────────────────────────────────────────────────────── + const projects = + isWorkspace && graph + ? graph.edges + .filter((e) => e.source === node.id && e.kind === "contains") + .map((e) => graph.nodes.find((n) => n.id === e.target)) + .filter((n): n is OrgNode => Boolean(n) && n!.type === "project") + : []; + + // ─── Project ─────────────────────────────────────────────────────────── + const projectId = isProject + ? String((node.meta as { project_id?: string })?.project_id || "") + : ""; + const projectTeams = + isProject && graph + ? graph.edges + .filter((e) => e.kind === "contains" && e.source === node.id) + .map((e) => graph.nodes.find((n) => n.id === e.target)) + .filter( + (n): n is OrgNode => + Boolean(n) && (n!.type === "team" || n!.type === "global_team") + ) + : []; + + // ─── Team / repo body data ───────────────────────────────────────────── + const repoMappings = isTeam ? repoMappingsFromNode(node) : []; + const teamMeta = metaRecord(node); + const teamId = isTeam ? String(teamMeta.team_id || "") : ""; + const developerCount = + typeof teamMeta.developer_count === "number" ? teamMeta.developer_count : 0; + const developerJoinEvents = Array.isArray(teamMeta.developer_join_events) + ? (teamMeta.developer_join_events as JoinEvent[]) + : []; + const collaboratingTeams = Array.isArray(teamMeta.collaborating_teams) + ? (teamMeta.collaborating_teams as CollaboratingTeam[]) + : []; + const allTeamNodes = graph + ? graph.nodes.filter( + (n) => + (n.type === "team" || n.type === "global_team") && + String((n.meta as { team_id?: string })?.team_id || "") !== teamId + ) + : []; + const folderMeta = metaRecord(node); + const selectedFolderPath = isFolder ? String(folderMeta.path || "") : ""; + const folderShared = isFolder ? Boolean(folderMeta.shared) : false; + const folderSessions = + isFolder && graph + ? graph.edges + .filter((e) => e.source === node.id && e.kind === "contains") + .map((e) => graph.nodes.find((n) => n.id === e.target)) + .filter((n): n is OrgNode => Boolean(n) && n!.type === "session") + : []; + + // ─── Actions ─────────────────────────────────────────────────────────── + const handleResetWorkspace = async () => { + setBusy("reset"); + try { + await api.enterpriseResetWorkspace(); + onChanged(); + } finally { + setBusy(null); + } + }; + + const handleCreateProject = async () => { + if (!projectName.trim()) return; + setBusy("create-project"); + try { + await api.enterpriseCreateProject({ name: projectName.trim() }); + setProjectName(""); + onChanged(); + } finally { + setBusy(null); + } + }; + + const handleCreateProjectTeam = async () => { + if (!projectId || !teamName.trim()) return; + setBusy("create-team"); + try { + await api.enterpriseCreateProjectTeam(projectId, { name: teamName.trim() }); + setTeamName(""); + onChanged(); + } finally { + setBusy(null); + } + }; + + const handleAddFolder = async () => { + if (!teamId || !folderPath.trim()) return; + setBusy("add-folder"); + try { + await api.enterpriseAddTeamFolder(teamId, { + local_path: folderPath.trim(), + label: folderLabel.trim() || undefined, + kind: "folder", + }); + setFolderPath(""); + setFolderLabel(""); + onChanged(); + } finally { + setBusy(null); + } + }; + + const handleAddGitRepo = async () => { + if (!teamId || !gitUrl.trim()) return; + setBusy("add-git"); + try { + await api.enterpriseAddTeamFolder(teamId, { + repo_url: gitUrl.trim(), + kind: "git", + }); + setGitUrl(""); + onChanged(); + } finally { + setBusy(null); + } + }; + + const handlePickFolder = async () => { + setBusy("pick-folder"); + try { + const r = await api.pickFolderPath("Pick a folder for this team"); + if (r.ok && r.path) setFolderPath(r.path); + } finally { + setBusy(null); + } + }; + + const handleDeleteProject = async () => { + if (!projectId) return; + setBusy("delete-project"); + try { + await api.enterpriseDeleteProject(projectId); + onChanged(); + } finally { + setBusy(null); + } + }; + + const handleRemoveFolder = async (mappingId?: string | null) => { + if (!mappingId) return; + setBusy("remove-folder"); + try { + await api.enterpriseRemoveFolder(mappingId); + onChanged(); + } finally { + setBusy(null); + } + }; + + const handleAddCollaborator = async () => { + if (!teamId || !collabTeamId.trim()) return; + setBusy("collaborate"); + try { + await api.enterpriseAddTeamCollaborator(teamId, collabTeamId.trim()); + setCollabTeamId(""); + onChanged(); + } finally { + setBusy(null); + } + }; + + const handleExtractProject = async () => { + if (!projectId) return; + setBusy("extract"); + try { + const result = await api.enterpriseExtractProject(projectId); + onChanged(); + const summary = + `AST extraction · ${result.folders_seen} folder(s) · ` + + `${result.files_seen} files (${result.files_extracted} new, ${result.files_cached} cached) · ` + + `${result.nodes_upserted} nodes · ${result.edges_upserted} edges`; + // eslint-disable-next-line no-alert + window.alert(summary); + } catch (err) { + // eslint-disable-next-line no-alert + window.alert(`Extraction failed: ${String(err)}`); + } finally { + setBusy(null); + } + }; + + const handleExtractTeam = async () => { + if (!teamId) return; + setBusy("extract"); + try { + const result = await api.enterpriseExtractTeam(teamId); + onChanged(); + const summary = + `AST extraction · ${result.folders_seen} folder(s) · ` + + `${result.files_seen} files (${result.files_extracted} new, ${result.files_cached} cached) · ` + + `${result.nodes_upserted} nodes · ${result.edges_upserted} edges`; + // eslint-disable-next-line no-alert + window.alert(summary); + } catch (err) { + // eslint-disable-next-line no-alert + window.alert(`Extraction failed: ${String(err)}`); + } finally { + setBusy(null); + } + }; + + const handleToggleFolderShare = async () => { + if (!selectedFolderPath) return; + setBusy("share-folder"); + try { + await api.localContextShareFolder({ + path: selectedFolderPath, + shared: !folderShared, + }); + onChanged(); + } finally { + setBusy(null); + } + }; + + return ( + + ); +} + +function nodeKindLabel(t: string): string { + if (t === "global_team") return "GLOBAL TEAM"; + if (t === "folder") return "LOCAL FOLDER"; + if (t === "session") return "AGENT SESSION"; + return t.toUpperCase(); +} + +function FolderBody({ + node, + sessions, + shared, + onToggleShare, + onOpenVault, + onOpenSession, + busy, +}: { + node: OrgNode; + sessions: OrgNode[]; + shared: boolean; + onToggleShare: () => void; + onOpenVault: () => void; + onOpenSession: (sessionId: string, taskId?: string | null) => void; + busy: string | null; +}) { + const meta = metaRecord(node); + const path = String(meta.path || ""); + const activeSessions = Number(meta.active_session_count || 0); + const manager = + typeof meta.context_manager === "object" && meta.context_manager + ? (meta.context_manager as Record ) + : null; + return ( + + ++ ); +} + +function SessionBody({ + node, + onOpenSession, +}: { + node: OrgNode; + onOpenSession: () => void; +}) { + const meta = metaRecord(node); + return ( +++Folder ++++ + + ++Context manager ++++ + ++Context sharing + ++ Shared folders exchange local context with the other folders you enable here. + +++Agent sessions ({sessions.length}) + {sessions.length === 0 ? ( +No Claude Code or Codex sessions detected for this folder yet. + ) : ( ++ {sessions.map((session) => { + const smeta = metaRecord(session); + const color = runtimeColor(smeta.runtime); + return ( ++ )} +++ ); + })} +++ ++ {session.label} +++ {String(smeta.runtime || "agent")} · {String(smeta.state || "recent")} +++ ++ ); +} + +// ─── Workspace ───────────────────────────────────────────────────────────── +function WorkspaceBody({ + projects, + projectName, + onProjectName, + onCreateProject, + confirmReset, + onAskReset, + onCancelReset, + onConfirmReset, + busy, +}: { + projects: OrgNode[]; + projectName: string; + onProjectName: (s: string) => void; + onCreateProject: () => void; + confirmReset: boolean; + onAskReset: () => void; + onCancelReset: () => void; + onConfirmReset: () => void; + busy: string | null; +}) { + return ( +++ {meta.preview ? ( +Session ++++ + {meta.model ? : null} + {meta.cwd ? : null} + {meta.updated_at ? : null} + ++ ) : null} +Preview ++ {String(meta.preview)} ++++ ); +} + +// ─── Project ─────────────────────────────────────────────────────────────── +function ProjectBody({ + teams, + teamName, + onTeamName, + onCreateTeam, + confirmDelete, + onAskDelete, + onCancelDelete, + onConfirmDelete, + busy, +}: { + teams: OrgNode[]; + teamName: string; + onTeamName: (s: string) => void; + onCreateTeam: () => void; + confirmDelete: boolean; + onAskDelete: () => void; + onCancelDelete: () => void; + onConfirmDelete: () => void; + busy: string | null; +}) { + return ( +++ +Add a project ++ onProjectName(e.target.value)} + placeholder="e.g. Text_to_Speech" + onKeyDown={(e) => { + if (e.key === "Enter") onCreateProject(); + }} + style={inputStyle} + /> + ++++ +Projects ({projects.length}) + {projects.length === 0 ? ( +No projects yet. Add one above. + ) : ( ++ {projects.map((p) => ( ++ )} ++ {p.label} ++ ))} ++ ++Danger zone + {!confirmReset ? ( + + ) : ( +++ )} ++ This deletes projects, teams, folders, context items, proposals, and findings for this org. Memory engrams in the Dhee tier are not affected. Continue? +++ + ++++ ); +} + +function TeamBody({ + node, + repoMappings, + developerCount, + developerJoinEvents, + collaboratingTeams, + collaboratorOptions, + collabTeamId, + onCollabTeamId, + onAddCollaborator, + folderPath, + folderLabel, + gitUrl, + onFolderPath, + onFolderLabel, + onGitUrl, + onPickFolder, + onAddFolder, + onAddGit, + onExtract, + onRemoveFolder, + onOpenVault, + busy, +}: { + node: OrgNode; + repoMappings: RepoMapping[]; + developerCount: number; + developerJoinEvents: JoinEvent[]; + collaboratingTeams: CollaboratingTeam[]; + collaboratorOptions: OrgNode[]; + collabTeamId: string; + onCollabTeamId: (value: string) => void; + onAddCollaborator: () => void; + folderPath: string; + folderLabel: string; + gitUrl: string; + onFolderPath: (value: string) => void; + onFolderLabel: (value: string) => void; + onGitUrl: (value: string) => void; + onPickFolder: () => void; + onAddFolder: () => void; + onAddGit: () => void; + onExtract: () => void; + onRemoveFolder: (mappingId?: string | null) => void; + onOpenVault: () => void; + isManager: boolean; + viewer: Viewer | null; + busy: string | null; +}) { + const meta = metaRecord(node); + const manager = meta.context_manager as Record++ +Add a team ++ onTeamName(e.target.value)} + placeholder="Backend, Frontend, Data, Mobile" + onKeyDown={(e) => { + if (e.key === "Enter") onCreateTeam(); + }} + style={inputStyle} + /> + ++++Teams ({teams.length}) + {teams.length === 0 ? ( +No teams yet. + ) : ( ++ {teams.map((team) => { + const mappings = uniqueMappings(repoMappingsFromNode(team)); + return ( ++ )} ++ + {team.label} + ++ ); + })} ++ + {!confirmDelete ? ( + + ) : ( ++++ )} ++ Deletes the project and all its teams + context. Continue? +++ + ++| undefined; + const teamId = String(meta.team_id || ""); + const projectId = String(meta.project_id || ""); + const mappings = uniqueMappings(repoMappings); + return ( + + ++ ); +} + +function RepoBody({ + node, + onRemove, + busy, +}: { + node: OrgNode; + onRemove: () => void; + busy: string | null; +}) { + const meta = (node.meta as { + repo_url?: string; + local_path?: string; + mapping_id?: string; + }) || {}; + return ( +++Team details ++++ {projectId ? : null} + + ++Manager ++ {manager?.display_name ? ( + <> ++{manager.display_name}++ {manager.manager_id} ++ > + ) : ( + no manager assigned + )} +++Add a local folder ++ onFolderPath(e.target.value)} + placeholder="/Users/me/code/backend" + onKeyDown={(e) => { + if (e.key === "Enter") onAddFolder(); + }} + style={inputStyle} + /> + +++ onFolderLabel(e.target.value)} + placeholder="Optional label" + style={inputStyle} + /> + ++++Add a git repo ++ onGitUrl(e.target.value)} + placeholder="git@github.com:org/backend.git" + onKeyDown={(e) => { + if (e.key === "Enter") onAddGit(); + }} + style={inputStyle} + /> + ++++ {developerJoinEvents.length ? ( +++ {mappings.length === 0 ? ( +Git + local folders ({mappings.length}) + +None mapped to this team. + ) : ( ++ {mappings.map((mapping) => { + const key = String(mapping.mapping_id || mapping.local_path || mapping.repo_url); + return ( ++ )} +++ ); + })} +++ {mapping.repo_url ? ( ++ {repoMappingLabel(mapping)} ++ ++ ) : null} + {mapping.local_path ? ( + + ) : null} + ++ ) : null} +Recent joins ++ {developerJoinEvents.slice(0, 4).map((event, idx) => ( ++++ ))} ++ {event.repo_root || "workspace"} +++ {event.role || "developer"} - {event.received_at || "recent"} ++++Collaborate teams + {collaboratingTeams.length === 0 ? ( +No team context shares yet. + ) : ( ++ {collaboratingTeams.map((team) => ( ++ )} ++ ))} + + + ++++ ); +} + +// ─── Atoms ───────────────────────────────────────────────────────────────── + +function KeyValue({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +Folder / path ++ {meta.local_path || meta.repo_url || node.label} ++ ++ + {label} + + + {value} + ++ ); +} + +function Hint({ children }: { children: React.ReactNode }) { + return ( ++ {children} ++ ); +} + +function Pill({ + label, + tone = "default", +}: { + label: string; + tone?: "default" | "green" | "indigo" | "rose" | "accent"; +}) { + const map = { + default: { bg: "var(--surface)", fg: "var(--ink2)" }, + green: { bg: "var(--green-dim)", fg: "var(--green)" }, + indigo: { bg: "var(--indigo-dim)", fg: "var(--indigo)" }, + rose: { bg: "var(--rose-dim)", fg: "var(--rose)" }, + accent: { bg: "var(--accent-dim)", fg: "var(--accent)" }, + } as const; + const c = map[tone]; + return ( + + {label} + + ); +} + +const inputStyle: React.CSSProperties = { + flex: 1, + fontFamily: "var(--mono)", + fontSize: 11, + padding: "6px 8px", + background: "var(--surface)", + border: "1px solid var(--border)", + borderRadius: 3, + color: "var(--ink)", +}; + +function primaryBtn(busy: boolean): React.CSSProperties { + return { + fontFamily: "var(--mono)", + fontSize: 10, + padding: "5px 12px", + background: busy ? "var(--surface)" : "var(--accent-dim)", + color: "var(--accent)", + border: "1px solid var(--accent)", + borderRadius: 3, + cursor: busy ? "wait" : "pointer", + }; +} + +function primaryBtnFilled(busy: boolean): React.CSSProperties { + return { + fontFamily: "var(--mono)", + fontSize: 11, + padding: "8px 12px", + background: busy ? "var(--surface)" : "var(--accent-dim)", + color: "var(--accent)", + border: "1px solid var(--accent)", + borderRadius: 4, + textAlign: "center" as const, + cursor: busy ? "wait" : "pointer", + }; +} + +function smallActionBtn(color: string): React.CSSProperties { + return { + fontFamily: "var(--mono)", + fontSize: 9, + padding: "5px 8px", + background: "white", + color, + border: `1px solid ${color}`, + borderRadius: 3, + cursor: "pointer", + whiteSpace: "nowrap", + }; +} + +const ghostBtn: React.CSSProperties = { + fontFamily: "var(--mono)", + fontSize: 10, + padding: "5px 10px", + background: "var(--surface)", + color: "var(--ink2)", + border: "1px solid var(--border)", + borderRadius: 3, +}; + +const dangerBtn: React.CSSProperties = { + fontFamily: "var(--mono)", + fontSize: 10, + padding: "5px 12px", + background: "var(--rose-dim)", + color: "var(--rose)", + border: "1px solid var(--rose)", + borderRadius: 3, +}; + +const iconBtn: React.CSSProperties = { + width: 22, + height: 22, + borderRadius: 3, + background: "var(--surface)", + color: "var(--ink2)", + border: "1px solid var(--border)", + fontSize: 12, + lineHeight: 1, +}; diff --git a/dhee/ui/web/src/components/TopBar.js b/dhee/ui/web/src/components/TopBar.js new file mode 100644 index 0000000..c0f3529 --- /dev/null +++ b/dhee/ui/web/src/components/TopBar.js @@ -0,0 +1,196 @@ +import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { useEffect, useRef, useState } from "react"; +import { api } from "../api"; +function formatCompact(n) { + if (!n || n <= 0) + return "0"; + return new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }).format(n); +} +function tokensSavedTotal(stats) { + if (!stats) + return 0; + const session = Number(stats.sessionTokensSaved || 0); + const enterprise = Number(stats.enterpriseSavedTokens || 0); + return session + enterprise; +} +function savedPct(stats) { + if (!stats) + return 0; + const ent = Number(stats.enterpriseSavedPct || 0); + return ent; +} +export function TopBar({ viewer, routerStats, onRefresh, onOpenTweaks, onResetWorkspace, }) { + const [menuOpen, setMenuOpen] = useState(false); + const [fallbackStats, setFallbackStats] = useState(null); + const menuRef = useRef(null); + useEffect(() => { + if (!menuOpen) + return; + const onClick = (e) => { + if (!menuRef.current) + return; + if (!menuRef.current.contains(e.target)) + setMenuOpen(false); + }; + window.addEventListener("mousedown", onClick); + return () => window.removeEventListener("mousedown", onClick); + }, [menuOpen]); + useEffect(() => { + if (routerStats) { + setFallbackStats(null); + return; + } + let cancelled = false; + const load = async () => { + try { + const stats = await api.routerStats(); + if (!cancelled) + setFallbackStats(stats); + } + catch { } + }; + void load(); + const timer = window.setInterval(load, 5000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [routerStats]); + const orgLabel = viewer?.org_id || "default"; + const projectLabel = viewer?.project_id || null; + const teamLabel = viewer?.team_id || null; + const breadcrumb = [orgLabel, projectLabel, teamLabel] + .filter(Boolean) + .join(" · "); + const effectiveStats = routerStats || fallbackStats; + const totalSaved = tokensSavedTotal(effectiveStats); + const pct = savedPct(effectiveStats); + const tooltip = (() => { + if (!effectiveStats) + return "loading"; + const session = Number(effectiveStats.sessionTokensSaved || 0); + const ent = Number(effectiveStats.enterpriseSavedTokens || 0); + const raw = Number(effectiveStats.enterpriseRawTokens || 0); + const summary = Number(effectiveStats.enterpriseSummaryTokens || 0); + const fallbacks = Number(effectiveStats.enterpriseRawFallbacks || 0); + const gates = Number(effectiveStats.enterpriseGateSuggestions || 0); + return `Session: ${formatCompact(session)} · Repo index: ${formatCompact(ent)} · Raw avoided: ${formatCompact(raw)} -> ${formatCompact(summary)} · Fallbacks: ${fallbacks} · Gates: ${gates}`; + })(); + return (_jsxs("div", { style: { + height: 32, + borderBottom: "1px solid var(--border)", + background: "var(--bg)", + display: "flex", + alignItems: "center", + padding: "0 12px", + gap: 10, + flexShrink: 0, + zIndex: 15, + }, children: [_jsxs("div", { className: "workspace-pill", title: breadcrumb, style: { + display: "inline-flex", + alignItems: "center", + gap: 6, + padding: "3px 9px", + borderRadius: 4, + background: "var(--surface)", + border: "1px solid var(--border)", + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink2)", + letterSpacing: "0.04em", + }, children: [_jsx("span", { style: { + width: 5, + height: 5, + borderRadius: "50%", + background: viewer?.live ? "var(--green)" : "var(--ink3)", + } }), _jsx("span", { children: breadcrumb || "no workspace" }), viewer?.role ? (_jsx("span", { style: { + marginLeft: 6, + padding: "1px 5px", + borderRadius: 3, + background: "var(--surface2)", + color: "var(--ink2)", + fontSize: 9, + }, children: String(viewer.role).toUpperCase() })) : null] }), _jsx("div", { style: { flex: 1 } }), _jsxs("div", { className: "tokens-chip", title: tooltip, style: { + display: "inline-flex", + alignItems: "center", + gap: 6, + padding: "3px 9px", + borderRadius: 4, + background: "var(--accent-dim)", + border: "1px solid var(--accent)", + color: "var(--accent)", + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: "0.04em", + }, children: [_jsx("span", { style: { fontSize: 11 }, children: "\u21AF" }), _jsxs("span", { children: [formatCompact(totalSaved), " saved"] }), pct > 0 ? (_jsxs("span", { style: { color: "var(--ink3)" }, children: ["\u00B7 ", pct.toFixed(0), "%"] })) : null] }), _jsxs("div", { ref: menuRef, style: { position: "relative", display: "inline-block" }, children: [_jsx("button", { "aria-label": "Menu", onClick: () => setMenuOpen((v) => !v), style: { + width: 22, + height: 22, + borderRadius: 4, + background: menuOpen ? "var(--surface2)" : "var(--surface)", + border: "1px solid var(--border)", + color: "var(--ink2)", + fontSize: 12, + lineHeight: 1, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + }, children: "\u22EE" }), menuOpen ? (_jsxs("div", { style: { + position: "absolute", + top: "calc(100% + 4px)", + right: 0, + minWidth: 180, + background: "var(--bg)", + border: "1px solid var(--border)", + borderRadius: 4, + boxShadow: "0 6px 18px rgba(20,16,10,0.08)", + zIndex: 30, + padding: 4, + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: "0.04em", + }, children: [_jsx(MenuItem, { label: "REFRESH", onClick: () => { + setMenuOpen(false); + onRefresh(); + } }), _jsx(MenuItem, { label: "TWEAKS", hint: "\u2318K", onClick: () => { + setMenuOpen(false); + onOpenTweaks(); + } }), onResetWorkspace ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { + height: 1, + background: "var(--border)", + margin: "3px 0", + } }), _jsx(MenuItem, { label: "RESET WORKSPACE", onClick: () => { + setMenuOpen(false); + onResetWorkspace(); + }, danger: true })] })) : null, _jsx("div", { style: { + height: 1, + background: "var(--border)", + margin: "3px 0", + } }), _jsx(MenuItem, { label: "USER ID", hint: viewer?.user_id || "—", onClick: () => setMenuOpen(false), dim: true })] })) : null] })] })); +} +function MenuItem({ label, hint, onClick, dim, danger, }) { + return (_jsxs("button", { onClick: onClick, style: { + width: "100%", + textAlign: "left", + padding: "5px 8px", + borderRadius: 3, + background: "transparent", + color: danger + ? "var(--rose)" + : dim + ? "var(--ink3)" + : "var(--ink2)", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + }, onMouseEnter: (e) => { + e.currentTarget.style.background = danger + ? "var(--rose-dim)" + : "var(--surface)"; + }, onMouseLeave: (e) => { + e.currentTarget.style.background = "transparent"; + }, children: [_jsx("span", { children: label }), hint ? (_jsx("span", { style: { color: "var(--ink3)", fontSize: 9 }, children: hint })) : null] })); +} diff --git a/dhee/ui/web/src/components/TopBar.tsx b/dhee/ui/web/src/components/TopBar.tsx new file mode 100644 index 0000000..07ca559 --- /dev/null +++ b/dhee/ui/web/src/components/TopBar.tsx @@ -0,0 +1,319 @@ +import { useEffect, useRef, useState } from "react"; +import { api } from "../api"; +import type { RouterStats, Viewer } from "../types"; + +function formatCompact(n: number | undefined | null): string { + if (!n || n <= 0) return "0"; + return new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }).format(n); +} + +function tokensSavedTotal(stats: RouterStats | null): number { + if (!stats) return 0; + const session = Number(stats.sessionTokensSaved || 0); + const enterprise = Number(stats.enterpriseSavedTokens || 0); + return session + enterprise; +} + +function savedPct(stats: RouterStats | null): number { + if (!stats) return 0; + const ent = Number(stats.enterpriseSavedPct || 0); + return ent; +} + +interface TopBarProps { + viewer: Viewer | null; + routerStats: RouterStats | null; + onRefresh: () => void; + onOpenTweaks: () => void; + onResetWorkspace?: () => void; +} + +export function TopBar({ + viewer, + routerStats, + onRefresh, + onOpenTweaks, + onResetWorkspace, +}: TopBarProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [fallbackStats, setFallbackStats] = useState(null); + const menuRef = useRef (null); + + useEffect(() => { + if (!menuOpen) return; + const onClick = (e: MouseEvent) => { + if (!menuRef.current) return; + if (!menuRef.current.contains(e.target as Node)) setMenuOpen(false); + }; + window.addEventListener("mousedown", onClick); + return () => window.removeEventListener("mousedown", onClick); + }, [menuOpen]); + + useEffect(() => { + if (routerStats) { + setFallbackStats(null); + return; + } + let cancelled = false; + const load = async () => { + try { + const stats = await api.routerStats(); + if (!cancelled) setFallbackStats(stats); + } catch {} + }; + void load(); + const timer = window.setInterval(load, 5000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [routerStats]); + + const orgLabel = viewer?.org_id || "default"; + const projectLabel = viewer?.project_id || null; + const teamLabel = viewer?.team_id || null; + const breadcrumb = [orgLabel, projectLabel, teamLabel] + .filter(Boolean) + .join(" · "); + const effectiveStats = routerStats || fallbackStats; + const totalSaved = tokensSavedTotal(effectiveStats); + const pct = savedPct(effectiveStats); + const tooltip = (() => { + if (!effectiveStats) return "loading"; + const session = Number(effectiveStats.sessionTokensSaved || 0); + const ent = Number(effectiveStats.enterpriseSavedTokens || 0); + const raw = Number(effectiveStats.enterpriseRawTokens || 0); + const summary = Number(effectiveStats.enterpriseSummaryTokens || 0); + const fallbacks = Number(effectiveStats.enterpriseRawFallbacks || 0); + const gates = Number(effectiveStats.enterpriseGateSuggestions || 0); + return `Session: ${formatCompact(session)} · Repo index: ${formatCompact(ent)} · Raw avoided: ${formatCompact(raw)} -> ${formatCompact(summary)} · Fallbacks: ${fallbacks} · Gates: ${gates}`; + })(); + + return ( + ++ ); +} + +function MenuItem({ + label, + hint, + onClick, + dim, + danger, +}: { + label: string; + hint?: string; + onClick: () => void; + dim?: boolean; + danger?: boolean; +}) { + return ( + + ); +} diff --git a/dhee/ui/web/src/components/TweaksPanel.js b/dhee/ui/web/src/components/TweaksPanel.js new file mode 100644 index 0000000..1de66aa --- /dev/null +++ b/dhee/ui/web/src/components/TweaksPanel.js @@ -0,0 +1,84 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +export function TweaksPanel({ tweaks, setTweaks, visible, }) { + if (!visible) + return null; + const set = (k, v) => { + const next = { ...tweaks, [k]: v }; + setTweaks(next); + }; + return (_jsxs("div", { style: { + position: "fixed", + bottom: 20, + right: 20, + width: 236, + border: "1px solid var(--border)", + background: "white", + zIndex: 1000, + boxShadow: "0 8px 32px rgba(0,0,0,0.1)", + }, children: [_jsx("div", { style: { + padding: "9px 14px", + borderBottom: "1px solid var(--border)", + fontFamily: "var(--mono)", + fontSize: 10, + fontWeight: 700, + letterSpacing: "0.06em", + }, children: "TWEAKS" }), _jsxs("div", { style: { padding: "14px" }, children: [_jsxs("div", { style: { marginBottom: 14 }, children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + marginBottom: 6, + textTransform: "uppercase", + }, children: "Accent hue" }), _jsx("input", { type: "range", min: "0", max: "360", value: tweaks.accentHue, onChange: (e) => { + const h = e.target.value; + set("accentHue", h); + document.documentElement.style.setProperty("--accent", `oklch(0.64 0.18 ${h})`); + document.documentElement.style.setProperty("--accent-dim", `oklch(0.97 0.04 ${h})`); + }, style: { width: "100%" } }), _jsxs("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + marginTop: 2, + }, children: ["hue ", tweaks.accentHue, "\u00B0"] })] }), _jsxs("div", { style: { marginBottom: 14 }, children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + marginBottom: 6, + textTransform: "uppercase", + }, children: "Compact nav" }), _jsx("button", { onClick: () => set("compactNav", !tweaks.compactNav), style: { + padding: "4px 10px", + border: "1px solid var(--border)", + fontFamily: "var(--mono)", + fontSize: 10, + background: tweaks.compactNav ? "var(--ink)" : "transparent", + color: tweaks.compactNav ? "var(--bg)" : "var(--ink)", + cursor: "pointer", + }, children: tweaks.compactNav ? "ON" : "OFF" })] }), _jsxs("div", { style: { marginBottom: 14 }, children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + marginBottom: 6, + textTransform: "uppercase", + }, children: "Canvas style" }), _jsx("div", { style: { display: "flex", gap: 5 }, children: ["dots", "grid"].map((s) => (_jsx("button", { onClick: () => set("canvasStyle", s), style: { + padding: "4px 10px", + border: "1px solid var(--border)", + fontFamily: "var(--mono)", + fontSize: 9, + background: tweaks.canvasStyle === s ? "var(--ink)" : "transparent", + color: tweaks.canvasStyle === s ? "var(--bg)" : "var(--ink)", + cursor: "pointer", + }, children: s }, s))) })] }), _jsxs("div", { children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + marginBottom: 6, + textTransform: "uppercase", + }, children: "Timestamps" }), _jsx("button", { onClick: () => set("showTimestamps", !tweaks.showTimestamps), style: { + padding: "4px 10px", + border: "1px solid var(--border)", + fontFamily: "var(--mono)", + fontSize: 10, + background: tweaks.showTimestamps ? "var(--ink)" : "transparent", + color: tweaks.showTimestamps ? "var(--bg)" : "var(--ink)", + cursor: "pointer", + }, children: tweaks.showTimestamps ? "ON" : "OFF" })] })] })] })); +} diff --git a/dhee/ui/web/src/components/TweaksPanel.tsx b/dhee/ui/web/src/components/TweaksPanel.tsx new file mode 100644 index 0000000..bde5213 --- /dev/null +++ b/dhee/ui/web/src/components/TweaksPanel.tsx @@ -0,0 +1,175 @@ +import type { Tweaks } from "../types"; + +export function TweaksPanel({ + tweaks, + setTweaks, + visible, +}: { + tweaks: Tweaks; + setTweaks: (t: Tweaks) => void; + visible: boolean; +}) { + if (!visible) return null; + const set =+ + {breadcrumb || "no workspace"} + {viewer?.role ? ( + + {String(viewer.role).toUpperCase()} + + ) : null} ++ + + ++ ↯ + {formatCompact(totalSaved)} saved + {pct > 0 ? ( + · {pct.toFixed(0)}% + ) : null} ++ ++ + {menuOpen ? ( ++++ ) : null} +(k: K, v: Tweaks[K]) => { + const next = { ...tweaks, [k]: v }; + setTweaks(next); + }; + return ( + ++ ); +} diff --git a/dhee/ui/web/src/components/WorkspaceManagerModal.js b/dhee/ui/web/src/components/WorkspaceManagerModal.js new file mode 100644 index 0000000..7cbbe79 --- /dev/null +++ b/dhee/ui/web/src/components/WorkspaceManagerModal.js @@ -0,0 +1,396 @@ +import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { useEffect, useState } from "react"; +import { api } from "../api"; +const overlayStyle = { + position: "fixed", + inset: 0, + background: "rgba(20,16,10,0.28)", + backdropFilter: "blur(4px)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 60, +}; +const cardStyle = { + width: 640, + maxWidth: "calc(100vw - 32px)", + maxHeight: "calc(100vh - 60px)", + background: "white", + border: "1px solid var(--border)", + borderRadius: 10, + boxShadow: "0 20px 60px rgba(20,16,10,0.20)", + display: "flex", + flexDirection: "column", + overflow: "hidden", +}; +const inputStyle = { + width: "100%", + border: "1px solid var(--border)", + padding: "9px 11px", + background: "var(--bg)", + fontSize: 13, + lineHeight: 1.4, +}; +const labelStyle = { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 0.5, + textTransform: "uppercase", + marginBottom: 4, + display: "block", +}; +const buttonPrimary = { + padding: "8px 14px", + border: "1px solid var(--ink)", + background: "var(--ink)", + color: "white", + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: 0.4, + cursor: "pointer", +}; +const buttonGhost = { + padding: "8px 14px", + border: "1px solid var(--border)", + background: "white", + color: "var(--ink2)", + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: 0.4, + cursor: "pointer", +}; +const buttonDanger = { + padding: "8px 14px", + border: "1px solid var(--rose)", + background: "white", + color: "var(--rose)", + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: 0.4, + cursor: "pointer", +}; +const runtimeOptions = ["codex", "claude-code"]; +export function WorkspaceManagerModal({ open, onClose, projectIndex, initialWorkspaceId, initialTab = "workspaces", onChanged, }) { + const workspaces = projectIndex?.workspaces || []; + const [tab, setTab] = useState(initialTab); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(initialWorkspaceId || workspaces[0]?.id || ""); + const currentWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceId) || null; + // Create-workspace state + const [newWsName, setNewWsName] = useState(""); + const [newWsDesc, setNewWsDesc] = useState(""); + // Edit-workspace state + const [editWsName, setEditWsName] = useState(""); + const [editWsDesc, setEditWsDesc] = useState(""); + const [deleteConfirm, setDeleteConfirm] = useState(""); + // Create-project state + const [newProjectName, setNewProjectName] = useState(""); + const [newProjectDesc, setNewProjectDesc] = useState(""); + const [newProjectRuntime, setNewProjectRuntime] = useState("codex"); + const [newProjectFolders, setNewProjectFolders] = useState([]); + // Edit-project state (keyed by project id so edits don't leak across selections) + const [projectEdits, setProjectEdits] = useState({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + useEffect(() => { + if (!open) + return; + setTab(initialTab); + setSelectedWorkspaceId(initialWorkspaceId || workspaces[0]?.id || ""); + setError(null); + setNotice(null); + setNewWsName(""); + setNewWsDesc(""); + setNewProjectName(""); + setNewProjectDesc(""); + setNewProjectRuntime("codex"); + setNewProjectFolders([]); + setDeleteConfirm(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + useEffect(() => { + if (!currentWorkspace) { + setEditWsName(""); + setEditWsDesc(""); + return; + } + setEditWsName(String(currentWorkspace.label || currentWorkspace.name || "")); + setEditWsDesc(String(currentWorkspace.description || "")); + setDeleteConfirm(""); + }, [currentWorkspace?.id, currentWorkspace?.label, currentWorkspace?.description]); + // Keep project edit drafts in sync with the live snapshot. + useEffect(() => { + if (!currentWorkspace) + return; + setProjectEdits((prev) => { + const next = {}; + for (const project of currentWorkspace.projects || []) { + const folders = (project.scopeRules || []).map((r) => r.pathPrefix); + next[project.id] = prev[project.id] || { + name: project.name, + description: project.description || "", + defaultRuntime: project.defaultRuntime || "codex", + folders, + }; + } + return next; + }); + }, [currentWorkspace?.id, currentWorkspace?.projects?.length]); + if (!open) + return null; + const pickFolder = async (onPicked, prompt) => { + setError(null); + try { + const res = await api.pickFolder(prompt || "Choose a folder"); + if (res.ok && res.path) + onPicked(res.path); + } + catch (e) { + setError(String(e)); + } + }; + const runGuarded = async (label, fn) => { + setBusy(true); + setError(null); + setNotice(null); + try { + await fn(); + setNotice(label); + await onChanged(); + } + catch (e) { + setError(String(e)); + } + finally { + setBusy(false); + } + }; + const onCreateWorkspace = () => runGuarded("Workspace created.", async () => { + const name = newWsName.trim(); + if (!name) { + throw new Error("Name is required."); + } + await api.createWorkspaceRoot(name, newWsDesc.trim() || undefined); + setNewWsName(""); + setNewWsDesc(""); + }); + const onUpdateWorkspace = () => runGuarded("Workspace updated.", async () => { + if (!currentWorkspace) + return; + const payload = {}; + if (editWsName.trim() && editWsName !== (currentWorkspace.label || currentWorkspace.name)) { + payload.label = editWsName.trim(); + } + if (editWsDesc !== (currentWorkspace.description || "")) { + payload.description = editWsDesc; + } + if (!Object.keys(payload).length) { + throw new Error("Nothing to save."); + } + await api.updateWorkspace(currentWorkspace.id, payload); + }); + const onDeleteWorkspace = () => runGuarded("Workspace deleted.", async () => { + if (!currentWorkspace) + return; + if (deleteConfirm.trim() !== (currentWorkspace.label || currentWorkspace.name)) { + throw new Error("Type the workspace name exactly to confirm."); + } + await api.deleteWorkspace(currentWorkspace.id); + setSelectedWorkspaceId(""); + setDeleteConfirm(""); + }); + const onCreateProject = () => runGuarded("Project created.", async () => { + if (!currentWorkspace) + throw new Error("Pick a workspace first."); + const name = newProjectName.trim(); + if (!name) + throw new Error("Project name is required."); + const rules = newProjectFolders + .map((p) => p.trim()) + .filter(Boolean) + .map((path_prefix) => ({ path_prefix })); + await api.createProject(currentWorkspace.id, { + name, + description: newProjectDesc.trim() || undefined, + default_runtime: newProjectRuntime, + scope_rules: rules, + }); + setNewProjectName(""); + setNewProjectDesc(""); + setNewProjectRuntime("codex"); + setNewProjectFolders([]); + }); + const onUpdateProject = (project) => () => runGuarded("Project updated.", async () => { + const draft = projectEdits[project.id]; + if (!draft) + return; + const payload = {}; + if (draft.name.trim() && draft.name.trim() !== project.name) + payload.name = draft.name.trim(); + if (draft.description !== (project.description || "")) + payload.description = draft.description; + if (draft.defaultRuntime && draft.defaultRuntime !== (project.defaultRuntime || "codex")) { + payload.default_runtime = draft.defaultRuntime; + } + const currentFolders = (project.scopeRules || []).map((r) => r.pathPrefix); + const draftFolders = draft.folders.map((p) => p.trim()).filter(Boolean); + const foldersChanged = currentFolders.length !== draftFolders.length || + currentFolders.some((p, i) => p !== draftFolders[i]); + if (foldersChanged) { + payload.scope_rules = draftFolders.map((path_prefix) => ({ path_prefix })); + } + if (!Object.keys(payload).length) + throw new Error("Nothing to save."); + await api.updateProject(project.id, payload); + }); + const onDeleteProject = (project) => () => runGuarded("Project deleted.", async () => { + const ok = window.confirm(`Delete project "${project.name}"? This removes its assets. The workspace stays.`); + if (!ok) + throw new Error("cancelled"); + await api.deleteProject(project.id); + }); + return (_jsx("div", { style: overlayStyle, onClick: onClose, children: _jsxs("div", { style: cardStyle, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { style: { + padding: "14px 20px", + borderBottom: "1px solid var(--border)", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: 15, fontWeight: 700 }, children: "Workspaces & projects" }), _jsx("div", { style: { fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink3)", marginTop: 2 }, children: "Organise every agent under a shared brain." })] }), _jsx("button", { onClick: onClose, style: { + width: 28, + height: 28, + border: "1px solid var(--border)", + borderRadius: 4, + background: "white", + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "var(--ink3)", + }, children: _jsx("svg", { width: 12, height: 12, viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M6 6l12 12M18 6L6 18", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" }) }) })] }), _jsx("div", { style: { + padding: "10px 20px", + borderBottom: "1px solid var(--border)", + display: "flex", + gap: 6, + }, children: ["workspaces", "projects"].map((key) => (_jsx("button", { onClick: () => setTab(key), style: { + padding: "6px 12px", + border: `1px solid ${tab === key ? "var(--ink)" : "var(--border)"}`, + background: tab === key ? "var(--ink)" : "white", + color: tab === key ? "white" : "var(--ink2)", + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: 0.4, + textTransform: "uppercase", + cursor: "pointer", + }, children: key }, key))) }), _jsxs("div", { style: { padding: 20, overflowY: "auto", display: "flex", flexDirection: "column", gap: 16 }, children: [tab === "workspaces" && (_jsxs(_Fragment, { children: [_jsxs("section", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx("div", { style: labelStyle, children: "New workspace" }), _jsx("input", { placeholder: "Name (e.g. Office, Personal, Sankhya AI Labs)", value: newWsName, onChange: (e) => setNewWsName(e.target.value), style: inputStyle }), _jsx("textarea", { placeholder: "Description (optional)", value: newWsDesc, onChange: (e) => setNewWsDesc(e.target.value), rows: 2, style: { ...inputStyle, resize: "vertical" } }), _jsx("div", { style: { fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink3)", lineHeight: 1.5 }, children: "A workspace is a collection of projects. Folders attach to projects, not workspaces." }), _jsx("div", { style: { display: "flex", justifyContent: "flex-end" }, children: _jsx("button", { onClick: () => void onCreateWorkspace(), disabled: busy || !newWsName.trim(), style: { + ...buttonPrimary, + opacity: busy || !newWsName.trim() ? 0.5 : 1, + }, children: "create workspace" }) })] }), _jsxs("section", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsxs("div", { style: labelStyle, children: ["Existing \u00B7 ", workspaces.length] }), workspaces.length === 0 ? (_jsx("div", { style: { + padding: 14, + border: "1px dashed var(--border)", + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + lineHeight: 1.55, + }, children: "No workspaces yet. Create one above." })) : (_jsx("div", { style: { display: "grid", gap: 6 }, children: workspaces.map((workspace) => { + const active = workspace.id === selectedWorkspaceId; + const projectCount = workspace.projects?.length || 0; + return (_jsxs("button", { onClick: () => setSelectedWorkspaceId(workspace.id), style: { + textAlign: "left", + padding: "10px 12px", + border: `1px solid ${active ? "var(--accent)" : "var(--border)"}`, + background: active ? "var(--surface)" : "white", + display: "flex", + justifyContent: "space-between", + gap: 12, + cursor: "pointer", + }, children: [_jsxs("div", { style: { minWidth: 0 }, children: [_jsx("div", { style: { fontSize: 13, fontWeight: 600, lineHeight: 1.3 }, children: workspace.label || workspace.name }), workspace.description && (_jsx("div", { style: { fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink3)", marginTop: 2 }, children: workspace.description }))] }), _jsxs("span", { style: { fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink3)" }, children: [projectCount, " project", projectCount === 1 ? "" : "s"] })] }, workspace.id)); + }) }))] }), currentWorkspace && (_jsxs("section", { style: { + display: "flex", + flexDirection: "column", + gap: 8, + borderTop: "1px solid var(--border)", + paddingTop: 14, + }, children: [_jsxs("div", { style: labelStyle, children: ["Edit \u00B7 ", currentWorkspace.label || currentWorkspace.name] }), _jsx("input", { value: editWsName, onChange: (e) => setEditWsName(e.target.value), placeholder: "Name", style: inputStyle }), _jsx("textarea", { value: editWsDesc, onChange: (e) => setEditWsDesc(e.target.value), placeholder: "Description", rows: 2, style: { ...inputStyle, resize: "vertical" } }), _jsx("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: _jsx("button", { onClick: () => void onUpdateWorkspace(), disabled: busy, style: { ...buttonPrimary, opacity: busy ? 0.5 : 1 }, children: "save changes" }) }), _jsxs("div", { style: { + marginTop: 8, + padding: 12, + border: "1px solid rgba(203,63,78,0.3)", + borderRadius: 4, + background: "rgba(203,63,78,0.04)", + }, children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--rose)", + letterSpacing: 0.5, + textTransform: "uppercase", + marginBottom: 6, + }, children: "Danger zone" }), _jsxs("div", { style: { fontSize: 11, color: "var(--ink2)", lineHeight: 1.5, marginBottom: 8 }, children: ["Deleting ", _jsx("strong", { children: currentWorkspace.label || currentWorkspace.name }), " removes every project, asset, and line message. Sessions remain but detach. Type the workspace name to confirm."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsx("input", { value: deleteConfirm, onChange: (e) => setDeleteConfirm(e.target.value), placeholder: currentWorkspace.label || currentWorkspace.name, style: { ...inputStyle, flex: 1 } }), _jsx("button", { onClick: () => void onDeleteWorkspace(), disabled: busy || + deleteConfirm.trim() !== (currentWorkspace.label || currentWorkspace.name), style: { + ...buttonDanger, + opacity: busy || + deleteConfirm.trim() !== (currentWorkspace.label || currentWorkspace.name) + ? 0.5 + : 1, + }, children: "delete workspace" })] })] })] }))] })), tab === "projects" && (_jsxs(_Fragment, { children: [_jsxs("section", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx("div", { style: labelStyle, children: "Workspace" }), _jsxs("select", { value: selectedWorkspaceId, onChange: (e) => setSelectedWorkspaceId(e.target.value), style: inputStyle, children: [_jsx("option", { value: "", children: "\u2014 pick a workspace \u2014" }), workspaces.map((workspace) => (_jsx("option", { value: workspace.id, children: workspace.label || workspace.name }, workspace.id)))] })] }), currentWorkspace && (_jsxs(_Fragment, { children: [_jsxs("section", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsxs("div", { style: labelStyle, children: ["Add project to ", currentWorkspace.label || currentWorkspace.name] }), _jsx("input", { placeholder: "Project name (e.g. frontend, backend, design)", value: newProjectName, onChange: (e) => setNewProjectName(e.target.value), style: inputStyle }), _jsx("textarea", { placeholder: "Description (optional)", value: newProjectDesc, onChange: (e) => setNewProjectDesc(e.target.value), rows: 2, style: { ...inputStyle, resize: "vertical" } }), _jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [_jsx("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink3)" }, children: "default runtime" }), _jsx("select", { value: newProjectRuntime, onChange: (e) => setNewProjectRuntime(e.target.value), style: { ...inputStyle, flex: 1 }, children: runtimeOptions.map((option) => (_jsx("option", { value: option, children: option }, option))) })] }), _jsx(FoldersEditor, { folders: newProjectFolders, onChange: setNewProjectFolders, onPick: (apply) => void pickFolder(apply, "Choose a project folder"), disabled: busy }), _jsx("div", { style: { display: "flex", justifyContent: "flex-end" }, children: _jsx("button", { onClick: () => void onCreateProject(), disabled: busy || !newProjectName.trim(), style: { + ...buttonPrimary, + opacity: busy || !newProjectName.trim() ? 0.5 : 1, + }, children: "add project" }) })] }), _jsxs("section", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsxs("div", { style: labelStyle, children: ["Projects \u00B7 ", (currentWorkspace.projects || []).length] }), (currentWorkspace.projects || []).length === 0 ? (_jsx("div", { style: { + padding: 14, + border: "1px dashed var(--border)", + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + lineHeight: 1.55, + }, children: "No projects yet \u2014 add the first one above." })) : (_jsx("div", { style: { display: "grid", gap: 10 }, children: (currentWorkspace.projects || []).map((project) => { + const draft = projectEdits[project.id] || { + name: project.name, + description: project.description || "", + defaultRuntime: project.defaultRuntime || "codex", + folders: (project.scopeRules || []).map((r) => r.pathPrefix), + }; + const setDraft = (patch) => setProjectEdits((prev) => ({ + ...prev, + [project.id]: { ...draft, ...patch }, + })); + return (_jsxs("div", { style: { + border: "1px solid var(--border)", + borderRadius: 6, + padding: 10, + display: "flex", + flexDirection: "column", + gap: 8, + }, children: [_jsx("input", { value: draft.name, onChange: (e) => setDraft({ name: e.target.value }), style: inputStyle }), _jsx("textarea", { value: draft.description, onChange: (e) => setDraft({ description: e.target.value }), rows: 2, style: { ...inputStyle, resize: "vertical" } }), _jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + }, children: "runtime" }), _jsx("select", { value: draft.defaultRuntime, onChange: (e) => setDraft({ defaultRuntime: e.target.value }), style: { ...inputStyle, flex: 1 }, children: runtimeOptions.map((option) => (_jsx("option", { value: option, children: option }, option))) })] }), _jsx(FoldersEditor, { folders: draft.folders, onChange: (folders) => setDraft({ folders }), onPick: (apply) => void pickFolder(apply, `Add a folder to ${project.name}`), disabled: busy }), _jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 8 }, children: [_jsx("button", { onClick: () => void onDeleteProject(project)(), disabled: busy, style: { ...buttonDanger, opacity: busy ? 0.5 : 1 }, children: "delete" }), _jsx("button", { onClick: () => void onUpdateProject(project)(), disabled: busy, style: { ...buttonPrimary, opacity: busy ? 0.5 : 1 }, children: "save" })] })] }, project.id)); + }) }))] })] }))] }))] }), _jsxs("div", { style: { + padding: "10px 20px", + borderTop: "1px solid var(--border)", + background: "var(--bg)", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + minHeight: 44, + }, children: [_jsx("div", { style: { fontFamily: "var(--mono)", fontSize: 10, lineHeight: 1.4 }, children: error ? (_jsx("span", { style: { color: "var(--rose)" }, children: error })) : notice ? (_jsx("span", { style: { color: "var(--green)" }, children: notice })) : (_jsx("span", { style: { color: "var(--ink3)" }, children: "Changes save immediately." })) }), _jsx("button", { onClick: onClose, style: buttonGhost, children: "close" })] })] }) })); +} +function FoldersEditor({ folders, onChange, onPick, disabled, }) { + const update = (index, value) => { + const next = folders.slice(); + next[index] = value; + onChange(next); + }; + const remove = (index) => { + const next = folders.slice(); + next.splice(index, 1); + onChange(next); + }; + const addBlank = () => onChange([...folders, ""]); + const addPicked = () => onPick((path) => onChange([...folders, path])); + return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [_jsxs("div", { style: labelStyle, children: ["Folders \u00B7 ", folders.length] }), folders.length === 0 && (_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + lineHeight: 1.5, + }, children: "No folders yet. A project can have one or many." })), folders.map((folder, index) => (_jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("input", { value: folder, onChange: (e) => update(index, e.target.value), placeholder: "/absolute/path", style: { ...inputStyle, flex: 1 } }), _jsx("button", { onClick: () => onPick((path) => update(index, path)), style: buttonGhost, disabled: disabled, children: "browse\u2026" }), _jsx("button", { onClick: () => remove(index), style: buttonGhost, disabled: disabled, title: "Remove folder", children: "\u2715" })] }, index))), _jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { onClick: addPicked, style: buttonGhost, disabled: disabled, children: "+ pick folder" }), _jsx("button", { onClick: addBlank, style: buttonGhost, disabled: disabled, children: "+ type path" })] })] })); +} diff --git a/dhee/ui/web/src/components/WorkspaceManagerModal.tsx b/dhee/ui/web/src/components/WorkspaceManagerModal.tsx new file mode 100644 index 0000000..06510cc --- /dev/null +++ b/dhee/ui/web/src/components/WorkspaceManagerModal.tsx @@ -0,0 +1,865 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import type { + ProjectIndexSnapshot, + ProjectScopeRule, + ProjectSummary, + WorkspaceSummary, +} from "../types"; + +// WorkspaceManagerModal — workspace is a collection of projects, +// project is one or more folders. Workspaces have no root folder; +// folders attach to projects via scope rules. + +type Tab = "workspaces" | "projects"; + +interface Props { + open: boolean; + onClose: () => void; + projectIndex?: ProjectIndexSnapshot | null; + initialWorkspaceId?: string; + initialTab?: Tab; + onChanged: () => Promise+ TWEAKS +++++++ Accent hue ++ { + const h = e.target.value; + set("accentHue", h); + document.documentElement.style.setProperty( + "--accent", + `oklch(0.64 0.18 ${h})` + ); + document.documentElement.style.setProperty( + "--accent-dim", + `oklch(0.97 0.04 ${h})` + ); + }} + style={{ width: "100%" }} + /> ++ hue {tweaks.accentHue}° +++++ Compact nav ++ ++++ Canvas style +++ {(["dots", "grid"] as const).map((s) => ( + + ))} +++++ Timestamps ++ +| void; +} + +const overlayStyle: React.CSSProperties = { + position: "fixed", + inset: 0, + background: "rgba(20,16,10,0.28)", + backdropFilter: "blur(4px)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 60, +}; + +const cardStyle: React.CSSProperties = { + width: 640, + maxWidth: "calc(100vw - 32px)", + maxHeight: "calc(100vh - 60px)", + background: "white", + border: "1px solid var(--border)", + borderRadius: 10, + boxShadow: "0 20px 60px rgba(20,16,10,0.20)", + display: "flex", + flexDirection: "column", + overflow: "hidden", +}; + +const inputStyle: React.CSSProperties = { + width: "100%", + border: "1px solid var(--border)", + padding: "9px 11px", + background: "var(--bg)", + fontSize: 13, + lineHeight: 1.4, +}; + +const labelStyle: React.CSSProperties = { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 0.5, + textTransform: "uppercase", + marginBottom: 4, + display: "block", +}; + +const buttonPrimary: React.CSSProperties = { + padding: "8px 14px", + border: "1px solid var(--ink)", + background: "var(--ink)", + color: "white", + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: 0.4, + cursor: "pointer", +}; + +const buttonGhost: React.CSSProperties = { + padding: "8px 14px", + border: "1px solid var(--border)", + background: "white", + color: "var(--ink2)", + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: 0.4, + cursor: "pointer", +}; + +const buttonDanger: React.CSSProperties = { + padding: "8px 14px", + border: "1px solid var(--rose)", + background: "white", + color: "var(--rose)", + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: 0.4, + cursor: "pointer", +}; + +const runtimeOptions = ["codex", "claude-code"]; + +export function WorkspaceManagerModal({ + open, + onClose, + projectIndex, + initialWorkspaceId, + initialTab = "workspaces", + onChanged, +}: Props) { + const workspaces = projectIndex?.workspaces || []; + const [tab, setTab] = useState (initialTab); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState ( + initialWorkspaceId || workspaces[0]?.id || "", + ); + const currentWorkspace: WorkspaceSummary | null = workspaces.find( + (workspace) => workspace.id === selectedWorkspaceId, + ) || null; + + // Create-workspace state + const [newWsName, setNewWsName] = useState(""); + const [newWsDesc, setNewWsDesc] = useState(""); + + // Edit-workspace state + const [editWsName, setEditWsName] = useState(""); + const [editWsDesc, setEditWsDesc] = useState(""); + const [deleteConfirm, setDeleteConfirm] = useState(""); + + // Create-project state + const [newProjectName, setNewProjectName] = useState(""); + const [newProjectDesc, setNewProjectDesc] = useState(""); + const [newProjectRuntime, setNewProjectRuntime] = useState ("codex"); + const [newProjectFolders, setNewProjectFolders] = useState ([]); + + // Edit-project state (keyed by project id so edits don't leak across selections) + const [projectEdits, setProjectEdits] = useState< + Record< + string, + { + name: string; + description: string; + defaultRuntime: string; + folders: string[]; + } + > + >({}); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState (null); + const [notice, setNotice] = useState (null); + + useEffect(() => { + if (!open) return; + setTab(initialTab); + setSelectedWorkspaceId(initialWorkspaceId || workspaces[0]?.id || ""); + setError(null); + setNotice(null); + setNewWsName(""); + setNewWsDesc(""); + setNewProjectName(""); + setNewProjectDesc(""); + setNewProjectRuntime("codex"); + setNewProjectFolders([]); + setDeleteConfirm(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + if (!currentWorkspace) { + setEditWsName(""); + setEditWsDesc(""); + return; + } + setEditWsName(String(currentWorkspace.label || currentWorkspace.name || "")); + setEditWsDesc(String(currentWorkspace.description || "")); + setDeleteConfirm(""); + }, [currentWorkspace?.id, currentWorkspace?.label, currentWorkspace?.description]); + + // Keep project edit drafts in sync with the live snapshot. + useEffect(() => { + if (!currentWorkspace) return; + setProjectEdits((prev) => { + const next: typeof prev = {}; + for (const project of currentWorkspace.projects || []) { + const folders = (project.scopeRules || []).map((r: ProjectScopeRule) => r.pathPrefix); + next[project.id] = prev[project.id] || { + name: project.name, + description: project.description || "", + defaultRuntime: project.defaultRuntime || "codex", + folders, + }; + } + return next; + }); + }, [currentWorkspace?.id, currentWorkspace?.projects?.length]); + + if (!open) return null; + + const pickFolder = async (onPicked: (path: string) => void, prompt?: string) => { + setError(null); + try { + const res = await api.pickFolder(prompt || "Choose a folder"); + if (res.ok && res.path) onPicked(res.path); + } catch (e) { + setError(String(e)); + } + }; + + const runGuarded = async (label: string, fn: () => Promise ) => { + setBusy(true); + setError(null); + setNotice(null); + try { + await fn(); + setNotice(label); + await onChanged(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const onCreateWorkspace = () => + runGuarded("Workspace created.", async () => { + const name = newWsName.trim(); + if (!name) { + throw new Error("Name is required."); + } + await api.createWorkspaceRoot(name, newWsDesc.trim() || undefined); + setNewWsName(""); + setNewWsDesc(""); + }); + + const onUpdateWorkspace = () => + runGuarded("Workspace updated.", async () => { + if (!currentWorkspace) return; + const payload: { label?: string; description?: string } = {}; + if (editWsName.trim() && editWsName !== (currentWorkspace.label || currentWorkspace.name)) { + payload.label = editWsName.trim(); + } + if (editWsDesc !== (currentWorkspace.description || "")) { + payload.description = editWsDesc; + } + if (!Object.keys(payload).length) { + throw new Error("Nothing to save."); + } + await api.updateWorkspace(currentWorkspace.id, payload); + }); + + const onDeleteWorkspace = () => + runGuarded("Workspace deleted.", async () => { + if (!currentWorkspace) return; + if (deleteConfirm.trim() !== (currentWorkspace.label || currentWorkspace.name)) { + throw new Error("Type the workspace name exactly to confirm."); + } + await api.deleteWorkspace(currentWorkspace.id); + setSelectedWorkspaceId(""); + setDeleteConfirm(""); + }); + + const onCreateProject = () => + runGuarded("Project created.", async () => { + if (!currentWorkspace) throw new Error("Pick a workspace first."); + const name = newProjectName.trim(); + if (!name) throw new Error("Project name is required."); + const rules = newProjectFolders + .map((p) => p.trim()) + .filter(Boolean) + .map((path_prefix) => ({ path_prefix })); + await api.createProject(currentWorkspace.id, { + name, + description: newProjectDesc.trim() || undefined, + default_runtime: newProjectRuntime, + scope_rules: rules, + }); + setNewProjectName(""); + setNewProjectDesc(""); + setNewProjectRuntime("codex"); + setNewProjectFolders([]); + }); + + const onUpdateProject = (project: ProjectSummary) => () => + runGuarded("Project updated.", async () => { + const draft = projectEdits[project.id]; + if (!draft) return; + const payload: { + name?: string; + description?: string; + default_runtime?: string; + scope_rules?: { path_prefix: string; label?: string }[]; + } = {}; + if (draft.name.trim() && draft.name.trim() !== project.name) payload.name = draft.name.trim(); + if (draft.description !== (project.description || "")) payload.description = draft.description; + if (draft.defaultRuntime && draft.defaultRuntime !== (project.defaultRuntime || "codex")) { + payload.default_runtime = draft.defaultRuntime; + } + const currentFolders = (project.scopeRules || []).map((r) => r.pathPrefix); + const draftFolders = draft.folders.map((p) => p.trim()).filter(Boolean); + const foldersChanged = + currentFolders.length !== draftFolders.length || + currentFolders.some((p, i) => p !== draftFolders[i]); + if (foldersChanged) { + payload.scope_rules = draftFolders.map((path_prefix) => ({ path_prefix })); + } + if (!Object.keys(payload).length) throw new Error("Nothing to save."); + await api.updateProject(project.id, payload); + }); + + const onDeleteProject = (project: ProjectSummary) => () => + runGuarded("Project deleted.", async () => { + const ok = window.confirm( + `Delete project "${project.name}"? This removes its assets. The workspace stays.`, + ); + if (!ok) throw new Error("cancelled"); + await api.deleteProject(project.id); + }); + + return ( + ++ ); +} + +function FoldersEditor({ + folders, + onChange, + onPick, + disabled, +}: { + folders: string[]; + onChange: (next: string[]) => void; + onPick: (apply: (path: string) => void) => void; + disabled?: boolean; +}) { + const update = (index: number, value: string) => { + const next = folders.slice(); + next[index] = value; + onChange(next); + }; + const remove = (index: number) => { + const next = folders.slice(); + next.splice(index, 1); + onChange(next); + }; + const addBlank = () => onChange([...folders, ""]); + const addPicked = () => onPick((path) => onChange([...folders, path])); + return ( +e.stopPropagation()}> + {/* Header */} ++++ + {/* Tabs */} +++ +Workspaces & projects++ Organise every agent under a shared brain. +++ {(["workspaces", "projects"] as Tab[]).map((key) => ( + + ))} ++ + {/* Body */} ++ {tab === "workspaces" && ( + <> + {/* Create workspace */} ++ + {/* Footer */} ++ + + {/* Existing workspaces */} +New workspace+ setNewWsName(e.target.value)} + style={inputStyle} + /> ++ + + {/* Edit selected workspace */} + {currentWorkspace && ( +Existing · {workspaces.length}+ {workspaces.length === 0 ? ( ++ No workspaces yet. Create one above. ++ ) : ( ++ {workspaces.map((workspace) => { + const active = workspace.id === selectedWorkspaceId; + const projectCount = workspace.projects?.length || 0; + return ( + + ); + })} ++ )} ++ + )} + > + )} + + {tab === "projects" && ( + <> +Edit · {currentWorkspace.label || currentWorkspace.name}+ setEditWsName(e.target.value)} + placeholder="Name" + style={inputStyle} + /> ++ + + {currentWorkspace && ( + <> + {/* Create project */} +Workspace+ ++ + + {/* Edit projects */} +Add project to {currentWorkspace.label || currentWorkspace.name}+ setNewProjectName(e.target.value)} + style={inputStyle} + /> ++ + > + )} + > + )} ++ Projects · {(currentWorkspace.projects || []).length} ++ {(currentWorkspace.projects || []).length === 0 ? ( ++ No projects yet — add the first one above. ++ ) : ( ++ {(currentWorkspace.projects || []).map((project) => { + const draft = + projectEdits[project.id] || { + name: project.name, + description: project.description || "", + defaultRuntime: project.defaultRuntime || "codex", + folders: (project.scopeRules || []).map((r) => r.pathPrefix), + }; + const setDraft = (patch: Partial+ )} +) => + setProjectEdits((prev) => ({ + ...prev, + [project.id]: { ...draft, ...patch }, + })); + return ( + + setDraft({ name: e.target.value })} + style={inputStyle} + /> ++ ); + })} ++++ {error ? ( + {error} + ) : notice ? ( + {notice} + ) : ( + Changes save immediately. + )} ++ +++ ); +} diff --git a/dhee/ui/web/src/components/canvas/CanvasControls.js b/dhee/ui/web/src/components/canvas/CanvasControls.js new file mode 100644 index 0000000..b280975 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/CanvasControls.js @@ -0,0 +1,88 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useState } from "react"; +import { Minimap } from "./Minimap"; +// --------------------------------------------------------------------------- +// CanvasControls — a floating panel stack in the bottom-right corner: +// minimap card (toggleable) + toolbar with zoom / fit / reset / minimap +// toggle. Matches openswarm's interaction grammar with plain SVG/HTML so +// we don't take a MUI dependency. +// --------------------------------------------------------------------------- +const ICON_SIZE = 14; +const buttonStyle = (active = false) => ({ + width: 26, + height: 26, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 0, + border: 0, + borderRadius: 4, + background: active ? "rgba(224, 107, 63, 0.12)" : "transparent", + color: active ? "var(--accent)" : "var(--ink3)", + cursor: "pointer", + transition: "background 0.14s ease, color 0.14s ease", +}); +function MinusIcon() { + return (_jsx("svg", { width: ICON_SIZE, height: ICON_SIZE, viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M5 12h14", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" }) })); +} +function PlusIcon() { + return (_jsx("svg", { width: ICON_SIZE, height: ICON_SIZE, viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M12 5v14M5 12h14", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" }) })); +} +function FitIcon() { + return (_jsx("svg", { width: ICON_SIZE, height: ICON_SIZE, viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M4 9V5a1 1 0 0 1 1-1h4M15 4h4a1 1 0 0 1 1 1v4M20 15v4a1 1 0 0 1-1 1h-4M9 20H5a1 1 0 0 1-1-1v-4", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" }) })); +} +function MapIcon() { + return (_jsx("svg", { width: ICON_SIZE, height: ICON_SIZE, viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z M9 4v14 M15 6v14", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round" }) })); +} +function TidyIcon() { + return (_jsxs("svg", { width: ICON_SIZE, height: ICON_SIZE, viewBox: "0 0 24 24", fill: "none", children: [_jsx("path", { d: "M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" }), _jsx("circle", { cx: 12, cy: 12, r: 3, stroke: "currentColor", strokeWidth: 1.8 })] })); +} +export function CanvasControls({ zoom, actions, onFitToContent, onTidy, minimapProps, onMinimapPan, }) { + const [minimapOpen, setMinimapOpen] = useState(true); + const pct = Math.round(zoom * 100); + const panelStyle = { + background: "rgba(255,255,255,0.94)", + backdropFilter: "blur(10px)", + WebkitBackdropFilter: "blur(10px)", + border: "1px solid rgba(20,16,10,0.12)", + borderRadius: 10, + boxShadow: "0 12px 32px rgba(20,16,10,0.08), 0 2px 8px rgba(20,16,10,0.04)", + }; + return (_jsxs("div", { style: { + position: "absolute", + right: 16, + bottom: 16, + display: "flex", + flexDirection: "column", + alignItems: "flex-end", + gap: 8, + zIndex: 30, + pointerEvents: "none", + }, children: [minimapOpen && (_jsx("div", { style: { + ...panelStyle, + width: 220, + height: 154, + padding: 6, + overflow: "hidden", + pointerEvents: "auto", + }, children: _jsx(Minimap, { ...minimapProps, onPan: onMinimapPan }) })), _jsxs("div", { style: { + ...panelStyle, + display: "flex", + alignItems: "center", + gap: 2, + padding: "4px 6px", + pointerEvents: "auto", + }, children: [_jsx("button", { title: "Zoom out (\u2318\u2212)", "aria-label": "Zoom out", onClick: actions.zoomOut, style: buttonStyle(), onMouseEnter: (e) => (e.currentTarget.style.background = "rgba(20,16,10,0.05)"), onMouseLeave: (e) => (e.currentTarget.style.background = "transparent"), children: _jsx(MinusIcon, {}) }), _jsxs("button", { title: "Reset to 100% (\u23180)", "aria-label": "Reset zoom", onClick: actions.resetZoom, style: { + ...buttonStyle(), + width: 46, + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink2)", + }, onMouseEnter: (e) => (e.currentTarget.style.background = "rgba(20,16,10,0.05)"), onMouseLeave: (e) => (e.currentTarget.style.background = "transparent"), children: [pct, "%"] }), _jsx("button", { title: "Zoom in (\u2318+)", "aria-label": "Zoom in", onClick: actions.zoomIn, style: buttonStyle(), onMouseEnter: (e) => (e.currentTarget.style.background = "rgba(20,16,10,0.05)"), onMouseLeave: (e) => (e.currentTarget.style.background = "transparent"), children: _jsx(PlusIcon, {}) }), _jsx("div", { style: { width: 1, height: 14, background: "rgba(20,16,10,0.12)", margin: "0 4px" } }), _jsx("button", { title: "Fit to content", "aria-label": "Fit to content", onClick: onFitToContent, style: buttonStyle(), onMouseEnter: (e) => (e.currentTarget.style.background = "rgba(20,16,10,0.05)"), onMouseLeave: (e) => (e.currentTarget.style.background = "transparent"), children: _jsx(FitIcon, {}) }), _jsx("button", { title: "Tidy layout", "aria-label": "Tidy layout", onClick: onTidy, style: buttonStyle(), onMouseEnter: (e) => (e.currentTarget.style.background = "rgba(20,16,10,0.05)"), onMouseLeave: (e) => (e.currentTarget.style.background = "transparent"), children: _jsx(TidyIcon, {}) }), _jsx("div", { style: { width: 1, height: 14, background: "rgba(20,16,10,0.12)", margin: "0 4px" } }), _jsx("button", { title: minimapOpen ? "Hide minimap" : "Show minimap", "aria-label": "Toggle minimap", onClick: () => setMinimapOpen((v) => !v), style: buttonStyle(minimapOpen), onMouseEnter: (e) => { + if (!minimapOpen) + e.currentTarget.style.background = "rgba(20,16,10,0.05)"; + }, onMouseLeave: (e) => { + if (!minimapOpen) + e.currentTarget.style.background = "transparent"; + }, children: _jsx(MapIcon, {}) })] })] })); +} diff --git a/dhee/ui/web/src/components/canvas/CanvasControls.tsx b/dhee/ui/web/src/components/canvas/CanvasControls.tsx new file mode 100644 index 0000000..4b6a93b --- /dev/null +++ b/dhee/ui/web/src/components/canvas/CanvasControls.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import type { CanvasActions } from "./useInfiniteCanvas"; +import { Minimap, type MinimapProps } from "./Minimap"; + +// --------------------------------------------------------------------------- +// CanvasControls — a floating panel stack in the bottom-right corner: +// minimap card (toggleable) + toolbar with zoom / fit / reset / minimap +// toggle. Matches openswarm's interaction grammar with plain SVG/HTML so +// we don't take a MUI dependency. +// --------------------------------------------------------------------------- + +const ICON_SIZE = 14; + +const buttonStyle = (active = false): React.CSSProperties => ({ + width: 26, + height: 26, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 0, + border: 0, + borderRadius: 4, + background: active ? "rgba(224, 107, 63, 0.12)" : "transparent", + color: active ? "var(--accent)" : "var(--ink3)", + cursor: "pointer", + transition: "background 0.14s ease, color 0.14s ease", +}); + +function MinusIcon() { + return ( + + ); +} +function PlusIcon() { + return ( + + ); +} +function FitIcon() { + return ( + + ); +} +function MapIcon() { + return ( + + ); +} +function TidyIcon() { + return ( + + ); +} + +interface Props { + zoom: number; + actions: CanvasActions; + onFitToContent: () => void; + onTidy: () => void; + minimapProps: OmitFolders · {folders.length}+ {folders.length === 0 && ( ++ No folders yet. A project can have one or many. ++ )} + {folders.map((folder, index) => ( ++ update(index, e.target.value)} + placeholder="/absolute/path" + style={{ ...inputStyle, flex: 1 }} + /> + + ++ ))} ++ + ++; + onMinimapPan: (panX: number, panY: number) => void; +} + +export function CanvasControls({ + zoom, + actions, + onFitToContent, + onTidy, + minimapProps, + onMinimapPan, +}: Props) { + const [minimapOpen, setMinimapOpen] = useState(true); + const pct = Math.round(zoom * 100); + + const panelStyle: React.CSSProperties = { + background: "rgba(255,255,255,0.94)", + backdropFilter: "blur(10px)", + WebkitBackdropFilter: "blur(10px)", + border: "1px solid rgba(20,16,10,0.12)", + borderRadius: 10, + boxShadow: "0 12px 32px rgba(20,16,10,0.08), 0 2px 8px rgba(20,16,10,0.04)", + }; + + return ( + + {minimapOpen && ( ++ ); +} diff --git a/dhee/ui/web/src/components/canvas/CanvasSkeleton.js b/dhee/ui/web/src/components/canvas/CanvasSkeleton.js new file mode 100644 index 0000000..a0670e3 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/CanvasSkeleton.js @@ -0,0 +1,31 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +// --------------------------------------------------------------------------- +// CanvasSkeleton — shimmer placeholders shown while the graph loads. +// Mimics the hierarchical layout (one workspace, a row of projects, one +// cluster of children) so the transition to real content feels continuous +// rather than a swap. +// --------------------------------------------------------------------------- +function ShimmerCard({ width, height, delay = 0, }) { + return (_jsx("div", { style: { + width, + height, + borderRadius: 8, + background: "linear-gradient(90deg, rgba(20,16,10,0.04) 0%, rgba(20,16,10,0.08) 50%, rgba(20,16,10,0.04) 100%)", + backgroundSize: "200% 100%", + animation: `dhee-shimmer 1.4s linear ${delay}ms infinite`, + border: "1px solid rgba(20,16,10,0.06)", + borderLeft: "3px solid rgba(20,16,10,0.12)", + } })); +} +export function CanvasSkeleton() { + return (_jsxs("div", { style: { + position: "absolute", + inset: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 28, + pointerEvents: "none", + }, children: [_jsx(ShimmerCard, { width: 320, height: 140 }), _jsxs("div", { style: { display: "flex", gap: 40 }, children: [_jsx(ShimmerCard, { width: 240, height: 120, delay: 120 }), _jsx(ShimmerCard, { width: 240, height: 120, delay: 240 }), _jsx(ShimmerCard, { width: 240, height: 120, delay: 360 })] }), _jsxs("div", { style: { display: "flex", gap: 20 }, children: [_jsx(ShimmerCard, { width: 200, height: 90, delay: 480 }), _jsx(ShimmerCard, { width: 200, height: 90, delay: 560 }), _jsx(ShimmerCard, { width: 200, height: 90, delay: 640 }), _jsx(ShimmerCard, { width: 200, height: 90, delay: 720 })] })] })); +} diff --git a/dhee/ui/web/src/components/canvas/CanvasSkeleton.tsx b/dhee/ui/web/src/components/canvas/CanvasSkeleton.tsx new file mode 100644 index 0000000..1fe4262 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/CanvasSkeleton.tsx @@ -0,0 +1,62 @@ +// --------------------------------------------------------------------------- +// CanvasSkeleton — shimmer placeholders shown while the graph loads. +// Mimics the hierarchical layout (one workspace, a row of projects, one +// cluster of children) so the transition to real content feels continuous +// rather than a swap. +// --------------------------------------------------------------------------- + +function ShimmerCard({ + width, + height, + delay = 0, +}: { + width: number; + height: number; + delay?: number; +}) { + return ( + + ); +} + +export function CanvasSkeleton() { + return ( +++ )} + ++ + + + + + + + + ++++ ); +} diff --git a/dhee/ui/web/src/components/canvas/DirectionHints.js b/dhee/ui/web/src/components/canvas/DirectionHints.js new file mode 100644 index 0000000..8c8379f --- /dev/null +++ b/dhee/ui/web/src/components/canvas/DirectionHints.js @@ -0,0 +1,44 @@ +import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; +const chevron = { + left: "M15 6l-6 6 6 6", + right: "M9 6l6 6-6 6", + up: "M6 15l6-6 6 6", + down: "M6 9l6 6 6-6", +}; +const positions = { + left: { left: 16, top: "50%", transform: "translateY(-50%)" }, + right: { right: 16, top: "50%", transform: "translateY(-50%)" }, + up: { top: 16, left: "50%", transform: "translateX(-50%)" }, + down: { bottom: 78, left: "50%", transform: "translateX(-50%)" }, +}; +function Hint({ direction, onClick }) { + return (_jsx("button", { onClick: onClick, "aria-label": `Pan ${direction}`, style: { + position: "absolute", + width: 32, + height: 32, + borderRadius: "50%", + background: "rgba(255,255,255,0.9)", + border: "1px solid rgba(20,16,10,0.12)", + boxShadow: "0 2px 10px rgba(20,16,10,0.08)", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "var(--ink3)", + cursor: "pointer", + opacity: 0.7, + backdropFilter: "blur(8px)", + WebkitBackdropFilter: "blur(8px)", + transition: "opacity 0.18s ease, transform 0.18s ease, color 0.18s ease", + padding: 0, + ...positions[direction], + }, onMouseEnter: (e) => { + e.currentTarget.style.opacity = "1"; + e.currentTarget.style.color = "var(--accent)"; + }, onMouseLeave: (e) => { + e.currentTarget.style.opacity = "0.7"; + e.currentTarget.style.color = "var(--ink3)"; + }, children: _jsx("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: chevron[direction], stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }) }) })); +} +export function DirectionHints({ hasLeft, hasRight, hasUp, hasDown, onPanTo }) { + return (_jsxs(_Fragment, { children: [hasLeft ? _jsx(Hint, { direction: "left", onClick: () => onPanTo("left") }) : null, hasRight ? _jsx(Hint, { direction: "right", onClick: () => onPanTo("right") }) : null, hasUp ? _jsx(Hint, { direction: "up", onClick: () => onPanTo("up") }) : null, hasDown ? _jsx(Hint, { direction: "down", onClick: () => onPanTo("down") }) : null] })); +} diff --git a/dhee/ui/web/src/components/canvas/DirectionHints.tsx b/dhee/ui/web/src/components/canvas/DirectionHints.tsx new file mode 100644 index 0000000..7cc12d5 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/DirectionHints.tsx @@ -0,0 +1,80 @@ +// --------------------------------------------------------------------------- +// DirectionHints — soft chevrons near each viewport edge when content +// extends off-screen in that direction. Taken from openswarm's +// DirectionHints but stripped of MUI / keyframe shake; we rely on a +// simple opacity ease so the indicators never compete with real content. +// --------------------------------------------------------------------------- + +interface Props { + hasLeft: boolean; + hasRight: boolean; + hasUp: boolean; + hasDown: boolean; + onPanTo: (direction: "left" | "right" | "up" | "down") => void; +} + +const chevron: Record+ +++ + + +++ + + + = { + left: "M15 6l-6 6 6 6", + right: "M9 6l6 6-6 6", + up: "M6 15l6-6 6 6", + down: "M6 9l6 6 6-6", +}; + +const positions: Record = { + left: { left: 16, top: "50%", transform: "translateY(-50%)" }, + right: { right: 16, top: "50%", transform: "translateY(-50%)" }, + up: { top: 16, left: "50%", transform: "translateX(-50%)" }, + down: { bottom: 78, left: "50%", transform: "translateX(-50%)" }, +}; + +function Hint({ direction, onClick }: { direction: keyof typeof chevron; onClick: () => void }) { + return ( + + ); +} + +export function DirectionHints({ hasLeft, hasRight, hasUp, hasDown, onPanTo }: Props) { + return ( + <> + {hasLeft ? onPanTo("left")} /> : null} + {hasRight ? onPanTo("right")} /> : null} + {hasUp ? onPanTo("up")} /> : null} + {hasDown ? onPanTo("down")} /> : null} + > + ); +} diff --git a/dhee/ui/web/src/components/canvas/Minimap.js b/dhee/ui/web/src/components/canvas/Minimap.js new file mode 100644 index 0000000..f4380b5 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/Minimap.js @@ -0,0 +1,90 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useCallback, useMemo, useRef } from "react"; +import { TYPE_COLOR } from "./NodeCard"; +// --------------------------------------------------------------------------- +// Minimap — scaled overview of the canvas. Shows every card as a filled +// rect, draws the viewport as an outlined rect, and lets the user click +// or drag to pan the main canvas. +// --------------------------------------------------------------------------- +const MINIMAP_W = 208; +const MINIMAP_H = 140; +const PADDING = 12; +export function Minimap({ panX, panY, zoom, viewportRef, cards, nodeTypes, onPan, }) { + const svgRef = useRef(null); + const isDraggingRef = useRef(false); + const layout = useMemo(() => { + const vp = viewportRef.current; + const vpW = vp ? vp.clientWidth : 1200; + const vpH = vp ? vp.clientHeight : 800; + const vpRect = { + x: -panX / zoom, + y: -panY / zoom, + width: vpW / zoom, + height: vpH / zoom, + }; + if (cards.length === 0) { + const scale = Math.min((MINIMAP_W - PADDING * 2) / Math.max(1, vpRect.width), (MINIMAP_H - PADDING * 2) / Math.max(1, vpRect.height)); + return { + scale, + offsetX: MINIMAP_W / 2 - (vpRect.x + vpRect.width / 2) * scale, + offsetY: MINIMAP_H / 2 - (vpRect.y + vpRect.height / 2) * scale, + vpRect, + }; + } + let minX = vpRect.x, minY = vpRect.y; + let maxX = vpRect.x + vpRect.width, maxY = vpRect.y + vpRect.height; + for (const card of cards) { + minX = Math.min(minX, card.x); + minY = Math.min(minY, card.y); + maxX = Math.max(maxX, card.x + card.width); + maxY = Math.max(maxY, card.y + card.height); + } + const contentW = Math.max(1, maxX - minX); + const contentH = Math.max(1, maxY - minY); + const scale = Math.min((MINIMAP_W - PADDING * 2) / contentW, (MINIMAP_H - PADDING * 2) / contentH); + return { + scale, + offsetX: (MINIMAP_W - contentW * scale) / 2 - minX * scale, + offsetY: (MINIMAP_H - contentH * scale) / 2 - minY * scale, + vpRect, + }; + }, [cards, panX, panY, zoom, viewportRef]); + const minimapToCanvas = useCallback((clientX, clientY) => { + const svg = svgRef.current; + if (!svg) + return; + const rect = svg.getBoundingClientRect(); + const mx = clientX - rect.left; + const my = clientY - rect.top; + const canvasX = (mx - layout.offsetX) / layout.scale; + const canvasY = (my - layout.offsetY) / layout.scale; + onPan(-(canvasX - layout.vpRect.width / 2) * zoom, -(canvasY - layout.vpRect.height / 2) * zoom); + }, [layout, zoom, onPan]); + const handleMouseDown = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingRef.current = true; + minimapToCanvas(e.clientX, e.clientY); + const onMove = (ev) => { + if (isDraggingRef.current) + minimapToCanvas(ev.clientX, ev.clientY); + }; + const onUp = () => { + isDraggingRef.current = false; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, [minimapToCanvas]); + return (_jsxs("svg", { ref: svgRef, width: MINIMAP_W, height: MINIMAP_H, onMouseDown: handleMouseDown, style: { + cursor: "pointer", + display: "block", + borderRadius: 6, + background: "radial-gradient(circle at 30% 20%, rgba(250,246,236,0.6), rgba(236,227,210,0.4))", + }, children: [cards.map((card) => { + const type = nodeTypes[card.id] || "session"; + const color = TYPE_COLOR[type] || "#666"; + return (_jsx("rect", { x: card.x * layout.scale + layout.offsetX, y: card.y * layout.scale + layout.offsetY, width: Math.max(2, card.width * layout.scale), height: Math.max(2, card.height * layout.scale), fill: color, opacity: 0.55, rx: 1.2 }, card.id)); + }), _jsx("rect", { x: layout.vpRect.x * layout.scale + layout.offsetX, y: layout.vpRect.y * layout.scale + layout.offsetY, width: Math.max(4, layout.vpRect.width * layout.scale), height: Math.max(4, layout.vpRect.height * layout.scale), fill: "rgba(224,107,63,0.08)", stroke: "#e06b3f", strokeWidth: 1.2, rx: 2 })] })); +} diff --git a/dhee/ui/web/src/components/canvas/Minimap.tsx b/dhee/ui/web/src/components/canvas/Minimap.tsx new file mode 100644 index 0000000..4548839 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/Minimap.tsx @@ -0,0 +1,164 @@ +import { useCallback, useMemo, useRef } from "react"; +import type { NodeLayout } from "./layout"; +import { TYPE_COLOR } from "./NodeCard"; + +// --------------------------------------------------------------------------- +// Minimap — scaled overview of the canvas. Shows every card as a filled +// rect, draws the viewport as an outlined rect, and lets the user click +// or drag to pan the main canvas. +// --------------------------------------------------------------------------- + +const MINIMAP_W = 208; +const MINIMAP_H = 140; +const PADDING = 12; + +export interface MinimapProps { + panX: number; + panY: number; + zoom: number; + viewportRef: React.RefObject ; + cards: NodeLayout[]; + nodeTypes: Record ; // id → type (for colour) + onPan: (panX: number, panY: number) => void; +} + +export function Minimap({ + panX, + panY, + zoom, + viewportRef, + cards, + nodeTypes, + onPan, +}: MinimapProps) { + const svgRef = useRef (null); + const isDraggingRef = useRef(false); + + const layout = useMemo(() => { + const vp = viewportRef.current; + const vpW = vp ? vp.clientWidth : 1200; + const vpH = vp ? vp.clientHeight : 800; + const vpRect = { + x: -panX / zoom, + y: -panY / zoom, + width: vpW / zoom, + height: vpH / zoom, + }; + + if (cards.length === 0) { + const scale = Math.min( + (MINIMAP_W - PADDING * 2) / Math.max(1, vpRect.width), + (MINIMAP_H - PADDING * 2) / Math.max(1, vpRect.height), + ); + return { + scale, + offsetX: MINIMAP_W / 2 - (vpRect.x + vpRect.width / 2) * scale, + offsetY: MINIMAP_H / 2 - (vpRect.y + vpRect.height / 2) * scale, + vpRect, + }; + } + + let minX = vpRect.x, + minY = vpRect.y; + let maxX = vpRect.x + vpRect.width, + maxY = vpRect.y + vpRect.height; + for (const card of cards) { + minX = Math.min(minX, card.x); + minY = Math.min(minY, card.y); + maxX = Math.max(maxX, card.x + card.width); + maxY = Math.max(maxY, card.y + card.height); + } + const contentW = Math.max(1, maxX - minX); + const contentH = Math.max(1, maxY - minY); + const scale = Math.min( + (MINIMAP_W - PADDING * 2) / contentW, + (MINIMAP_H - PADDING * 2) / contentH, + ); + return { + scale, + offsetX: (MINIMAP_W - contentW * scale) / 2 - minX * scale, + offsetY: (MINIMAP_H - contentH * scale) / 2 - minY * scale, + vpRect, + }; + }, [cards, panX, panY, zoom, viewportRef]); + + const minimapToCanvas = useCallback( + (clientX: number, clientY: number) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const mx = clientX - rect.left; + const my = clientY - rect.top; + const canvasX = (mx - layout.offsetX) / layout.scale; + const canvasY = (my - layout.offsetY) / layout.scale; + onPan( + -(canvasX - layout.vpRect.width / 2) * zoom, + -(canvasY - layout.vpRect.height / 2) * zoom, + ); + }, + [layout, zoom, onPan], + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingRef.current = true; + minimapToCanvas(e.clientX, e.clientY); + const onMove = (ev: MouseEvent) => { + if (isDraggingRef.current) minimapToCanvas(ev.clientX, ev.clientY); + }; + const onUp = () => { + isDraggingRef.current = false; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [minimapToCanvas], + ); + + return ( + + ); +} diff --git a/dhee/ui/web/src/components/canvas/NodeCard.js b/dhee/ui/web/src/components/canvas/NodeCard.js new file mode 100644 index 0000000..d5d2b6a --- /dev/null +++ b/dhee/ui/web/src/components/canvas/NodeCard.js @@ -0,0 +1,170 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { memo } from "react"; +// --------------------------------------------------------------------------- +// NodeCard — content-aware card rendered at a fixed position on the +// infinite canvas. One card per graph node. Rendering is +// content-conscious: workspace/project/session cards show rich meta; +// result/file/asset chips are deliberately compact so dozens of them +// still fit in a zoomed-out view. +// +// Styling principles (to match openswarm's premium feel): +// - paper-white surface with a 1px tonal border +// - type-coloured accent strip along the left edge +// - subtle shadow at rest, stronger on hover/selection +// - hover lift via transform (GPU-friendly, no repaint jitter) +// --------------------------------------------------------------------------- +export const TYPE_COLOR = { + workspace: "#e06b3f", + project: "#4d6cff", + channel: "#1fa971", + session: "#1a1a1a", + task: "#0f9f55", + result: "#0b8b5f", + file: "#64748b", + asset: "#d74b7b", + broadcast: "#e08b3f", +}; +const TYPE_LABEL = { + workspace: "Workspace", + project: "Project", + channel: "Channel", + session: "Session", + task: "Task", + result: "Tool result", + file: "File", + asset: "Asset", + broadcast: "Broadcast", +}; +function accentFor(node) { + return node.accent || TYPE_COLOR[node.type] || "#555"; +} +function fmtTime(value) { + if (!value) + return ""; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) + return ""; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} +function clamp(text, max) { + const trimmed = text.trim(); + if (trimmed.length <= max) + return trimmed; + return `${trimmed.slice(0, max - 1).trimEnd()}…`; +} +function RawNodeCard({ node, x, y, width, height, selected, dim, onSelect, onHover, entranceDelay = 0, }) { + const accent = accentFor(node); + const meta = (node.meta || {}); + const isCompact = height < 120; + const type = node.type; + const baseStyle = { + position: "absolute", + left: x, + top: y, + width, + height, + display: "flex", + boxSizing: "border-box", + background: "white", + border: `1px solid ${selected ? accent : "rgba(20,16,10,0.12)"}`, + borderLeft: `3px solid ${accent}`, + borderRadius: 8, + boxShadow: selected + ? `0 10px 26px rgba(20,16,10,0.12), 0 0 0 3px ${accent}22` + : "0 1px 2px rgba(20,16,10,0.04), 0 2px 10px rgba(20,16,10,0.04)", + transition: "box-shadow 0.18s ease, border-color 0.18s ease, transform 0.18s ease, opacity 0.18s ease", + cursor: "pointer", + userSelect: "none", + opacity: dim ? 0.32 : 1, + willChange: "transform", + transform: "translate3d(0, 0, 0)", + overflow: "hidden", + // Slight entrance stagger — pure CSS keyframe set below. + animation: `dhee-card-in 320ms ${entranceDelay}ms cubic-bezier(0.17, 0.67, 0.3, 1) both`, + }; + const content = { + flex: 1, + padding: isCompact ? "10px 12px" : "12px 14px", + display: "flex", + flexDirection: "column", + gap: isCompact ? 4 : 6, + minWidth: 0, + }; + const headerRow = { + display: "flex", + alignItems: "center", + gap: 6, + }; + const chip = (text, tone) => ({ + fontFamily: "var(--mono)", + fontSize: 9, + color: tone || "var(--ink3)", + letterSpacing: 0.4, + textTransform: "uppercase", + lineHeight: 1.1, + padding: "2px 6px", + border: `1px solid ${tone || "var(--border)"}`, + borderRadius: 2, + whiteSpace: "nowrap", + background: "white", + ...(text ? {} : {}), + }); + const titleStyle = { + fontSize: isCompact ? 12 : 14, + fontWeight: 600, + lineHeight: 1.25, + color: "var(--ink)", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }; + const bodyStyle = { + fontSize: isCompact ? 11 : 12, + color: "var(--ink2)", + lineHeight: 1.5, + display: "-webkit-box", + WebkitLineClamp: isCompact ? 2 : 3, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }; + const monoMuted = { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 0.3, + }; + const typeLabel = TYPE_LABEL[type] || type; + const runtime = String(meta.runtime || ""); + const state = String(meta.state || ""); + const ptr = String(meta.ptr || ""); + const tool = String(meta.toolName || meta.tool_name || ""); + const sourcePath = String(meta.sourcePath || meta.source_path || ""); + const model = String(meta.model || ""); + const harness = String(meta.harness || ""); + const sessionCount = Number(meta.sessionCount ?? 0); + const projectCount = Number(meta.projectCount ?? 0); + const taskCount = Number(meta.taskCount ?? 0); + const messageCount = Number(meta.messageCount ?? 0); + const updatedAt = meta.updatedAt || meta.last_seen_at; + const onEnter = () => onHover(node); + const onLeave = () => onHover(null); + const onClick = (e) => { + e.stopPropagation(); + onSelect(node); + }; + return (_jsx("div", { style: baseStyle, onClick: onClick, onMouseEnter: onEnter, onMouseLeave: onLeave, "data-canvas-draggable": "false", "data-node-id": node.id, className: "dhee-node-card", children: _jsxs("div", { style: content, children: [_jsxs("div", { style: headerRow, children: [_jsx("span", { style: { + width: 8, + height: 8, + borderRadius: "50%", + background: accent, + flexShrink: 0, + } }), _jsx("span", { style: { ...monoMuted, color: accent }, children: typeLabel }), node.status ? _jsx("span", { style: chip(node.status), children: node.status }) : null, state ? _jsx("span", { style: chip(state), children: state }) : null, runtime ? _jsx("span", { style: chip(runtime), children: runtime }) : null, harness && !runtime ? _jsx("span", { style: chip(harness), children: harness }) : null, type === "session" && meta.isCurrent ? (_jsx("span", { style: { + width: 7, + height: 7, + borderRadius: "50%", + background: "var(--green)", + boxShadow: "0 0 0 3px rgba(31,169,113,0.22)", + marginLeft: "auto", + } })) : null] }), _jsx("div", { style: titleStyle, children: node.label || "(unnamed)" }), node.subLabel ? (_jsx("div", { style: { ...monoMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: node.subLabel })) : null, !isCompact && node.body ? _jsx("div", { style: bodyStyle, children: node.body }) : null, type === "workspace" && !isCompact ? (_jsxs("div", { style: { display: "flex", gap: 12, ...monoMuted }, children: [_jsxs("span", { children: [projectCount || "—", " projects"] }), _jsxs("span", { children: [sessionCount || "—", " sessions"] })] })) : null, type === "project" && !isCompact ? (_jsxs("div", { style: { display: "flex", gap: 12, ...monoMuted }, children: [_jsxs("span", { children: [sessionCount || "—", " sessions"] }), _jsxs("span", { children: [taskCount || "—", " tasks"] })] })) : null, type === "session" && !isCompact ? (_jsxs("div", { style: { display: "flex", gap: 12, ...monoMuted }, children: [model ? _jsx("span", { children: clamp(model, 22) }) : null, updatedAt ? _jsx("span", { children: fmtTime(updatedAt) }) : null] })) : null, type === "task" && !isCompact ? (_jsxs("div", { style: { display: "flex", gap: 12, ...monoMuted }, children: [messageCount ? _jsxs("span", { children: [messageCount, " messages"] }) : null, updatedAt ? _jsx("span", { children: fmtTime(updatedAt) }) : null] })) : null, type === "result" ? (_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", ...monoMuted }, children: [tool ? _jsx("span", { children: tool }) : null, ptr ? _jsx("span", { children: ptr }) : null, sourcePath ? (_jsxs("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: ["\u00B7 ", sourcePath.split("/").pop()] })) : null] })) : null, type === "broadcast" ? (_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", ...monoMuted }, children: [String(meta.sourceChannel || meta.sourceProject || "") ? (_jsxs("span", { children: ["from ", String(meta.sourceChannel || meta.sourceProject || "")] })) : null, String(meta.targetProject || "") ? (_jsxs("span", { children: ["\u2192 ", String(meta.targetProject)] })) : null] })) : null] }) })); +} +export const NodeCard = memo(RawNodeCard); diff --git a/dhee/ui/web/src/components/canvas/NodeCard.tsx b/dhee/ui/web/src/components/canvas/NodeCard.tsx new file mode 100644 index 0000000..3e3ff2f --- /dev/null +++ b/dhee/ui/web/src/components/canvas/NodeCard.tsx @@ -0,0 +1,299 @@ +import { memo } from "react"; +import type { WorkspaceGraphNode } from "../../types"; + +// --------------------------------------------------------------------------- +// NodeCard — content-aware card rendered at a fixed position on the +// infinite canvas. One card per graph node. Rendering is +// content-conscious: workspace/project/session cards show rich meta; +// result/file/asset chips are deliberately compact so dozens of them +// still fit in a zoomed-out view. +// +// Styling principles (to match openswarm's premium feel): +// - paper-white surface with a 1px tonal border +// - type-coloured accent strip along the left edge +// - subtle shadow at rest, stronger on hover/selection +// - hover lift via transform (GPU-friendly, no repaint jitter) +// --------------------------------------------------------------------------- + +export const TYPE_COLOR: Record = { + workspace: "#e06b3f", + project: "#4d6cff", + channel: "#1fa971", + session: "#1a1a1a", + task: "#0f9f55", + result: "#0b8b5f", + file: "#64748b", + asset: "#d74b7b", + broadcast: "#e08b3f", +}; + +const TYPE_LABEL: Record = { + workspace: "Workspace", + project: "Project", + channel: "Channel", + session: "Session", + task: "Task", + result: "Tool result", + file: "File", + asset: "Asset", + broadcast: "Broadcast", +}; + +function accentFor(node: WorkspaceGraphNode) { + return node.accent || TYPE_COLOR[node.type] || "#555"; +} + +function fmtTime(value?: unknown): string { + if (!value) return ""; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function clamp(text: string, max: number): string { + const trimmed = text.trim(); + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max - 1).trimEnd()}…`; +} + +interface Props { + node: WorkspaceGraphNode; + x: number; + y: number; + width: number; + height: number; + selected: boolean; + dim: boolean; + onSelect: (node: WorkspaceGraphNode) => void; + onHover: (node: WorkspaceGraphNode | null) => void; + entranceDelay?: number; +} + +function RawNodeCard({ + node, + x, + y, + width, + height, + selected, + dim, + onSelect, + onHover, + entranceDelay = 0, +}: Props) { + const accent = accentFor(node); + const meta = (node.meta || {}) as Record ; + const isCompact = height < 120; + const type = node.type; + + const baseStyle: React.CSSProperties = { + position: "absolute", + left: x, + top: y, + width, + height, + display: "flex", + boxSizing: "border-box", + background: "white", + border: `1px solid ${selected ? accent : "rgba(20,16,10,0.12)"}`, + borderLeft: `3px solid ${accent}`, + borderRadius: 8, + boxShadow: selected + ? `0 10px 26px rgba(20,16,10,0.12), 0 0 0 3px ${accent}22` + : "0 1px 2px rgba(20,16,10,0.04), 0 2px 10px rgba(20,16,10,0.04)", + transition: "box-shadow 0.18s ease, border-color 0.18s ease, transform 0.18s ease, opacity 0.18s ease", + cursor: "pointer", + userSelect: "none", + opacity: dim ? 0.32 : 1, + willChange: "transform", + transform: "translate3d(0, 0, 0)", + overflow: "hidden", + // Slight entrance stagger — pure CSS keyframe set below. + animation: `dhee-card-in 320ms ${entranceDelay}ms cubic-bezier(0.17, 0.67, 0.3, 1) both`, + }; + + const content: React.CSSProperties = { + flex: 1, + padding: isCompact ? "10px 12px" : "12px 14px", + display: "flex", + flexDirection: "column", + gap: isCompact ? 4 : 6, + minWidth: 0, + }; + + const headerRow: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 6, + }; + + const chip = (text: string, tone?: string): React.CSSProperties => ({ + fontFamily: "var(--mono)", + fontSize: 9, + color: tone || "var(--ink3)", + letterSpacing: 0.4, + textTransform: "uppercase", + lineHeight: 1.1, + padding: "2px 6px", + border: `1px solid ${tone || "var(--border)"}`, + borderRadius: 2, + whiteSpace: "nowrap", + background: "white", + ...(text ? {} : {}), + }); + + const titleStyle: React.CSSProperties = { + fontSize: isCompact ? 12 : 14, + fontWeight: 600, + lineHeight: 1.25, + color: "var(--ink)", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }; + + const bodyStyle: React.CSSProperties = { + fontSize: isCompact ? 11 : 12, + color: "var(--ink2)", + lineHeight: 1.5, + display: "-webkit-box", + WebkitLineClamp: isCompact ? 2 : 3, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }; + + const monoMuted: React.CSSProperties = { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 0.3, + }; + + const typeLabel = TYPE_LABEL[type] || type; + const runtime = String(meta.runtime || ""); + const state = String(meta.state || ""); + const ptr = String(meta.ptr || ""); + const tool = String(meta.toolName || meta.tool_name || ""); + const sourcePath = String(meta.sourcePath || meta.source_path || ""); + const model = String(meta.model || ""); + const harness = String(meta.harness || ""); + const sessionCount = Number(meta.sessionCount ?? 0); + const projectCount = Number(meta.projectCount ?? 0); + const taskCount = Number(meta.taskCount ?? 0); + const messageCount = Number(meta.messageCount ?? 0); + const updatedAt = meta.updatedAt || meta.last_seen_at; + + const onEnter = () => onHover(node); + const onLeave = () => onHover(null); + const onClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(node); + }; + + return ( + ++ ); +} + +export const NodeCard = memo(RawNodeCard); diff --git a/dhee/ui/web/src/components/canvas/__tests__/layout.smoke.js b/dhee/ui/web/src/components/canvas/__tests__/layout.smoke.js new file mode 100644 index 0000000..999cf6e --- /dev/null +++ b/dhee/ui/web/src/components/canvas/__tests__/layout.smoke.js @@ -0,0 +1,68 @@ +// Tiny self-check for the layout function. Run with `node --loader +// tsx src/components/canvas/__tests__/layout.smoke.ts` (dev only — the +// production build never imports this file). +import { layoutGraph } from "../layout"; +function assert(cond, msg) { + if (!cond) + throw new Error(`assertion failed: ${msg}`); +} +function demoGraph() { + const nodes = [ + { id: "ws", type: "workspace", label: "Sankhya AI Labs" }, + { id: "p-be", type: "project", label: "backend" }, + { id: "p-fe", type: "project", label: "frontend" }, + { id: "s-be-1", type: "session", label: "codex · api-refactor", meta: { runtime: "codex" } }, + { id: "s-fe-1", type: "session", label: "claude · ui polish", meta: { runtime: "claude-code" } }, + { id: "t-1", type: "task", label: "wire plan badge" }, + { id: "r-1", type: "result", label: "read · schema.sql", meta: { ptr: "R-001" } }, + { id: "b-1", type: "broadcast", label: "api contract changed" }, + { id: "f-1", type: "file", label: "api/main.py" }, + { id: "loose", type: "result", label: "orphan result" }, + ]; + const links = [ + { id: "e1", source: "ws", target: "p-be", label: "contains" }, + { id: "e2", source: "ws", target: "p-fe", label: "contains" }, + { id: "e3", source: "p-be", target: "s-be-1", label: "session" }, + { id: "e4", source: "p-fe", target: "s-fe-1", label: "session" }, + { id: "e5", source: "p-fe", target: "t-1", label: "task" }, + { id: "e6", source: "p-be", target: "r-1", label: "result" }, + { id: "e7", source: "p-be", target: "b-1", label: "broadcast" }, + { id: "e8", source: "p-be", target: "f-1", label: "file" }, + ]; + return { nodes, links }; +} +function run() { + const graph = demoGraph(); + const laid = layoutGraph(graph.nodes, graph.links); + assert(laid.nodes.length === graph.nodes.length, "all nodes laid out"); + assert(laid.links.length === graph.links.length, "links preserved"); + assert(isFinite(laid.bounds.minX), "bounds resolved"); + assert(laid.bounds.maxX > laid.bounds.minX, "bounds width > 0"); + assert(laid.bounds.maxY > laid.bounds.minY, "bounds height > 0"); + const byId = new Map(laid.nodes.map((n) => [n.id, n])); + const ws = byId.get("ws"); + const pbe = byId.get("p-be"); + const pfe = byId.get("p-fe"); + const sbe = byId.get("s-be-1"); + assert(pbe.y > ws.y, "project below workspace"); + assert(pfe.y > ws.y, "sibling project below workspace"); + assert(Math.abs(pbe.y - pfe.y) < 1, "sibling projects share y-row"); + assert(sbe.y > pbe.y, "session below its project"); + assert(Math.abs(pbe.x - pfe.x) > 100, "projects spaced apart"); + // Determinism — same input twice, same output. + const again = layoutGraph(graph.nodes, graph.links); + for (const n of laid.nodes) { + const other = again.nodes.find((o) => o.id === n.id); + assert(other.x === n.x && other.y === n.y, `stable layout for ${n.id}`); + } + // Orphan lands in the overflow strip (below all placed content). + const loose = byId.get("loose"); + const maxPlacedY = Math.max(...laid.nodes.filter((n) => n.id !== "loose").map((n) => n.y + n.height)); + assert(loose.y > maxPlacedY + 100, "orphan placed below the cluster"); + // eslint-disable-next-line no-console + console.log("layout smoke: OK", { + nodes: laid.nodes.length, + bounds: laid.bounds, + }); +} +run(); diff --git a/dhee/ui/web/src/components/canvas/__tests__/layout.smoke.ts b/dhee/ui/web/src/components/canvas/__tests__/layout.smoke.ts new file mode 100644 index 0000000..1a3148b --- /dev/null +++ b/dhee/ui/web/src/components/canvas/__tests__/layout.smoke.ts @@ -0,0 +1,79 @@ +// Tiny self-check for the layout function. Run with `node --loader +// tsx src/components/canvas/__tests__/layout.smoke.ts` (dev only — the +// production build never imports this file). + +import { layoutGraph } from "../layout"; +import type { WorkspaceGraphEdge, WorkspaceGraphNode } from "../../../types"; + +function assert(cond: unknown, msg: string) { + if (!cond) throw new Error(`assertion failed: ${msg}`); +} + +function demoGraph(): { nodes: WorkspaceGraphNode[]; links: WorkspaceGraphEdge[] } { + const nodes: WorkspaceGraphNode[] = [ + { id: "ws", type: "workspace", label: "Sankhya AI Labs" }, + { id: "p-be", type: "project", label: "backend" }, + { id: "p-fe", type: "project", label: "frontend" }, + { id: "s-be-1", type: "session", label: "codex · api-refactor", meta: { runtime: "codex" } }, + { id: "s-fe-1", type: "session", label: "claude · ui polish", meta: { runtime: "claude-code" } }, + { id: "t-1", type: "task", label: "wire plan badge" }, + { id: "r-1", type: "result", label: "read · schema.sql", meta: { ptr: "R-001" } }, + { id: "b-1", type: "broadcast", label: "api contract changed" }, + { id: "f-1", type: "file", label: "api/main.py" }, + { id: "loose", type: "result", label: "orphan result" }, + ]; + const links: WorkspaceGraphEdge[] = [ + { id: "e1", source: "ws", target: "p-be", label: "contains" }, + { id: "e2", source: "ws", target: "p-fe", label: "contains" }, + { id: "e3", source: "p-be", target: "s-be-1", label: "session" }, + { id: "e4", source: "p-fe", target: "s-fe-1", label: "session" }, + { id: "e5", source: "p-fe", target: "t-1", label: "task" }, + { id: "e6", source: "p-be", target: "r-1", label: "result" }, + { id: "e7", source: "p-be", target: "b-1", label: "broadcast" }, + { id: "e8", source: "p-be", target: "f-1", label: "file" }, + ]; + return { nodes, links }; +} + +function run() { + const graph = demoGraph(); + const laid = layoutGraph(graph.nodes, graph.links); + + assert(laid.nodes.length === graph.nodes.length, "all nodes laid out"); + assert(laid.links.length === graph.links.length, "links preserved"); + assert(isFinite(laid.bounds.minX), "bounds resolved"); + assert(laid.bounds.maxX > laid.bounds.minX, "bounds width > 0"); + assert(laid.bounds.maxY > laid.bounds.minY, "bounds height > 0"); + + const byId = new Map(laid.nodes.map((n) => [n.id, n])); + const ws = byId.get("ws")!; + const pbe = byId.get("p-be")!; + const pfe = byId.get("p-fe")!; + const sbe = byId.get("s-be-1")!; + + assert(pbe.y > ws.y, "project below workspace"); + assert(pfe.y > ws.y, "sibling project below workspace"); + assert(Math.abs(pbe.y - pfe.y) < 1, "sibling projects share y-row"); + assert(sbe.y > pbe.y, "session below its project"); + assert(Math.abs(pbe.x - pfe.x) > 100, "projects spaced apart"); + + // Determinism — same input twice, same output. + const again = layoutGraph(graph.nodes, graph.links); + for (const n of laid.nodes) { + const other = again.nodes.find((o) => o.id === n.id)!; + assert(other.x === n.x && other.y === n.y, `stable layout for ${n.id}`); + } + + // Orphan lands in the overflow strip (below all placed content). + const loose = byId.get("loose")!; + const maxPlacedY = Math.max(...laid.nodes.filter((n) => n.id !== "loose").map((n) => n.y + n.height)); + assert(loose.y > maxPlacedY + 100, "orphan placed below the cluster"); + + // eslint-disable-next-line no-console + console.log("layout smoke: OK", { + nodes: laid.nodes.length, + bounds: laid.bounds, + }); +} + +run(); diff --git a/dhee/ui/web/src/components/canvas/forceLayout.js b/dhee/ui/web/src/components/canvas/forceLayout.js new file mode 100644 index 0000000..e811b60 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/forceLayout.js @@ -0,0 +1,198 @@ +// Force-directed layout for OrgGraph canvas. Pure JS, no library. +// +// Each node has {x, y, vx, vy, fx?, fy?}. fx/fy pin a node (drag, anchor). +// Forces per tick: +// - Center attraction: pulls everyone toward (cx, cy) with k_center. +// - Pairwise repulsion: 1/r^2 with floor on r to prevent blowup. +// - Edge spring: Hooke's law toward rest length. +// - Damping: v *= friction each tick (explicit Euler). +// Auto-quiesces when total kinetic energy stays under EPS for STILL_FRAMES. +export class ForceSim { + constructor(nodes, edges, options) { + Object.defineProperty(this, "opts", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "idx", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "adj", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "nodes", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "stillCount", { + enumerable: true, + configurable: true, + writable: true, + value: 0 + }); + this.opts = { + width: options.width, + height: options.height, + centerX: options.centerX ?? options.width / 2, + centerY: options.centerY ?? options.height / 2, + kCenter: options.kCenter ?? 0.005, + kRepulse: options.kRepulse ?? 2200, + kSpring: options.kSpring ?? 0.04, + rest: options.rest ?? 140, + minDist: options.minDist ?? 40, + friction: options.friction ?? 0.86, + stillFrames: options.stillFrames ?? 30, + energyEps: options.energyEps ?? 0.05, + }; + this.nodes = nodes; + this.idx = new Map(nodes.map((n) => [n.id, n])); + this.adj = new Map(); + for (const e of edges) { + const rest = e.rest ?? this.opts.rest; + const a = this.adj.get(e.source) ?? []; + a.push({ other: e.target, rest }); + this.adj.set(e.source, a); + const b = this.adj.get(e.target) ?? []; + b.push({ other: e.source, rest }); + this.adj.set(e.target, b); + } + } + /** Sync the underlying node array if topology changes externally. */ + setNodes(nodes) { + this.nodes = nodes; + this.idx = new Map(nodes.map((n) => [n.id, n])); + } + setEdges(edges) { + this.adj = new Map(); + for (const e of edges) { + const rest = e.rest ?? this.opts.rest; + const a = this.adj.get(e.source) ?? []; + a.push({ other: e.target, rest }); + this.adj.set(e.source, a); + const b = this.adj.get(e.target) ?? []; + b.push({ other: e.source, rest }); + this.adj.set(e.target, b); + } + } + pin(id, fx, fy) { + const n = this.idx.get(id); + if (!n) + return; + n.fx = fx; + n.fy = fy; + } + unpin(id) { + const n = this.idx.get(id); + if (!n) + return; + n.fx = null; + n.fy = null; + } + /** One Verlet-like tick. Returns total kinetic energy. */ + tick() { + const { kCenter, kRepulse, kSpring, minDist, friction, centerX, centerY } = this.opts; + const nodes = this.nodes; + const fx = new Float64Array(nodes.length); + const fy = new Float64Array(nodes.length); + // Center attraction + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + fx[i] += -kCenter * (n.x - centerX); + fy[i] += -kCenter * (n.y - centerY); + } + // Pairwise repulsion (O(n^2); fine for org charts <500 nodes) + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i]; + const b = nodes[j]; + let dx = a.x - b.x; + let dy = a.y - b.y; + let dist = Math.sqrt(dx * dx + dy * dy); + if (dist < minDist) { + // jitter to avoid singularities + dx += (Math.random() - 0.5) * 0.5; + dy += (Math.random() - 0.5) * 0.5; + dist = Math.max(minDist, Math.sqrt(dx * dx + dy * dy)); + } + const force = kRepulse / (dist * dist); + const ux = dx / dist; + const uy = dy / dist; + fx[i] += force * ux; + fy[i] += force * uy; + fx[j] -= force * ux; + fy[j] -= force * uy; + } + } + // Edge springs + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + const adj = this.adj.get(n.id); + if (!adj) + continue; + for (const { other, rest } of adj) { + const m = this.idx.get(other); + if (!m) + continue; + const dx = m.x - n.x; + const dy = m.y - n.y; + const dist = Math.max(0.0001, Math.sqrt(dx * dx + dy * dy)); + const f = kSpring * (dist - rest); + fx[i] += f * (dx / dist); + fy[i] += f * (dy / dist); + } + } + // Integrate + let energy = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (n.fx != null && n.fy != null) { + n.x = n.fx; + n.y = n.fy; + n.vx = 0; + n.vy = 0; + continue; + } + n.vx = (n.vx + fx[i]) * friction; + n.vy = (n.vy + fy[i]) * friction; + n.x += n.vx; + n.y += n.vy; + energy += n.vx * n.vx + n.vy * n.vy; + } + if (energy < this.opts.energyEps) + this.stillCount += 1; + else + this.stillCount = 0; + return energy; + } + isQuiesced() { + return this.stillCount >= this.opts.stillFrames; + } + resetQuiescence() { + this.stillCount = 0; + } +} +/** Seed positions in a circle; cheap and avoids identical (0,0) starts. */ +export function seedCircle(ids, width, height, radius) { + const cx = width / 2; + const cy = height / 2; + const r = radius ?? Math.min(width, height) * 0.35; + return ids.map((id, i) => { + const a = (i / Math.max(1, ids.length)) * Math.PI * 2; + return { + id, + x: cx + Math.cos(a) * r * (0.6 + Math.random() * 0.4), + y: cy + Math.sin(a) * r * (0.6 + Math.random() * 0.4), + vx: 0, + vy: 0, + }; + }); +} diff --git a/dhee/ui/web/src/components/canvas/forceLayout.ts b/dhee/ui/web/src/components/canvas/forceLayout.ts new file mode 100644 index 0000000..2d24e96 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/forceLayout.ts @@ -0,0 +1,219 @@ +// Force-directed layout for OrgGraph canvas. Pure JS, no library. +// +// Each node has {x, y, vx, vy, fx?, fy?}. fx/fy pin a node (drag, anchor). +// Forces per tick: +// - Center attraction: pulls everyone toward (cx, cy) with k_center. +// - Pairwise repulsion: 1/r^2 with floor on r to prevent blowup. +// - Edge spring: Hooke's law toward rest length. +// - Damping: v *= friction each tick (explicit Euler). +// Auto-quiesces when total kinetic energy stays under EPS for STILL_FRAMES. + +export interface SimNode { + id: string; + x: number; + y: number; + vx: number; + vy: number; + fx?: number | null; + fy?: number | null; + weight?: number; +} + +export interface SimEdge { + source: string; + target: string; + rest?: number; +} + +export interface SimOptions { + width: number; + height: number; + centerX?: number; + centerY?: number; + kCenter?: number; + kRepulse?: number; + kSpring?: number; + rest?: number; + minDist?: number; + friction?: number; + stillFrames?: number; + energyEps?: number; +} + +export class ForceSim { + private opts: Required+++ + {typeLabel} + {node.status ? {node.status} : null} + {state ? {state} : null} + {runtime ? {runtime} : null} + {harness && !runtime ? {harness} : null} + {type === "session" && meta.isCurrent ? ( + + ) : null} ++ +{node.label || "(unnamed)"}+ + {node.subLabel ? ( ++ {node.subLabel} ++ ) : null} + + {/* Type-specific body */} + {!isCompact && node.body ?{node.body}: null} + + {type === "workspace" && !isCompact ? ( ++ {projectCount || "—"} projects + {sessionCount || "—"} sessions ++ ) : null} + + {type === "project" && !isCompact ? ( ++ {sessionCount || "—"} sessions + {taskCount || "—"} tasks ++ ) : null} + + {type === "session" && !isCompact ? ( ++ {model ? {clamp(model, 22)} : null} + {updatedAt ? {fmtTime(updatedAt)} : null} ++ ) : null} + + {type === "task" && !isCompact ? ( ++ {messageCount ? {messageCount} messages : null} + {updatedAt ? {fmtTime(updatedAt)} : null} ++ ) : null} + + {type === "result" ? ( ++ {tool ? {tool} : null} + {ptr ? {ptr} : null} + {sourcePath ? ( + + · {sourcePath.split("/").pop()} + + ) : null} ++ ) : null} + + {type === "broadcast" ? ( ++ {String(meta.sourceChannel || meta.sourceProject || "") ? ( + from {String(meta.sourceChannel || meta.sourceProject || "")} + ) : null} + {String(meta.targetProject || "") ? ( + → {String(meta.targetProject)} + ) : null} ++ ) : null} +; + private idx: Map ; + private adj: Map ; + private nodes: SimNode[]; + private stillCount = 0; + + constructor(nodes: SimNode[], edges: SimEdge[], options: SimOptions) { + this.opts = { + width: options.width, + height: options.height, + centerX: options.centerX ?? options.width / 2, + centerY: options.centerY ?? options.height / 2, + kCenter: options.kCenter ?? 0.005, + kRepulse: options.kRepulse ?? 2200, + kSpring: options.kSpring ?? 0.04, + rest: options.rest ?? 140, + minDist: options.minDist ?? 40, + friction: options.friction ?? 0.86, + stillFrames: options.stillFrames ?? 30, + energyEps: options.energyEps ?? 0.05, + }; + this.nodes = nodes; + this.idx = new Map(nodes.map((n) => [n.id, n] as const)); + this.adj = new Map(); + for (const e of edges) { + const rest = e.rest ?? this.opts.rest; + const a = this.adj.get(e.source) ?? []; + a.push({ other: e.target, rest }); + this.adj.set(e.source, a); + const b = this.adj.get(e.target) ?? []; + b.push({ other: e.source, rest }); + this.adj.set(e.target, b); + } + } + + /** Sync the underlying node array if topology changes externally. */ + setNodes(nodes: SimNode[]): void { + this.nodes = nodes; + this.idx = new Map(nodes.map((n) => [n.id, n] as const)); + } + + setEdges(edges: SimEdge[]): void { + this.adj = new Map(); + for (const e of edges) { + const rest = e.rest ?? this.opts.rest; + const a = this.adj.get(e.source) ?? []; + a.push({ other: e.target, rest }); + this.adj.set(e.source, a); + const b = this.adj.get(e.target) ?? []; + b.push({ other: e.source, rest }); + this.adj.set(e.target, b); + } + } + + pin(id: string, fx: number, fy: number): void { + const n = this.idx.get(id); + if (!n) return; + n.fx = fx; + n.fy = fy; + } + + unpin(id: string): void { + const n = this.idx.get(id); + if (!n) return; + n.fx = null; + n.fy = null; + } + + /** One Verlet-like tick. Returns total kinetic energy. */ + tick(): number { + const { kCenter, kRepulse, kSpring, minDist, friction, centerX, centerY } = + this.opts; + const nodes = this.nodes; + const fx = new Float64Array(nodes.length); + const fy = new Float64Array(nodes.length); + + // Center attraction + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + fx[i] += -kCenter * (n.x - centerX); + fy[i] += -kCenter * (n.y - centerY); + } + + // Pairwise repulsion (O(n^2); fine for org charts <500 nodes) + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i]; + const b = nodes[j]; + let dx = a.x - b.x; + let dy = a.y - b.y; + let dist = Math.sqrt(dx * dx + dy * dy); + if (dist < minDist) { + // jitter to avoid singularities + dx += (Math.random() - 0.5) * 0.5; + dy += (Math.random() - 0.5) * 0.5; + dist = Math.max(minDist, Math.sqrt(dx * dx + dy * dy)); + } + const force = kRepulse / (dist * dist); + const ux = dx / dist; + const uy = dy / dist; + fx[i] += force * ux; + fy[i] += force * uy; + fx[j] -= force * ux; + fy[j] -= force * uy; + } + } + + // Edge springs + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + const adj = this.adj.get(n.id); + if (!adj) continue; + for (const { other, rest } of adj) { + const m = this.idx.get(other); + if (!m) continue; + const dx = m.x - n.x; + const dy = m.y - n.y; + const dist = Math.max(0.0001, Math.sqrt(dx * dx + dy * dy)); + const f = kSpring * (dist - rest); + fx[i] += f * (dx / dist); + fy[i] += f * (dy / dist); + } + } + + // Integrate + let energy = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (n.fx != null && n.fy != null) { + n.x = n.fx; + n.y = n.fy; + n.vx = 0; + n.vy = 0; + continue; + } + n.vx = (n.vx + fx[i]) * friction; + n.vy = (n.vy + fy[i]) * friction; + n.x += n.vx; + n.y += n.vy; + energy += n.vx * n.vx + n.vy * n.vy; + } + if (energy < this.opts.energyEps) this.stillCount += 1; + else this.stillCount = 0; + return energy; + } + + isQuiesced(): boolean { + return this.stillCount >= this.opts.stillFrames; + } + + resetQuiescence(): void { + this.stillCount = 0; + } +} + +/** Seed positions in a circle; cheap and avoids identical (0,0) starts. */ +export function seedCircle( + ids: string[], + width: number, + height: number, + radius?: number +): SimNode[] { + const cx = width / 2; + const cy = height / 2; + const r = radius ?? Math.min(width, height) * 0.35; + return ids.map((id, i) => { + const a = (i / Math.max(1, ids.length)) * Math.PI * 2; + return { + id, + x: cx + Math.cos(a) * r * (0.6 + Math.random() * 0.4), + y: cy + Math.sin(a) * r * (0.6 + Math.random() * 0.4), + vx: 0, + vy: 0, + }; + }); +} diff --git a/dhee/ui/web/src/components/canvas/layout.js b/dhee/ui/web/src/components/canvas/layout.js new file mode 100644 index 0000000..dfaef36 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/layout.js @@ -0,0 +1,252 @@ +// Card dimensions per type. Tuned so the most important node types get a +// larger footprint and less-informative ones (result chips) stay compact. +export const CARD_SIZE = { + workspace: { w: 320, h: 140 }, + project: { w: 260, h: 130 }, + session: { w: 280, h: 150 }, + task: { w: 260, h: 120 }, + result: { w: 240, h: 100 }, + broadcast: { w: 260, h: 110 }, + file: { w: 220, h: 90 }, + asset: { w: 220, h: 100 }, + channel: { w: 240, h: 100 }, +}; +function size(type) { + return CARD_SIZE[type] || { w: 220, h: 100 }; +} +// Column order under a project. Items in the same column are stacked top-to- +// bottom. Missing types just collapse. +const CHILD_COLUMNS = [ + ["session"], + ["task", "broadcast"], + ["result"], + ["file", "asset"], +]; +// Horizontal gap between sibling columns / projects. +const COL_GAP = 48; +const ROW_GAP = 28; +const PROJECT_X_GAP = 160; +const WORKSPACE_TO_PROJECT_Y = 240; +const ORPHAN_Y_OFFSET = 460; +function linkEndpoint(endpoint) { + if (!endpoint) + return ""; + if (typeof endpoint === "string") + return endpoint; + if (typeof endpoint === "object" && endpoint !== null && "id" in endpoint) + return String(endpoint.id || ""); + return ""; +} +function buildChildMap(nodes, links) { + const byId = new Map(nodes.map((node) => [node.id, node])); + const byParent = new Map(); + const claimed = new Set(); + const addChild = (parentId, childId) => { + const parent = byId.get(parentId); + const child = byId.get(childId); + if (!parent || !child) + return; + let bucket = byParent.get(parentId); + if (!bucket) { + bucket = new Map(); + byParent.set(parentId, bucket); + } + const list = bucket.get(child.type) || []; + if (!list.includes(childId)) { + list.push(childId); + bucket.set(child.type, list); + } + claimed.add(childId); + }; + // Use the link graph to associate children to parents. Each project + // should appear under its workspace; sessions/tasks under their project. + for (const link of links) { + const src = linkEndpoint(link.source); + const tgt = linkEndpoint(link.target); + const srcNode = byId.get(src); + const tgtNode = byId.get(tgt); + if (!srcNode || !tgtNode) + continue; + if (srcNode.type === "workspace" && tgtNode.type === "project") { + addChild(src, tgt); + } + else if (srcNode.type === "project" && tgtNode.type === "workspace") { + addChild(tgt, src); + } + else if (srcNode.type === "project") { + addChild(src, tgt); + } + else if (tgtNode.type === "project") { + addChild(tgt, src); + } + } + // Use meta.projectId / meta.workspaceId as a second-chance parenting + // mechanism so the layout works even when edges are sparse. + for (const node of nodes) { + if (claimed.has(node.id)) + continue; + const meta = (node.meta || {}); + const parentProjectId = String(meta.projectId || ""); + if (parentProjectId && byId.has(parentProjectId)) { + addChild(parentProjectId, node.id); + continue; + } + const parentWorkspaceId = String(meta.workspaceId || ""); + if (parentWorkspaceId && byId.has(parentWorkspaceId) && node.type === "project") { + addChild(parentWorkspaceId, node.id); + } + } + return { byParent }; +} +export function layoutGraph(nodes, links) { + const placed = new Map(); + const childMap = buildChildMap(nodes, links); + const workspaces = nodes.filter((n) => n.type === "workspace"); + const projects = nodes.filter((n) => n.type === "project"); + const projectParents = new Map(); + for (const [parentId, typesMap] of childMap.byParent) { + for (const [childType, ids] of typesMap) { + if (childType === "project") { + for (const id of ids) + projectParents.set(id, parentId); + } + } + } + // If no workspace nodes exist, synthesize a virtual origin so projects + // still cluster coherently. + const rootAnchors = []; + if (workspaces.length > 0) { + // Stack workspaces vertically if there are multiple, otherwise origin. + workspaces.forEach((workspace, idx) => { + const { w, h } = size(workspace.type); + const x = -w / 2; + const y = idx * 260 - h / 2; + placed.set(workspace.id, { id: workspace.id, x, y, width: w, height: h }); + rootAnchors.push({ id: workspace.id, x: x + w / 2, y: y + h }); + }); + } + else { + rootAnchors.push({ id: "__virtual__", x: 0, y: 0 }); + } + // Group projects by their workspace parent. + const projectsByRoot = new Map(); + for (const project of projects) { + const rootId = projectParents.get(project.id) || rootAnchors[0].id; + const list = projectsByRoot.get(rootId) || []; + list.push(project); + projectsByRoot.set(rootId, list); + } + // Lay out each project band under its workspace anchor. + const projectBottoms = new Map(); // projectId → y of its child start + for (const anchor of rootAnchors) { + const list = projectsByRoot.get(anchor.id) || []; + if (list.length === 0) + continue; + const projectWidths = list.map((p) => size(p.type).w); + const totalWidth = projectWidths.reduce((sum, w) => sum + w, 0) + PROJECT_X_GAP * (list.length - 1); + let cursorX = anchor.x - totalWidth / 2; + const projectY = anchor.y + WORKSPACE_TO_PROJECT_Y; + list.forEach((project, idx) => { + const { w, h } = size(project.type); + placed.set(project.id, { id: project.id, x: cursorX, y: projectY, width: w, height: h }); + projectBottoms.set(project.id, projectY + h + 40); + cursorX += w + PROJECT_X_GAP; + // avoid unused var warning + void idx; + }); + } + // Place children under each project in type-grouped columns. + for (const [parentId, typesMap] of childMap.byParent) { + const parent = placed.get(parentId); + if (!parent) + continue; + const baseY = projectBottoms.get(parentId) ?? parent.y + parent.height + 40; + // Build the columns in canonical order, skipping empty types. + const columns = []; + for (const typeGroup of CHILD_COLUMNS) { + for (const type of typeGroup) { + const ids = typesMap.get(type) || []; + if (!ids.length) + continue; + columns.push({ type, ids, w: size(type).w }); + } + } + // Include any leftover types we didn't enumerate explicitly. + for (const [type, ids] of typesMap) { + if (type === "project") + continue; + if (columns.some((col) => col.type === type)) + continue; + columns.push({ type, ids, w: size(type).w }); + } + if (!columns.length) + continue; + const totalW = columns.reduce((sum, col) => sum + col.w, 0) + COL_GAP * (columns.length - 1); + let cx = parent.x + parent.width / 2 - totalW / 2; + for (const col of columns) { + let cy = baseY; + for (const id of col.ids) { + const node = nodes.find((n) => n.id === id); + if (!node) + continue; + const { w, h } = size(node.type); + placed.set(id, { id, x: cx + (col.w - w) / 2, y: cy, width: w, height: h }); + cy += h + ROW_GAP; + } + cx += col.w + COL_GAP; + } + } + // Orphans — whatever we haven't placed. Park them in a row at the bottom. + const orphans = nodes.filter((n) => !placed.has(n.id)); + if (orphans.length) { + let cursorX = 0; + // Find lowest Y among placed to decide orphan row baseline. + let lowestY = 0; + for (const laid of placed.values()) { + lowestY = Math.max(lowestY, laid.y + laid.height); + } + const orphanY = lowestY + ORPHAN_Y_OFFSET; + for (const orphan of orphans) { + const { w, h } = size(orphan.type); + placed.set(orphan.id, { id: orphan.id, x: cursorX, y: orphanY, width: w, height: h }); + cursorX += w + 36; + } + // Center the orphan strip under the content. + const strip = orphans.map((o) => placed.get(o.id)).filter(Boolean); + if (strip.length) { + const totalW = strip[strip.length - 1].x + strip[strip.length - 1].width - strip[0].x; + const shift = -totalW / 2 - strip[0].x; + for (const laid of strip) + laid.x += shift; + } + } + const laidNodes = nodes.map((node) => { + const laid = placed.get(node.id); + const fallback = size(node.type); + return { + ...node, + x: laid?.x ?? 0, + y: laid?.y ?? 0, + width: laid?.width ?? fallback.w, + height: laid?.height ?? fallback.h, + }; + }); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of laidNodes) { + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + n.width); + maxY = Math.max(maxY, n.y + n.height); + } + if (!isFinite(minX)) { + minX = -400; + minY = -300; + maxX = 400; + maxY = 300; + } + return { + nodes: laidNodes, + links, + bounds: { minX, minY, maxX, maxY }, + }; +} diff --git a/dhee/ui/web/src/components/canvas/layout.ts b/dhee/ui/web/src/components/canvas/layout.ts new file mode 100644 index 0000000..0a7f53f --- /dev/null +++ b/dhee/ui/web/src/components/canvas/layout.ts @@ -0,0 +1,305 @@ +import type { + WorkspaceGraphEdge, + WorkspaceGraphNode, +} from "../../types"; + +// --------------------------------------------------------------------------- +// Deterministic hierarchical canvas layout. +// +// Our graph has real semantics: workspace → projects → (sessions, tasks, +// broadcasts, results, files, assets). Force-directed jiggles hide that +// structure. We instead assign coordinates by walking the hierarchy: +// +// - workspace at origin +// - projects on a horizontal band below the workspace (evenly spaced) +// - each project has a column of children grouped by type +// +// Unrelated nodes (no workspace/project parent in the edge list) are +// placed in an overflow strip at the bottom so they are still visible +// but clearly distinct. +// --------------------------------------------------------------------------- + +export interface NodeLayout { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface LaidOutGraph { + nodes: Array ; + links: WorkspaceGraphEdge[]; + bounds: { minX: number; minY: number; maxX: number; maxY: number }; +} + +// Card dimensions per type. Tuned so the most important node types get a +// larger footprint and less-informative ones (result chips) stay compact. +export const CARD_SIZE: Record = { + workspace: { w: 320, h: 140 }, + project: { w: 260, h: 130 }, + session: { w: 280, h: 150 }, + task: { w: 260, h: 120 }, + result: { w: 240, h: 100 }, + broadcast: { w: 260, h: 110 }, + file: { w: 220, h: 90 }, + asset: { w: 220, h: 100 }, + channel: { w: 240, h: 100 }, +}; + +function size(type: string) { + return CARD_SIZE[type] || { w: 220, h: 100 }; +} + +// Column order under a project. Items in the same column are stacked top-to- +// bottom. Missing types just collapse. +const CHILD_COLUMNS: string[][] = [ + ["session"], + ["task", "broadcast"], + ["result"], + ["file", "asset"], +]; + +// Horizontal gap between sibling columns / projects. +const COL_GAP = 48; +const ROW_GAP = 28; +const PROJECT_X_GAP = 160; +const WORKSPACE_TO_PROJECT_Y = 240; +const ORPHAN_Y_OFFSET = 460; + +function linkEndpoint(endpoint: unknown): string { + if (!endpoint) return ""; + if (typeof endpoint === "string") return endpoint; + if (typeof endpoint === "object" && endpoint !== null && "id" in endpoint) + return String((endpoint as { id?: unknown }).id || ""); + return ""; +} + +interface ChildMap { + byParent: Map >; // parentId → type → [childId] +} + +function buildChildMap( + nodes: WorkspaceGraphNode[], + links: WorkspaceGraphEdge[], +): ChildMap { + const byId = new Map(nodes.map((node) => [node.id, node])); + const byParent = new Map >(); + const claimed = new Set (); + + const addChild = (parentId: string, childId: string) => { + const parent = byId.get(parentId); + const child = byId.get(childId); + if (!parent || !child) return; + let bucket = byParent.get(parentId); + if (!bucket) { + bucket = new Map(); + byParent.set(parentId, bucket); + } + const list = bucket.get(child.type) || []; + if (!list.includes(childId)) { + list.push(childId); + bucket.set(child.type, list); + } + claimed.add(childId); + }; + + // Use the link graph to associate children to parents. Each project + // should appear under its workspace; sessions/tasks under their project. + for (const link of links) { + const src = linkEndpoint((link as { source?: unknown }).source); + const tgt = linkEndpoint((link as { target?: unknown }).target); + const srcNode = byId.get(src); + const tgtNode = byId.get(tgt); + if (!srcNode || !tgtNode) continue; + + if (srcNode.type === "workspace" && tgtNode.type === "project") { + addChild(src, tgt); + } else if (srcNode.type === "project" && tgtNode.type === "workspace") { + addChild(tgt, src); + } else if (srcNode.type === "project") { + addChild(src, tgt); + } else if (tgtNode.type === "project") { + addChild(tgt, src); + } + } + + // Use meta.projectId / meta.workspaceId as a second-chance parenting + // mechanism so the layout works even when edges are sparse. + for (const node of nodes) { + if (claimed.has(node.id)) continue; + const meta = (node.meta || {}) as Record ; + const parentProjectId = String(meta.projectId || ""); + if (parentProjectId && byId.has(parentProjectId)) { + addChild(parentProjectId, node.id); + continue; + } + const parentWorkspaceId = String(meta.workspaceId || ""); + if (parentWorkspaceId && byId.has(parentWorkspaceId) && node.type === "project") { + addChild(parentWorkspaceId, node.id); + } + } + + return { byParent }; +} + +export function layoutGraph( + nodes: WorkspaceGraphNode[], + links: WorkspaceGraphEdge[], +): LaidOutGraph { + const placed = new Map (); + const childMap = buildChildMap(nodes, links); + + const workspaces = nodes.filter((n) => n.type === "workspace"); + const projects = nodes.filter((n) => n.type === "project"); + const projectParents = new Map (); + for (const [parentId, typesMap] of childMap.byParent) { + for (const [childType, ids] of typesMap) { + if (childType === "project") { + for (const id of ids) projectParents.set(id, parentId); + } + } + } + + // If no workspace nodes exist, synthesize a virtual origin so projects + // still cluster coherently. + const rootAnchors: { id: string; x: number; y: number }[] = []; + if (workspaces.length > 0) { + // Stack workspaces vertically if there are multiple, otherwise origin. + workspaces.forEach((workspace, idx) => { + const { w, h } = size(workspace.type); + const x = -w / 2; + const y = idx * 260 - h / 2; + placed.set(workspace.id, { id: workspace.id, x, y, width: w, height: h }); + rootAnchors.push({ id: workspace.id, x: x + w / 2, y: y + h }); + }); + } else { + rootAnchors.push({ id: "__virtual__", x: 0, y: 0 }); + } + + // Group projects by their workspace parent. + const projectsByRoot = new Map (); + for (const project of projects) { + const rootId = projectParents.get(project.id) || rootAnchors[0].id; + const list = projectsByRoot.get(rootId) || []; + list.push(project); + projectsByRoot.set(rootId, list); + } + + // Lay out each project band under its workspace anchor. + const projectBottoms = new Map (); // projectId → y of its child start + for (const anchor of rootAnchors) { + const list = projectsByRoot.get(anchor.id) || []; + if (list.length === 0) continue; + const projectWidths = list.map((p) => size(p.type).w); + const totalWidth = + projectWidths.reduce((sum, w) => sum + w, 0) + PROJECT_X_GAP * (list.length - 1); + let cursorX = anchor.x - totalWidth / 2; + const projectY = anchor.y + WORKSPACE_TO_PROJECT_Y; + list.forEach((project, idx) => { + const { w, h } = size(project.type); + placed.set(project.id, { id: project.id, x: cursorX, y: projectY, width: w, height: h }); + projectBottoms.set(project.id, projectY + h + 40); + cursorX += w + PROJECT_X_GAP; + // avoid unused var warning + void idx; + }); + } + + // Place children under each project in type-grouped columns. + for (const [parentId, typesMap] of childMap.byParent) { + const parent = placed.get(parentId); + if (!parent) continue; + const baseY = projectBottoms.get(parentId) ?? parent.y + parent.height + 40; + + // Build the columns in canonical order, skipping empty types. + const columns: Array<{ type: string; ids: string[]; w: number }> = []; + for (const typeGroup of CHILD_COLUMNS) { + for (const type of typeGroup) { + const ids = typesMap.get(type) || []; + if (!ids.length) continue; + columns.push({ type, ids, w: size(type).w }); + } + } + // Include any leftover types we didn't enumerate explicitly. + for (const [type, ids] of typesMap) { + if (type === "project") continue; + if (columns.some((col) => col.type === type)) continue; + columns.push({ type, ids, w: size(type).w }); + } + if (!columns.length) continue; + + const totalW = columns.reduce((sum, col) => sum + col.w, 0) + COL_GAP * (columns.length - 1); + let cx = parent.x + parent.width / 2 - totalW / 2; + for (const col of columns) { + let cy = baseY; + for (const id of col.ids) { + const node = nodes.find((n) => n.id === id); + if (!node) continue; + const { w, h } = size(node.type); + placed.set(id, { id, x: cx + (col.w - w) / 2, y: cy, width: w, height: h }); + cy += h + ROW_GAP; + } + cx += col.w + COL_GAP; + } + } + + // Orphans — whatever we haven't placed. Park them in a row at the bottom. + const orphans = nodes.filter((n) => !placed.has(n.id)); + if (orphans.length) { + let cursorX = 0; + // Find lowest Y among placed to decide orphan row baseline. + let lowestY = 0; + for (const laid of placed.values()) { + lowestY = Math.max(lowestY, laid.y + laid.height); + } + const orphanY = lowestY + ORPHAN_Y_OFFSET; + for (const orphan of orphans) { + const { w, h } = size(orphan.type); + placed.set(orphan.id, { id: orphan.id, x: cursorX, y: orphanY, width: w, height: h }); + cursorX += w + 36; + } + // Center the orphan strip under the content. + const strip = orphans.map((o) => placed.get(o.id)).filter(Boolean) as NodeLayout[]; + if (strip.length) { + const totalW = strip[strip.length - 1].x + strip[strip.length - 1].width - strip[0].x; + const shift = -totalW / 2 - strip[0].x; + for (const laid of strip) laid.x += shift; + } + } + + const laidNodes = nodes.map((node) => { + const laid = placed.get(node.id); + const fallback = size(node.type); + return { + ...node, + x: laid?.x ?? 0, + y: laid?.y ?? 0, + width: laid?.width ?? fallback.w, + height: laid?.height ?? fallback.h, + }; + }); + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const n of laidNodes) { + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + n.width); + maxY = Math.max(maxY, n.y + n.height); + } + if (!isFinite(minX)) { + minX = -400; + minY = -300; + maxX = 400; + maxY = 300; + } + + return { + nodes: laidNodes, + links, + bounds: { minX, minY, maxX, maxY }, + }; +} diff --git a/dhee/ui/web/src/components/canvas/treeLayout.js b/dhee/ui/web/src/components/canvas/treeLayout.js new file mode 100644 index 0000000..8afce5c --- /dev/null +++ b/dhee/ui/web/src/components/canvas/treeLayout.js @@ -0,0 +1,120 @@ +// Subtree-width-packed tidy tree layout. Pure function, no side effects. +// +// Each node has a fixed (w, h). For each node we compute the maximum of +// (own width, sum of children's subtree widths + gap*(n-1)). Then x is +// assigned by left-packing children inside that subtree. y is depth * levelH. +// Children are centred under their parent unless that would force overlap. +export function layoutTree(inputs, options = {}) { + const SIBLING_GAP = options.siblingGap ?? 24; + const LEVEL_GAP = options.levelGap ?? 100; + const map = new Map(); + for (const n of inputs) { + map.set(n.id, { + id: n.id, + parent: n.parent, + children: [], + width: n.width, + height: n.height, + depth: n.depth, + subtreeWidth: n.width, + x: 0, + y: 0, + }); + } + const roots = []; + for (const n of map.values()) { + if (n.parent && map.has(n.parent)) { + map.get(n.parent).children.push(n.id); + } + else { + roots.push(n); + } + } + const computeSubtree = (id) => { + const n = map.get(id); + if (!n.children.length) { + n.subtreeWidth = n.width; + return n.subtreeWidth; + } + let total = 0; + for (const cid of n.children) + total += computeSubtree(cid); + total += SIBLING_GAP * (n.children.length - 1); + n.subtreeWidth = Math.max(n.width, total); + return n.subtreeWidth; + }; + // Compute heights per level (max h within depth) so y positions are even. + const levelHeight = new Map(); + for (const n of map.values()) { + levelHeight.set(n.depth, Math.max(levelHeight.get(n.depth) || 0, n.height)); + } + const cumY = new Map(); + let runningY = 0; + const maxDepth = Math.max(0, ...Array.from(levelHeight.keys())); + for (let d = 0; d <= maxDepth; d += 1) { + cumY.set(d, runningY); + runningY += (levelHeight.get(d) || 0) + LEVEL_GAP; + } + const positionSubtree = (id, leftX) => { + const n = map.get(id); + n.y = cumY.get(n.depth) || 0; + if (!n.children.length) { + n.x = leftX + n.width / 2; + return; + } + let cursor = leftX; + let childCenters = []; + for (const cid of n.children) { + const child = map.get(cid); + positionSubtree(cid, cursor); + childCenters.push(child.x); + cursor += child.subtreeWidth + SIBLING_GAP; + } + if (childCenters.length === 0) { + n.x = leftX + n.width / 2; + } + else { + const minC = Math.min(...childCenters); + const maxC = Math.max(...childCenters); + n.x = (minC + maxC) / 2; + } + }; + let cursor = 0; + for (const root of roots) { + computeSubtree(root.id); + positionSubtree(root.id, cursor); + cursor += root.subtreeWidth + SIBLING_GAP * 2; + } + // Compute final bounds + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const out = []; + for (const n of map.values()) { + const halfW = n.width / 2; + minX = Math.min(minX, n.x - halfW); + maxX = Math.max(maxX, n.x + halfW); + minY = Math.min(minY, n.y); + maxY = Math.max(maxY, n.y + n.height); + out.push({ + id: n.id, + parent: n.parent, + width: n.width, + height: n.height, + depth: n.depth, + x: n.x, + y: n.y, + }); + } + if (!Number.isFinite(minX)) { + minX = 0; + minY = 0; + maxX = 0; + maxY = 0; + } + return { + nodes: out, + bounds: { minX, minY, maxX, maxY }, + }; +} diff --git a/dhee/ui/web/src/components/canvas/treeLayout.ts b/dhee/ui/web/src/components/canvas/treeLayout.ts new file mode 100644 index 0000000..95fb965 --- /dev/null +++ b/dhee/ui/web/src/components/canvas/treeLayout.ts @@ -0,0 +1,159 @@ +// Subtree-width-packed tidy tree layout. Pure function, no side effects. +// +// Each node has a fixed (w, h). For each node we compute the maximum of +// (own width, sum of children's subtree widths + gap*(n-1)). Then x is +// assigned by left-packing children inside that subtree. y is depth * levelH. +// Children are centred under their parent unless that would force overlap. + +export interface TreeNodeIn { + id: string; + parent: string | null; + width: number; + height: number; + depth: number; +} + +export interface TreeNodeOut extends TreeNodeIn { + x: number; + y: number; +} + +export interface TreeLayoutResult { + nodes: TreeNodeOut[]; + bounds: { minX: number; minY: number; maxX: number; maxY: number }; +} + +interface Internal { + id: string; + parent: string | null; + children: string[]; + width: number; + height: number; + depth: number; + subtreeWidth: number; + x: number; + y: number; +} + +export function layoutTree( + inputs: TreeNodeIn[], + options: { + siblingGap?: number; + levelGap?: number; + } = {} +): TreeLayoutResult { + const SIBLING_GAP = options.siblingGap ?? 24; + const LEVEL_GAP = options.levelGap ?? 100; + const map = new Map (); + for (const n of inputs) { + map.set(n.id, { + id: n.id, + parent: n.parent, + children: [], + width: n.width, + height: n.height, + depth: n.depth, + subtreeWidth: n.width, + x: 0, + y: 0, + }); + } + const roots: Internal[] = []; + for (const n of map.values()) { + if (n.parent && map.has(n.parent)) { + map.get(n.parent)!.children.push(n.id); + } else { + roots.push(n); + } + } + + const computeSubtree = (id: string): number => { + const n = map.get(id)!; + if (!n.children.length) { + n.subtreeWidth = n.width; + return n.subtreeWidth; + } + let total = 0; + for (const cid of n.children) total += computeSubtree(cid); + total += SIBLING_GAP * (n.children.length - 1); + n.subtreeWidth = Math.max(n.width, total); + return n.subtreeWidth; + }; + + // Compute heights per level (max h within depth) so y positions are even. + const levelHeight = new Map (); + for (const n of map.values()) { + levelHeight.set(n.depth, Math.max(levelHeight.get(n.depth) || 0, n.height)); + } + const cumY = new Map (); + let runningY = 0; + const maxDepth = Math.max(0, ...Array.from(levelHeight.keys())); + for (let d = 0; d <= maxDepth; d += 1) { + cumY.set(d, runningY); + runningY += (levelHeight.get(d) || 0) + LEVEL_GAP; + } + + const positionSubtree = (id: string, leftX: number): void => { + const n = map.get(id)!; + n.y = cumY.get(n.depth) || 0; + if (!n.children.length) { + n.x = leftX + n.width / 2; + return; + } + let cursor = leftX; + let childCenters: number[] = []; + for (const cid of n.children) { + const child = map.get(cid)!; + positionSubtree(cid, cursor); + childCenters.push(child.x); + cursor += child.subtreeWidth + SIBLING_GAP; + } + if (childCenters.length === 0) { + n.x = leftX + n.width / 2; + } else { + const minC = Math.min(...childCenters); + const maxC = Math.max(...childCenters); + n.x = (minC + maxC) / 2; + } + }; + + let cursor = 0; + for (const root of roots) { + computeSubtree(root.id); + positionSubtree(root.id, cursor); + cursor += root.subtreeWidth + SIBLING_GAP * 2; + } + + // Compute final bounds + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const out: TreeNodeOut[] = []; + for (const n of map.values()) { + const halfW = n.width / 2; + minX = Math.min(minX, n.x - halfW); + maxX = Math.max(maxX, n.x + halfW); + minY = Math.min(minY, n.y); + maxY = Math.max(maxY, n.y + n.height); + out.push({ + id: n.id, + parent: n.parent, + width: n.width, + height: n.height, + depth: n.depth, + x: n.x, + y: n.y, + }); + } + if (!Number.isFinite(minX)) { + minX = 0; + minY = 0; + maxX = 0; + maxY = 0; + } + return { + nodes: out, + bounds: { minX, minY, maxX, maxY }, + }; +} diff --git a/dhee/ui/web/src/components/canvas/useInfiniteCanvas.js b/dhee/ui/web/src/components/canvas/useInfiniteCanvas.js new file mode 100644 index 0000000..79a7f4d --- /dev/null +++ b/dhee/ui/web/src/components/canvas/useInfiniteCanvas.js @@ -0,0 +1,383 @@ +import { useState, useCallback, useRef, useEffect, useMemo, } from "react"; +// --------------------------------------------------------------------------- +// useInfiniteCanvas — DOM-based infinite canvas with momentum pan, cursor- +// centered pinch zoom, spring-back boundaries, fit-to-content, and +// keyboard shortcuts. Adapted from openswarm's Dashboard canvas but +// dependency-free (no MUI) and typed for Dhee's node model. +// --------------------------------------------------------------------------- +const MIN_ZOOM = 0.15; +const MAX_ZOOM = 2.8; +const ZOOM_IN_FACTOR = 1.1; +const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR; +const FIT_PADDING = 160; +const BOUNDARY_MARGIN = 800; +const FRICTION = 0.93; +const MIN_VELOCITY = 0.5; +function clamp(val, min, max) { + return Math.min(max, Math.max(min, val)); +} +// 1–100 user setting → wheel zoom multiplier. 50 → ~0.004. +function sensitivityToMultiplier(setting) { + return 0.00008 * setting; +} +export function useInfiniteCanvas({ zoomSensitivity = 50, contentBounds, enabled = true, initial, } = {}) { + const viewportRef = useRef(null); + const contentRef = useRef(null); + const [state, setState] = useState({ + panX: initial?.panX ?? 0, + panY: initial?.panY ?? 0, + zoom: initial?.zoom ?? 1, + }); + const [isPanning, setIsPanning] = useState(false); + const [spaceHeld, setSpaceHeld] = useState(false); + const panStartRef = useRef(null); + const stateRef = useRef(state); + stateRef.current = state; + const spaceRef = useRef(false); + const sensitivityRef = useRef(zoomSensitivity); + sensitivityRef.current = zoomSensitivity; + const contentBoundsRef = useRef(contentBounds); + contentBoundsRef.current = contentBounds; + const animFrameRef = useRef(null); + const inertiaFrameRef = useRef(null); + const velocityHistoryRef = useRef([]); + const animateToRef = useRef(null); + // ---- Animation helpers ---- + const cancelAnimation = useCallback(() => { + if (animFrameRef.current) { + cancelAnimationFrame(animFrameRef.current); + animFrameRef.current = null; + } + }, []); + const cancelInertia = useCallback(() => { + if (inertiaFrameRef.current) { + cancelAnimationFrame(inertiaFrameRef.current); + inertiaFrameRef.current = null; + } + }, []); + const animateTo = useCallback((target, duration = 320) => { + cancelAnimation(); + const start = { ...stateRef.current }; + const startTime = performance.now(); + const step = (now) => { + const t = Math.min((now - startTime) / duration, 1); + const ease = 1 - Math.pow(1 - t, 3); + setState({ + panX: start.panX + (target.panX - start.panX) * ease, + panY: start.panY + (target.panY - start.panY) * ease, + zoom: start.zoom + (target.zoom - start.zoom) * ease, + }); + if (t < 1) + animFrameRef.current = requestAnimationFrame(step); + else + animFrameRef.current = null; + }; + animFrameRef.current = requestAnimationFrame(step); + }, [cancelAnimation]); + animateToRef.current = animateTo; + // ---- Boundary spring-back ---- + const springBackIfNeeded = useCallback(() => { + const bounds = contentBoundsRef.current; + const vp = viewportRef.current; + if (!bounds || !vp) + return; + const cur = stateRef.current; + const vpW = vp.clientWidth; + const vpH = vp.clientHeight; + const vpLeft = -cur.panX / cur.zoom; + const vpTop = -cur.panY / cur.zoom; + const vpRight = vpLeft + vpW / cur.zoom; + const vpBottom = vpTop + vpH / cur.zoom; + const bLeft = bounds.minX - BOUNDARY_MARGIN; + const bTop = bounds.minY - BOUNDARY_MARGIN; + const bRight = bounds.maxX + BOUNDARY_MARGIN; + const bBottom = bounds.maxY + BOUNDARY_MARGIN; + let newPanX = cur.panX; + let newPanY = cur.panY; + if (vpRight < bLeft) + newPanX = -(bLeft - vpW / cur.zoom) * cur.zoom; + else if (vpLeft > bRight) + newPanX = -bRight * cur.zoom; + if (vpBottom < bTop) + newPanY = -(bTop - vpH / cur.zoom) * cur.zoom; + else if (vpTop > bBottom) + newPanY = -bBottom * cur.zoom; + if (newPanX !== cur.panX || newPanY !== cur.panY) { + animateToRef.current?.({ panX: newPanX, panY: newPanY, zoom: cur.zoom }, 250); + } + }, []); + // ---- Momentum ---- + const startInertia = useCallback((vx, vy) => { + cancelInertia(); + let velocityX = vx; + let velocityY = vy; + const step = () => { + velocityX *= FRICTION; + velocityY *= FRICTION; + if (Math.abs(velocityX) < MIN_VELOCITY && Math.abs(velocityY) < MIN_VELOCITY) { + inertiaFrameRef.current = null; + springBackIfNeeded(); + return; + } + setState((prev) => ({ ...prev, panX: prev.panX + velocityX, panY: prev.panY + velocityY })); + inertiaFrameRef.current = requestAnimationFrame(step); + }; + inertiaFrameRef.current = requestAnimationFrame(step); + }, [cancelInertia, springBackIfNeeded]); + // ---- Wheel (pinch-zoom + two-finger pan) ---- + useEffect(() => { + const el = viewportRef.current; + if (!el || !enabled) + return; + const onWheel = (e) => { + const isPinchZoom = e.ctrlKey || e.metaKey; + const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; + const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX; + // Let inner scrollable elements consume the event until they hit their + // boundary — same UX as openswarm, keeps nested lists scrollable. + let target = e.target; + while (target && target !== el) { + const style = getComputedStyle(target); + const overflowY = style.overflowY; + const overflowX = style.overflowX; + const canScrollY = target.scrollHeight > target.clientHeight && + (overflowY === "auto" || overflowY === "scroll"); + const canScrollX = target.scrollWidth > target.clientWidth && + (overflowX === "auto" || overflowX === "scroll"); + if ((canScrollY || canScrollX) && !isPinchZoom) { + const atYBoundary = !canScrollY || + (dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) || + (dy < 0 && target.scrollTop <= 1); + const atXBoundary = !canScrollX || + (dx > 0 && target.scrollLeft + target.clientWidth >= target.scrollWidth - 1) || + (dx < 0 && target.scrollLeft <= 1); + if (atYBoundary && atXBoundary) { + target = target.parentElement; + continue; + } + return; + } + target = target.parentElement; + } + e.preventDefault(); + cancelInertia(); + if (isPinchZoom) { + const rect = el.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + setState((prev) => { + const factor = Math.pow(2, -dy * sensitivityToMultiplier(sensitivityRef.current)); + const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM); + const ratio = newZoom / prev.zoom; + return { + panX: cx - (cx - prev.panX) * ratio, + panY: cy - (cy - prev.panY) * ratio, + zoom: newZoom, + }; + }); + } + else { + setState((prev) => ({ ...prev, panX: prev.panX - dx, panY: prev.panY - dy })); + } + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [enabled, cancelInertia]); + // ---- Mouse pan ---- + const handleMouseDown = useCallback((e) => { + // Only left-button; ignore clicks on interactive elements + if (e.button !== 0) + return; + const t = e.target; + const isInteractive = t.closest("button, a, input, textarea, select, [data-canvas-draggable], [data-no-pan]"); + if (isInteractive && !spaceRef.current) + return; + e.preventDefault(); + cancelAnimation(); + cancelInertia(); + setIsPanning(true); + velocityHistoryRef.current = [{ x: e.clientX, y: e.clientY, t: performance.now() }]; + panStartRef.current = { + x: e.clientX, + y: e.clientY, + panX: stateRef.current.panX, + panY: stateRef.current.panY, + }; + }, [cancelAnimation, cancelInertia]); + const handleMouseMove = useCallback((e) => { + const start = panStartRef.current; + if (!start) + return; + const dx = e.clientX - start.x; + const dy = e.clientY - start.y; + const now = performance.now(); + const history = velocityHistoryRef.current; + history.push({ x: e.clientX, y: e.clientY, t: now }); + if (history.length > 5) + history.shift(); + setState((prev) => ({ ...prev, panX: start.panX + dx, panY: start.panY + dy })); + }, []); + const handleMouseUp = useCallback(() => { + const wasPanning = !!panStartRef.current; + let didInertia = false; + if (wasPanning) { + const history = velocityHistoryRef.current; + if (history.length >= 2) { + const oldest = history[0]; + const newest = history[history.length - 1]; + const dt = newest.t - oldest.t; + if (dt > 0 && dt < 200) { + const vx = (newest.x - oldest.x) / (dt / 16.67); + const vy = (newest.y - oldest.y) / (dt / 16.67); + if (Math.abs(vx) > MIN_VELOCITY || Math.abs(vy) > MIN_VELOCITY) { + startInertia(vx, vy); + didInertia = true; + } + } + } + velocityHistoryRef.current = []; + } + panStartRef.current = null; + setIsPanning(false); + if (wasPanning && !didInertia) + springBackIfNeeded(); + }, [startInertia, springBackIfNeeded]); + useEffect(() => { + const onUp = () => { + if (panStartRef.current) { + panStartRef.current = null; + setIsPanning(false); + } + }; + window.addEventListener("mouseup", onUp); + return () => window.removeEventListener("mouseup", onUp); + }, []); + useEffect(() => { + return () => { + cancelAnimation(); + cancelInertia(); + }; + }, [cancelAnimation, cancelInertia]); + // ---- Zoom actions ---- + const zoomAround = useCallback((nextZoom, duration = 180) => { + const prev = stateRef.current; + const el = viewportRef.current; + const newZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM); + if (!el) { + animateTo({ ...prev, zoom: newZoom }, duration); + return; + } + const rect = el.getBoundingClientRect(); + const cx = rect.width / 2; + const cy = rect.height / 2; + const ratio = newZoom / prev.zoom; + animateTo({ + panX: cx - (cx - prev.panX) * ratio, + panY: cy - (cy - prev.panY) * ratio, + zoom: newZoom, + }, duration); + }, [animateTo]); + const zoomIn = useCallback(() => zoomAround(stateRef.current.zoom * ZOOM_IN_FACTOR), [zoomAround]); + const zoomOut = useCallback(() => zoomAround(stateRef.current.zoom * ZOOM_OUT_FACTOR), [zoomAround]); + const resetZoom = useCallback(() => animateTo({ panX: 0, panY: 0, zoom: 1 }), [animateTo]); + // ---- Fit to cards (explicit rects) ---- + const fitToCards = useCallback((cardRects, opts) => { + cancelAnimation(); + const viewport = viewportRef.current; + if (!viewport || cardRects.length === 0) { + setState({ panX: 0, panY: 0, zoom: 1 }); + return; + } + const vRect = viewport.getBoundingClientRect(); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const card of cardRects) { + minX = Math.min(minX, card.x); + minY = Math.min(minY, card.y); + maxX = Math.max(maxX, card.x + card.width); + maxY = Math.max(maxY, card.y + card.height); + } + if (!isFinite(minX)) { + setState({ panX: 0, panY: 0, zoom: 1 }); + return; + } + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + const availW = vRect.width - FIT_PADDING * 2; + const availH = vRect.height - FIT_PADDING * 2; + const ceiling = opts?.maxZoom ?? 1; + const floor = opts?.minZoom ?? MIN_ZOOM; + const targetZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), floor, ceiling); + const targetPanX = (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom; + const targetPanY = (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom; + const target = { panX: targetPanX, panY: targetPanY, zoom: targetZoom }; + const doAnimate = opts?.animate ?? true; + if (doAnimate) { + const cur = stateRef.current; + const dPan = Math.abs(cur.panX - target.panX) + Math.abs(cur.panY - target.panY); + const dZoom = Math.abs(cur.zoom - target.zoom); + if (dPan < 5 && dZoom < 0.01) + return; + animateTo(target); + } + else { + setState(target); + } + }, [cancelAnimation, animateTo]); + // ---- Keyboard ---- + const zoomInRef = useRef(zoomIn); + zoomInRef.current = zoomIn; + const zoomOutRef = useRef(zoomOut); + zoomOutRef.current = zoomOut; + const resetZoomRef = useRef(resetZoom); + resetZoomRef.current = resetZoom; + useEffect(() => { + const onKeyDown = (e) => { + const t = e.target; + const inField = t instanceof HTMLInputElement || + t instanceof HTMLTextAreaElement || + t?.isContentEditable; + if (e.code === "Space" && !e.repeat && !inField) { + e.preventDefault(); + spaceRef.current = true; + setSpaceHeld(true); + } + if (e.ctrlKey || e.metaKey) { + if (e.key === "0") { + e.preventDefault(); + resetZoomRef.current(); + } + else if (e.key === "=" || e.key === "+") { + e.preventDefault(); + zoomInRef.current(); + } + else if (e.key === "-") { + e.preventDefault(); + zoomOutRef.current(); + } + } + }; + const onKeyUp = (e) => { + if (e.code === "Space") { + spaceRef.current = false; + setSpaceHeld(false); + } + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }; + }, []); + const handlers = useMemo(() => ({ onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp }), [handleMouseDown, handleMouseMove, handleMouseUp]); + const actions = useMemo(() => ({ zoomIn, zoomOut, resetZoom, fitToCards, animateTo, cancelAnimation, setState }), [zoomIn, zoomOut, resetZoom, fitToCards, animateTo, cancelAnimation]); + return { + ...state, + isPanning, + spaceHeld, + viewportRef, + contentRef, + handlers, + actions, + }; +} diff --git a/dhee/ui/web/src/components/canvas/useInfiniteCanvas.ts b/dhee/ui/web/src/components/canvas/useInfiniteCanvas.ts new file mode 100644 index 0000000..19ee54a --- /dev/null +++ b/dhee/ui/web/src/components/canvas/useInfiniteCanvas.ts @@ -0,0 +1,471 @@ +import { + useState, + useCallback, + useRef, + useEffect, + useMemo, +} from "react"; + +// --------------------------------------------------------------------------- +// useInfiniteCanvas — DOM-based infinite canvas with momentum pan, cursor- +// centered pinch zoom, spring-back boundaries, fit-to-content, and +// keyboard shortcuts. Adapted from openswarm's Dashboard canvas but +// dependency-free (no MUI) and typed for Dhee's node model. +// --------------------------------------------------------------------------- + +const MIN_ZOOM = 0.15; +const MAX_ZOOM = 2.8; +const ZOOM_IN_FACTOR = 1.1; +const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR; +const FIT_PADDING = 160; +const BOUNDARY_MARGIN = 800; +const FRICTION = 0.93; +const MIN_VELOCITY = 0.5; + +export interface CanvasState { + panX: number; + panY: number; + zoom: number; +} + +export interface ContentBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +function clamp(val: number, min: number, max: number) { + return Math.min(max, Math.max(min, val)); +} + +// 1–100 user setting → wheel zoom multiplier. 50 → ~0.004. +function sensitivityToMultiplier(setting: number): number { + return 0.00008 * setting; +} + +export interface UseInfiniteCanvasOptions { + zoomSensitivity?: number; + contentBounds?: ContentBounds; + enabled?: boolean; + initial?: Partial ; +} + +export function useInfiniteCanvas({ + zoomSensitivity = 50, + contentBounds, + enabled = true, + initial, +}: UseInfiniteCanvasOptions = {}) { + const viewportRef = useRef (null); + const contentRef = useRef (null); + + const [state, setState] = useState ({ + panX: initial?.panX ?? 0, + panY: initial?.panY ?? 0, + zoom: initial?.zoom ?? 1, + }); + const [isPanning, setIsPanning] = useState(false); + const [spaceHeld, setSpaceHeld] = useState(false); + + const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null); + const stateRef = useRef(state); + stateRef.current = state; + const spaceRef = useRef(false); + const sensitivityRef = useRef(zoomSensitivity); + sensitivityRef.current = zoomSensitivity; + const contentBoundsRef = useRef(contentBounds); + contentBoundsRef.current = contentBounds; + const animFrameRef = useRef (null); + const inertiaFrameRef = useRef (null); + const velocityHistoryRef = useRef >([]); + const animateToRef = useRef<((target: CanvasState, duration?: number) => void) | null>(null); + + // ---- Animation helpers ---- + const cancelAnimation = useCallback(() => { + if (animFrameRef.current) { + cancelAnimationFrame(animFrameRef.current); + animFrameRef.current = null; + } + }, []); + const cancelInertia = useCallback(() => { + if (inertiaFrameRef.current) { + cancelAnimationFrame(inertiaFrameRef.current); + inertiaFrameRef.current = null; + } + }, []); + + const animateTo = useCallback( + (target: CanvasState, duration: number = 320) => { + cancelAnimation(); + const start = { ...stateRef.current }; + const startTime = performance.now(); + const step = (now: number) => { + const t = Math.min((now - startTime) / duration, 1); + const ease = 1 - Math.pow(1 - t, 3); + setState({ + panX: start.panX + (target.panX - start.panX) * ease, + panY: start.panY + (target.panY - start.panY) * ease, + zoom: start.zoom + (target.zoom - start.zoom) * ease, + }); + if (t < 1) animFrameRef.current = requestAnimationFrame(step); + else animFrameRef.current = null; + }; + animFrameRef.current = requestAnimationFrame(step); + }, + [cancelAnimation], + ); + animateToRef.current = animateTo; + + // ---- Boundary spring-back ---- + const springBackIfNeeded = useCallback(() => { + const bounds = contentBoundsRef.current; + const vp = viewportRef.current; + if (!bounds || !vp) return; + + const cur = stateRef.current; + const vpW = vp.clientWidth; + const vpH = vp.clientHeight; + + const vpLeft = -cur.panX / cur.zoom; + const vpTop = -cur.panY / cur.zoom; + const vpRight = vpLeft + vpW / cur.zoom; + const vpBottom = vpTop + vpH / cur.zoom; + + const bLeft = bounds.minX - BOUNDARY_MARGIN; + const bTop = bounds.minY - BOUNDARY_MARGIN; + const bRight = bounds.maxX + BOUNDARY_MARGIN; + const bBottom = bounds.maxY + BOUNDARY_MARGIN; + + let newPanX = cur.panX; + let newPanY = cur.panY; + if (vpRight < bLeft) newPanX = -(bLeft - vpW / cur.zoom) * cur.zoom; + else if (vpLeft > bRight) newPanX = -bRight * cur.zoom; + if (vpBottom < bTop) newPanY = -(bTop - vpH / cur.zoom) * cur.zoom; + else if (vpTop > bBottom) newPanY = -bBottom * cur.zoom; + + if (newPanX !== cur.panX || newPanY !== cur.panY) { + animateToRef.current?.({ panX: newPanX, panY: newPanY, zoom: cur.zoom }, 250); + } + }, []); + + // ---- Momentum ---- + const startInertia = useCallback( + (vx: number, vy: number) => { + cancelInertia(); + let velocityX = vx; + let velocityY = vy; + const step = () => { + velocityX *= FRICTION; + velocityY *= FRICTION; + if (Math.abs(velocityX) < MIN_VELOCITY && Math.abs(velocityY) < MIN_VELOCITY) { + inertiaFrameRef.current = null; + springBackIfNeeded(); + return; + } + setState((prev) => ({ ...prev, panX: prev.panX + velocityX, panY: prev.panY + velocityY })); + inertiaFrameRef.current = requestAnimationFrame(step); + }; + inertiaFrameRef.current = requestAnimationFrame(step); + }, + [cancelInertia, springBackIfNeeded], + ); + + // ---- Wheel (pinch-zoom + two-finger pan) ---- + useEffect(() => { + const el = viewportRef.current; + if (!el || !enabled) return; + const onWheel = (e: WheelEvent) => { + const isPinchZoom = e.ctrlKey || e.metaKey; + const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; + const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX; + + // Let inner scrollable elements consume the event until they hit their + // boundary — same UX as openswarm, keeps nested lists scrollable. + let target = e.target as HTMLElement | null; + while (target && target !== el) { + const style = getComputedStyle(target); + const overflowY = style.overflowY; + const overflowX = style.overflowX; + const canScrollY = + target.scrollHeight > target.clientHeight && + (overflowY === "auto" || overflowY === "scroll"); + const canScrollX = + target.scrollWidth > target.clientWidth && + (overflowX === "auto" || overflowX === "scroll"); + if ((canScrollY || canScrollX) && !isPinchZoom) { + const atYBoundary = + !canScrollY || + (dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) || + (dy < 0 && target.scrollTop <= 1); + const atXBoundary = + !canScrollX || + (dx > 0 && target.scrollLeft + target.clientWidth >= target.scrollWidth - 1) || + (dx < 0 && target.scrollLeft <= 1); + if (atYBoundary && atXBoundary) { + target = target.parentElement; + continue; + } + return; + } + target = target.parentElement; + } + + e.preventDefault(); + cancelInertia(); + + if (isPinchZoom) { + const rect = el.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + setState((prev) => { + const factor = Math.pow(2, -dy * sensitivityToMultiplier(sensitivityRef.current)); + const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM); + const ratio = newZoom / prev.zoom; + return { + panX: cx - (cx - prev.panX) * ratio, + panY: cy - (cy - prev.panY) * ratio, + zoom: newZoom, + }; + }); + } else { + setState((prev) => ({ ...prev, panX: prev.panX - dx, panY: prev.panY - dy })); + } + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [enabled, cancelInertia]); + + // ---- Mouse pan ---- + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only left-button; ignore clicks on interactive elements + if (e.button !== 0) return; + const t = e.target as HTMLElement; + const isInteractive = t.closest( + "button, a, input, textarea, select, [data-canvas-draggable], [data-no-pan]", + ); + if (isInteractive && !spaceRef.current) return; + e.preventDefault(); + cancelAnimation(); + cancelInertia(); + setIsPanning(true); + velocityHistoryRef.current = [{ x: e.clientX, y: e.clientY, t: performance.now() }]; + panStartRef.current = { + x: e.clientX, + y: e.clientY, + panX: stateRef.current.panX, + panY: stateRef.current.panY, + }; + }, + [cancelAnimation, cancelInertia], + ); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + const start = panStartRef.current; + if (!start) return; + const dx = e.clientX - start.x; + const dy = e.clientY - start.y; + const now = performance.now(); + const history = velocityHistoryRef.current; + history.push({ x: e.clientX, y: e.clientY, t: now }); + if (history.length > 5) history.shift(); + setState((prev) => ({ ...prev, panX: start.panX + dx, panY: start.panY + dy })); + }, []); + + const handleMouseUp = useCallback(() => { + const wasPanning = !!panStartRef.current; + let didInertia = false; + if (wasPanning) { + const history = velocityHistoryRef.current; + if (history.length >= 2) { + const oldest = history[0]; + const newest = history[history.length - 1]; + const dt = newest.t - oldest.t; + if (dt > 0 && dt < 200) { + const vx = (newest.x - oldest.x) / (dt / 16.67); + const vy = (newest.y - oldest.y) / (dt / 16.67); + if (Math.abs(vx) > MIN_VELOCITY || Math.abs(vy) > MIN_VELOCITY) { + startInertia(vx, vy); + didInertia = true; + } + } + } + velocityHistoryRef.current = []; + } + panStartRef.current = null; + setIsPanning(false); + if (wasPanning && !didInertia) springBackIfNeeded(); + }, [startInertia, springBackIfNeeded]); + + useEffect(() => { + const onUp = () => { + if (panStartRef.current) { + panStartRef.current = null; + setIsPanning(false); + } + }; + window.addEventListener("mouseup", onUp); + return () => window.removeEventListener("mouseup", onUp); + }, []); + + useEffect(() => { + return () => { + cancelAnimation(); + cancelInertia(); + }; + }, [cancelAnimation, cancelInertia]); + + // ---- Zoom actions ---- + const zoomAround = useCallback( + (nextZoom: number, duration = 180) => { + const prev = stateRef.current; + const el = viewportRef.current; + const newZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM); + if (!el) { + animateTo({ ...prev, zoom: newZoom }, duration); + return; + } + const rect = el.getBoundingClientRect(); + const cx = rect.width / 2; + const cy = rect.height / 2; + const ratio = newZoom / prev.zoom; + animateTo( + { + panX: cx - (cx - prev.panX) * ratio, + panY: cy - (cy - prev.panY) * ratio, + zoom: newZoom, + }, + duration, + ); + }, + [animateTo], + ); + + const zoomIn = useCallback(() => zoomAround(stateRef.current.zoom * ZOOM_IN_FACTOR), [zoomAround]); + const zoomOut = useCallback(() => zoomAround(stateRef.current.zoom * ZOOM_OUT_FACTOR), [zoomAround]); + const resetZoom = useCallback(() => animateTo({ panX: 0, panY: 0, zoom: 1 }), [animateTo]); + + // ---- Fit to cards (explicit rects) ---- + const fitToCards = useCallback( + ( + cardRects: Array<{ x: number; y: number; width: number; height: number }>, + opts?: { maxZoom?: number; minZoom?: number; animate?: boolean }, + ) => { + cancelAnimation(); + const viewport = viewportRef.current; + if (!viewport || cardRects.length === 0) { + setState({ panX: 0, panY: 0, zoom: 1 }); + return; + } + const vRect = viewport.getBoundingClientRect(); + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const card of cardRects) { + minX = Math.min(minX, card.x); + minY = Math.min(minY, card.y); + maxX = Math.max(maxX, card.x + card.width); + maxY = Math.max(maxY, card.y + card.height); + } + if (!isFinite(minX)) { + setState({ panX: 0, panY: 0, zoom: 1 }); + return; + } + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + const availW = vRect.width - FIT_PADDING * 2; + const availH = vRect.height - FIT_PADDING * 2; + const ceiling = opts?.maxZoom ?? 1; + const floor = opts?.minZoom ?? MIN_ZOOM; + const targetZoom = clamp( + Math.min(availW / contentWidth, availH / contentHeight), + floor, + ceiling, + ); + const targetPanX = (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom; + const targetPanY = (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom; + const target = { panX: targetPanX, panY: targetPanY, zoom: targetZoom }; + const doAnimate = opts?.animate ?? true; + if (doAnimate) { + const cur = stateRef.current; + const dPan = Math.abs(cur.panX - target.panX) + Math.abs(cur.panY - target.panY); + const dZoom = Math.abs(cur.zoom - target.zoom); + if (dPan < 5 && dZoom < 0.01) return; + animateTo(target); + } else { + setState(target); + } + }, + [cancelAnimation, animateTo], + ); + + // ---- Keyboard ---- + const zoomInRef = useRef(zoomIn); + zoomInRef.current = zoomIn; + const zoomOutRef = useRef(zoomOut); + zoomOutRef.current = zoomOut; + const resetZoomRef = useRef(resetZoom); + resetZoomRef.current = resetZoom; + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const t = e.target as HTMLElement | null; + const inField = + t instanceof HTMLInputElement || + t instanceof HTMLTextAreaElement || + (t as HTMLElement | null)?.isContentEditable; + if (e.code === "Space" && !e.repeat && !inField) { + e.preventDefault(); + spaceRef.current = true; + setSpaceHeld(true); + } + if (e.ctrlKey || e.metaKey) { + if (e.key === "0") { + e.preventDefault(); + resetZoomRef.current(); + } else if (e.key === "=" || e.key === "+") { + e.preventDefault(); + zoomInRef.current(); + } else if (e.key === "-") { + e.preventDefault(); + zoomOutRef.current(); + } + } + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.code === "Space") { + spaceRef.current = false; + setSpaceHeld(false); + } + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }; + }, []); + + const handlers = useMemo( + () => ({ onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp }), + [handleMouseDown, handleMouseMove, handleMouseUp], + ); + const actions = useMemo( + () => ({ zoomIn, zoomOut, resetZoom, fitToCards, animateTo, cancelAnimation, setState }), + [zoomIn, zoomOut, resetZoom, fitToCards, animateTo, cancelAnimation], + ); + + return { + ...state, + isPanning, + spaceHeld, + viewportRef, + contentRef, + handlers, + actions, + }; +} + +export type CanvasActions = ReturnType ["actions"]; diff --git a/dhee/ui/web/src/components/cards/Cards.js b/dhee/ui/web/src/components/cards/Cards.js new file mode 100644 index 0000000..7e32322 --- /dev/null +++ b/dhee/ui/web/src/components/cards/Cards.js @@ -0,0 +1,172 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useState } from "react"; +export function BrowserCard({ url, title, lines, }) { + const [open, setOpen] = useState(true); + return (_jsxs("div", { style: { + border: "1px solid var(--border)", + marginTop: 8, + background: "white", + }, children: [_jsxs("div", { onClick: () => setOpen((o) => !o), style: { + borderBottom: open ? "1px solid var(--border)" : "none", + padding: "6px 10px", + display: "flex", + alignItems: "center", + gap: 8, + background: "var(--surface)", + cursor: "pointer", + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 1, + }, children: "BROWSER" }), _jsx("span", { style: { + flex: 1, + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink2)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, children: url }), _jsx("span", { style: { fontSize: 10, color: "var(--ink3)" }, children: open ? "▲" : "▼" })] }), open && (_jsxs("div", { style: { padding: "10px 12px" }, children: [_jsx("div", { style: { fontWeight: 600, fontSize: 13, marginBottom: 8 }, children: title }), lines.map((l, i) => (_jsxs("div", { style: { display: "flex", gap: 7, marginBottom: 3 }, children: [_jsx("span", { style: { + color: "var(--accent)", + fontFamily: "var(--mono)", + fontSize: 9, + marginTop: 2, + flexShrink: 0, + }, children: "\u2192" }), _jsx("span", { style: { + color: "var(--ink2)", + fontSize: 12.5, + lineHeight: 1.4, + }, children: l })] }, i)))] }))] })); +} +export function GrepCard({ query, files, }) { + return (_jsxs("div", { style: { border: "1px solid var(--border)", marginTop: 8, background: "white" }, children: [_jsxs("div", { style: { + borderBottom: "1px solid var(--border)", + padding: "6px 10px", + display: "flex", + gap: 8, + alignItems: "center", + background: "var(--surface)", + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 1, + }, children: "GREP" }), _jsxs("span", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--accent)", + }, children: ["\"", query, "\""] }), _jsxs("span", { style: { + marginLeft: "auto", + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + }, children: [files.length, " matches"] })] }), _jsx("div", { style: { fontFamily: "var(--mono)", fontSize: 11.5 }, children: files.map((f, i) => (_jsxs("div", { style: { + padding: "8px 12px", + borderBottom: i < files.length - 1 ? "1px solid var(--surface2)" : "none", + }, children: [_jsxs("div", { style: { marginBottom: 4 }, children: [_jsx("span", { style: { color: "var(--indigo)", fontWeight: 500 }, children: f.name }), _jsxs("span", { style: { color: "var(--ink3)", marginLeft: 4 }, children: [":", f.line] })] }), _jsxs("div", { style: { + paddingLeft: 10, + borderLeft: "2px solid var(--border)", + }, children: [_jsx("span", { style: { color: "var(--ink)" }, children: f.match }), f.note && (_jsx("span", { style: { color: "var(--accent)", marginLeft: 4 }, children: f.note }))] })] }, i))) })] })); +} +export function CodeCard({ lang, lines, }) { + return (_jsxs("div", { style: { + border: "1px solid var(--border)", + marginTop: 8, + background: "oklch(0.1 0.01 260)", + }, children: [_jsxs("div", { style: { + borderBottom: "1px solid oklch(0.2 0.01 260)", + padding: "5px 12px", + display: "flex", + gap: 8, + background: "oklch(0.12 0.01 260)", + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "oklch(0.5 0.01 260)", + letterSpacing: 1, + }, children: "CODE" }), _jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--accent)", + }, children: lang })] }), _jsx("div", { style: { + padding: "10px 14px", + fontFamily: "var(--mono)", + fontSize: 12, + lineHeight: 1.7, + }, children: lines.map((l, i) => (_jsx("div", { style: { + color: l.c === "comment" + ? "oklch(0.5 0.01 260)" + : l.c === "bad" + ? "var(--rose)" + : l.c === "good" + ? "var(--green-mid)" + : "oklch(0.88 0.01 260)", + }, children: l.t || " " }, i))) })] })); +} +export function DocumentCard({ title, lines, }) { + return (_jsxs("div", { style: { + border: "1px solid var(--border)", + marginTop: 8, + background: "white", + }, children: [_jsxs("div", { style: { + borderBottom: "1px solid var(--border)", + padding: "6px 12px", + display: "flex", + gap: 8, + alignItems: "center", + background: "var(--surface)", + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 1, + }, children: "DOCUMENT" }), _jsx("span", { style: { + fontSize: 11, + fontWeight: 500, + fontFamily: "var(--mono)", + color: "var(--ink2)", + }, children: title })] }), _jsx("div", { style: { padding: "12px 16px", fontSize: 13, lineHeight: 1.65 }, children: lines.map((l, i) => typeof l === "object" && "h" in l ? (_jsx("div", { style: { + fontWeight: 700, + marginTop: i > 0 ? 12 : 0, + marginBottom: 3, + fontSize: 10.5, + textTransform: "uppercase", + letterSpacing: "0.06em", + color: "var(--ink2)", + }, children: l.h }, i)) : (_jsx("div", { style: { color: "var(--ink)" }, children: l }, i))) })] })); +} +export function LinkCard({ linkedTask, preview, tasks, onSelectTask, }) { + const linked = tasks.find((t) => t.id === linkedTask); + if (!linked) + return null; + const colorMap = { + green: "var(--green)", + indigo: "var(--indigo)", + orange: "var(--accent)", + rose: "var(--rose)", + }; + const c = colorMap[linked.color] || "var(--accent)"; + return (_jsxs("div", { onClick: () => onSelectTask(linked.id), style: { + border: `1px solid ${c}`, + marginTop: 8, + cursor: "pointer", + display: "flex", + gap: 12, + padding: "9px 12px", + background: "white", + transition: "background 0.12s", + }, onMouseEnter: (e) => (e.currentTarget.style.background = "var(--surface)"), onMouseLeave: (e) => (e.currentTarget.style.background = "white"), children: [_jsx("div", { style: { + width: 8, + height: 8, + background: c, + flexShrink: 0, + marginTop: 3, + } }), _jsxs("div", { style: { flex: 1 }, children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + letterSpacing: 1, + marginBottom: 2, + }, children: "LINKED TASK" }), _jsx("div", { style: { fontWeight: 500, fontSize: 13 }, children: linked.title }), _jsx("div", { style: { fontSize: 11, color: "var(--ink2)", marginTop: 2 }, children: preview })] }), _jsx("div", { style: { color: c, fontSize: 15, alignSelf: "center" }, children: "\u2192" })] })); +} diff --git a/dhee/ui/web/src/components/cards/Cards.tsx b/dhee/ui/web/src/components/cards/Cards.tsx new file mode 100644 index 0000000..71edc43 --- /dev/null +++ b/dhee/ui/web/src/components/cards/Cards.tsx @@ -0,0 +1,403 @@ +import { useState } from "react"; +import type { SankhyaTask, TaskColor } from "../../types"; + +export function BrowserCard({ + url, + title, + lines, +}: { + url: string; + title: string; + lines: string[]; +}) { + const [open, setOpen] = useState(true); + return ( + ++ ); +} + +export function GrepCard({ + query, + files, +}: { + query: string; + files: { name: string; line: number; match: string; note?: string }[]; +}) { + return ( +setOpen((o) => !o)} + style={{ + borderBottom: open ? "1px solid var(--border)" : "none", + padding: "6px 10px", + display: "flex", + alignItems: "center", + gap: 8, + background: "var(--surface)", + cursor: "pointer", + }} + > + + BROWSER + + + {url} + + + {open ? "▲" : "▼"} + ++ {open && ( +++ )} ++ {title} ++ {lines.map((l, i) => ( ++ + → + + + {l} + ++ ))} +++ ); +} + +export function CodeCard({ + lang, + lines, +}: { + lang: string; + lines: { t: string; c?: "comment" | "bad" | "good" | string }[]; +}) { + return ( ++ + GREP + + + "{query}" + + + {files.length} matches + +++ {files.map((f, i) => ( ++++ ))} ++ + {f.name} + + + :{f.line} + +++ {f.match} + {f.note && ( + + {f.note} + + )} ++++ ); +} + +type DocLine = string | { h: string }; + +export function DocumentCard({ + title, + lines, +}: { + title: string; + lines: DocLine[]; +}) { + return ( ++ + CODE + + + {lang} + +++ {lines.map((l, i) => ( +++ {l.t || " "} ++ ))} +++ ); +} + +export function LinkCard({ + linkedTask, + preview, + tasks, + onSelectTask, +}: { + linkedTask: string; + preview: string; + tasks: SankhyaTask[]; + onSelectTask: (id: string) => void; +}) { + const linked = tasks.find((t) => t.id === linkedTask); + if (!linked) return null; + const colorMap: Record+ + DOCUMENT + + + {title} + +++ {lines.map((l, i) => + typeof l === "object" && "h" in l ? ( ++0 ? 12 : 0, + marginBottom: 3, + fontSize: 10.5, + textTransform: "uppercase", + letterSpacing: "0.06em", + color: "var(--ink2)", + }} + > + {l.h} ++ ) : ( ++ {l as string} ++ ) + )} += { + green: "var(--green)", + indigo: "var(--indigo)", + orange: "var(--accent)", + rose: "var(--rose)", + }; + const c = colorMap[linked.color] || "var(--accent)"; + return ( + onSelectTask(linked.id)} + style={{ + border: `1px solid ${c}`, + marginTop: 8, + cursor: "pointer", + display: "flex", + gap: 12, + padding: "9px 12px", + background: "white", + transition: "background 0.12s", + }} + onMouseEnter={(e) => + (e.currentTarget.style.background = "var(--surface)") + } + onMouseLeave={(e) => (e.currentTarget.style.background = "white")} + > + ++ ); +} diff --git a/dhee/ui/web/src/components/graph/CanvasNodeCard.js b/dhee/ui/web/src/components/graph/CanvasNodeCard.js new file mode 100644 index 0000000..da7cd62 --- /dev/null +++ b/dhee/ui/web/src/components/graph/CanvasNodeCard.js @@ -0,0 +1,53 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { StatPill } from "../ui/StatPill"; +const TYPE_BG = { + project: "oklch(0.99 0.02 75)", + workspace: "oklch(0.99 0.01 250)", + session: "oklch(0.98 0.02 145)", + task: "white", + result: "oklch(0.99 0.01 85)", + file: "oklch(0.98 0.015 265)", + asset: "oklch(0.99 0.02 20)", +}; +export function CanvasNodeCard({ node, active, onClick, }) { + const accent = node.accent || "var(--accent)"; + const meta = node.meta || {}; + const plan = Array.isArray(meta.plan) ? meta.plan : []; + const tools = Array.isArray(meta.tools) ? meta.tools : []; + return (_jsxs("div", { onClick: onClick, style: { + width: node.type === "result" ? 220 : node.type === "file" ? 190 : 240, + minHeight: node.type === "file" ? 84 : 122, + background: TYPE_BG[node.type] || "white", + border: `1.5px solid ${active ? accent : "var(--border)"}`, + boxShadow: active ? `0 12px 32px color-mix(in oklch, ${accent} 18%, transparent)` : "0 6px 16px rgba(0,0,0,0.05)", + cursor: onClick ? "pointer" : "default", + transition: "border-color 0.14s ease, box-shadow 0.14s ease, transform 0.14s ease", + }, children: [_jsx("div", { style: { height: 4, background: accent } }), _jsxs("div", { style: { padding: "12px 13px 11px" }, children: [_jsxs("div", { style: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 10, + marginBottom: 6, + }, children: [_jsxs("div", { style: { minWidth: 0 }, children: [_jsx("div", { style: { + fontSize: 12.5, + fontWeight: 600, + lineHeight: 1.35, + marginBottom: 4, + }, children: node.label }), node.subLabel && (_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + lineHeight: 1.45, + }, children: node.subLabel }))] }), _jsx(StatPill, { label: node.type, tone: accent })] }), node.body && (_jsx("div", { style: { + fontSize: 11.5, + color: "var(--ink2)", + lineHeight: 1.45, + marginBottom: 8, + whiteSpace: "pre-wrap", + }, children: node.body })), (plan.length > 0 || tools.length > 0 || node.status) && (_jsxs("div", { style: { + display: "flex", + flexWrap: "wrap", + gap: 6, + alignItems: "center", + }, children: [node.status && _jsx(StatPill, { label: node.status, tone: accent }), plan.length > 0 && _jsx(StatPill, { label: `${plan.length} plan items` }), tools.length > 0 && _jsx(StatPill, { label: `${tools.length} tool events` })] }))] })] })); +} diff --git a/dhee/ui/web/src/components/graph/CanvasNodeCard.tsx b/dhee/ui/web/src/components/graph/CanvasNodeCard.tsx new file mode 100644 index 0000000..d896efb --- /dev/null +++ b/dhee/ui/web/src/components/graph/CanvasNodeCard.tsx @@ -0,0 +1,110 @@ +import { StatPill } from "../ui/StatPill"; +import type { WorkspaceGraphNode } from "../../types"; + +const TYPE_BG: Record+++ LINKED TASK ++{linked.title}++ {preview} ++→+= { + project: "oklch(0.99 0.02 75)", + workspace: "oklch(0.99 0.01 250)", + session: "oklch(0.98 0.02 145)", + task: "white", + result: "oklch(0.99 0.01 85)", + file: "oklch(0.98 0.015 265)", + asset: "oklch(0.99 0.02 20)", +}; + +export function CanvasNodeCard({ + node, + active, + onClick, +}: { + node: WorkspaceGraphNode; + active?: boolean; + onClick?: () => void; +}) { + const accent = node.accent || "var(--accent)"; + const meta = node.meta || {}; + const plan = Array.isArray(meta.plan) ? meta.plan : []; + const tools = Array.isArray(meta.tools) ? meta.tools : []; + + return ( + + ++ ); +} diff --git a/dhee/ui/web/src/components/ui/DecayBar.js b/dhee/ui/web/src/components/ui/DecayBar.js new file mode 100644 index 0000000..9a493af --- /dev/null +++ b/dhee/ui/web/src/components/ui/DecayBar.js @@ -0,0 +1,21 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +export function DecayBar({ decay, width = 56 }) { + const color = decay > 0.8 ? "var(--green)" : decay > 0.5 ? "var(--accent)" : "var(--rose)"; + return (_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 5 }, children: [_jsx("div", { style: { + width, + height: 3, + background: "var(--surface2)", + position: "relative", + }, children: _jsx("div", { style: { + position: "absolute", + top: 0, + left: 0, + height: "100%", + width: `${decay * 100}%`, + background: color, + } }) }), _jsxs("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + }, children: [Math.round(decay * 100), "%"] })] })); +} diff --git a/dhee/ui/web/src/components/ui/DecayBar.tsx b/dhee/ui/web/src/components/ui/DecayBar.tsx new file mode 100644 index 0000000..f628f59 --- /dev/null +++ b/dhee/ui/web/src/components/ui/DecayBar.tsx @@ -0,0 +1,36 @@ +export function DecayBar({ decay, width = 56 }: { decay: number; width?: number }) { + const color = + decay > 0.8 ? "var(--green)" : decay > 0.5 ? "var(--accent)" : "var(--rose)"; + return ( +++++ + {node.body && ( ++++ {node.label} ++ {node.subLabel && ( ++ {node.subLabel} ++ )} ++ + {node.body} ++ )} + + {(plan.length > 0 || tools.length > 0 || node.status) && ( ++ {node.status &&+ )} +} + {plan.length > 0 && } + {tools.length > 0 && } + ++ ); +} diff --git a/dhee/ui/web/src/components/ui/Markdown.js b/dhee/ui/web/src/components/ui/Markdown.js new file mode 100644 index 0000000..3ba9963 --- /dev/null +++ b/dhee/ui/web/src/components/ui/Markdown.js @@ -0,0 +1,130 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +// Tiny dependency-free Markdown renderer. Inputs are text-escaped before +// regex substitution so user content can never break out into HTML. Supports: +// #, ##, ### headings +// **bold**, *italic* +// `inline code` +// ```fenced code``` +// [label](url) +// [[wiki-link]] (resolved against `wikiTitles` to a hash route) +// - list, * list, 1. list +// > blockquote +const ESC = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; +function escape(s) { + return s.replace(/[&<>"']/g, (c) => ESC[c] || c); +} +function safeUrl(url) { + const trimmed = url.trim(); + if (/^(https?:|mailto:|#|\/)/i.test(trimmed)) + return trimmed; + return "#"; +} +function renderInline(src, wikiResolve) { + let out = escape(src); + // inline code (`code`) + out = out.replace(/`([^`]+)`/g, (_, code) => `+ ++ + {Math.round(decay * 100)}% + +${code}`); + // wiki [[link]] — escape rendered title; the target is resolved via callback + out = out.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, title, alias) => { + const label = (alias || title).trim(); + const href = wikiResolve ? wikiResolve(title.trim()) : null; + if (!href) + return `${label}`; + return `${label}`; + }); + // markdown links [label](url) + out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { + return `${label}`; + }); + // bold **text** + out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); + // italic *text* + out = out.replace(/(^|[^*])\*([^*]+)\*/g, "$1$2"); + return out; +} +export function Markdown({ source, wikiResolve }) { + const lines = (source || "").replace(/\r\n/g, "\n").split("\n"); + const parts = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (/^```/.test(line)) { + const lang = line.slice(3).trim(); + const buf = []; + i += 1; + while (i < lines.length && !/^```/.test(lines[i])) { + buf.push(lines[i]); + i += 1; + } + i += 1; + parts.push(``); + continue; + } + if (/^### /.test(line)) { + parts.push(`${escape(buf.join("\n"))}${renderInline(line.slice(4), wikiResolve)}
`); + i += 1; + continue; + } + if (/^## /.test(line)) { + parts.push(`${renderInline(line.slice(3), wikiResolve)}
`); + i += 1; + continue; + } + if (/^# /.test(line)) { + parts.push(`${renderInline(line.slice(2), wikiResolve)}
`); + i += 1; + continue; + } + if (/^>\s?/.test(line)) { + const buf = []; + while (i < lines.length && /^>\s?/.test(lines[i])) { + buf.push(lines[i].replace(/^>\s?/, "")); + i += 1; + } + parts.push(`${renderInline(buf.join(" "), wikiResolve)}`); + continue; + } + if (/^\s*[-*]\s/.test(line)) { + const items = []; + while (i < lines.length && /^\s*[-*]\s/.test(lines[i])) { + items.push(`${renderInline(lines[i].replace(/^\s*[-*]\s/, ""), wikiResolve)} `); + i += 1; + } + parts.push(`${items.join("")}
`); + continue; + } + if (/^\s*\d+\.\s/.test(line)) { + const items = []; + while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) { + items.push(`${renderInline(lines[i].replace(/^\s*\d+\.\s/, ""), wikiResolve)} `); + i += 1; + } + parts.push(`${items.join("")}
`); + continue; + } + if (line.trim() === "") { + i += 1; + continue; + } + const buf = [line]; + i += 1; + while (i < lines.length && + lines[i].trim() !== "" && + !/^(#{1,3} |```|\s*[-*]\s|\s*\d+\.\s|>\s?)/.test(lines[i])) { + buf.push(lines[i]); + i += 1; + } + parts.push(`${renderInline(buf.join("\n"), wikiResolve)}
`); + } + return (_jsx("div", { className: "md-root", style: { + fontFamily: "var(--font)", + fontSize: 13.5, + lineHeight: 1.55, + color: "var(--ink)", + }, dangerouslySetInnerHTML: { __html: parts.join("") } })); +} diff --git a/dhee/ui/web/src/components/ui/Markdown.tsx b/dhee/ui/web/src/components/ui/Markdown.tsx new file mode 100644 index 0000000..ff82016 --- /dev/null +++ b/dhee/ui/web/src/components/ui/Markdown.tsx @@ -0,0 +1,155 @@ +// Tiny dependency-free Markdown renderer. Inputs are text-escaped before +// regex substitution so user content can never break out into HTML. Supports: +// #, ##, ### headings +// **bold**, *italic* +// `inline code` +// ```fenced code``` +// [label](url) +// [[wiki-link]] (resolved against `wikiTitles` to a hash route) +// - list, * list, 1. list +// > blockquote + +const ESC: Record= { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escape(s: string): string { + return s.replace(/[&<>"']/g, (c) => ESC[c] || c); +} + +function safeUrl(url: string): string { + const trimmed = url.trim(); + if (/^(https?:|mailto:|#|\/)/i.test(trimmed)) return trimmed; + return "#"; +} + +function renderInline(src: string, wikiResolve?: (title: string) => string | null): string { + let out = escape(src); + // inline code (`code`) + out = out.replace(/`([^`]+)`/g, (_, code) => ` ${code}`); + // wiki [[link]] — escape rendered title; the target is resolved via callback + out = out.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, title: string, alias?: string) => { + const label = (alias || title).trim(); + const href = wikiResolve ? wikiResolve(title.trim()) : null; + if (!href) return `${label}`; + return `${label}`; + }); + // markdown links [label](url) + out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label: string, url: string) => { + return `${label}`; + }); + // bold **text** + out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); + // italic *text* + out = out.replace(/(^|[^*])\*([^*]+)\*/g, "$1$2"); + return out; +} + +export interface MarkdownProps { + source: string; + wikiResolve?: (title: string) => string | null; +} + +export function Markdown({ source, wikiResolve }: MarkdownProps) { + const lines = (source || "").replace(/\r\n/g, "\n").split("\n"); + const parts: string[] = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (/^```/.test(line)) { + const lang = line.slice(3).trim(); + const buf: string[] = []; + i += 1; + while (i < lines.length && !/^```/.test(lines[i])) { + buf.push(lines[i]); + i += 1; + } + i += 1; + parts.push( + `` + ); + continue; + } + if (/^### /.test(line)) { + parts.push(`${escape(buf.join("\n"))}${renderInline(line.slice(4), wikiResolve)}
`); + i += 1; + continue; + } + if (/^## /.test(line)) { + parts.push(`${renderInline(line.slice(3), wikiResolve)}
`); + i += 1; + continue; + } + if (/^# /.test(line)) { + parts.push(`${renderInline(line.slice(2), wikiResolve)}
`); + i += 1; + continue; + } + if (/^>\s?/.test(line)) { + const buf: string[] = []; + while (i < lines.length && /^>\s?/.test(lines[i])) { + buf.push(lines[i].replace(/^>\s?/, "")); + i += 1; + } + parts.push( + `${renderInline(buf.join(" "), wikiResolve)}` + ); + continue; + } + if (/^\s*[-*]\s/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\s*[-*]\s/.test(lines[i])) { + items.push( + `${renderInline(lines[i].replace(/^\s*[-*]\s/, ""), wikiResolve)} ` + ); + i += 1; + } + parts.push(`${items.join("")}
`); + continue; + } + if (/^\s*\d+\.\s/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) { + items.push( + `${renderInline(lines[i].replace(/^\s*\d+\.\s/, ""), wikiResolve)} ` + ); + i += 1; + } + parts.push(`${items.join("")}
`); + continue; + } + if (line.trim() === "") { + i += 1; + continue; + } + const buf: string[] = [line]; + i += 1; + while ( + i < lines.length && + lines[i].trim() !== "" && + !/^(#{1,3} |```|\s*[-*]\s|\s*\d+\.\s|>\s?)/.test(lines[i]) + ) { + buf.push(lines[i]); + i += 1; + } + parts.push( + `${renderInline(buf.join("\n"), wikiResolve)}
` + ); + } + return ( + + ); +} diff --git a/dhee/ui/web/src/components/ui/SectionHeader.js b/dhee/ui/web/src/components/ui/SectionHeader.js new file mode 100644 index 0000000..ba792af --- /dev/null +++ b/dhee/ui/web/src/components/ui/SectionHeader.js @@ -0,0 +1,21 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +export function SectionHeader({ label, sub, children, }) { + const text = label || children; + return (_jsxs("div", { style: { + display: "flex", + alignItems: "baseline", + gap: 10, + marginBottom: 12, + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + fontWeight: 700, + color: "var(--ink3)", + letterSpacing: "0.1em", + textTransform: "uppercase", + }, children: text }), sub && (_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--border2)", + }, children: sub }))] })); +} diff --git a/dhee/ui/web/src/components/ui/SectionHeader.tsx b/dhee/ui/web/src/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..4203826 --- /dev/null +++ b/dhee/ui/web/src/components/ui/SectionHeader.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; + +export function SectionHeader({ + label, + sub, + children, +}: { + label?: string; + sub?: string; + children?: ReactNode; +}) { + const text = label || children; + return ( ++ + {text} + + {sub && ( + + {sub} + + )} ++ ); +} diff --git a/dhee/ui/web/src/components/ui/StatPill.js b/dhee/ui/web/src/components/ui/StatPill.js new file mode 100644 index 0000000..7b60395 --- /dev/null +++ b/dhee/ui/web/src/components/ui/StatPill.js @@ -0,0 +1,15 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +export function StatPill({ label, tone = "var(--ink3)", }) { + return (_jsx("span", { style: { + display: "inline-flex", + alignItems: "center", + gap: 5, + padding: "2px 7px", + border: `1px solid ${tone}`, + color: tone, + fontFamily: "var(--mono)", + fontSize: 9, + lineHeight: 1.2, + whiteSpace: "nowrap", + }, children: label })); +} diff --git a/dhee/ui/web/src/components/ui/StatPill.tsx b/dhee/ui/web/src/components/ui/StatPill.tsx new file mode 100644 index 0000000..0f6e6a4 --- /dev/null +++ b/dhee/ui/web/src/components/ui/StatPill.tsx @@ -0,0 +1,26 @@ +export function StatPill({ + label, + tone = "var(--ink3)", +}: { + label: string; + tone?: string; +}) { + return ( + + {label} + + ); +} diff --git a/dhee/ui/web/src/components/ui/TierBadge.js b/dhee/ui/web/src/components/ui/TierBadge.js new file mode 100644 index 0000000..f69ec6a --- /dev/null +++ b/dhee/ui/web/src/components/ui/TierBadge.js @@ -0,0 +1,41 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +const cfg = { + canonical: { + bg: "oklch(0.95 0.07 85)", + txt: "oklch(0.38 0.16 85)", + label: "CANONICAL", + }, + high: { + bg: "oklch(0.94 0.06 265)", + txt: "oklch(0.38 0.18 265)", + label: "HIGH", + }, + medium: { + bg: "oklch(0.95 0.06 55)", + txt: "oklch(0.42 0.14 55)", + label: "MEDIUM", + }, + "short-term": { + bg: "oklch(0.94 0.01 260)", + txt: "oklch(0.48 0.04 260)", + label: "SHORT-TERM", + }, + avoid: { + bg: "oklch(0.96 0.05 10)", + txt: "oklch(0.45 0.18 10)", + label: "AVOID", + }, +}; +export function TierBadge({ tier }) { + const c = cfg[tier] ?? cfg["short-term"]; + return (_jsx("span", { style: { + background: c.bg, + color: c.txt, + fontFamily: "var(--mono)", + fontSize: 9, + fontWeight: 700, + padding: "2px 6px", + letterSpacing: "0.08em", + flexShrink: 0, + }, children: c.label })); +} diff --git a/dhee/ui/web/src/components/ui/TierBadge.tsx b/dhee/ui/web/src/components/ui/TierBadge.tsx new file mode 100644 index 0000000..cdb6782 --- /dev/null +++ b/dhee/ui/web/src/components/ui/TierBadge.tsx @@ -0,0 +1,49 @@ +import type { Tier } from "../../types"; + +const cfg: Record= { + canonical: { + bg: "oklch(0.95 0.07 85)", + txt: "oklch(0.38 0.16 85)", + label: "CANONICAL", + }, + high: { + bg: "oklch(0.94 0.06 265)", + txt: "oklch(0.38 0.18 265)", + label: "HIGH", + }, + medium: { + bg: "oklch(0.95 0.06 55)", + txt: "oklch(0.42 0.14 55)", + label: "MEDIUM", + }, + "short-term": { + bg: "oklch(0.94 0.01 260)", + txt: "oklch(0.48 0.04 260)", + label: "SHORT-TERM", + }, + avoid: { + bg: "oklch(0.96 0.05 10)", + txt: "oklch(0.45 0.18 10)", + label: "AVOID", + }, +}; + +export function TierBadge({ tier }: { tier: Tier }) { + const c = cfg[tier] ?? cfg["short-term"]; + return ( + + {c.label} + + ); +} diff --git a/dhee/ui/web/src/main.js b/dhee/ui/web/src/main.js new file mode 100644 index 0000000..74603c2 --- /dev/null +++ b/dhee/ui/web/src/main.js @@ -0,0 +1,6 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./styles.css"; +ReactDOM.createRoot(document.getElementById("root")).render(_jsx(React.StrictMode, { children: _jsx(App, {}) })); diff --git a/dhee/ui/web/src/main.tsx b/dhee/ui/web/src/main.tsx new file mode 100644 index 0000000..16958a2 --- /dev/null +++ b/dhee/ui/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + +); diff --git a/dhee/ui/web/src/styles.css b/dhee/ui/web/src/styles.css new file mode 100644 index 0000000..df02337 --- /dev/null +++ b/dhee/ui/web/src/styles.css @@ -0,0 +1,329 @@ +:root { + --bg: oklch(0.975 0.006 80); + --ink: oklch(0.1 0.01 260); + --ink2: oklch(0.42 0.015 260); + --ink3: oklch(0.64 0.01 260); + --surface: oklch(0.955 0.008 80); + --surface2: oklch(0.93 0.01 80); + --border: oklch(0.87 0.012 80); + --border2: oklch(0.78 0.012 80); + --accent: oklch(0.64 0.18 36); + --accent-dim: oklch(0.97 0.04 36); + --green: oklch(0.52 0.22 145); + --green-dim: oklch(0.94 0.06 145); + --green-mid: oklch(0.72 0.14 145); + --indigo: oklch(0.52 0.2 265); + --indigo-dim: oklch(0.95 0.04 265); + --rose: oklch(0.58 0.2 10); + --rose-dim: oklch(0.96 0.05 10); + --font: "Space Grotesk", sans-serif; + --mono: "JetBrains Mono", monospace; + --nav: 52px; +} +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} +html, +body, +#root { + height: 100%; + width: 100%; +} +body { + font-family: var(--font); + background: var(--bg); + color: var(--ink); + overflow: hidden; + font-size: 13.5px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +::selection { + background: var(--accent-dim); +} +::-webkit-scrollbar { + width: 3px; + height: 3px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border2); +} +button { + font-family: var(--font); + cursor: pointer; + border: none; + background: none; + color: inherit; +} +textarea, +input { + font-family: var(--font); + color: var(--ink); + background: transparent; + border: none; + outline: none; + resize: none; +} +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} +@keyframes fadein { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes dhee-card-in { + from { + opacity: 0; + transform: translate3d(0, 8px, 0) scale(0.98); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } +} +@keyframes dhee-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} +@keyframes dhee-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(224, 107, 63, 0.35); + } + 50% { + box-shadow: 0 0 0 6px rgba(224, 107, 63, 0); + } +} +.dhee-node-card:hover { + transform: translate3d(0, -2px, 0) !important; + box-shadow: + 0 6px 18px rgba(20, 16, 10, 0.08), + 0 2px 6px rgba(20, 16, 10, 0.06) !important; +} +.dhee-canvas-bg { + background-color: #faf7ef; + background-image: + radial-gradient(rgba(20, 16, 10, 0.06) 1px, transparent 1px); + background-size: 28px 28px; + background-position: 0 0; +} +.dhee-edge-path { + fill: none; + stroke: rgba(20, 16, 10, 0.14); + stroke-width: 1.4; + transition: + stroke 0.18s ease, + stroke-width 0.18s ease, + opacity 0.18s ease; +} +.dhee-edge-path--highlight { + stroke: rgba(224, 107, 63, 0.55); + stroke-width: 2; +} +.dhee-edge-path--dim { + opacity: 0.28; +} + +/* ── Markdown (Vault read pane) ────────────────────────────────────────── */ +.md-root { + word-wrap: break-word; + overflow-wrap: break-word; +} +.md-root .md-h1, +.md-root .md-h2, +.md-root .md-h3 { + margin: 14px 0 8px; + line-height: 1.3; + color: var(--ink); +} +.md-root .md-h1 { + font-size: 22px; + font-weight: 600; + border-bottom: 1px solid var(--border); + padding-bottom: 5px; +} +.md-root .md-h2 { + font-size: 18px; + font-weight: 600; +} +.md-root .md-h3 { + font-size: 15px; + font-weight: 600; + color: var(--ink2); +} +.md-root .md-p { + margin: 8px 0; +} +.md-root .md-code { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 5px; + font-family: var(--mono); + font-size: 12px; + color: var(--ink); +} +.md-root .md-pre { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 4px; + padding: 10px 12px; + overflow-x: auto; + margin: 10px 0; +} +.md-root .md-pre .md-code { + background: transparent; + border: 0; + padding: 0; + font-size: 12px; + line-height: 1.5; +} +.md-root .md-link { + color: var(--accent); + text-decoration: none; + border-bottom: 1px solid rgba(224, 107, 63, 0.3); +} +.md-root .md-link:hover { + border-bottom-color: var(--accent); +} +.md-root .md-wiki { + color: var(--indigo); + font-weight: 500; +} +.md-root .md-wiki-missing { + color: var(--ink3); + border-bottom: 1px dashed var(--ink3); + cursor: not-allowed; +} +.md-root .md-list { + margin: 8px 0 8px 22px; + padding: 0; +} +.md-root .md-list li { + margin: 3px 0; +} +.md-root .md-quote { + border-left: 3px solid var(--border2); + padding: 2px 12px; + margin: 10px 0; + color: var(--ink2); + font-style: italic; +} + +/* ── Proposal status pills ─────────────────────────────────────────────── */ +.proposal-status { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 3px; + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.proposal-status.active { + background: var(--green-dim); + color: var(--green); +} +.proposal-status.pending_review { + background: var(--accent-dim); + color: var(--accent); +} +.proposal-status.rejected { + background: var(--rose-dim); + color: var(--rose); +} + +/* ── Inbox row (action queue) ──────────────────────────────────────────── */ +.inbox-row { + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; + animation: dhee-card-in 0.18s ease; +} +.inbox-row[data-severity="high"] { + border-left: 3px solid var(--rose); +} +.inbox-row[data-severity="medium"] { + border-left: 3px solid var(--accent); +} +.inbox-row[data-severity="low"] { + border-left: 3px solid var(--indigo); +} + +/* ── TopBar pills (already inline-styled but classes available) ───────── */ +.workspace-pill { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 360px; +} +.tokens-chip { + cursor: default; +} + +/* ── Vault tree leaves ─────────────────────────────────────────────────── */ +.vault-leaf { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + color: var(--ink2); + background: transparent; + border: 0; + text-align: left; + width: 100%; +} +.vault-leaf:hover { + background: var(--surface); +} +.vault-leaf[aria-selected="true"] { + background: var(--accent-dim); + color: var(--accent); +} +.vault-leaf[data-status="pending_review"]::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} +.vault-leaf[data-status="active"]::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green); + flex-shrink: 0; +} diff --git a/dhee/ui/web/src/types.js b/dhee/ui/web/src/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dhee/ui/web/src/types.js @@ -0,0 +1 @@ +export {}; diff --git a/dhee/ui/web/src/types.ts b/dhee/ui/web/src/types.ts new file mode 100644 index 0000000..f6467ee --- /dev/null +++ b/dhee/ui/web/src/types.ts @@ -0,0 +1,944 @@ +export type Tier = "canonical" | "high" | "medium" | "short-term" | "avoid"; + +export interface Engram { + id: string; + tier: Tier; + content: string; + source: string; + created: string; + tags: string[]; + decay: number; + reaffirmed: number; + tokens: number; +} + +export interface ToolStats { + name: string; + calls: number; + tokensSaved: number; + avgDigest: number; + avgRaw: number; + expansions: number; +} + +export interface AgentOption { + id: string; + label: string; + calls: number; + tokensSaved: number; + bytesStored: number; + expansionRate: number; + sessions: number; +} + +export interface CodexNativeStats { + available: boolean; + threadId?: string; + title?: string; + model?: string | null; + updatedAt?: string | null; + totalTokens?: number | null; + inputTokens?: number | null; + cachedInputTokens?: number | null; + outputTokens?: number | null; + reasoningOutputTokens?: number | null; + lastTurnTokens?: number | null; + lastTurnInputTokens?: number | null; + lastTurnCachedInputTokens?: number | null; + lastTurnOutputTokens?: number | null; + contextWindow?: number | null; + primaryUsedPercent?: number | null; + secondaryUsedPercent?: number | null; + resetAt?: string | null; + secondaryResetAt?: string | null; + rateLimits?: Record+ ; +} + +export interface RouterStats { + live: boolean; + selectedAgent: string; + sessionTokensSaved: number; + enterpriseSavedTokens?: number; + enterpriseSavedPct?: number; + enterpriseRawTokens?: number; + enterpriseSummaryTokens?: number; + enterpriseRawFallbacks?: number; + enterpriseGateSuggestions?: number; + enterpriseGateDenials?: number; + enterpriseGateFallbacks?: number; + totalCalls: number; + expansionRate: number; + sessionCost: number; + estimatedFullCost: number; + tools: ToolStats[]; + agents: AgentOption[]; + dailySavings: number[]; + days: string[]; + sessions: number; + bytesStored: number; + codexNative?: CodexNativeStats; + error?: string; +} + +export interface StoredKeyVersion { + id: string; + label: string; + createdAt?: string; + retiredAt?: string; + preview: string; + active: boolean; +} + +export interface ApiKeyProviderStatus { + provider: string; + label: string; + envVars: string[]; + hasEnvKey: boolean; + hasStoredKey: boolean; + activeSource: "env" | "stored" | "none"; + activeEnvVar?: string | null; + activePreview: string; + storedVersions: StoredKeyVersion[]; + storedVersionsCount: number; + updatedAt?: string; + rotatedAt?: string; + note?: string; +} + +export interface PolicyRow { + intent: string; + label: string; + depth: number; + prevDepth: number; + expansionRate: number; + tuned: boolean; + tool: string; +} + +export interface ConfidenceGroup { + group: string; + confidence: number; + trend: "up" | "down" | "stable"; +} + +export interface MetaBuddhiSnapshot { + live: boolean; + status: string; + strategy: string; + sessionInsights: number; + totalInsights: number; + pendingProposals: number; + lastGate: string; + confidenceGroups: ConfidenceGroup[]; + error?: string; +} + +export interface EvolutionEvent { + id: string; + time: string; + type: "tune" | "commit" | "rollback" | "nididhyasana" | "promotion"; + label: string; + detail: string; + impact: "positive" | "negative" | "neutral"; +} + +export interface Belief { + id: string; + content: string; + confidence: number; + created: string; + source: string; + tier: Tier; + domain?: string; + freshness?: string | null; + lifecycle?: string | null; + truthStatus?: string | null; + sourceMemoryIds?: string[]; + evidence?: { + id?: string; + content: string; + supports?: boolean; + source?: string; + confidence?: number; + created_at?: string; + memory_id?: string; + episode_id?: string; + }[]; + history?: { + event_type?: string; + reason?: string; + actor?: string; + created_at?: string; + payload?: Record ; + }[]; +} + +export interface Conflict { + id: string; + kind?: string; + severity: "high" | "medium" | "low"; + reason: string; + resolutionOptions?: string[]; + belief_a: Belief; + belief_b: Belief; +} + +export interface ConflictSnapshot { + live: boolean; + supported: boolean; + resolutionMode: "native" | "read-only" | "unavailable"; + conflicts: Conflict[]; +} + +export type TaskColor = "green" | "indigo" | "orange" | "rose"; + +export type TaskHarness = "claude-code" | "codex" | "both" | null; + +export type TaskStatus = + | "active" + | "paused" + | "completed" + | "closed" + | "abandoned"; + +export interface TaskMessage { + id: string; + role: "user" | "agent" | "component"; + content?: string; + type?: string; + [k: string]: unknown; +} + +export interface SankhyaTask { + id: string; + color: TaskColor; + title: string; + created: string; + updatedAt?: string | null; + status?: TaskStatus | string; + links: string[]; + pos: { x: number; y: number }; + harness: TaskHarness; + source?: string; + messages: TaskMessage[]; +} + +export interface SharedTaskResult { + id: string; + packet_kind: string; + tool_name: string; + result_status: string; + source_path?: string | null; + ptr?: string | null; + artifact_id?: string | null; + digest?: string | null; + metadata?: Record ; + harness?: string | null; + agent_id?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface SessionAsset { + id: string; + project_id?: string | null; + workspace_id?: string | null; + session_id: string; + artifact_id?: string | null; + storage_path: string; + name: string; + mime_type?: string | null; + size_bytes?: number; + metadata?: Record ; + created_at?: string | null; + updated_at?: string | null; +} + +export interface ProjectAsset { + id: string; + workspace_id: string; + project_id?: string | null; + user_id?: string; + artifact_id?: string | null; + folder?: string | null; + storage_path: string; + name: string; + mime_type?: string | null; + size_bytes?: number; + checksum?: string | null; + metadata?: Record ; + created_at?: string | null; + updated_at?: string | null; + results?: SharedTaskResult[]; +} + +export interface FileContextSummary { + path: string; + workspaceId?: string | null; + results: SharedTaskResult[]; + memories: Engram[]; + summary: string; +} + +export interface AssetContextSummary { + asset: SessionAsset; + session?: AgentSessionSummary | null; + artifact?: Record | null; + summary?: string; + chunks?: { chunk_index?: number; content: string }[]; +} + +export interface WorkspaceFolderMount { + path: string; + label: string; + primary?: boolean; +} + +export interface ProjectScopeRule { + id: string; + pathPrefix: string; + label?: string; +} + +export interface WorkspaceLineMessage { + id: string; + workspace_id: string; + project_id?: string | null; + target_project_id?: string | null; + channel: string; + session_id?: string | null; + task_id?: string | null; + message_kind: string; + title?: string | null; + body?: string | null; + metadata?: Record ; + created_at?: string | null; + updated_at?: string | null; +} + +export interface WorkspaceChannel { + id: string; + type: "workspace" | "project"; + label: string; + workspaceId: string; + projectId?: string | null; +} + +export interface AgentSessionSummary { + id: string; + nativeSessionId: string; + projectId?: string | null; + workspaceId?: string | null; + taskId?: string | null; + runtime: "claude-code" | "codex" | string; + title: string; + state: string; + isCurrent?: boolean; + model?: string | null; + cwd?: string | null; + rolloutPath?: string | null; + startedAt?: string | null; + updatedAt?: string | null; + permissionMode?: string | null; + preview?: string; + messages: TaskMessage[]; + recentTools?: string[]; + plan?: { step: string; status: string }[]; + touchedFiles?: string[]; + rateLimits?: Record ; + taskStatus?: string | null; +} + +export interface ProjectSummary { + id: string; + workspaceId?: string | null; + name: string; + label?: string; + description?: string | null; + defaultRuntime?: string | null; + color?: string | null; + icon?: string | null; + updatedAt?: string | null; + scopeRules?: ProjectScopeRule[]; + sessions: AgentSessionSummary[]; +} + +export interface WorkspaceSummary { + id: string; + name: string; + label?: string; + description?: string | null; + rootPath: string; + workspacePath: string; + folders?: WorkspaceFolderMount[]; + mounts?: WorkspaceFolderMount[]; + updatedAt?: string | null; + projects: ProjectSummary[]; + sessions: AgentSessionSummary[]; + sessionCount?: number; +} + +export interface TaskDetailSnapshot { + live: boolean; + task: SankhyaTask; + results: SharedTaskResult[]; + runtime: { + live: boolean; + repo: string; + runtimes: RuntimeStatusCard[]; + }; +} + +export interface WorkspaceGraphNode { + id: string; + type: + | "workspace" + | "project" + | "channel" + | "session" + | "task" + | "result" + | "file" + | "asset" + | "broadcast" + | string; + label: string; + subLabel?: string; + body?: string; + accent?: string; + status?: string; + val?: number; + meta?: Record ; +} + +export interface WorkspaceGraphEdge { + id: string; + source: string; + target: string; + label: string; + curvature?: number; +} + +export interface CanvasSelectionState { + nodeId?: string | null; + nodeType?: string | null; +} + +export interface SuggestedTask { + id: string; + title: string; + status: string; + projectId?: string | null; + workspaceId?: string | null; +} + +export interface WorkspaceSessionSnapshot { + id: string; + title: string; + cwd: string; + model?: string | null; + updatedAt?: string | null; + updatedAtLabel?: string; + rolloutPath?: string; + isCurrent?: boolean; + preview?: string; + messages?: { role: string; content: string; timestamp?: string }[]; + recentTools?: string[]; + plan?: { step: string; status: string }[]; + touchedFiles?: string[]; + rateLimits?: Record ; +} + +export interface WorkspaceGraphSnapshot { + live: boolean; + repo: string; + workspace?: WorkspaceSummary | null; + graph: { + nodes: WorkspaceGraphNode[]; + links: WorkspaceGraphEdge[]; + }; + sessions: AgentSessionSummary[]; + tasks: SankhyaTask[]; + files: WorkspaceGraphNode[]; + currentSessionId?: string; + currentProjectId?: string; + currentWorkspaceId?: string; + workspaces?: WorkspaceSummary[]; + line?: { messages: WorkspaceLineMessage[] }; + runtime?: { + live: boolean; + repo: string; + runtimes: RuntimeStatusCard[]; + }; + error?: string; +} + +export interface ProjectIndexSnapshot { + live: boolean; + workspaces: WorkspaceSummary[]; + currentProjectId?: string; + currentWorkspaceId?: string; + currentSessionId?: string; + error?: string; +} + +export interface WorkspaceDetailSnapshot { + live: boolean; + workspace: WorkspaceSummary; + projects?: ProjectSummary[]; + sessions: AgentSessionSummary[]; + line?: { messages: WorkspaceLineMessage[] }; + runtime: { + live: boolean; + repo: string; + runtimes: RuntimeStatusCard[]; + }; +} + +export interface SessionDetailSnapshot { + live: boolean; + project?: ProjectSummary | null; + workspace?: WorkspaceSummary | null; + session: AgentSessionSummary; + task?: SankhyaTask | null; + results: SharedTaskResult[]; + assets: SessionAsset[]; + files: FileContextSummary[]; + line?: { messages: WorkspaceLineMessage[] }; + runtime: { + live: boolean; + repo: string; + runtimes: RuntimeStatusCard[]; + }; +} + +export interface RuntimeLimitStatus { + supported: boolean; + state: string; + lastHitAt?: string | null; + resetAt?: string | null; + model?: string | null; + note?: string; +} + +export interface RuntimeSession { + id: string; + cwd: string; + pid?: number; + title?: string | null; + model?: string | null; + version?: string | null; + entrypoint?: string | null; + startedAt?: string | null; + updatedAt?: string | null; + rolloutPath?: string | null; + state: string; + note?: string; +} + +export interface RuntimeStatusCard { + id: "claude-code" | "codex"; + label: string; + installed: boolean; + state: string; + configured: Record ; + currentSession?: RuntimeSession | null; + limits: RuntimeLimitStatus; +} + +export interface CaptureSessionRecord { + id: string; + user_id: string; + source_app: string; + namespace: string; + status: string; + started_at: string; + ended_at?: string | null; + metadata?: Record ; +} + +export interface CaptureSurfaceRecord { + id: string; + session_id: string; + source_app: string; + surface_type: string; + title: string; + url: string; + app_path: string; + path_hint: string[]; + first_seen_at: string; + last_seen_at: string; + metadata?: Record ; +} + +export interface CaptureGraphRecord { + manifest?: Record ; + surfaces: CaptureSurfaceRecord[]; + actions: Record []; + observations: Record []; + artifacts: Record []; + links: Record []; +} + +export interface ActiveCaptureRecord { + session: CaptureSessionRecord; + graph: CaptureGraphRecord; +} + +export interface MemoryNowSnapshot { + live: boolean; + sessions: CaptureSessionRecord[]; + events: Record []; + activeCapture: ActiveCaptureRecord[]; + memories: Record []; + transitions: Record []; +} + +export interface CaptureTimelineItem { + kind: string; + timestamp: string; + item: Record ; +} + +export interface Tweaks { + accentHue: string; + compactNav: boolean; + showTimestamps: boolean; + canvasStyle: "dots" | "grid" | "force" | "tree"; +} + +export interface Viewer { + live: boolean; + user_id: string; + org_id: string; + project_id?: string | null; + team_id?: string | null; + team_ids: string[]; + role: "developer" | "manager" | "admin" | string; + repo_root?: string; +} + +export type OrgNodeType = + | "workspace" + | "project" + | "team" + | "global_team" + | "repo" + | string; // includes integration:slack, integration:docs, etc. + +export interface OrgNode { + id: string; + type: OrgNodeType; + label: string; + health?: "healthy" | "watch" | "needs_work"; + meta?: Record ; +} + +export interface OrgEdge { + source: string; + target: string; + kind: string; +} + +export interface ContextItem { + context_id: string; + scope: string; + project_id?: string | null; + team_id?: string | null; + user_id?: string | null; + agent_id?: string | null; + kind: string; + title: string; + content?: string; + summary?: string; + tags: string[]; + status: string; + proposal_status?: "active" | "pending_review" | "rejected" | string; + proposed_by_user_id?: string | null; + reviewed_by_user_id?: string | null; + review_requested_at?: string | null; + review_decision_at?: string | null; + supersedes_id?: string | null; + quality_score?: number; + freshness_score?: number; + confidence?: number; + token_cost?: number; + usage_count?: number; + last_used_at?: string | null; + metadata?: Record ; + updated_at?: string; + shares?: Record []; +} + +export interface Proposal extends ContextItem { + proposal_status: "active" | "pending_review" | "rejected" | string; +} + +export interface Finding { + finding_id: string; + team_id: string; + manager_id: string; + finding_type: string; + severity: "low" | "medium" | "high" | string; + title: string; + detail: string; + related_context_id?: string | null; + status: string; + metadata?: Record ; + created_at?: string; + updated_at?: string; +} + +export interface OrgGraphSnapshot { + live: boolean; + org_id: string; + nodes: OrgNode[]; + edges: OrgEdge[]; + totals: { + projects: number; + teams: number; + repos: number; + context_items: number; + pending_proposals: number; + folders?: number; + sessions?: number; + shared_folders?: number; + }; + raw?: { + mode?: string; + folders?: OrgNode[]; + sessions?: Record []; + shared_folder_paths?: string[]; + context_index: ContextItem[]; + pending_proposals: Proposal[]; + context_managers_by_team: Record >; + }; + error?: string; +} + +export interface InboxSnapshot { + live: boolean; + proposals: Proposal[]; + findings: Finding[]; + conflicts: Record []; + totals: { + proposals: number; + findings: number; + conflicts: number; + }; +} + +export interface BacklinksSnapshot { + live: boolean; + backlinks: { + src: string; + edge_type: string; + metadata: Record ; + created_at: string; + src_title?: string; + src_kind?: string; + src_scope?: string; + }[]; + shares: Record []; + error?: string; +} + +export interface ContinuitySnapshot { + live: boolean; + repo: string; + repo_config?: { + org_id?: string; + project_id?: string; + team_id?: string; + default_role?: string; + schema?: number; + [key: string]: unknown; + }; + last_session?: { + task_summary?: string; + last_user_message?: string; + last_assistant_summary?: string; + files_touched?: string[]; + key_commands?: string[]; + todos?: string[]; + decisions?: string[]; + source?: string; + updated?: string; + ended_at?: string; + message_count?: number; + [key: string]: unknown; + } | null; + claude_sessions: { + id: string; + title?: string; + state?: string; + cwd?: string; + updatedAt?: string; + preview?: string; + touchedFiles?: string[]; + recentTools?: string[]; + [key: string]: unknown; + }[]; + error?: string; +} + +// ─── Slice-2: workspace primitive + router dashboard + context view ──────── + +export interface LocalWorkspace { + id: string; + name: string; + created_at?: string | null; + folder_count: number; + folders?: string[]; +} + +export interface RouterSessionRow { + session_id: string; + title: string; + state: string; + agent: string; + agents: string[]; + cwd: string; + repo_root: string; + runtime?: string | null; + model?: string | null; + updated_at: string; + started_at?: string | null; + tokens_saved: number; + estimated_cost_saved_usd?: number; + pricing?: { + provider?: string; + model_family?: string; + input_cost_per_million?: number; + cached_input_cost_per_million?: number | null; + output_cost_per_million?: number | null; + currency?: string; + unit?: string; + source?: string; + note?: string; + }; + live_usage?: { + available?: boolean; + source?: string; + exact?: boolean; + input_tokens?: number | null; + cached_input_tokens?: number | null; + output_tokens?: number | null; + reasoning_output_tokens?: number | null; + total_tokens?: number | null; + last_turn_tokens?: number | null; + last_turn_input_tokens?: number | null; + last_turn_cached_input_tokens?: number | null; + last_turn_output_tokens?: number | null; + context_window?: number | null; + updated_at?: string | null; + note?: string; + } | null; + router_calls: number; + tool_breakdown: Record ; + active: boolean; + task: { id?: string | null; status?: string | null }; + preview: string; +} + +export interface RouterSessionsPage { + items: RouterSessionRow[]; + next_cursor: string | null; + active_only: boolean; + totals: { + tokens_saved: number; + estimated_cost_saved_usd?: number; + theoretical_api_value_usd?: number; + realized_cost_saved_usd?: number; + router_calls: number; + sessions: number; + }; + budget?: { + currency?: string; + monthly_budget_usd?: number; + daily_budget_usd?: number; + weekly_budget_usd?: number; + yearly_budget_usd?: number; + basis?: string; + note?: string; + }; + money_math?: Record ; +} + +export interface ContextUsageRow { + context_id: string; + title: string; + scope?: string | null; + kind?: string | null; + team_id?: string | null; + project_id?: string | null; + usage_count: number; + last_used_at?: string | null; + token_cost: number; + tokens_served: number; + proven_tokens_saved: number; + theoretical_api_value_usd: number; + realized_cost_saved_usd: number; + quality_score?: number | null; + freshness_score?: number | null; + confidence?: number | null; + evidence?: { + router_calls?: number; + metadata_tokens?: number; + has_direct_savings_evidence?: boolean; + }; +} + +export interface ContextUsageSnapshot { + live: boolean; + items: ContextUsageRow[]; + totals: { + contexts: number; + used_contexts: number; + usage_count: number; + tokens_served: number; + proven_tokens_saved: number; + theoretical_api_value_usd: number; + realized_cost_saved_usd: number; + }; + budget?: RouterSessionsPage["budget"]; + money_math?: Record ; + error?: string; +} + +export interface RepoContextEntry { + id: string; + kind: string; + title: string; + content: string; + created_at: string; + created_by: string; + meta?: Record ; + source_memory_id?: string | null; + deleted?: boolean; +} + +export interface ContextEntriesSnapshot { + repo_root: string | null; + linked: boolean; + repo_entries: RepoContextEntry[]; + promoted_in: { + memory_id: string; + memory: string; + entry_id?: string; + promoted_at?: string; + }[]; + demoted_out: { + memory_id: string; + memory: string; + entry_id?: string; + demoted_at?: string; + }[]; + share_matrix: { repo_root: string; label: string; entry_count: number }[]; + totals?: { + repo_entries: number; + promoted_in: number; + demoted_out: number; + linked_peers: number; + }; +} diff --git a/dhee/ui/web/src/views/CanvasView.js b/dhee/ui/web/src/views/CanvasView.js new file mode 100644 index 0000000..001cb0d --- /dev/null +++ b/dhee/ui/web/src/views/CanvasView.js @@ -0,0 +1,460 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { CanvasControls } from "../components/canvas/CanvasControls"; +import { CanvasSkeleton } from "../components/canvas/CanvasSkeleton"; +import { DirectionHints } from "../components/canvas/DirectionHints"; +import { NodeCard, TYPE_COLOR } from "../components/canvas/NodeCard"; +import { layoutGraph } from "../components/canvas/layout"; +import { useInfiniteCanvas } from "../components/canvas/useInfiniteCanvas"; +import { StatPill } from "../components/ui/StatPill"; +import { OrgCanvas } from "./OrgCanvas"; +// --------------------------------------------------------------------------- +// CanvasView — openswarm-inspired infinite canvas. Renders every graph +// node as a real DOM card laid out hierarchically (workspace → projects → +// children), with smooth pan/zoom/momentum, minimap, direction hints, +// skeleton loader, and an inspector that's neighbour-aware. +// --------------------------------------------------------------------------- +const TYPE_LABEL = { + workspace: "Workspace", + project: "Project", + channel: "Channel", + session: "Session", + task: "Task", + result: "Tool result", + file: "File", + asset: "Asset", + broadcast: "Broadcast", +}; +function linkEndpointId(endpoint) { + if (!endpoint) + return ""; + if (typeof endpoint === "string") + return endpoint; + if (typeof endpoint === "object" && endpoint !== null && "id" in endpoint) + return String(endpoint.id || ""); + return ""; +} +function edgeHighlightClass(edge, focusedId, neighbourSet) { + const src = linkEndpointId(edge.source); + const tgt = linkEndpointId(edge.target); + if (focusedId && (src === focusedId || tgt === focusedId)) + return "dhee-edge-path dhee-edge-path--highlight"; + if (neighbourSet && !(neighbourSet.has(src) && neighbourSet.has(tgt))) + return "dhee-edge-path dhee-edge-path--dim"; + return "dhee-edge-path"; +} +function NodeInspector({ node, neighbourCount, onOpenWorkspace, onOpenProject, onOpenSession, onOpenTask, }) { + const [showMeta, setShowMeta] = useState(false); + if (!node) { + return (_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + lineHeight: 1.6, + }, children: "Hover or click a card to inspect it. Drag the canvas with mouse or space-bar. \u2318/Ctrl + wheel to zoom." })); + } + const meta = (node.meta || {}); + const color = node.accent || TYPE_COLOR[node.type] || "#555"; + const buttonStyle = { + padding: "8px 12px", + border: "1px solid var(--ink)", + background: "var(--ink)", + color: "white", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + textTransform: "uppercase", + cursor: "pointer", + }; + const kv = (key, value) => { + if (value === undefined || value === null || value === "") + return null; + return (_jsxs("div", { style: { display: "flex", gap: 10, fontSize: 12, lineHeight: 1.55 }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + minWidth: 92, + flexShrink: 0, + }, children: key }), _jsx("span", { style: { color: "var(--ink2)", wordBreak: "break-word" }, children: String(value) })] }, key)); + }; + const typeLabel = TYPE_LABEL[node.type] || node.type; + return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs("div", { children: [_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }, children: [_jsx("span", { style: { + width: 10, + height: 10, + borderRadius: 2, + background: color, + boxShadow: `0 0 0 2px ${color}22`, + } }), _jsx(StatPill, { label: typeLabel, tone: color }), node.status ? _jsx(StatPill, { label: node.status }) : null, _jsxs("span", { style: { marginLeft: "auto", fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink3)" }, children: [neighbourCount, " connection", neighbourCount === 1 ? "" : "s"] })] }), _jsx("div", { style: { fontSize: 18, fontWeight: 700, lineHeight: 1.25, wordBreak: "break-word" }, children: node.label || "(unnamed)" }), node.subLabel ? (_jsx("div", { style: { + marginTop: 4, + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + letterSpacing: 0.3, + }, children: node.subLabel })) : null] }), node.body ? (_jsx("div", { style: { + fontSize: 13, + color: "var(--ink2)", + lineHeight: 1.6, + whiteSpace: "pre-wrap", + borderLeft: `2px solid ${color}55`, + paddingLeft: 10, + }, children: node.body })) : null, node.type === "workspace" && (_jsxs("div", { style: { display: "grid", gap: 4 }, children: [kv("root", meta.rootPath), kv("projects", meta.projectCount), kv("sessions", meta.sessionCount)] })), node.type === "project" && (_jsxs("div", { style: { display: "grid", gap: 4 }, children: [kv("workspace", meta.workspaceLabel), kv("runtime", meta.defaultRuntime), kv("sessions", meta.sessionCount), kv("tasks", meta.taskCount)] })), node.type === "session" && (_jsxs("div", { style: { display: "grid", gap: 4 }, children: [kv("runtime", meta.runtime), kv("model", meta.model), kv("state", meta.state), kv("updated", meta.updatedAt)] })), node.type === "task" && (_jsxs("div", { style: { display: "grid", gap: 4 }, children: [kv("harness", meta.harness), kv("status", meta.status), kv("messages", meta.messageCount)] })), node.type === "result" && (_jsxs("div", { style: { display: "grid", gap: 4 }, children: [kv("tool", meta.toolName), kv("packet", meta.packetKind), kv("ptr", meta.ptr), kv("harness", meta.harness), kv("source", meta.sourcePath)] })), node.type === "broadcast" && (_jsxs("div", { style: { display: "grid", gap: 4 }, children: [kv("from", meta.sourceProject || meta.sourceChannel), kv("to", meta.targetProject || meta.targetChannel), kv("kind", meta.messageKind)] })), _jsxs("div", { style: { display: "flex", gap: 8, flexWrap: "wrap" }, children: [node.type === "workspace" && Boolean(meta.workspaceId) && (_jsx("button", { onClick: () => onOpenWorkspace(String(meta.workspaceId)), style: buttonStyle, children: "open workspace" })), node.type === "project" && Boolean(meta.projectId) && (_jsx("button", { onClick: () => onOpenProject(String(meta.projectId), meta.workspaceId || undefined), style: buttonStyle, children: "open project" })), node.type === "session" && (_jsx("button", { onClick: () => onOpenSession(node.id, meta.taskId || null), style: buttonStyle, children: "open session" })), node.type === "task" && (_jsx("button", { onClick: () => onOpenTask(node.id), style: buttonStyle, children: "open task" }))] }), Object.keys(meta).length > 0 && (_jsxs("div", { style: { borderTop: "1px solid var(--border)", paddingTop: 10 }, children: [_jsx("button", { onClick: () => setShowMeta((v) => !v), style: { + background: "transparent", + border: 0, + padding: 0, + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + cursor: "pointer", + letterSpacing: 0.5, + }, children: showMeta ? "▾ hide raw metadata" : "▸ show raw metadata" }), showMeta && (_jsx("pre", { style: { + marginTop: 8, + border: "1px solid var(--border)", + background: "var(--bg)", + padding: 10, + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + lineHeight: 1.5, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + maxHeight: 260, + overflow: "auto", + }, children: JSON.stringify(meta, null, 2) }))] }))] })); +} +const EMPTY_LOCAL_GRAPH = { + live: false, + org_id: "local", + nodes: [], + edges: [], + totals: { + projects: 0, + teams: 0, + repos: 0, + context_items: 0, + pending_proposals: 0, + folders: 0, + sessions: 0, + shared_folders: 0, + }, + raw: { + mode: "local_context", + folders: [], + sessions: [], + shared_folder_paths: [], + context_index: [], + pending_proposals: [], + context_managers_by_team: {}, + }, +}; +function folderGraph(graph) { + if (!graph) + return EMPTY_LOCAL_GRAPH; + return { + ...EMPTY_LOCAL_GRAPH, + ...graph, + org_id: graph.org_id || "local", + nodes: graph.nodes || [], + edges: graph.edges || [], + totals: { + ...EMPTY_LOCAL_GRAPH.totals, + ...(graph.totals || {}), + }, + raw: { + ...EMPTY_LOCAL_GRAPH.raw, + ...(graph.raw || {}), + mode: graph.raw?.mode || "local_context", + }, + }; +} +export function CanvasView(props) { + return (_jsx(OrgCanvas, { graph: folderGraph(props.orgGraph), viewer: props.viewer || null, tweaks: props.tweaks, onOpenVault: props.onOpenVault || (() => { }), onOpenSession: props.onSelectSession, onChanged: props.onOrgGraphChanged || (() => { }) })); +} +function WorkspaceCanvasView({ tasks, selectedProjectId, workspaceGraph, onSelectTask, onSelectSession, onSelectWorkspace, onSelectProject, onClose, }) { + const [selection, setSelection] = useState({}); + const [hovered, setHovered] = useState(null); + const [typeFilter, setTypeFilter] = useState(null); + // Fallback: if the server hasn't produced a graph, show each task as a + // standalone card. Keeps the canvas useful before any agent session + // exists in the workspace. + const rawGraph = useMemo(() => { + if (workspaceGraph?.graph?.nodes?.length) { + return workspaceGraph.graph; + } + const nodes = tasks.map((task) => ({ + id: task.id, + type: "task", + label: task.title, + subLabel: `${task.status || "active"} · ${task.harness || "dhee"}`, + body: task.messages[task.messages.length - 1]?.content || "No activity yet.", + val: 8, + accent: TYPE_COLOR.task, + })); + return { nodes, links: [] }; + }, [tasks, workspaceGraph]); + const laidOut = useMemo(() => layoutGraph(rawGraph.nodes, rawGraph.links), [rawGraph]); + const nodeMap = useMemo(() => new Map(laidOut.nodes.map((node) => [node.id, node])), [laidOut.nodes]); + const nodeTypes = useMemo(() => { + const out = {}; + for (const n of laidOut.nodes) + out[n.id] = n.type; + return out; + }, [laidOut.nodes]); + const neighbours = useMemo(() => { + const map = new Map(); + for (const node of laidOut.nodes) + map.set(node.id, new Set()); + for (const link of laidOut.links) { + const src = linkEndpointId(link.source); + const tgt = linkEndpointId(link.target); + if (!src || !tgt) + continue; + map.get(src)?.add(tgt); + map.get(tgt)?.add(src); + } + return map; + }, [laidOut.nodes, laidOut.links]); + const presentTypes = useMemo(() => { + const seen = new Set(); + for (const node of laidOut.nodes) + seen.add(node.type); + return Array.from(seen); + }, [laidOut.nodes]); + const selectedNode = selection.nodeId ? nodeMap.get(selection.nodeId) || null : null; + const focused = hovered || selectedNode; + const focusedId = focused?.id || null; + const neighbourSet = useMemo(() => { + if (!focused) + return null; + const set = new Set([focused.id]); + for (const id of neighbours.get(focused.id) || []) + set.add(id); + return set; + }, [focused, neighbours]); + const { panX, panY, zoom, isPanning, spaceHeld, viewportRef, contentRef, handlers, actions, } = useInfiniteCanvas({ + contentBounds: laidOut.bounds, + }); + // Initial fit: centre the whole graph when data first arrives, and + // re-fit any time the graph materially changes shape. + const lastBoundsKey = useRef(""); + useEffect(() => { + const key = `${laidOut.nodes.length}:${Math.round(laidOut.bounds.minX)}:${Math.round(laidOut.bounds.maxX)}:${Math.round(laidOut.bounds.minY)}:${Math.round(laidOut.bounds.maxY)}`; + if (key === lastBoundsKey.current) + return; + lastBoundsKey.current = key; + if (!laidOut.nodes.length) + return; + const handle = window.setTimeout(() => { + const rects = laidOut.nodes.map((n) => ({ x: n.x, y: n.y, width: n.width, height: n.height })); + actions.fitToCards(rects, { maxZoom: 1, animate: true }); + }, 50); + return () => window.clearTimeout(handle); + }, [laidOut, actions]); + // When the inspector selection changes (via link clicks or similar), + // bring that card into view. + useEffect(() => { + if (!selection.nodeId) + return; + const node = nodeMap.get(selection.nodeId); + if (!node) + return; + const rect = { x: node.x, y: node.y, width: node.width, height: node.height }; + actions.fitToCards([rect], { maxZoom: 1.3, animate: true }); + }, [selection.nodeId, nodeMap, actions]); + // Direction hints: does content extend past the viewport edges right now? + const offscreen = useMemo(() => { + const vp = viewportRef.current; + if (!vp || laidOut.nodes.length === 0) + return { hasLeft: false, hasRight: false, hasUp: false, hasDown: false }; + const vRect = vp.getBoundingClientRect(); + // canvas-coord edges of visible region + const visLeft = -panX / zoom; + const visTop = -panY / zoom; + const visRight = visLeft + vRect.width / zoom; + const visBottom = visTop + vRect.height / zoom; + const { minX, minY, maxX, maxY } = laidOut.bounds; + return { + hasLeft: minX < visLeft - 20, + hasRight: maxX > visRight + 20, + hasUp: minY < visTop - 20, + hasDown: maxY > visBottom + 20, + }; + }, [laidOut, panX, panY, zoom, viewportRef]); + const panToDirection = (direction) => { + const vp = viewportRef.current; + if (!vp) + return; + const { minX, minY, maxX, maxY } = laidOut.bounds; + const vRect = vp.getBoundingClientRect(); + const vpW = vRect.width; + const vpH = vRect.height; + let targetPanX = panX; + let targetPanY = panY; + const PADDING = 40; + if (direction === "left") + targetPanX = -minX * zoom + PADDING; + if (direction === "right") + targetPanX = vpW - maxX * zoom - PADDING; + if (direction === "up") + targetPanY = -minY * zoom + PADDING; + if (direction === "down") + targetPanY = vpH - maxY * zoom - PADDING; + actions.animateTo({ panX: targetPanX, panY: targetPanY, zoom }); + }; + const handleSelect = (node) => { + setSelection({ nodeId: node.id, nodeType: node.type }); + // Delegate to the host when the node has a natural "open" action. + const meta = (node.meta || {}); + if (node.type === "workspace" && meta.workspaceId) { + onSelectWorkspace(String(meta.workspaceId)); + } + else if (node.type === "project" && meta.projectId) { + onSelectProject(String(meta.projectId), meta.workspaceId || undefined); + } + else if (node.type === "session") { + onSelectSession(node.id, meta.taskId || undefined); + } + else if (node.type === "task" && tasks.some((t) => t.id === node.id)) { + onSelectTask(node.id); + } + }; + const isLoading = workspaceGraph === undefined; + // Filtered visibility — fades the non-matching nodes instead of hiding + // them, so the overall structure stays legible. + const matchesFilter = (type) => !typeFilter || type === typeFilter; + const handleFit = () => { + const rects = laidOut.nodes.map((n) => ({ x: n.x, y: n.y, width: n.width, height: n.height })); + actions.fitToCards(rects, { maxZoom: 1, animate: true }); + }; + // "Tidy" re-applies the deterministic layout in place — gives the user + // an explicit affordance for snapping back to the canonical view after + // drags or scrolls. + const handleTidy = () => handleFit(); + return (_jsxs("div", { style: { height: "100%", display: "flex", flexDirection: "column" }, children: [_jsxs("div", { style: { + borderBottom: "1px solid var(--border)", + padding: "0 18px", + height: 48, + display: "flex", + alignItems: "center", + gap: 10, + flexShrink: 0, + }, children: [_jsxs("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink3)" }, children: [workspaceGraph?.workspace?.label || workspaceGraph?.workspace?.name || "workspace", selectedProjectId + ? ` / ${laidOut.nodes.find((node) => node.type === "project" && + String((node.meta || {}).projectId || "") === selectedProjectId)?.label || "project"}` + : ""] }), _jsxs("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink3)" }, children: [laidOut.nodes.length, " cards \u00B7 ", laidOut.links.length, " links"] }), workspaceGraph?.currentSessionId && _jsx(StatPill, { label: "live session", tone: "var(--green)" }), typeFilter && _jsx(StatPill, { label: `filtered · ${typeFilter}`, tone: TYPE_COLOR[typeFilter] }), _jsxs("div", { style: { marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }, children: [presentTypes.map((type) => (_jsxs("button", { onClick: () => setTypeFilter((curr) => (curr === type ? null : type)), style: { + display: "flex", + alignItems: "center", + gap: 5, + padding: "4px 8px", + border: `1px solid ${typeFilter === type ? TYPE_COLOR[type] || "var(--ink)" : "var(--border)"}`, + background: typeFilter === type ? `${TYPE_COLOR[type] || "#555"}14` : "white", + color: typeFilter === type ? TYPE_COLOR[type] || "var(--ink)" : "var(--ink2)", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.5, + textTransform: "uppercase", + cursor: "pointer", + }, children: [_jsx("span", { style: { + width: 7, + height: 7, + borderRadius: "50%", + background: TYPE_COLOR[type] || "#999", + } }), type] }, type))), _jsx("button", { onClick: onClose, style: { + padding: "6px 12px", + border: "1px solid var(--border)", + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink2)", + background: "white", + cursor: "pointer", + }, children: "exit" })] })] }), _jsxs("div", { style: { flex: 1, display: "grid", gridTemplateColumns: "minmax(0, 1fr) 340px", overflow: "hidden" }, children: [_jsxs("div", { ref: viewportRef, className: "dhee-canvas-bg", onMouseDown: handlers.onMouseDown, onMouseMove: handlers.onMouseMove, onMouseUp: handlers.onMouseUp, onClick: (e) => { + // Deselect when clicking on the background (not a card). + if (e.target === e.currentTarget || e.target.dataset.canvasBg) { + setSelection({}); + } + }, style: { + position: "relative", + overflow: "hidden", + cursor: spaceHeld ? (isPanning ? "grabbing" : "grab") : isPanning ? "grabbing" : "default", + }, children: [_jsxs("div", { ref: contentRef, "data-canvas-bg": "true", style: { + position: "absolute", + left: 0, + top: 0, + transformOrigin: "0 0", + transform: `translate3d(${panX}px, ${panY}px, 0) scale(${zoom})`, + willChange: "transform", + }, children: [laidOut.links.length > 0 ? (_jsx("svg", { style: { + position: "absolute", + left: laidOut.bounds.minX - 200, + top: laidOut.bounds.minY - 200, + width: laidOut.bounds.maxX - laidOut.bounds.minX + 400, + height: laidOut.bounds.maxY - laidOut.bounds.minY + 400, + pointerEvents: "none", + }, children: laidOut.links.map((link) => { + const src = linkEndpointId(link.source); + const tgt = linkEndpointId(link.target); + const srcNode = nodeMap.get(src); + const tgtNode = nodeMap.get(tgt); + if (!srcNode || !tgtNode) + return null; + const ox = laidOut.bounds.minX - 200; + const oy = laidOut.bounds.minY - 200; + const x1 = srcNode.x + srcNode.width / 2 - ox; + const y1 = srcNode.y + srcNode.height / 2 - oy; + const x2 = tgtNode.x + tgtNode.width / 2 - ox; + const y2 = tgtNode.y + tgtNode.height / 2 - oy; + // Cubic curve: vertical-biased bezier so parent→child lines + // flow top-down but stay visually soft. + const dx = x2 - x1; + const dy = y2 - y1; + const mid = Math.abs(dy) > Math.abs(dx) ? Math.abs(dy) * 0.45 : Math.abs(dx) * 0.3; + const c1x = x1; + const c1y = y1 + (dy > 0 ? mid : -mid); + const c2x = x2; + const c2y = y2 - (dy > 0 ? mid : -mid); + return (_jsx("path", { d: `M ${x1} ${y1} C ${c1x} ${c1y} ${c2x} ${c2y} ${x2} ${y2}`, className: edgeHighlightClass(link, focusedId, neighbourSet) }, link.id)); + }) })) : null, laidOut.nodes.map((node, idx) => { + const isFocus = focusedId === node.id; + const matches = matchesFilter(node.type); + const inNeighbourhood = neighbourSet ? neighbourSet.has(node.id) : true; + const dim = !matches || (neighbourSet ? !inNeighbourhood : false); + return (_jsx(NodeCard, { node: node, x: node.x, y: node.y, width: node.width, height: node.height, selected: isFocus, dim: dim, onSelect: handleSelect, onHover: setHovered, entranceDelay: Math.min(idx * 18, 540) }, node.id)); + })] }), isLoading && _jsx(CanvasSkeleton, {}), !isLoading && laidOut.nodes.length === 0 && (_jsx("div", { style: { + position: "absolute", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + pointerEvents: "none", + fontFamily: "var(--mono)", + fontSize: 11, + color: "var(--ink3)", + }, children: "no cards yet \u2014 launch a session to populate the canvas" })), _jsx(DirectionHints, { hasLeft: offscreen.hasLeft, hasRight: offscreen.hasRight, hasUp: offscreen.hasUp, hasDown: offscreen.hasDown, onPanTo: panToDirection }), _jsx(CanvasControls, { zoom: zoom, actions: actions, onFitToContent: handleFit, onTidy: handleTidy, minimapProps: { + panX, + panY, + zoom, + viewportRef, + cards: laidOut.nodes.map((n) => ({ + id: n.id, + x: n.x, + y: n.y, + width: n.width, + height: n.height, + })), + nodeTypes, + }, onMinimapPan: (nextPanX, nextPanY) => actions.setState({ panX: nextPanX, panY: nextPanY, zoom }) })] }), _jsxs("div", { style: { + borderLeft: "1px solid var(--border)", + background: "white", + padding: 20, + overflowY: "auto", + }, children: [_jsxs("div", { style: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 14, + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + letterSpacing: 0.5, + textTransform: "uppercase", + }, children: [_jsx("span", { children: "Inspector" }), isLoading ? _jsx("span", { children: "loading\u2026" }) : null] }), _jsx(NodeInspector, { node: focused, neighbourCount: focused ? neighbours.get(focused.id)?.size || 0 : 0, onOpenWorkspace: onSelectWorkspace, onOpenProject: onSelectProject, onOpenSession: onSelectSession, onOpenTask: onSelectTask })] })] })] })); +} diff --git a/dhee/ui/web/src/views/CanvasView.tsx b/dhee/ui/web/src/views/CanvasView.tsx new file mode 100644 index 0000000..dd4a469 --- /dev/null +++ b/dhee/ui/web/src/views/CanvasView.tsx @@ -0,0 +1,830 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { CanvasControls } from "../components/canvas/CanvasControls"; +import { CanvasSkeleton } from "../components/canvas/CanvasSkeleton"; +import { DirectionHints } from "../components/canvas/DirectionHints"; +import { NodeCard, TYPE_COLOR } from "../components/canvas/NodeCard"; +import { layoutGraph, type NodeLayout } from "../components/canvas/layout"; +import { useInfiniteCanvas } from "../components/canvas/useInfiniteCanvas"; +import { StatPill } from "../components/ui/StatPill"; +import { OrgCanvas } from "./OrgCanvas"; +import type { + CanvasSelectionState, + OrgGraphSnapshot, + SankhyaTask, + Tweaks, + Viewer, + WorkspaceGraphEdge, + WorkspaceGraphNode, + WorkspaceGraphSnapshot, +} from "../types"; + +// --------------------------------------------------------------------------- +// CanvasView — openswarm-inspired infinite canvas. Renders every graph +// node as a real DOM card laid out hierarchically (workspace → projects → +// children), with smooth pan/zoom/momentum, minimap, direction hints, +// skeleton loader, and an inspector that's neighbour-aware. +// --------------------------------------------------------------------------- + +const TYPE_LABEL: Record = { + workspace: "Workspace", + project: "Project", + channel: "Channel", + session: "Session", + task: "Task", + result: "Tool result", + file: "File", + asset: "Asset", + broadcast: "Broadcast", +}; + +function linkEndpointId(endpoint: unknown): string { + if (!endpoint) return ""; + if (typeof endpoint === "string") return endpoint; + if (typeof endpoint === "object" && endpoint !== null && "id" in endpoint) + return String((endpoint as { id?: unknown }).id || ""); + return ""; +} + +function edgeHighlightClass( + edge: WorkspaceGraphEdge, + focusedId: string | null, + neighbourSet: Set | null, +): string { + const src = linkEndpointId((edge as { source?: unknown }).source); + const tgt = linkEndpointId((edge as { target?: unknown }).target); + if (focusedId && (src === focusedId || tgt === focusedId)) + return "dhee-edge-path dhee-edge-path--highlight"; + if (neighbourSet && !(neighbourSet.has(src) && neighbourSet.has(tgt))) + return "dhee-edge-path dhee-edge-path--dim"; + return "dhee-edge-path"; +} + +function NodeInspector({ + node, + neighbourCount, + onOpenWorkspace, + onOpenProject, + onOpenSession, + onOpenTask, +}: { + node: WorkspaceGraphNode | null; + neighbourCount: number; + onOpenWorkspace: (id: string) => void; + onOpenProject: (projectId: string, workspaceId?: string) => void; + onOpenSession: (sessionId: string, taskId?: string | null) => void; + onOpenTask: (id: string) => void; +}) { + const [showMeta, setShowMeta] = useState(false); + if (!node) { + return ( + + Hover or click a card to inspect it. Drag the canvas with mouse or + space-bar. ⌘/Ctrl + wheel to zoom. ++ ); + } + + const meta = (node.meta || {}) as Record; + const color = node.accent || TYPE_COLOR[node.type] || "#555"; + const buttonStyle: React.CSSProperties = { + padding: "8px 12px", + border: "1px solid var(--ink)", + background: "var(--ink)", + color: "white", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + textTransform: "uppercase", + cursor: "pointer", + }; + + const kv = (key: string, value: unknown) => { + if (value === undefined || value === null || value === "") return null; + return ( + + + {key} + + {String(value)} ++ ); + }; + + const typeLabel = TYPE_LABEL[node.type] || node.type; + + return ( +++ ); +} + +interface CanvasViewProps { + tasks: SankhyaTask[]; + selectedProjectId?: string; + workspaceGraph?: WorkspaceGraphSnapshot | null; + orgGraph?: OrgGraphSnapshot | null; + viewer?: Viewer | null; + onOpenVault?: (teamId?: string) => void; + onOrgGraphChanged?: () => void; + onSelectTask: (id: string) => void; + onSelectSession: (sessionId: string, taskId?: string | null) => void; + onSelectWorkspace: (workspaceId: string) => void; + onSelectProject: (projectId: string, workspaceId?: string | null) => void; + onClose: () => void; + tweaks: Tweaks; +} + +const EMPTY_LOCAL_GRAPH: OrgGraphSnapshot = { + live: false, + org_id: "local", + nodes: [], + edges: [], + totals: { + projects: 0, + teams: 0, + repos: 0, + context_items: 0, + pending_proposals: 0, + folders: 0, + sessions: 0, + shared_folders: 0, + }, + raw: { + mode: "local_context", + folders: [], + sessions: [], + shared_folder_paths: [], + context_index: [], + pending_proposals: [], + context_managers_by_team: {}, + }, +}; + +function folderGraph(graph?: OrgGraphSnapshot | null): OrgGraphSnapshot { + if (!graph) return EMPTY_LOCAL_GRAPH; + return { + ...EMPTY_LOCAL_GRAPH, + ...graph, + org_id: graph.org_id || "local", + nodes: graph.nodes || [], + edges: graph.edges || [], + totals: { + ...EMPTY_LOCAL_GRAPH.totals, + ...(graph.totals || {}), + }, + raw: { + ...EMPTY_LOCAL_GRAPH.raw!, + ...(graph.raw || {}), + mode: graph.raw?.mode || "local_context", + }, + }; +} + +export function CanvasView(props: CanvasViewProps) { + return ( +++ + {node.body ? ( ++ +++ {node.status ? : null} + + {neighbourCount} connection{neighbourCount === 1 ? "" : "s"} + + + {node.label || "(unnamed)"} ++ {node.subLabel ? ( ++ {node.subLabel} ++ ) : null} ++ {node.body} ++ ) : null} + + {node.type === "workspace" && ( ++ {kv("root", meta.rootPath)} + {kv("projects", meta.projectCount)} + {kv("sessions", meta.sessionCount)} ++ )} + {node.type === "project" && ( ++ {kv("workspace", meta.workspaceLabel)} + {kv("runtime", meta.defaultRuntime)} + {kv("sessions", meta.sessionCount)} + {kv("tasks", meta.taskCount)} ++ )} + {node.type === "session" && ( ++ {kv("runtime", meta.runtime)} + {kv("model", meta.model)} + {kv("state", meta.state)} + {kv("updated", meta.updatedAt)} ++ )} + {node.type === "task" && ( ++ {kv("harness", meta.harness)} + {kv("status", meta.status)} + {kv("messages", meta.messageCount)} ++ )} + {node.type === "result" && ( ++ {kv("tool", meta.toolName)} + {kv("packet", meta.packetKind)} + {kv("ptr", meta.ptr)} + {kv("harness", meta.harness)} + {kv("source", meta.sourcePath)} ++ )} + {node.type === "broadcast" && ( ++ {kv("from", meta.sourceProject || meta.sourceChannel)} + {kv("to", meta.targetProject || meta.targetChannel)} + {kv("kind", meta.messageKind)} ++ )} + ++ {node.type === "workspace" && Boolean(meta.workspaceId) && ( + + )} + {node.type === "project" && Boolean(meta.projectId) && ( + + )} + {node.type === "session" && ( + + )} + {node.type === "task" && ( + + )} ++ + {Object.keys(meta).length > 0 && ( ++ + {showMeta && ( ++ )} ++ {JSON.stringify(meta, null, 2)} ++ )} +{})} + onOpenSession={props.onSelectSession} + onChanged={props.onOrgGraphChanged || (() => {})} + /> + ); +} + +function WorkspaceCanvasView({ + tasks, + selectedProjectId, + workspaceGraph, + onSelectTask, + onSelectSession, + onSelectWorkspace, + onSelectProject, + onClose, +}: CanvasViewProps) { + const [selection, setSelection] = useState ({}); + const [hovered, setHovered] = useState (null); + const [typeFilter, setTypeFilter] = useState (null); + + // Fallback: if the server hasn't produced a graph, show each task as a + // standalone card. Keeps the canvas useful before any agent session + // exists in the workspace. + const rawGraph = useMemo(() => { + if (workspaceGraph?.graph?.nodes?.length) { + return workspaceGraph.graph; + } + const nodes: WorkspaceGraphNode[] = tasks.map((task) => ({ + id: task.id, + type: "task", + label: task.title, + subLabel: `${task.status || "active"} · ${task.harness || "dhee"}`, + body: task.messages[task.messages.length - 1]?.content || "No activity yet.", + val: 8, + accent: TYPE_COLOR.task, + })); + return { nodes, links: [] as WorkspaceGraphEdge[] }; + }, [tasks, workspaceGraph]); + + const laidOut = useMemo(() => layoutGraph(rawGraph.nodes, rawGraph.links), [rawGraph]); + + const nodeMap = useMemo( + () => new Map(laidOut.nodes.map((node) => [node.id, node])), + [laidOut.nodes], + ); + + const nodeTypes = useMemo(() => { + const out: Record = {}; + for (const n of laidOut.nodes) out[n.id] = n.type; + return out; + }, [laidOut.nodes]); + + const neighbours = useMemo(() => { + const map = new Map >(); + for (const node of laidOut.nodes) map.set(node.id, new Set()); + for (const link of laidOut.links) { + const src = linkEndpointId((link as { source?: unknown }).source); + const tgt = linkEndpointId((link as { target?: unknown }).target); + if (!src || !tgt) continue; + map.get(src)?.add(tgt); + map.get(tgt)?.add(src); + } + return map; + }, [laidOut.nodes, laidOut.links]); + + const presentTypes = useMemo(() => { + const seen = new Set (); + for (const node of laidOut.nodes) seen.add(node.type); + return Array.from(seen); + }, [laidOut.nodes]); + + const selectedNode = selection.nodeId ? nodeMap.get(selection.nodeId) || null : null; + const focused = hovered || selectedNode; + const focusedId = focused?.id || null; + + const neighbourSet = useMemo(() => { + if (!focused) return null; + const set = new Set ([focused.id]); + for (const id of neighbours.get(focused.id) || []) set.add(id); + return set; + }, [focused, neighbours]); + + const { + panX, + panY, + zoom, + isPanning, + spaceHeld, + viewportRef, + contentRef, + handlers, + actions, + } = useInfiniteCanvas({ + contentBounds: laidOut.bounds, + }); + + // Initial fit: centre the whole graph when data first arrives, and + // re-fit any time the graph materially changes shape. + const lastBoundsKey = useRef(""); + useEffect(() => { + const key = `${laidOut.nodes.length}:${Math.round(laidOut.bounds.minX)}:${Math.round(laidOut.bounds.maxX)}:${Math.round(laidOut.bounds.minY)}:${Math.round(laidOut.bounds.maxY)}`; + if (key === lastBoundsKey.current) return; + lastBoundsKey.current = key; + if (!laidOut.nodes.length) return; + const handle = window.setTimeout(() => { + const rects = laidOut.nodes.map((n) => ({ x: n.x, y: n.y, width: n.width, height: n.height })); + actions.fitToCards(rects, { maxZoom: 1, animate: true }); + }, 50); + return () => window.clearTimeout(handle); + }, [laidOut, actions]); + + // When the inspector selection changes (via link clicks or similar), + // bring that card into view. + useEffect(() => { + if (!selection.nodeId) return; + const node = nodeMap.get(selection.nodeId); + if (!node) return; + const rect = { x: node.x, y: node.y, width: node.width, height: node.height }; + actions.fitToCards([rect], { maxZoom: 1.3, animate: true }); + }, [selection.nodeId, nodeMap, actions]); + + // Direction hints: does content extend past the viewport edges right now? + const offscreen = useMemo(() => { + const vp = viewportRef.current; + if (!vp || laidOut.nodes.length === 0) + return { hasLeft: false, hasRight: false, hasUp: false, hasDown: false }; + const vRect = vp.getBoundingClientRect(); + // canvas-coord edges of visible region + const visLeft = -panX / zoom; + const visTop = -panY / zoom; + const visRight = visLeft + vRect.width / zoom; + const visBottom = visTop + vRect.height / zoom; + const { minX, minY, maxX, maxY } = laidOut.bounds; + return { + hasLeft: minX < visLeft - 20, + hasRight: maxX > visRight + 20, + hasUp: minY < visTop - 20, + hasDown: maxY > visBottom + 20, + }; + }, [laidOut, panX, panY, zoom, viewportRef]); + + const panToDirection = (direction: "left" | "right" | "up" | "down") => { + const vp = viewportRef.current; + if (!vp) return; + const { minX, minY, maxX, maxY } = laidOut.bounds; + const vRect = vp.getBoundingClientRect(); + const vpW = vRect.width; + const vpH = vRect.height; + let targetPanX = panX; + let targetPanY = panY; + const PADDING = 40; + if (direction === "left") targetPanX = -minX * zoom + PADDING; + if (direction === "right") targetPanX = vpW - maxX * zoom - PADDING; + if (direction === "up") targetPanY = -minY * zoom + PADDING; + if (direction === "down") targetPanY = vpH - maxY * zoom - PADDING; + actions.animateTo({ panX: targetPanX, panY: targetPanY, zoom }); + }; + + const handleSelect = (node: WorkspaceGraphNode) => { + setSelection({ nodeId: node.id, nodeType: node.type }); + // Delegate to the host when the node has a natural "open" action. + const meta = (node.meta || {}) as Record ; + if (node.type === "workspace" && meta.workspaceId) { + onSelectWorkspace(String(meta.workspaceId)); + } else if (node.type === "project" && meta.projectId) { + onSelectProject(String(meta.projectId), (meta.workspaceId as string) || undefined); + } else if (node.type === "session") { + onSelectSession(node.id, (meta.taskId as string) || undefined); + } else if (node.type === "task" && tasks.some((t) => t.id === node.id)) { + onSelectTask(node.id); + } + }; + + const isLoading = workspaceGraph === undefined; + + // Filtered visibility — fades the non-matching nodes instead of hiding + // them, so the overall structure stays legible. + const matchesFilter = (type: string) => !typeFilter || type === typeFilter; + + const handleFit = () => { + const rects = laidOut.nodes.map((n) => ({ x: n.x, y: n.y, width: n.width, height: n.height })); + actions.fitToCards(rects, { maxZoom: 1, animate: true }); + }; + + // "Tidy" re-applies the deterministic layout in place — gives the user + // an explicit affordance for snapping back to the canonical view after + // drags or scrolls. + const handleTidy = () => handleFit(); + + return ( + + {/* Header */} ++ ); +} diff --git a/dhee/ui/web/src/views/ChannelView.js b/dhee/ui/web/src/views/ChannelView.js new file mode 100644 index 0000000..df1203b --- /dev/null +++ b/dhee/ui/web/src/views/ChannelView.js @@ -0,0 +1,385 @@ +import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; +import { useEffect, useMemo, useState } from "react"; +import { api } from "../api"; +import { AssetDrawer } from "../components/AssetDrawer"; +import { ConnectedAgents, LineComposer, LineMessageCard, useWorkspaceLine, } from "../components/LinePanel"; +import { StatPill } from "../components/ui/StatPill"; +// --------------------------------------------------------------------------- +// ChannelView — the landing page and the *entire product surface* per the +// pitch deck: a single shared information line that every agent in the +// workspace broadcasts into and reads from. +// +// Layout (three columns on wide viewports, stack on narrow): +// - Left: workspace + project tree, connected-agents block +// - Center: live SSE stream of line messages, newest first +// - Right: composer with target-project picker, suggested-task list +// --------------------------------------------------------------------------- +function countSuggestedTasks(workspace, tasks) { + if (!workspace) + return []; + const workspaceProjectIds = new Set(workspace.projects.map((project) => project.id)); + return tasks.filter((task) => { + const source = String(task.source || "").toLowerCase(); + if (source !== "broadcast" && !source.includes("suggested")) + return false; + const projectId = task.project_id; + return !projectId || workspaceProjectIds.has(String(projectId)); + }); +} +export function ChannelView({ projectIndex, workspaceGraph, tasks, viewer, orgGraph, selectedWorkspaceId, selectedProjectId, onSelectWorkspace, onSelectProject, onSelectTask, onTasksRefresh, onOpenCanvas, onLaunchSession, onOpenManager, }) { + const workspaces = projectIndex?.workspaces || []; + const [continuity, setContinuity] = useState(null); + const scopedContext = useMemo(() => { + const all = orgGraph?.raw?.context_index || []; + const teamId = viewer?.team_id || ""; + const projectId = viewer?.project_id || ""; + return all.filter((item) => { + if (item.scope === "company") + return true; + if (teamId && item.team_id === teamId) + return true; + if (projectId && item.project_id === projectId) + return true; + if (item.scope === "user" && item.user_id === viewer?.user_id) + return true; + return false; + }); + }, [orgGraph?.raw?.context_index, viewer?.project_id, viewer?.team_id, viewer?.user_id]); + const pendingForViewer = useMemo(() => { + const proposals = orgGraph?.raw?.pending_proposals || []; + const teamId = viewer?.team_id || ""; + if (!teamId) + return proposals; + return proposals.filter((item) => !item.team_id || item.team_id === teamId); + }, [orgGraph?.raw?.pending_proposals, viewer?.team_id]); + // Local state for kind filters so operators can scope the feed. + const [kindFilter, setKindFilter] = useState("all"); + const currentWorkspace = useMemo(() => { + return (workspaces.find((workspace) => workspace.id === selectedWorkspaceId) || + workspaces.find((workspace) => workspace.id === projectIndex?.currentWorkspaceId) || + workspaces[0] || + workspaceGraph?.workspace || + null); + }, [workspaces, selectedWorkspaceId, projectIndex?.currentWorkspaceId, workspaceGraph]); + const currentProject = useMemo(() => { + if (!currentWorkspace) + return null; + return (currentWorkspace.projects.find((project) => project.id === selectedProjectId) || + currentWorkspace.projects.find((project) => project.id === projectIndex?.currentProjectId) || + null); + }, [currentWorkspace, selectedProjectId, projectIndex?.currentProjectId]); + const workspaceSessions = currentWorkspace?.sessions || []; + const activeSession = useMemo(() => { + const currentId = projectIndex?.currentSessionId; + if (!currentWorkspace) + return null; + const inProject = currentProject?.sessions?.find((session) => session.id === currentId) || + currentProject?.sessions?.[0]; + if (inProject) + return inProject; + return (workspaceSessions.find((session) => session.id === currentId) || workspaceSessions[0] || null); + }, [currentWorkspace, currentProject, projectIndex?.currentSessionId, workspaceSessions]); + const { messages, live, error, refresh } = useWorkspaceLine(currentWorkspace?.id, currentProject?.id); + useEffect(() => { + let mounted = true; + api + .continuity() + .then((snapshot) => { + if (mounted) + setContinuity(snapshot); + }) + .catch(() => { + if (mounted) + setContinuity(null); + }); + return () => { + mounted = false; + }; + }, [viewer?.org_id, viewer?.team_id]); + // Apply the kind filter after merge so user-facing counts are honest. + const filtered = useMemo(() => { + if (kindFilter === "all") + return messages; + return messages.filter((message) => { + const kind = String(message.message_kind || "").toLowerCase(); + if (kindFilter === "broadcast") + return kind === "broadcast"; + if (kindFilter === "tool") + return kind.startsWith("tool."); + if (kindFilter === "note") + return kind === "note" || kind === "update"; + return true; + }); + }, [messages, kindFilter]); + const suggestedTasks = useMemo(() => countSuggestedTasks(currentWorkspace, tasks), [currentWorkspace, tasks]); + // Auto-scroll the feed to top when a new message arrives (new messages + // are at the head of the sorted list). + const [latestId, setLatestId] = useState(null); + useEffect(() => { + const head = messages[0]?.id; + if (head && head !== latestId) + setLatestId(head); + }, [messages, latestId]); + const chipButton = (active) => ({ + padding: "5px 10px", + border: `1px solid ${active ? "var(--ink)" : "var(--border)"}`, + background: active ? "var(--ink)" : "white", + color: active ? "white" : "var(--ink2)", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.5, + textTransform: "uppercase", + cursor: "pointer", + }); + const navChipStyle = (active) => ({ + width: "100%", + textAlign: "left", + padding: "9px 10px", + border: `1px solid ${active ? "var(--accent)" : "var(--border)"}`, + background: active ? "var(--surface)" : "white", + fontFamily: active ? "var(--sans)" : "var(--mono)", + fontSize: active ? 12 : 11, + color: "var(--ink)", + cursor: "pointer", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + }); + return (_jsxs("div", { style: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }, children: [_jsxs("div", { style: { + height: 48, + borderBottom: "1px solid var(--border)", + padding: "0 20px", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + flexShrink: 0, + }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 10, minWidth: 0 }, children: [_jsxs("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink3)" }, children: [currentWorkspace?.label || currentWorkspace?.name || "channel", currentProject ? ` / ${currentProject.name}` : ""] }), _jsx(StatPill, { label: live ? "live" : "offline", tone: live ? "var(--green)" : "var(--ink3)" }), _jsxs("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink3)" }, children: [filtered.length, " events \u00B7 ", suggestedTasks.length, " suggested tasks"] }), _jsx(StatPill, { label: `${viewer?.role || "developer"} context`, tone: viewer?.role === "manager" || viewer?.role === "admin" ? "var(--accent)" : "var(--indigo)" })] }), _jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [_jsx("button", { onClick: onOpenCanvas, style: { + padding: "6px 12px", + border: "1px solid var(--border)", + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink2)", + background: "white", + cursor: "pointer", + letterSpacing: 0.4, + }, children: "open canvas" }), _jsx("button", { onClick: () => void refresh(), style: { + padding: "6px 12px", + border: "1px solid var(--border)", + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink2)", + background: "white", + cursor: "pointer", + letterSpacing: 0.4, + }, children: "refresh" })] })] }), _jsxs("div", { style: { + flex: 1, + display: "grid", + gridTemplateColumns: "260px minmax(0, 1fr) 360px", + overflow: "hidden", + }, children: [_jsxs("div", { style: { + borderRight: "1px solid var(--border)", + padding: 16, + overflowY: "auto", + display: "flex", + flexDirection: "column", + gap: 14, + }, children: [_jsxs("div", { style: { + padding: 12, + border: "1px solid var(--border)", + background: "var(--surface)", + display: "grid", + gap: 8, + }, children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: "Context scope" }), _jsx("div", { style: { fontSize: 13, color: "var(--ink)" }, children: viewer?.team_id || viewer?.project_id || viewer?.org_id || "local dhee" }), _jsxs("div", { style: { + display: "flex", + flexWrap: "wrap", + gap: 6, + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + }, children: [_jsxs("span", { children: [scopedContext.length, " context items"] }), _jsx("span", { children: "\u00B7" }), _jsxs("span", { children: [pendingForViewer.length, " pending"] })] }), _jsx("button", { onClick: onOpenCanvas, style: { + padding: "6px 8px", + border: "1px solid var(--accent)", + background: "var(--accent-dim)", + color: "var(--accent)", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.5, + cursor: "pointer", + }, children: "OPEN ORG MAP" })] }), _jsxs("div", { style: { + padding: 12, + border: "1px solid var(--border)", + background: "white", + display: "grid", + gap: 8, + }, children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: "Continue context" }), _jsx("div", { style: { fontSize: 12, color: "var(--ink2)", lineHeight: 1.45 }, children: continuity?.last_session?.task_summary || + continuity?.claude_sessions?.[0]?.preview || + "No Claude session recovered yet for this repo." }), _jsxs("div", { style: { + display: "flex", + gap: 6, + flexWrap: "wrap", + fontFamily: "var(--mono)", + fontSize: 9, + color: "var(--ink3)", + }, children: [_jsx("span", { children: continuity?.last_session?.source || "waiting" }), _jsx("span", { children: "\u00B7" }), _jsxs("span", { children: [continuity?.claude_sessions?.length || 0, " claude sessions"] })] })] }), _jsxs("div", { children: [_jsxs("div", { style: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + gap: 8, + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: "Workspace" }), onOpenManager ? (_jsx("button", { onClick: () => onOpenManager("workspaces"), title: "Manage workspaces", style: { + padding: "3px 7px", + border: "1px solid var(--border)", + background: "white", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.4, + color: "var(--ink3)", + cursor: "pointer", + }, children: "+ new / manage" })) : null] }), workspaces.length === 0 ? (_jsx("button", { onClick: () => onOpenManager?.("workspaces"), style: { + width: "100%", + padding: "10px 12px", + border: "1px dashed var(--border)", + background: "white", + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink2)", + textAlign: "left", + cursor: "pointer", + lineHeight: 1.5, + }, children: "Create your first workspace \u2192 e.g. Office, Personal, Sankhya AI Labs." })) : (_jsx("select", { value: currentWorkspace?.id || "", onChange: (e) => onSelectWorkspace(e.target.value), style: { + width: "100%", + padding: "9px 10px", + border: "1px solid var(--border)", + background: "white", + fontSize: 12, + }, children: workspaces.map((workspace) => (_jsx("option", { value: workspace.id, children: workspace.label || workspace.name }, workspace.id))) }))] }), _jsxs("div", { children: [_jsxs("div", { style: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + gap: 8, + }, children: [_jsxs("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: ["Projects \u00B7 ", currentWorkspace?.projects?.length || 0] }), onOpenManager && currentWorkspace ? (_jsx("button", { onClick: () => onOpenManager("projects"), title: "Add or edit projects", style: { + padding: "3px 7px", + border: "1px solid var(--border)", + background: "white", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.4, + color: "var(--ink3)", + cursor: "pointer", + }, children: "+ project" })) : null] }), _jsxs("div", { style: { display: "grid", gap: 6 }, children: [_jsxs("button", { onClick: () => onSelectProject("", currentWorkspace?.id), style: navChipStyle(!currentProject), children: [_jsx("span", { children: "All projects (workspace line)" }), _jsx("span", { style: { fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink3)" }, children: workspaceSessions.length })] }), (currentWorkspace?.projects || []).map((project) => { + const active = project.id === currentProject?.id; + const sessionCount = project.sessions?.length || 0; + return (_jsxs("button", { onClick: () => onSelectProject(project.id, currentWorkspace?.id), style: navChipStyle(active), children: [_jsx("span", { children: project.name }), _jsx("span", { style: { fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink3)" }, children: sessionCount })] }, project.id)); + })] })] }), _jsx(ConnectedAgents, { workspace: currentWorkspace, projects: currentWorkspace?.projects || [], workspaceSessions: workspaceSessions }), currentWorkspace && (_jsxs("div", { style: { + display: "flex", + flexDirection: "column", + gap: 6, + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: "Launch" }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [_jsx("button", { onClick: () => void onLaunchSession("channel session", "codex", currentWorkspace.id, undefined, currentProject?.id), style: chipButton(false), children: "+ codex" }), _jsx("button", { onClick: () => void onLaunchSession("channel session", "claude-code", currentWorkspace.id, "standard", currentProject?.id), style: chipButton(false), children: "+ claude" })] })] }))] }), _jsxs("div", { style: { + display: "flex", + flexDirection: "column", + overflow: "hidden", + background: "var(--bg)", + }, children: [_jsxs("div", { style: { + padding: "12px 20px", + borderBottom: "1px solid var(--border)", + display: "flex", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + }, children: [_jsx("span", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: "Shared line" }), _jsx("button", { onClick: () => setKindFilter("all"), style: chipButton(kindFilter === "all"), children: "all" }), _jsx("button", { onClick: () => setKindFilter("broadcast"), style: chipButton(kindFilter === "broadcast"), children: "broadcasts" }), _jsx("button", { onClick: () => setKindFilter("tool"), style: chipButton(kindFilter === "tool"), children: "tool events" }), _jsx("button", { onClick: () => setKindFilter("note"), style: chipButton(kindFilter === "note"), children: "notes" }), _jsx("span", { style: { flex: 1 } }), error ? (_jsx("span", { style: { fontFamily: "var(--mono)", fontSize: 9, color: "var(--rose)" }, children: error })) : null] }), _jsx("div", { style: { + flex: 1, + overflowY: "auto", + padding: 20, + display: "flex", + flexDirection: "column", + gap: 10, + }, children: filtered.length === 0 ? (_jsxs("div", { style: { + padding: 24, + border: "1px dashed var(--border)", + background: "white", + textAlign: "center", + }, children: [_jsx("div", { style: { fontSize: 13, fontWeight: 600, marginBottom: 6 }, children: "The line is quiet." }), _jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + lineHeight: 1.55, + }, children: "Every agent tool-call in this workspace will appear here. Launch a session from the left rail, or broadcast a note from the composer to get started." })] })) : (filtered.map((message) => (_jsx(LineMessageCard, { message: message, workspace: currentWorkspace, onOpenTask: onSelectTask }, message.id)))) })] }), _jsxs("div", { style: { + borderLeft: "1px solid var(--border)", + padding: 16, + overflowY: "auto", + display: "flex", + flexDirection: "column", + gap: 14, + }, children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + color: "var(--ink3)", + textTransform: "uppercase", + }, children: "Broadcast" }), _jsx(LineComposer, { workspace: currentWorkspace, activeProjectId: currentProject?.id, sessionId: activeSession?.id, onPublished: async () => { + await onTasksRefresh(); + void refresh(); + } }), _jsx(AssetDrawer, { workspace: currentWorkspace, project: currentProject, onActivity: () => void refresh() }), _jsxs("div", { style: { + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.6, + color: "var(--ink3)", + textTransform: "uppercase", + marginTop: 2, + }, children: ["Suggested tasks \u00B7 ", suggestedTasks.length] }), _jsx("div", { style: { display: "grid", gap: 8 }, children: suggestedTasks.length === 0 ? (_jsx("div", { style: { + padding: 12, + border: "1px dashed var(--border)", + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + lineHeight: 1.55, + background: "white", + }, children: "When an agent broadcasts to another project, a task is auto-created there. It will show up here." })) : (suggestedTasks.slice(0, 10).map((task) => (_jsxs("button", { onClick: () => onSelectTask(task.id), style: { + textAlign: "left", + padding: "10px 12px", + border: "1px solid var(--border)", + background: "white", + cursor: "pointer", + display: "flex", + flexDirection: "column", + gap: 6, + }, children: [_jsx("div", { style: { fontSize: 12, fontWeight: 600, lineHeight: 1.35 }, children: task.title }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [_jsx(StatPill, { label: task.status || "active", tone: "var(--accent)" }), task.harness ? _jsx(StatPill, { label: String(task.harness) }) : null] })] }, task.id)))) })] })] })] })); +} diff --git a/dhee/ui/web/src/views/ChannelView.tsx b/dhee/ui/web/src/views/ChannelView.tsx new file mode 100644 index 0000000..8812f37 --- /dev/null +++ b/dhee/ui/web/src/views/ChannelView.tsx @@ -0,0 +1,794 @@ +import { useEffect, useMemo, useState } from "react"; +import { api } from "../api"; +import { AssetDrawer } from "../components/AssetDrawer"; +import { + ConnectedAgents, + LineComposer, + LineMessageCard, + useWorkspaceLine, +} from "../components/LinePanel"; +import { StatPill } from "../components/ui/StatPill"; +import type { + ContinuitySnapshot, + OrgGraphSnapshot, + ProjectIndexSnapshot, + ProjectSummary, + SankhyaTask, + Tweaks, + Viewer, + WorkspaceGraphSnapshot, + WorkspaceSummary, +} from "../types"; + +// --------------------------------------------------------------------------- +// ChannelView — the landing page and the *entire product surface* per the +// pitch deck: a single shared information line that every agent in the +// workspace broadcasts into and reads from. +// +// Layout (three columns on wide viewports, stack on narrow): +// - Left: workspace + project tree, connected-agents block +// - Center: live SSE stream of line messages, newest first +// - Right: composer with target-project picker, suggested-task list +// --------------------------------------------------------------------------- + +function countSuggestedTasks( + workspace: WorkspaceSummary | null, + tasks: SankhyaTask[], +): SankhyaTask[] { + if (!workspace) return []; + const workspaceProjectIds = new Set(workspace.projects.map((project) => project.id)); + return tasks.filter((task) => { + const source = String(task.source || "").toLowerCase(); + if (source !== "broadcast" && !source.includes("suggested")) return false; + const projectId = (task as unknown as { project_id?: string }).project_id; + return !projectId || workspaceProjectIds.has(String(projectId)); + }); +} + +export function ChannelView({ + projectIndex, + workspaceGraph, + tasks, + viewer, + orgGraph, + selectedWorkspaceId, + selectedProjectId, + onSelectWorkspace, + onSelectProject, + onSelectTask, + onTasksRefresh, + onOpenCanvas, + onLaunchSession, + onOpenManager, +}: { + projectIndex?: ProjectIndexSnapshot | null; + workspaceGraph?: WorkspaceGraphSnapshot | null; + tasks: SankhyaTask[]; + viewer?: Viewer | null; + orgGraph?: OrgGraphSnapshot | null; + selectedWorkspaceId: string; + selectedProjectId: string; + onSelectWorkspace: (workspaceId: string) => void; + onSelectProject: (projectId: string, workspaceId?: string | null) => void; + onSelectTask: (taskId: string) => void; + onTasksRefresh: () => Promise+ + {workspaceGraph?.workspace?.label || workspaceGraph?.workspace?.name || "workspace"} + {selectedProjectId + ? ` / ${ + laidOut.nodes.find( + (node) => + node.type === "project" && + String((node.meta || {}).projectId || "") === selectedProjectId, + )?.label || "project" + }` + : ""} + + + {laidOut.nodes.length} cards · {laidOut.links.length} links + + {workspaceGraph?.currentSessionId &&+ + {/* Body */} +} + {typeFilter && } + + {presentTypes.map((type) => ( + + ))} + +++ {/* Canvas viewport */} ++{ + // Deselect when clicking on the background (not a card). + if (e.target === e.currentTarget || (e.target as HTMLElement).dataset.canvasBg) { + setSelection({}); + } + }} + style={{ + position: "relative", + overflow: "hidden", + cursor: spaceHeld ? (isPanning ? "grabbing" : "grab") : isPanning ? "grabbing" : "default", + }} + > + {/* The transformed content plane. Contains the SVG edges + the + card layer. Panning/zooming is applied here via one matrix. */} ++ + {/* Inspector */} ++ {/* Edges layer */} + {laidOut.links.length > 0 ? ( + + ) : null} + + {/* Card layer */} + {laidOut.nodes.map((node, idx) => { + const isFocus = focusedId === node.id; + const matches = matchesFilter(node.type); + const inNeighbourhood = neighbourSet ? neighbourSet.has(node.id) : true; + const dim = !matches || (neighbourSet ? !inNeighbourhood : false); + return ( ++ + {isLoading &&+ ); + })} + } + {!isLoading && laidOut.nodes.length === 0 && ( + + no cards yet — launch a session to populate the canvas ++ )} + ++ + ((n) => ({ + id: n.id, + x: n.x, + y: n.y, + width: n.width, + height: n.height, + })), + nodeTypes, + }} + onMinimapPan={(nextPanX, nextPanY) => + actions.setState({ panX: nextPanX, panY: nextPanY, zoom }) + } + /> + +++ Inspector + {isLoading ? loading… : null} +++ | void; + onOpenCanvas: () => void; + onLaunchSession: ( + title: string, + runtime: "claude-code" | "codex", + workspaceId?: string, + permissionMode?: "standard" | "full-access", + projectId?: string, + ) => Promise | void; + onOpenManager?: (tab?: "workspaces" | "projects") => void; + tweaks: Tweaks; +}) { + const workspaces = projectIndex?.workspaces || []; + const [continuity, setContinuity] = useState (null); + const scopedContext = useMemo(() => { + const all = orgGraph?.raw?.context_index || []; + const teamId = viewer?.team_id || ""; + const projectId = viewer?.project_id || ""; + return all.filter((item) => { + if (item.scope === "company") return true; + if (teamId && item.team_id === teamId) return true; + if (projectId && item.project_id === projectId) return true; + if (item.scope === "user" && item.user_id === viewer?.user_id) return true; + return false; + }); + }, [orgGraph?.raw?.context_index, viewer?.project_id, viewer?.team_id, viewer?.user_id]); + const pendingForViewer = useMemo(() => { + const proposals = orgGraph?.raw?.pending_proposals || []; + const teamId = viewer?.team_id || ""; + if (!teamId) return proposals; + return proposals.filter((item) => !item.team_id || item.team_id === teamId); + }, [orgGraph?.raw?.pending_proposals, viewer?.team_id]); + + // Local state for kind filters so operators can scope the feed. + const [kindFilter, setKindFilter] = useState<"all" | "broadcast" | "tool" | "note">("all"); + + const currentWorkspace: WorkspaceSummary | null = useMemo(() => { + return ( + workspaces.find((workspace) => workspace.id === selectedWorkspaceId) || + workspaces.find((workspace) => workspace.id === projectIndex?.currentWorkspaceId) || + workspaces[0] || + workspaceGraph?.workspace || + null + ); + }, [workspaces, selectedWorkspaceId, projectIndex?.currentWorkspaceId, workspaceGraph]); + + const currentProject: ProjectSummary | null = useMemo(() => { + if (!currentWorkspace) return null; + return ( + currentWorkspace.projects.find((project) => project.id === selectedProjectId) || + currentWorkspace.projects.find((project) => project.id === projectIndex?.currentProjectId) || + null + ); + }, [currentWorkspace, selectedProjectId, projectIndex?.currentProjectId]); + + const workspaceSessions = currentWorkspace?.sessions || []; + const activeSession = useMemo(() => { + const currentId = projectIndex?.currentSessionId; + if (!currentWorkspace) return null; + const inProject = currentProject?.sessions?.find((session) => session.id === currentId) || + currentProject?.sessions?.[0]; + if (inProject) return inProject; + return ( + workspaceSessions.find((session) => session.id === currentId) || workspaceSessions[0] || null + ); + }, [currentWorkspace, currentProject, projectIndex?.currentSessionId, workspaceSessions]); + + const { messages, live, error, refresh } = useWorkspaceLine( + currentWorkspace?.id, + currentProject?.id, + ); + + useEffect(() => { + let mounted = true; + api + .continuity() + .then((snapshot) => { + if (mounted) setContinuity(snapshot); + }) + .catch(() => { + if (mounted) setContinuity(null); + }); + return () => { + mounted = false; + }; + }, [viewer?.org_id, viewer?.team_id]); + + // Apply the kind filter after merge so user-facing counts are honest. + const filtered = useMemo(() => { + if (kindFilter === "all") return messages; + return messages.filter((message) => { + const kind = String(message.message_kind || "").toLowerCase(); + if (kindFilter === "broadcast") return kind === "broadcast"; + if (kindFilter === "tool") return kind.startsWith("tool."); + if (kindFilter === "note") return kind === "note" || kind === "update"; + return true; + }); + }, [messages, kindFilter]); + + const suggestedTasks = useMemo( + () => countSuggestedTasks(currentWorkspace, tasks), + [currentWorkspace, tasks], + ); + + // Auto-scroll the feed to top when a new message arrives (new messages + // are at the head of the sorted list). + const [latestId, setLatestId] = useState (null); + useEffect(() => { + const head = messages[0]?.id; + if (head && head !== latestId) setLatestId(head); + }, [messages, latestId]); + + const chipButton = (active: boolean): React.CSSProperties => ({ + padding: "5px 10px", + border: `1px solid ${active ? "var(--ink)" : "var(--border)"}`, + background: active ? "var(--ink)" : "white", + color: active ? "white" : "var(--ink2)", + fontFamily: "var(--mono)", + fontSize: 9, + letterSpacing: 0.5, + textTransform: "uppercase", + cursor: "pointer", + }); + + const navChipStyle = (active: boolean): React.CSSProperties => ({ + width: "100%", + textAlign: "left", + padding: "9px 10px", + border: `1px solid ${active ? "var(--accent)" : "var(--border)"}`, + background: active ? "var(--surface)" : "white", + fontFamily: active ? "var(--sans)" : "var(--mono)", + fontSize: active ? 12 : 11, + color: "var(--ink)", + cursor: "pointer", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + }); + + return ( + + {/* Header */} ++ ); +} diff --git a/dhee/ui/web/src/views/ConflictView.js b/dhee/ui/web/src/views/ConflictView.js new file mode 100644 index 0000000..4303d5b --- /dev/null +++ b/dhee/ui/web/src/views/ConflictView.js @@ -0,0 +1,261 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useEffect, useMemo, useState } from "react"; +import { api } from "../api"; +const EMPTY = { + live: false, + proposals: [], + findings: [], + conflicts: [], + totals: { proposals: 0, findings: 0, conflicts: 0 }, +}; +function severityColor(severity) { + if (severity === "high") + return "var(--rose)"; + if (severity === "medium") + return "var(--accent)"; + return "var(--indigo)"; +} +function proposalSnippet(proposal) { + const content = proposal.summary || proposal.content || ""; + return content.length > 260 ? `${content.slice(0, 260)}...` : content; +} +export function ConflictView({ viewer, onChanged }) { + const [snapshot, setSnapshot] = useState(EMPTY); + const [selected, setSelected] = useState(null); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const loadInbox = async () => { + try { + const box = await api.inbox(viewer?.team_id ? { team: viewer.team_id, user: viewer.user_id } : { user: viewer?.user_id }); + setSnapshot(box); + setSelected((current) => { + if (current) + return current; + return (box.proposals?.[0]?.context_id || + box.findings?.[0]?.finding_id || + String(box.conflicts?.[0]?.id || "") || + null); + }); + } + catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } + }; + useEffect(() => { + void loadInbox(); + const timer = window.setInterval(() => void loadInbox(), 6000); + return () => window.clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewer?.team_id, viewer?.user_id]); + const totalOpen = useMemo(() => (snapshot.totals?.proposals || 0) + + (snapshot.totals?.findings || 0) + + (snapshot.totals?.conflicts || 0), [snapshot.totals]); + const decideProposal = async (proposal, decision) => { + setBusy(`${decision}:${proposal.context_id}`); + setError(null); + try { + if (decision === "approve") { + await api.approveProposal(proposal.context_id, viewer?.user_id || "manager"); + } + else { + await api.rejectProposal(proposal.context_id, viewer?.user_id || "manager"); + } + await loadInbox(); + await onChanged?.(); + } + catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } + finally { + setBusy(null); + } + }; + const resolveFinding = async (finding) => { + setBusy(`finding:${finding.finding_id}`); + setError(null); + try { + await api.resolveFinding(finding.finding_id, viewer?.user_id || "manager"); + await loadInbox(); + await onChanged?.(); + } + catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } + finally { + setBusy(null); + } + }; + const resolveConflict = async (conflict, action) => { + const id = String(conflict.id || ""); + if (!id) + return; + setBusy(`conflict:${id}:${action}`); + setError(null); + try { + await api.resolveConflictDetailed(id, { action }); + await loadInbox(); + await onChanged?.(); + } + catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } + finally { + setBusy(null); + } + }; + return (_jsxs("div", { style: { display: "flex", height: "100%", minHeight: 0 }, children: [_jsxs("aside", { style: { + width: 300, + borderRight: "1px solid var(--border)", + background: "white", + padding: 16, + overflowY: "auto", + flexShrink: 0, + }, children: [_jsx("div", { style: { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + letterSpacing: "0.08em", + textTransform: "uppercase", + }, children: "Inbox" }), _jsxs("div", { style: { fontSize: 22, fontWeight: 650, marginTop: 6 }, children: [totalOpen, " open"] }), _jsxs("div", { style: { + marginTop: 10, + display: "grid", + gap: 8, + fontFamily: "var(--mono)", + fontSize: 10, + }, children: [_jsx(SummaryRow, { label: "Proposals", value: snapshot.totals?.proposals || 0 }), _jsx(SummaryRow, { label: "Findings", value: snapshot.totals?.findings || 0 }), _jsx(SummaryRow, { label: "Conflicts", value: snapshot.totals?.conflicts || 0 })] }), _jsx("div", { style: { + marginTop: 18, + padding: 12, + border: "1px solid var(--border)", + background: "var(--surface)", + fontSize: 12, + lineHeight: 1.5, + color: "var(--ink2)", + }, children: "Review context changes, stale-context findings, and memory conflicts from one queue. Approvals activate context used by routing." }), error ? (_jsx("div", { style: { + marginTop: 12, + padding: 10, + border: "1px solid var(--rose)", + background: "var(--rose-dim)", + color: "var(--rose)", + fontFamily: "var(--mono)", + fontSize: 10, + lineHeight: 1.5, + }, children: error })) : null] }), _jsxs("main", { style: { + flex: 1, + minWidth: 0, + overflowY: "auto", + background: "var(--bg)", + padding: 18, + display: "grid", + gap: 16, + alignContent: "start", + }, children: [_jsx(InboxSection, { title: "Pending Proposals", count: snapshot.proposals.length, empty: "No context edits are waiting for approval.", children: snapshot.proposals.map((proposal) => (_jsxs("article", { onClick: () => setSelected(proposal.context_id), style: rowStyle(selected === proposal.context_id), children: [_jsxs("div", { style: rowHeaderStyle, children: [_jsxs("div", { children: [_jsx("div", { style: rowTitleStyle, children: proposal.title }), _jsxs("div", { style: rowMetaStyle, children: [proposal.proposed_by_user_id || "developer", " \u00B7 ", proposal.team_id || proposal.project_id || proposal.scope] })] }), _jsx(Badge, { color: "var(--accent)", children: "pending" })] }), _jsx("p", { style: snippetStyle, children: proposalSnippet(proposal) || "No preview available." }), _jsxs("div", { style: { display: "flex", gap: 8, justifyContent: "flex-end" }, children: [_jsx(QueueButton, { label: "Open in Context", onClick: (e) => { + e.stopPropagation(); + window.location.hash = `#vault/item/${proposal.context_id}`; + window.history.replaceState(null, "", `?view=context${window.location.hash}`); + window.dispatchEvent(new PopStateEvent("popstate")); + } }), _jsx(QueueButton, { label: "Reject", color: "var(--rose)", busy: busy === `reject:${proposal.context_id}`, onClick: (e) => { + e.stopPropagation(); + void decideProposal(proposal, "reject"); + } }), _jsx(QueueButton, { label: "Approve", color: "var(--green)", busy: busy === `approve:${proposal.context_id}`, onClick: (e) => { + e.stopPropagation(); + void decideProposal(proposal, "approve"); + } })] })] }, proposal.context_id))) }), _jsx(InboxSection, { title: "Manager Findings", count: snapshot.findings.length, empty: "No stale, low-quality, or duplicate context findings.", children: snapshot.findings.map((finding) => (_jsxs("article", { onClick: () => setSelected(finding.finding_id), style: rowStyle(selected === finding.finding_id), children: [_jsxs("div", { style: rowHeaderStyle, children: [_jsxs("div", { children: [_jsx("div", { style: rowTitleStyle, children: finding.title }), _jsxs("div", { style: rowMetaStyle, children: [finding.team_id, " \u00B7 ", finding.finding_type] })] }), _jsx(Badge, { color: severityColor(finding.severity), children: finding.severity })] }), _jsx("p", { style: snippetStyle, children: finding.detail }), _jsx("div", { style: { display: "flex", justifyContent: "flex-end" }, children: _jsx(QueueButton, { label: "Resolve", color: "var(--green)", busy: busy === `finding:${finding.finding_id}`, onClick: (e) => { + e.stopPropagation(); + void resolveFinding(finding); + } }) })] }, finding.finding_id))) }), _jsx(InboxSection, { title: "Memory Conflicts", count: snapshot.conflicts.length, empty: "No memory contradictions detected.", children: snapshot.conflicts.map((conflict) => { + const c = conflict; + const id = String(c.id || Math.random()); + return (_jsxs("article", { onClick: () => setSelected(id), style: rowStyle(selected === id), children: [_jsxs("div", { style: rowHeaderStyle, children: [_jsxs("div", { children: [_jsx("div", { style: rowTitleStyle, children: "Memory conflict" }), _jsx("div", { style: rowMetaStyle, children: c.reason || "Contradiction" })] }), _jsx(Badge, { color: severityColor(c.severity), children: c.severity || "open" })] }), _jsxs("div", { style: { display: "grid", gap: 6, marginTop: 10 }, children: [_jsx(ConflictQuote, { label: "A", text: c.belief_a?.content }), _jsx(ConflictQuote, { label: "B", text: c.belief_b?.content })] }), _jsx("div", { style: { display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 10 }, children: ["KEEP A", "KEEP B", "MERGE"].map((action) => (_jsx(QueueButton, { label: action, busy: busy === `conflict:${id}:${action}`, onClick: (e) => { + e.stopPropagation(); + void resolveConflict(conflict, action); + } }, action))) })] }, id)); + }) })] })] })); +} +function SummaryRow({ label, value }) { + return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between" }, children: [_jsx("span", { style: { color: "var(--ink3)" }, children: label }), _jsx("span", { style: { color: value ? "var(--accent)" : "var(--ink2)" }, children: value })] })); +} +function InboxSection({ title, count, empty, children, }) { + return (_jsxs("section", { style: { display: "grid", gap: 10 }, children: [_jsxs("div", { style: { + display: "flex", + alignItems: "center", + gap: 8, + fontFamily: "var(--mono)", + fontSize: 10, + letterSpacing: "0.08em", + color: "var(--ink3)", + textTransform: "uppercase", + }, children: [_jsx("span", { children: title }), _jsx(Badge, { children: count })] }), count === 0 ? (_jsx("div", { style: { + border: "1px dashed var(--border)", + background: "white", + color: "var(--ink3)", + padding: 16, + fontSize: 12, + }, children: empty })) : (children)] })); +} +function Badge({ children, color = "var(--ink3)", }) { + return (_jsx("span", { style: { + display: "inline-flex", + alignItems: "center", + padding: "2px 7px", + border: `1px solid ${color}`, + color, + background: "white", + borderRadius: 3, + fontFamily: "var(--mono)", + fontSize: 9, + }, children: children })); +} +function QueueButton({ label, onClick, color = "var(--ink2)", busy, }) { + return (_jsx("button", { onClick: onClick, disabled: busy, style: { + padding: "6px 9px", + border: `1px solid ${color}`, + color, + background: "white", + fontFamily: "var(--mono)", + fontSize: 9, + borderRadius: 3, + cursor: busy ? "wait" : "pointer", + }, children: busy ? "..." : label })); +} +function ConflictQuote({ label, text }) { + return (_jsxs("div", { style: { + border: "1px solid var(--border)", + background: "var(--surface)", + padding: 10, + display: "grid", + gridTemplateColumns: "20px minmax(0, 1fr)", + gap: 8, + }, children: [_jsx("span", { style: { fontFamily: "var(--mono)", color: "var(--ink3)" }, children: label }), _jsx("span", { style: { color: "var(--ink2)", fontSize: 12, lineHeight: 1.5 }, children: text || "No content" })] })); +} +const rowHeaderStyle = { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + gap: 12, +}; +const rowTitleStyle = { + fontSize: 15, + fontWeight: 650, + color: "var(--ink)", +}; +const rowMetaStyle = { + fontFamily: "var(--mono)", + fontSize: 10, + color: "var(--ink3)", + marginTop: 3, +}; +const snippetStyle = { + margin: "10px 0", + color: "var(--ink2)", + fontSize: 12, + lineHeight: 1.55, +}; +function rowStyle(active) { + return { + border: `1px solid ${active ? "var(--accent)" : "var(--border)"}`, + background: "white", + padding: 14, + boxShadow: active ? "0 10px 24px rgba(20,16,10,0.06)" : "none", + cursor: "pointer", + }; +} diff --git a/dhee/ui/web/src/views/ConflictView.tsx b/dhee/ui/web/src/views/ConflictView.tsx new file mode 100644 index 0000000..caf1a15 --- /dev/null +++ b/dhee/ui/web/src/views/ConflictView.tsx @@ -0,0 +1,517 @@ +import { useEffect, useMemo, useState } from "react"; +import type { CSSProperties, MouseEvent, ReactNode } from "react"; +import { api } from "../api"; +import type { Finding, InboxSnapshot, Proposal, Viewer } from "../types"; + +interface ConflictViewProps { + viewer?: Viewer | null; + onChanged?: () => Promise++ + {/* Body grid */} ++ + {currentWorkspace?.label || currentWorkspace?.name || "channel"} + {currentProject ? ` / ${currentProject.name}` : ""} + +++ + {filtered.length} events · {suggestedTasks.length} suggested tasks + + + + + +++ {/* Left rail */} ++++ + {/* Center feed */} +++ ++ Context scope +++ {viewer?.team_id || viewer?.project_id || viewer?.org_id || "local dhee"} +++ {scopedContext.length} context items + · + {pendingForViewer.length} pending ++ +++ ++ Continue context +++ {continuity?.last_session?.task_summary || + continuity?.claude_sessions?.[0]?.preview || + "No Claude session recovered yet for this repo."} +++ {continuity?.last_session?.source || "waiting"} + · + {continuity?.claude_sessions?.length || 0} claude sessions ++++ ++ + Workspace + + {onOpenManager ? ( + + ) : null} ++ {workspaces.length === 0 ? ( + + ) : ( + + )} +++ ++ + Projects · {currentWorkspace?.projects?.length || 0} + + {onOpenManager && currentWorkspace ? ( + + ) : null} +++ + {(currentWorkspace?.projects || []).map((project) => { + const active = project.id === currentProject?.id; + const sessionCount = project.sessions?.length || 0; + return ( + + ); + })} +++ + {currentWorkspace && ( + + + Launch + ++ )} ++ + ++++ + {/* Right rail */} ++ + Shared line + + + + + + + {error ? ( + + {error} + + ) : null} +++ {filtered.length === 0 ? ( ++++ ) : ( + filtered.map((message) => ( ++ The line is quiet. +++ Every agent tool-call in this workspace will appear here. Launch a session from + the left rail, or broadcast a note from the composer to get started. +++ )) + )} + +++ Broadcast ++{ + await onTasksRefresh(); + void refresh(); + }} + /> + + void refresh()} + /> + + + Suggested tasks · {suggestedTasks.length} +++ {suggestedTasks.length === 0 ? ( +++ When an agent broadcasts to another project, a task is auto-created there. It will + show up here. ++ ) : ( + suggestedTasks.slice(0, 10).map((task) => ( + + )) + )} +| void; +} + +const EMPTY: InboxSnapshot = { + live: false, + proposals: [], + findings: [], + conflicts: [], + totals: { proposals: 0, findings: 0, conflicts: 0 }, +}; + +function severityColor(severity?: string): string { + if (severity === "high") return "var(--rose)"; + if (severity === "medium") return "var(--accent)"; + return "var(--indigo)"; +} + +function proposalSnippet(proposal: Proposal): string { + const content = proposal.summary || proposal.content || ""; + return content.length > 260 ? `${content.slice(0, 260)}...` : content; +} + +export function ConflictView({ viewer, onChanged }: ConflictViewProps) { + const [snapshot, setSnapshot] = useState (EMPTY); + const [selected, setSelected] = useState (null); + const [busy, setBusy] = useState (null); + const [error, setError] = useState (null); + + const loadInbox = async () => { + try { + const box = await api.inbox( + viewer?.team_id ? { team: viewer.team_id, user: viewer.user_id } : { user: viewer?.user_id } + ); + setSnapshot(box); + setSelected((current) => { + if (current) return current; + return ( + box.proposals?.[0]?.context_id || + box.findings?.[0]?.finding_id || + String((box.conflicts?.[0] as { id?: string })?.id || "") || + null + ); + }); + } catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } + }; + + useEffect(() => { + void loadInbox(); + const timer = window.setInterval(() => void loadInbox(), 6000); + return () => window.clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewer?.team_id, viewer?.user_id]); + + const totalOpen = useMemo( + () => + (snapshot.totals?.proposals || 0) + + (snapshot.totals?.findings || 0) + + (snapshot.totals?.conflicts || 0), + [snapshot.totals] + ); + + const decideProposal = async (proposal: Proposal, decision: "approve" | "reject") => { + setBusy(`${decision}:${proposal.context_id}`); + setError(null); + try { + if (decision === "approve") { + await api.approveProposal(proposal.context_id, viewer?.user_id || "manager"); + } else { + await api.rejectProposal(proposal.context_id, viewer?.user_id || "manager"); + } + await loadInbox(); + await onChanged?.(); + } catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } finally { + setBusy(null); + } + }; + + const resolveFinding = async (finding: Finding) => { + setBusy(`finding:${finding.finding_id}`); + setError(null); + try { + await api.resolveFinding(finding.finding_id, viewer?.user_id || "manager"); + await loadInbox(); + await onChanged?.(); + } catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } finally { + setBusy(null); + } + }; + + const resolveConflict = async (conflict: Record , action: string) => { + const id = String(conflict.id || ""); + if (!id) return; + setBusy(`conflict:${id}:${action}`); + setError(null); + try { + await api.resolveConflictDetailed(id, { action }); + await loadInbox(); + await onChanged?.(); + } catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } finally { + setBusy(null); + } + }; + + return ( + + + ++ + {snapshot.proposals.map((proposal) => ( + setSelected(proposal.context_id)} + style={rowStyle(selected === proposal.context_id)} + > + ++