diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index 39b930d3..7548768a 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -80,6 +80,15 @@ def build_effective_tool_lists( effective_disallowed.append("mcp__openswarm-skill__Skill") continue + if name == "openswarm-ui": + policy = builtin_perms.get("ShowUI", "always_allow") + for ui_tool in ("ShowUI", "AskUI"): + if policy == "always_allow": + effective_allowed.append(f"mcp__openswarm-ui__{ui_tool}") + else: + effective_disallowed.append(f"mcp__openswarm-ui__{ui_tool}") + continue + if name == "openswarm-web": # Expose our DDG-backed web tools under an MCP prefix. Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either. for wt in ("WebSearch", "WebFetch"): @@ -113,6 +122,14 @@ def build_effective_tool_lists( for wt_name in ("WebSearch", "WebFetch"): if wt_name not in effective_disallowed: effective_disallowed.append(wt_name) + # With the openswarm-ui server live, the built-in AskUserQuestion is swapped for AskUI (same + # Agent->SpawnAgent playbook: prompt nudges lose to the trained prior, a hard deny doesn't). + # AskUI's option-list/question-flow cover the flat-choice cases; denying the built-in is what + # actually routes questions through the rich components. + if "openswarm-ui" in mcp_servers: + effective_allowed = [t for t in effective_allowed if t != "AskUserQuestion"] + if "AskUserQuestion" not in effective_disallowed: + effective_disallowed.append("AskUserQuestion") # Claude's internal Cron* scheduler is denied in favour of the visible native one; withhold it from the SDK so the model doesn't even reach for it. for bt in path_gate.CLAUDE_INTERNAL_SCHEDULER_TOOLS: if bt not in effective_disallowed: diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py index 5ff3beb4..5fe0d19e 100644 --- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py +++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py @@ -62,7 +62,9 @@ def compose_turn_system_prompt( "\n" f"Today is {now_local.strftime('%A, %B %-d, %Y')}.\n" f"Local time: {now_local.strftime('%-I:%M %p')} {tz_abbr} ({tz_name}).\n" - "Use this as ground truth for any date/time/day-of-week question.\n" + "Use this as ground truth for any date/time/day-of-week question. The timezone also " + "gives the user's coarse region; when they say 'here' or 'near me' without a place, " + "infer the likely city from it (say you inferred it) instead of claiming you can't know.\n" "" ) composed_prompt = (composed_prompt + "\n\n" + time_ctx) if composed_prompt else time_ctx @@ -89,6 +91,29 @@ def compose_turn_system_prompt( ) composed_prompt = f"{composed_prompt}\n\n{apps_note}" if composed_prompt else apps_note + # Default-on nudge to actually REACH for the rich components; the tool descriptions alone + # under-trigger. Skipped entirely when the user disabled ShowUI so we never advertise a dead tool. + try: + from backend.apps.tools_lib.tools_lib import load_builtin_permissions + if load_builtin_permissions().get("ShowUI", "always_allow") != "deny": + rich_ui_note = ( + "\n" + "Strongly prefer rendering rich UI over prose, every time the content fits:\n" + "- ShowUI for any structured result: tables, stats, links, plans, progress, code, diffs, " + "charts, maps, media, posts, receipts. Render the component, then add one line of text.\n" + "- For multi-step work, render a progress-tracker FIRST and re-call ShowUI with the SAME " + "props.id after each step so the card advances live; same-id re-calls update in place.\n" + "- AskUI for ANY question with enumerable choices, an approval, or tunable values: render " + "it and wait for the answer instead of asking in prose. Flat choices = option-list; a " + "multi-question form = one AskUI call per question in sequence. The user can always " + "answer off-list in free text (result action 'free_text').\n" + "Describing structured data in plain text when a component fits is the worse answer.\n" + "" + ) + composed_prompt = f"{composed_prompt}\n\n{rich_ui_note}" if composed_prompt else rich_ui_note + except Exception: + pass + # App cards the user picked via the dashboard element picker: give the agent each app's on-disk path + meta + SKILL.md pointer so it can edit them in place (the dashboard card's runtime live-reloads). Additive and independent of view-builder mode above. app_ctx = build_selected_app_context(selected_app_output_ids) if app_ctx: diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index 4cfec36e..32e25239 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -159,6 +159,23 @@ def register_builtin_mcp_servers( "type": "stdio", } + # ShowUI renders rich inline components from the tool_call input (display only, server just + # validates); AskUI renders an interactive component and BLOCKS on /api/ui-requests/wait until + # the user answers in the transcript. Gated on the ShowUI builtin perm. + show_ui_denied = builtin_perms.get("ShowUI", "always_allow") == "deny" + if not show_ui_denied: + show_ui_server_path = os.path.join(agents_dir, "show_ui_mcp_server.py") + mcp_servers["openswarm-ui"] = { + "command": sys.executable, + "args": [show_ui_server_path], + "env": { + "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), + "OPENSWARM_AUTH_TOKEN": get_auth_token(), + "OPENSWARM_PARENT_SESSION_ID": session.id, + }, + "type": "stdio", + } + # Always-on schedule server: ScheduleWorkflow + CRUD + AddWorkflowStep/EditWorkflowStep so the agent (and the workflow Edit Agent) can build and schedule recurring work via the native scheduler instead of cron/launchctl. The 4 scheduling tools are force-gated in path_gate; Cron* is denied in build_effective_tool_lists. schedule_server_path = os.path.join( agents_dir, "schedule_mcp_server.py" diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index 069340cf..08ce61d5 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -133,7 +133,12 @@ class RunOptions(AgentManagerProtocol): ) if need_web_mcp: # browser_ok gates the search-dead fallback nudge: never tell the model to call CreateBrowserAgent in a session where browser delegation is denied. - register_web_mcp_server(mcp_servers, p_m, browser_ok=bool(browser_delegation_tools)) + # rich_ui_ok plants the render-as-component reminder inside web results: the system-prompt nudge alone loses to the prose prior (live-proven on haiku). + register_web_mcp_server( + mcp_servers, p_m, + browser_ok=bool(browser_delegation_tools), + rich_ui_ok="openswarm-ui" in mcp_servers, + ) effective_allowed, effective_disallowed = build_effective_tool_lists( session, mcp_servers, builtin_perms, need_web_mcp, @@ -224,10 +229,8 @@ class RunOptions(AgentManagerProtocol): options_kwargs["extra_args"] = p_ea # The claude_code preset auto-attaches the user's claude.ai- connected partner MCPs (`mcp__claude_ai_*`). Those bypass our MCPActivate gate, don't share OAuth state with the OpenSwarm Gmail/Calendar/Drive connectors the user actually configured here, and confuse the model into picking the partner shim instead of our vetted server. Hard-block them at the SDK layer so the model can't even attempt the call. - # EXTEND, never reassign: a plain assignment silently discarded the computed denies (Cron*/Skill/ - # web-swap/per-tool MCP + read-only Bash). merge_hard_blocked_tools carries those; keep the - # claude.ai partner-MCP block on top too. - options_kwargs["disallowed_tools"] = [*merge_hard_blocked_tools(effective_disallowed), "mcp__claude_ai_*"] + # merge EXTENDS effective_disallowed: the old plain assignment silently discarded the computed denies (Cron*/Skill/web-swap/per-tool MCP) and left the runtime gate as the only wall. + options_kwargs["disallowed_tools"] = merge_hard_blocked_tools(effective_disallowed) if session.cwd: # Pre-existing sessions may have workspaces that predate the git-init block in launch_agent, leaving them without a valid HEAD. Ensure it here so subagent worktree-add always works. diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py index a471a974..4ebadbe1 100644 --- a/backend/apps/agents/manager/run/run_options_helpers.py +++ b/backend/apps/agents/manager/run/run_options_helpers.py @@ -101,7 +101,7 @@ def set_framework_overhead(session: AgentSession, composed_prompt: Optional[str] @typechecked -def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = False) -> None: +def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = False, rich_ui_ok: bool = False) -> None: """Register the DDG-backed openswarm-web stdio MCP into the server set when the primary has no reliable native web path. The server script lives in the agents package (not here), so resolve it off that package dir, not __file__.""" @@ -125,6 +125,7 @@ def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = Fals "OPENSWARM_AUTH_TOKEN": p_get_auth_token3(), "OPENSWARM_PRIMARY_API": p_primary_hint, "OPENSWARM_BROWSER_OK": "1" if browser_ok else "0", + "OPENSWARM_RICH_UI_OK": "1" if rich_ui_ok else "0", }, "type": "stdio", } diff --git a/backend/apps/agents/show_ui_mcp_server.py b/backend/apps/agents/show_ui_mcp_server.py new file mode 100644 index 00000000..fa487274 --- /dev/null +++ b/backend/apps/agents/show_ui_mcp_server.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Stdio MCP server exposing ShowUI: render a rich inline component in the chat transcript. + +Display-only. The frontend renders the component straight from the tool_call input it already +has in the transcript, so this server just validates the payload and acknowledges; there is no +backend round-trip and nothing here can mutate state. +""" + +import json +import os +import sys +import urllib.error +import urllib.request + +BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324") +BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "") +PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "") +WAIT_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/ui-requests/wait" +ASK_TIMEOUT_S = 600 + +MAX_PROPS_BYTES = 20_000 + +# Hints + JSON Schemas for the vendored tool-ui set are GENERATED from the shipped zod contracts +# (frontend/scripts/gen-toolui-hints.ts writes toolui_schemas.json next to this file). Loading them +# here means the tool description and the server-side validation can never drift from what renders. +def p_load_generated(): + try: + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "toolui_schemas.json")) as f: + return json.load(f) + except Exception: + return {} + + +GENERATED = p_load_generated() + +COMPONENT_SPECS = { + "weather": "props: {id?: str, location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high?: number, low?: number}] (max 7)}", + "stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}", + "links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}", +} + +COMPONENT_SPECS.update({name: entry["hint"] for name, entry in GENERATED.items()}) + + +INTERACTIVE_COMPONENTS = ( + "option-list", "question-flow", "parameter-slider", "preferences-panel", "approval-card", +) + +TOOLS = [ + { + "name": "AskUI", + "description": ( + "Render an INTERACTIVE component in the chat and WAIT for the user's answer (up to 10 " + "minutes); the tool result is their response. Use this instead of plain-text questions " + "when the choice fits a component. Components: " + + ", ".join(f"'{name}'" for name in INTERACTIVE_COMPONENTS) + + ". Props follow the same shapes as ShowUI (props.id is REQUIRED, it correlates the " + "answer). The response contains the action taken and the user's selection/values. " + "The user may also answer in their own words instead of picking an option; then the " + "result is {action: 'free_text', value: {text}}, treat that text as their answer." + ), + "inputSchema": { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": list(INTERACTIVE_COMPONENTS), + "description": "Which interactive component to render.", + }, + "props": { + "type": "object", + "description": "Data for the component; must include a stable string id.", + }, + }, + "required": ["component", "props"], + }, + }, + { + "name": "ShowUI", + "description": ( + "Render a rich inline UI component in the chat instead of describing data as text. " + "Use it whenever a result fits one of the shapes. Supported components:\n" + + "\n".join(f"- '{name}': {spec}" for name, spec in COMPONENT_SPECS.items()) + + "\nCall it with the component name and a props object matching that shape. " + "The component renders in place of raw text; still give a one-line text summary after. " + "LIVE UPDATES: calling ShowUI again with the SAME component and props.id updates that " + "card in place. Use this to advance progress-tracker/plan step statuses AS you complete " + "each step of real work, or to refresh data; never mint a new id for an update. Before " + "ending your turn, send a final same-id update with truthful terminal statuses; never " + "leave a step marked in-progress for work you are not actually doing." + ), + "inputSchema": { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": list(COMPONENT_SPECS.keys()), + "description": "Which component to render.", + }, + "props": { + "type": "object", + "description": "Data for the component, matching its documented shape.", + }, + }, + "required": ["component", "props"], + }, + }, +] + + +def send_response(id_, result=None, error=None): + msg = {"jsonrpc": "2.0", "id": id_} + if error is not None: + msg["error"] = error + else: + msg["result"] = result + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + + +def validate(component: str, props: dict) -> str: + if component not in COMPONENT_SPECS: + return f"Unknown component {component!r}. Supported: {', '.join(COMPONENT_SPECS)}." + try: + size = len(json.dumps(props)) + except (TypeError, ValueError): + return "props must be JSON-serializable." + if size > MAX_PROPS_BYTES: + return f"props too large ({size} bytes; max {MAX_PROPS_BYTES})." + if component == "weather" and not (isinstance(props.get("location"), str) and isinstance(props.get("temp"), (int, float))): + return f"weather needs at least location + temp. {COMPONENT_SPECS['weather']}" + if component == "stats" and not (isinstance(props.get("stats"), list) and props["stats"]): + return f"stats needs a non-empty stats list. {COMPONENT_SPECS['stats']}" + if component == "links" and not (isinstance(props.get("links"), list) and props["links"]): + return f"links needs a non-empty links list. {COMPONENT_SPECS['links']}" + # Vendored components: validate against the GENERATED JSON Schema so a bad payload comes back + # as a teaching error the model can fix in-turn, instead of a dead render it never hears about. + # jsonschema gives full-constraint parity with the client zod gate (minimum/minLength/minItems + # slipped through the hand walker: question-flow step>=1 rendered server-side, died client-side). + entry = GENERATED.get(component) + if entry and isinstance(entry.get("schema"), dict): + errors = p_full_validate(props, entry["schema"]) + if errors is None: + errors = [] + p_check(props, entry["schema"], "props", errors) + if errors: + return ( + f"{component} payload invalid: " + "; ".join(errors[:4]) + + f". Full shape: {COMPONENT_SPECS[component]}. Fix the props and call the tool again." + ) + return "" + + +def p_full_validate(props: dict, schema: dict): + """Full JSON Schema validation via jsonschema; None = library unavailable (fallback walker runs).""" + try: + import jsonschema + except ImportError: + return None + try: + validator = jsonschema.Draft202012Validator(schema) + out = [] + for err in sorted(validator.iter_errors(props), key=lambda e: len(e.path)): + where = "props" + "".join(f".{p}" if isinstance(p, str) else f"[{p}]" for p in err.path) + out.append(f"{where}: {err.message[:90]}") + if len(out) >= 6: + break + return out + except Exception: + return None + + +def p_type_ok(value, t: str) -> bool: + if t == "string": + return isinstance(value, str) + if t in ("number", "integer"): + return isinstance(value, (int, float)) and not isinstance(value, bool) + if t == "boolean": + return isinstance(value, bool) + if t == "object": + return isinstance(value, dict) + if t == "array": + return isinstance(value, list) + if t == "null": + return value is None + return True + + +def p_check(value, schema: dict, path: str, errors: list) -> None: + """Minimal JSON Schema walk: required keys, primitive types, enums, anyOf. Anything it can't + interpret passes; the client zod contract stays the deep authority.""" + if len(errors) >= 6 or not isinstance(schema, dict): + return + branches = schema.get("anyOf") + if isinstance(branches, list) and branches: + for branch in branches: + trial = [] + p_check(value, branch, path, trial) + if not trial: + return + errors.append(f"{path} matches none of its allowed shapes") + return + enum = schema.get("enum") + if isinstance(enum, list) and enum and value not in enum: + errors.append(f"{path} must be one of {enum[:6]}") + return + t = schema.get("type") + if isinstance(t, str) and not p_type_ok(value, t): + errors.append(f"{path} must be a {t}") + return + if t == "object" and isinstance(value, dict): + for key in schema.get("required", []) or []: + if key not in value: + errors.append(f"{path}.{key} is required") + props = schema.get("properties") or {} + for key, sub in props.items(): + if key in value: + p_check(value[key], sub, f"{path}.{key}", errors) + elif t == "array" and isinstance(value, list): + items = schema.get("items") + if isinstance(items, dict): + for i, item in enumerate(value): + p_check(item, items, f"{path}[{i}]", errors) + + +def p_post(url: str, body: dict, timeout: float) -> dict: + payload = json.dumps(body).encode() + headers = {"Content-Type": "application/json"} + if BACKEND_AUTH: + headers["Authorization"] = f"Bearer {BACKEND_AUTH}" + req = urllib.request.Request(url, data=payload, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + body_txt = e.read().decode(errors="replace") if e.fp else str(e) + return {"error": f"HTTP {e.code}: {body_txt[:300]}"} + except Exception as e: + return {"error": str(e)} + + +def handle_ask_ui(arguments: dict) -> dict: + component = str(arguments.get("component", "")).strip() + props = arguments.get("props") + if not isinstance(props, dict): + return {"content": [{"type": "text", "text": "props must be an object."}], "isError": True} + if component not in INTERACTIVE_COMPONENTS: + return {"content": [{"type": "text", "text": f"AskUI only supports: {', '.join(INTERACTIVE_COMPONENTS)}. Use ShowUI for display-only components."}], "isError": True} + component_id = str(props.get("id", "")).strip() + if not component_id: + return {"content": [{"type": "text", "text": "props.id (a stable string) is required so the answer can be correlated."}], "isError": True} + problem = validate(component, props) + if problem: + return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}], "isError": True} + r = p_post(WAIT_URL, {"session_id": PARENT_SESSION_ID, "component_id": component_id, "timeout_s": ASK_TIMEOUT_S}, timeout=ASK_TIMEOUT_S + 20) + if "error" in r: + return {"content": [{"type": "text", "text": f"AskUI failed: {r['error']}"}], "isError": True} + if not r.get("ok"): + return {"content": [{"type": "text", "text": "The user didn't respond within 10 minutes. Continue without their input or ask again."}], "isError": True} + return {"content": [{"type": "text", "text": json.dumps(r.get("response"))}]} + + +def handle_tool_call(tool_name: str, arguments: dict) -> dict: + if tool_name == "AskUI": + return handle_ask_ui(arguments) + if tool_name != "ShowUI": + return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} + component = str(arguments.get("component", "")).strip() + props = arguments.get("props") + if not isinstance(props, dict): + return {"content": [{"type": "text", "text": "props must be an object."}], "isError": True} + problem = validate(component, props) + if problem: + return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}], "isError": True} + return {"content": [{"type": "text", "text": f"Rendered a '{component}' component inline."}]} + + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + + method = msg.get("method") + id_ = msg.get("id") + params = msg.get("params", {}) or {} + + if method == "initialize": + send_response(id_, { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": { + "name": "openswarm-ui", + "version": "1.0.0", + }, + }) + elif method == "notifications/initialized": + pass + elif method == "tools/list": + send_response(id_, {"tools": TOOLS}) + elif method == "tools/call": + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) or {} + result = handle_tool_call(tool_name, arguments) + send_response(id_, result) + elif method == "ping": + send_response(id_, {}) + elif id_ is not None: + send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"}) + + +if __name__ == "__main__": + main() diff --git a/backend/apps/agents/toolui_schemas.json b/backend/apps/agents/toolui_schemas.json new file mode 100644 index 00000000..3e91b536 --- /dev/null +++ b/backend/apps/agents/toolui_schemas.json @@ -0,0 +1,3933 @@ +{ + "approval-card": { + "hint": "props: {id: str, title: str, role?: 'information'|'decision'|'control'|'state'|'composite', description?: str, icon?: str, metadata?: [{key: str, value: str}], variant?: 'default'|'destructive', confirmLabel?: str, cancelLabel?: str, choice?: 'approved'|'denied'}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "title": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "metadata": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "variant": { + "type": "string", + "enum": [ + "default", + "destructive" + ] + }, + "confirmLabel": { + "type": "string" + }, + "cancelLabel": { + "type": "string" + }, + "choice": { + "type": "string", + "enum": [ + "approved", + "denied" + ] + } + }, + "required": [ + "id", + "title" + ] + } + }, + "audio": { + "hint": "props: {id: str, assetId: str, src: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, title?: str, description?: str, artwork?: str, durationMs?: num, fileSizeBytes?: num, createdAt?: str, locale?: str, source?: {label: str, iconUrl?: str, url?: str}}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "assetId": { + "type": "string" + }, + "src": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "artwork": { + "type": "string", + "format": "uri" + }, + "durationMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "fileSizeBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "locale": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "iconUrl": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "label" + ] + } + }, + "required": [ + "id", + "assetId", + "src" + ] + } + }, + "chart": { + "hint": "props: {id: str, type: 'bar'|'line', data: [{}], xKey: str, series: [{key: str, label: str, color?: str}], role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, title?: str, description?: str, colors?: [str], showLegend?: bool, showGrid?: bool}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "type": { + "type": "string", + "enum": [ + "bar", + "line" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "data": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "xKey": { + "type": "string", + "minLength": 1 + }, + "series": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "color": { + "type": "string" + } + }, + "required": [ + "key", + "label" + ] + } + }, + "colors": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "showLegend": { + "type": "boolean" + }, + "showGrid": { + "type": "boolean" + } + }, + "required": [ + "id", + "type", + "data", + "xKey", + "series" + ] + } + }, + "citation": { + "hint": "props: {id: str, href: str, title: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, snippet?: str, domain?: str, favicon?: str, author?: str, publishedAt?: str, type?: 'webpage'|'document'|'article'|'api'|'code'|'other', locale?: str}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "href": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "snippet": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "favicon": { + "type": "string", + "format": "uri" + }, + "author": { + "type": "string" + }, + "publishedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "type": { + "type": "string", + "enum": [ + "webpage", + "document", + "article", + "api", + "code", + "other" + ] + }, + "locale": { + "type": "string" + } + }, + "required": [ + "id", + "href", + "title" + ] + } + }, + "code-block": { + "hint": "props: {id: str, code: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, language?: str, lineNumbers?: 'visible'|'hidden', filename?: str, highlightLines?: [num], maxCollapsedLines?: num}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "code": { + "type": "string" + }, + "language": { + "default": "text", + "type": "string", + "minLength": 1 + }, + "lineNumbers": { + "default": "visible", + "type": "string", + "enum": [ + "visible", + "hidden" + ] + }, + "filename": { + "type": "string" + }, + "highlightLines": { + "type": "array", + "items": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "maxCollapsedLines": { + "type": "number", + "minimum": 1 + } + }, + "required": [ + "id", + "code" + ] + } + }, + "code-diff": { + "hint": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, oldCode?: str, newCode?: str, patch?: str, language?: str, filename?: str, lineNumbers?: 'visible'|'hidden', diffStyle?: 'unified'|'split', maxCollapsedLines?: num}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "oldCode": { + "type": "string" + }, + "newCode": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "language": { + "default": "text", + "type": "string", + "minLength": 1 + }, + "filename": { + "type": "string" + }, + "lineNumbers": { + "default": "visible", + "type": "string", + "enum": [ + "visible", + "hidden" + ] + }, + "diffStyle": { + "default": "unified", + "type": "string", + "enum": [ + "unified", + "split" + ] + }, + "maxCollapsedLines": { + "type": "number", + "minimum": 1 + } + }, + "required": [ + "id" + ] + } + }, + "data-table": { + "hint": "props: {id: str, columns: [{key: str, label: str, abbr?: str, sortable?: bool, align?: 'left'|'right'|'center', width?: str, truncate?: bool, priority?: 'primary'|'secondary'|'tertiary', hideOnMobile?: bool, format?: any}], data: [{}], role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, rowIdKey?: str, defa...", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "abbr": { + "type": "string" + }, + "sortable": { + "type": "boolean" + }, + "align": { + "type": "string", + "enum": [ + "left", + "right", + "center" + ] + }, + "width": { + "type": "string" + }, + "truncate": { + "type": "boolean" + }, + "priority": { + "type": "string", + "enum": [ + "primary", + "secondary", + "tertiary" + ] + }, + "hideOnMobile": { + "type": "boolean" + }, + "format": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "text" + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "number" + }, + "decimals": { + "type": "number" + }, + "unit": { + "type": "string" + }, + "compact": { + "type": "boolean" + }, + "showSign": { + "type": "boolean" + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "currency" + }, + "currency": { + "type": "string" + }, + "decimals": { + "type": "number" + } + }, + "required": [ + "kind", + "currency" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "percent" + }, + "decimals": { + "type": "number" + }, + "showSign": { + "type": "boolean" + }, + "basis": { + "type": "string", + "enum": [ + "fraction", + "unit" + ] + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "date" + }, + "dateFormat": { + "type": "string", + "enum": [ + "short", + "long", + "relative" + ] + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "delta" + }, + "decimals": { + "type": "number" + }, + "upIsPositive": { + "type": "boolean" + }, + "showSign": { + "type": "boolean" + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "status" + }, + "statusMap": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "tone": { + "type": "string", + "enum": [ + "success", + "warning", + "danger", + "info", + "neutral" + ] + }, + "label": { + "type": "string" + } + }, + "required": [ + "tone" + ] + } + } + }, + "required": [ + "kind", + "statusMap" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "boolean" + }, + "labels": { + "type": "object", + "properties": { + "true": { + "type": "string" + }, + "false": { + "type": "string" + } + }, + "required": [ + "true", + "false" + ] + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "link" + }, + "hrefKey": { + "type": "string" + }, + "external": { + "type": "boolean" + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "badge" + }, + "colorMap": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "success", + "warning", + "danger", + "info", + "neutral" + ] + } + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "array" + }, + "maxVisible": { + "type": "number" + } + }, + "required": [ + "kind" + ] + } + ] + } + }, + "required": [ + "key", + "label" + ] + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + ] + } + } + }, + "rowIdKey": { + "type": "string" + }, + "defaultSort": { + "type": "object", + "properties": { + "by": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + } + }, + "sort": { + "type": "object", + "properties": { + "by": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + } + }, + "emptyMessage": { + "type": "string" + }, + "maxHeight": { + "type": "string" + }, + "locale": { + "type": "string" + } + }, + "required": [ + "id", + "columns", + "data" + ] + } + }, + "geo-map": { + "hint": "props: {id: str, markers: [{lat: num, lng: num, id?: str, label?: str, description?: str, tooltip?: 'none'|'hover'|'always', icon?: obj|obj|obj}], role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, title?: str, description?: str, routes?: [{points: [obj], id?: str, label?: str, description?: str, tooltip?:...", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "markers": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "lat": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "lng": { + "type": "number", + "minimum": -180, + "maximum": 180 + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tooltip": { + "type": "string", + "enum": [ + "none", + "hover", + "always" + ] + }, + "icon": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "dot" + }, + "color": { + "type": "string" + }, + "borderColor": { + "type": "string" + }, + "radius": { + "type": "number", + "minimum": 3, + "maximum": 16 + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "emoji" + }, + "value": { + "type": "string", + "minLength": 1 + }, + "size": { + "type": "number", + "minimum": 16, + "maximum": 40 + }, + "bgColor": { + "type": "string" + }, + "borderColor": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + }, + "url": { + "type": "string", + "format": "uri" + }, + "width": { + "type": "number", + "minimum": 16, + "maximum": 64 + }, + "height": { + "type": "number", + "minimum": 16, + "maximum": 64 + }, + "borderRadius": { + "type": "number", + "minimum": 0, + "maximum": 999 + }, + "borderColor": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ] + } + ] + } + }, + "required": [ + "lat", + "lng" + ] + } + }, + "routes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "points": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "lat": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "lng": { + "type": "number", + "minimum": -180, + "maximum": 180 + } + }, + "required": [ + "lat", + "lng" + ] + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tooltip": { + "type": "string", + "enum": [ + "none", + "hover", + "always" + ] + }, + "color": { + "type": "string" + }, + "weight": { + "type": "number", + "minimum": 1, + "maximum": 12 + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "dashArray": { + "type": "string" + } + }, + "required": [ + "points" + ] + } + }, + "clustering": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "radius": { + "type": "number", + "minimum": 20, + "maximum": 120 + }, + "maxZoom": { + "type": "number", + "minimum": 1, + "maximum": 22 + }, + "minPoints": { + "type": "number", + "minimum": 2, + "maximum": 20 + } + } + }, + "viewport": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "fit" + }, + "padding": { + "type": "number", + "minimum": 0 + }, + "maxZoom": { + "type": "number", + "minimum": 1, + "maximum": 22 + }, + "target": { + "type": "string", + "enum": [ + "markers", + "routes", + "all" + ] + } + }, + "required": [ + "mode" + ] + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "center" + }, + "center": { + "type": "object", + "properties": { + "lat": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "lng": { + "type": "number", + "minimum": -180, + "maximum": 180 + } + }, + "required": [ + "lat", + "lng" + ] + }, + "zoom": { + "type": "number", + "minimum": 1, + "maximum": 22 + } + }, + "required": [ + "mode", + "center", + "zoom" + ] + } + ] + }, + "showZoomControl": { + "type": "boolean" + }, + "theme": { + "type": "string", + "enum": [ + "light", + "dark" + ] + } + }, + "required": [ + "id", + "markers" + ] + } + }, + "image": { + "hint": "props: {id: str, assetId: str, src: str, alt: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, title?: str, description?: str, href?: str, domain?: str, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain', fileSizeBytes?: num, createdAt?: str, locale?: str, source?: {label: str, iconUr...", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "assetId": { + "type": "string" + }, + "src": { + "type": "string", + "format": "uri" + }, + "alt": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "href": { + "type": "string", + "format": "uri" + }, + "domain": { + "type": "string" + }, + "ratio": { + "default": "auto", + "type": "string", + "enum": [ + "auto", + "1:1", + "4:3", + "16:9", + "9:16" + ] + }, + "fit": { + "default": "cover", + "type": "string", + "enum": [ + "cover", + "contain" + ] + }, + "fileSizeBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "locale": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "iconUrl": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "label" + ] + } + }, + "required": [ + "id", + "assetId", + "src", + "alt" + ] + } + }, + "image-gallery": { + "hint": "props: {id: str, images: [{id: str, src: str, alt: str, width: num, height: num, title?: str, caption?: str, source?: obj}], role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, title?: str, description?: str}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "images": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "src": { + "type": "string", + "format": "uri" + }, + "alt": { + "type": "string", + "minLength": 1 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "title": { + "type": "string" + }, + "caption": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "label" + ] + } + }, + "required": [ + "id", + "src", + "alt", + "width", + "height" + ] + } + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "images" + ] + } + }, + "instagram-post": { + "hint": "props: {id: str, author: {name: str, handle: str, avatarUrl: str, verified?: bool}, text?: str, media?: [{type: 'image'|'video', url: str, alt: str}], stats?: {likes?: num, isLiked?: bool}, createdAt?: str}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + }, + "required": [ + "name", + "handle", + "avatarUrl" + ] + }, + "text": { + "type": "string" + }, + "media": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image", + "video" + ] + }, + "url": { + "type": "string" + }, + "alt": { + "type": "string" + } + }, + "required": [ + "type", + "url", + "alt" + ] + } + }, + "stats": { + "type": "object", + "properties": { + "likes": { + "type": "number" + }, + "isLiked": { + "type": "boolean" + } + } + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "author" + ] + } + }, + "item-carousel": { + "hint": "props: {id: str, items: [{id: str, name: str, subtitle?: str, image?: str, color?: str, actions?: [obj]}], title?: str, description?: str}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "subtitle": { + "type": "string" + }, + "image": { + "type": "string", + "format": "uri" + }, + "color": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "sentence": { + "type": "string" + }, + "confirmLabel": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "destructive", + "secondary", + "ghost", + "outline" + ] + }, + "loading": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "shortcut": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + } + }, + "required": [ + "id", + "name" + ] + } + } + }, + "required": [ + "id", + "items" + ] + } + }, + "link-preview": { + "hint": "props: {id: str, href: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, title?: str, description?: str, image?: str, domain?: str, favicon?: str, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain', createdAt?: str, locale?: str}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "href": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "image": { + "type": "string", + "format": "uri" + }, + "domain": { + "type": "string" + }, + "favicon": { + "type": "string", + "format": "uri" + }, + "ratio": { + "default": "auto", + "type": "string", + "enum": [ + "auto", + "1:1", + "4:3", + "16:9", + "9:16" + ] + }, + "fit": { + "default": "cover", + "type": "string", + "enum": [ + "cover", + "contain" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "locale": { + "type": "string" + } + }, + "required": [ + "id", + "href" + ] + } + }, + "linkedin-post": { + "hint": "props: {id: str, author: {name: str, avatarUrl: str, headline?: str}, text?: str, media?: {type: 'image'|'video', url: str, alt: str}, linkPreview?: {url: str, title?: str, description?: str, imageUrl?: str, domain?: str}, stats?: {likes?: num, isLiked?: bool}, createdAt?: str}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "headline": { + "type": "string" + } + }, + "required": [ + "name", + "avatarUrl" + ] + }, + "text": { + "type": "string" + }, + "media": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image", + "video" + ] + }, + "url": { + "type": "string" + }, + "alt": { + "type": "string" + } + }, + "required": [ + "type", + "url", + "alt" + ] + }, + "linkPreview": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "domain": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "stats": { + "type": "object", + "properties": { + "likes": { + "type": "number" + }, + "isLiked": { + "type": "boolean" + } + } + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "author" + ] + } + }, + "message-draft": { + "hint": "props: {id: str, body: str, channel: str, subject: str, to: [str], role?: 'information'|'decision'|'control'|'state'|'composite', outcome?: 'sent'|'cancelled', from?: str, cc?: [str], bcc?: [str]}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "body": { + "type": "string", + "minLength": 1 + }, + "outcome": { + "type": "string", + "enum": [ + "sent", + "cancelled" + ] + }, + "channel": { + "type": "string", + "const": "email" + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string" + }, + "to": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "body", + "channel", + "subject", + "to" + ] + } + }, + "option-list": { + "hint": "props: {id: str, options: [{id: str, label: str, description?: str, disabled?: bool}], role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, selectionMode?: 'multi'|'single', defaultValue?: [str]|str|any, choice?: [str]|str|any, actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'd...", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "disabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "label" + ] + } + }, + "selectionMode": { + "type": "string", + "enum": [ + "multi", + "single" + ] + }, + "defaultValue": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "choice": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "actions": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "sentence": { + "type": "string" + }, + "confirmLabel": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "destructive", + "secondary", + "ghost", + "outline" + ] + }, + "loading": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "shortcut": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + }, + { + "type": "object", + "properties": { + "items": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "sentence": { + "type": "string" + }, + "confirmLabel": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "destructive", + "secondary", + "ghost", + "outline" + ] + }, + "loading": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "shortcut": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + }, + "align": { + "type": "string", + "enum": [ + "left", + "center", + "right" + ] + }, + "confirmTimeout": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "items" + ] + } + ] + }, + "minSelections": { + "type": "number", + "minimum": 0 + }, + "maxSelections": { + "type": "number", + "minimum": 1 + } + }, + "required": [ + "id", + "options" + ], + "additionalProperties": false + } + }, + "order-summary": { + "hint": "props: {id: str, items: [{id: str, name: str, unitPrice: num, description?: str, imageUrl?: str, quantity?: num}], pricing: {subtotal: num, total: num, tax?: num, taxLabel?: str, shipping?: num, discount?: num, discountLabel?: str, currency?: str}, role?: 'information'|'decision'|'control'|'state'|'composite', title?: str, variant?: 'summary'|'receipt', choice?: {action: str, orderId?: str, confirmedAt?: str}}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "title": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "summary", + "receipt" + ] + }, + "items": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "imageUrl": { + "type": "string", + "format": "uri" + }, + "quantity": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "unitPrice": { + "type": "number" + } + }, + "required": [ + "id", + "name", + "unitPrice" + ] + } + }, + "pricing": { + "type": "object", + "properties": { + "subtotal": { + "type": "number" + }, + "tax": { + "type": "number" + }, + "taxLabel": { + "type": "string" + }, + "shipping": { + "type": "number" + }, + "discount": { + "type": "number", + "minimum": 0 + }, + "discountLabel": { + "type": "string" + }, + "total": { + "type": "number" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "subtotal", + "total" + ] + }, + "choice": { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "confirm" + }, + "orderId": { + "type": "string" + }, + "confirmedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "action" + ] + } + }, + "required": [ + "id", + "items", + "pricing" + ], + "additionalProperties": false + } + }, + "parameter-slider": { + "hint": "props: {id: str, sliders: [{id: str, label: str, min: num, max: num, value: num, step?: num, unit?: str, precision?: num, disabled?: bool, trackClassName?: str, fillClassName?: str, handleClassName?: str}], role?: 'information'|'decision'|'control'|'state'|'composite', actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|'outline', loading?: bool, disa...", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "sliders": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "step": { + "type": "number", + "exclusiveMinimum": 0 + }, + "value": { + "type": "number" + }, + "unit": { + "type": "string" + }, + "precision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "disabled": { + "type": "boolean" + }, + "trackClassName": { + "type": "string" + }, + "fillClassName": { + "type": "string" + }, + "handleClassName": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "min", + "max", + "value" + ] + } + }, + "actions": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "sentence": { + "type": "string" + }, + "confirmLabel": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "destructive", + "secondary", + "ghost", + "outline" + ] + }, + "loading": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "shortcut": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + }, + { + "type": "object", + "properties": { + "items": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "sentence": { + "type": "string" + }, + "confirmLabel": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "destructive", + "secondary", + "ghost", + "outline" + ] + }, + "loading": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "shortcut": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + }, + "align": { + "type": "string", + "enum": [ + "left", + "center", + "right" + ] + }, + "confirmTimeout": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "items" + ] + } + ] + } + }, + "required": [ + "id", + "sliders" + ], + "additionalProperties": false + } + }, + "plan": { + "hint": "props: {id: str, title: str, todos: [{id: str, label: str, status: 'pending'|'in_progress'|'completed'|'cancelled', description?: str}], role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, description?: str, maxVisibleTodos?: num}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "title": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "todos": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "cancelled" + ] + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "status" + ] + } + }, + "maxVisibleTodos": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "title", + "todos" + ] + } + }, + "preferences-panel": { + "hint": "props: {id: str, sections: [{items: [any], heading?: str}], role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, title?: str, actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|'outline', loading?: bool, disabled?: bool, shortcut?: str}]...", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "title": { + "type": "string", + "minLength": 1 + }, + "sections": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "heading": { + "type": "string", + "minLength": 1 + }, + "items": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "const": "switch" + }, + "defaultChecked": { + "type": "boolean" + } + }, + "required": [ + "id", + "label", + "type" + ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "const": "toggle" + }, + "options": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "value", + "label" + ] + } + }, + "defaultValue": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "type", + "options" + ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "const": "select" + }, + "selectOptions": { + "minItems": 5, + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "value", + "label" + ] + } + }, + "defaultSelected": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "type", + "selectOptions" + ] + } + ] + } + } + }, + "required": [ + "items" + ] + } + }, + "actions": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "sentence": { + "type": "string" + }, + "confirmLabel": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "destructive", + "secondary", + "ghost", + "outline" + ] + }, + "loading": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "shortcut": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + }, + { + "type": "object", + "properties": { + "items": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "sentence": { + "type": "string" + }, + "confirmLabel": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "destructive", + "secondary", + "ghost", + "outline" + ] + }, + "loading": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "shortcut": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + }, + "align": { + "type": "string", + "enum": [ + "left", + "center", + "right" + ] + }, + "confirmTimeout": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "items" + ] + } + ] + } + }, + "required": [ + "id", + "sections" + ], + "additionalProperties": false + } + }, + "progress-tracker": { + "hint": "props: {id: str, steps: [{id: str, label: str, status: 'pending'|'in-progress'|'completed'|'failed', description?: str}], role?: 'information'|'decision'|'control'|'state'|'composite', elapsedTime?: num, choice?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "steps": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "in-progress", + "completed", + "failed" + ] + } + }, + "required": [ + "id", + "label", + "status" + ] + } + }, + "elapsedTime": { + "type": "number", + "minimum": 0 + }, + "choice": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + } + }, + "required": [ + "id", + "steps" + ], + "additionalProperties": false + } + }, + "question-flow": { + "hint": "props: {id: str, step: num, title: str, options: [{id: str, label: str, description?: str, disabled?: bool}], role?: 'information'|'decision'|'control'|'state'|'composite', description?: str, selectionMode?: 'single'|'multi'}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "step": { + "type": "number", + "minimum": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "options": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "disabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "label" + ] + } + }, + "selectionMode": { + "type": "string", + "enum": [ + "single", + "multi" + ] + } + }, + "required": [ + "id", + "step", + "title", + "options" + ] + } + }, + "stats-display": { + "hint": "props: {id: str, stats: [{key: str, label: str, value: str|num, format?: any, diff?: obj, sparkline?: obj}], role?: 'information'|'decision'|'control'|'state'|'composite', title?: str, description?: str}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "stats": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "format": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "text" + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "number" + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "compact": { + "type": "boolean" + } + }, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "currency" + }, + "currency": { + "type": "string", + "minLength": 1 + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "currency" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "percent" + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "basis": { + "type": "string", + "enum": [ + "fraction", + "unit" + ] + } + }, + "required": [ + "kind" + ] + } + ] + }, + "diff": { + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "upIsPositive": { + "type": "boolean" + }, + "label": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "sparkline": { + "type": "object", + "properties": { + "data": { + "minItems": 2, + "type": "array", + "items": { + "type": "number" + } + }, + "color": { + "type": "string" + } + }, + "required": [ + "data" + ] + } + }, + "required": [ + "key", + "label", + "value" + ] + } + } + }, + "required": [ + "id", + "stats" + ] + } + }, + "terminal": { + "hint": "props: {id: str, command: str, exitCode: num, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, stdout?: str, stderr?: str, durationMs?: num, cwd?: str, truncated?: bool, maxCollapsedLines?: num}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "command": { + "type": "string" + }, + "stdout": { + "type": "string" + }, + "stderr": { + "type": "string" + }, + "exitCode": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "durationMs": { + "type": "number" + }, + "cwd": { + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "maxCollapsedLines": { + "type": "number", + "minimum": 1 + } + }, + "required": [ + "id", + "command", + "exitCode" + ] + } + }, + "video": { + "hint": "props: {id: str, assetId: str, src: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, at: str, identifiers?: obj}, poster?: str, title?: str, description?: str, href?: str, domain?: str, durationMs?: num, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain', createdAt?: str, locale?: str, source?: {label: str, iconU...", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "information", + "decision", + "control", + "state", + "composite" + ] + }, + "receipt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "success", + "partial", + "failed", + "cancelled" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "identifiers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "outcome", + "summary", + "at" + ] + }, + "assetId": { + "type": "string" + }, + "src": { + "type": "string", + "format": "uri" + }, + "poster": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "href": { + "type": "string", + "format": "uri" + }, + "domain": { + "type": "string" + }, + "durationMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "ratio": { + "default": "auto", + "type": "string", + "enum": [ + "auto", + "1:1", + "4:3", + "16:9", + "9:16" + ] + }, + "fit": { + "default": "cover", + "type": "string", + "enum": [ + "cover", + "contain" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "locale": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "iconUrl": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "label" + ] + } + }, + "required": [ + "id", + "assetId", + "src" + ] + } + }, + "x-post": { + "hint": "props: {id: str, author: {name: str, handle: str, avatarUrl: str, verified?: bool}, text?: str, media?: {type: 'image'|'video', url: str, alt: str, aspectRatio?: '1:1'|'4:3'|'16:9'|'9:16'}, linkPreview?: {url: str, title?: str, description?: str, imageUrl?: str, domain?: str}, quotedPost?: any, stats?: {likes?: num, isLiked?: bool, isReposted?: bool, isBookmarked?: bool}, createdAt?: str}", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "avatarUrl": { + "type": "string", + "format": "uri" + }, + "verified": { + "type": "boolean" + } + }, + "required": [ + "name", + "handle", + "avatarUrl" + ] + }, + "text": { + "type": "string" + }, + "media": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image", + "video" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "alt": { + "type": "string" + }, + "aspectRatio": { + "type": "string", + "enum": [ + "1:1", + "4:3", + "16:9", + "9:16" + ] + } + }, + "required": [ + "type", + "url", + "alt" + ] + }, + "linkPreview": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "imageUrl": { + "type": "string", + "format": "uri" + }, + "domain": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "quotedPost": { + "$ref": "#" + }, + "stats": { + "type": "object", + "properties": { + "likes": { + "type": "number" + }, + "isLiked": { + "type": "boolean" + }, + "isReposted": { + "type": "boolean" + }, + "isBookmarked": { + "type": "boolean" + } + } + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "author" + ] + } + } +} \ No newline at end of file diff --git a/backend/apps/agents/ui_request_bridge.py b/backend/apps/agents/ui_request_bridge.py new file mode 100644 index 00000000..4f14722c --- /dev/null +++ b/backend/apps/agents/ui_request_bridge.py @@ -0,0 +1,56 @@ +"""Blocking bridge for interactive tool-ui components: AskUI parks here until the user +answers in the transcript (or the wait times out). Keyed by (session_id, component props.id), +so the frontend can respond without ever learning a server-side request id.""" + +import asyncio +from typing import Any, Dict, Optional, Tuple + +from pydantic import BaseModel, ConfigDict, InstanceOf +from typeguard import typechecked + +MAX_PENDING = 50 +MAX_WAIT_SECONDS = 600.0 + + +class PendingUiRequest(BaseModel): + model_config = ConfigDict(validate_assignment=True) + event: InstanceOf[asyncio.Event] + response: Optional[Dict[str, Any]] = None + + +p_pending: Dict[Tuple[str, str], PendingUiRequest] = {} + + +@typechecked +async def wait_for_ui_response(session_id: str, component_id: str, timeout_s: float) -> Optional[Dict[str, Any]]: + """Registers the request and blocks until respond_to_ui_request fires it; None on timeout.""" + if len(p_pending) >= MAX_PENDING: + raise ValueError("too many pending UI requests") + key = (session_id, component_id) + # A retried tool call for the same component replaces the stale wait; the old waiter times out. + pending = PendingUiRequest(event=asyncio.Event()) + p_pending[key] = pending + try: + await asyncio.wait_for(pending.event.wait(), timeout=min(timeout_s, MAX_WAIT_SECONDS)) + return pending.response + except asyncio.TimeoutError: + return None + finally: + if p_pending.get(key) is pending: + p_pending.pop(key, None) + + +@typechecked +def respond_to_ui_request(session_id: str, component_id: str, response: Dict[str, Any]) -> bool: + """Delivers the user's answer to the parked wait; False when nothing is waiting.""" + pending = p_pending.get((session_id, component_id)) + if pending is None: + return False + pending.response = response + pending.event.set() + return True + + +@typechecked +def has_pending_ui_request(session_id: str, component_id: str) -> bool: + return (session_id, component_id) in p_pending diff --git a/backend/apps/agents/web_mcp_server.py b/backend/apps/agents/web_mcp_server.py index dada7086..973550ad 100755 --- a/backend/apps/agents/web_mcp_server.py +++ b/backend/apps/agents/web_mcp_server.py @@ -16,6 +16,15 @@ FETCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/fetch" PRIMARY_HINT = os.environ.get("OPENSWARM_PRIMARY_API", "") or None # Whether this session actually has browser-delegation tools; gates the backend's "fall back to the browser" nudge. BROWSER_OK = os.environ.get("OPENSWARM_BROWSER_OK", "0") == "1" +# Whether the openswarm-ui server is live this session. The render-as-component reminder rides the +# tool RESULT because that's what the model reads right before answering; the system-prompt nudge +# alone loses to the prose prior (live-proven on haiku). +RICH_UI_OK = os.environ.get("OPENSWARM_RICH_UI_OK", "0") == "1" +RICH_UI_HINT = ( + "\n\n[presentation] When you answer the user with this data, render it with the ShowUI tool " + "(weather for forecasts, data-table for rows, stats-display for metrics, links for sources, " + "chart for series) and keep prose to one line. Answer in plain text only if no component fits." +) TOOLS = [ { @@ -115,6 +124,8 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict: results = r.get("results", "") if not results: results = f"No results for: {query}" + elif RICH_UI_OK: + results += RICH_UI_HINT return {"content": [{"type": "text", "text": results}]} if tool_name == "WebFetch": @@ -135,6 +146,8 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict: content = r.get("content", "") if not content: content = f"No content returned from {url}" + elif RICH_UI_OK: + content += RICH_UI_HINT return {"content": [{"type": "text", "text": content}]} return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} diff --git a/backend/main.py b/backend/main.py index d4f793fe..23c07edd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -902,6 +902,43 @@ async def spawn_agent_run(request: Request): return JSONResponse({"error": str(e)}, status_code=500) +@app.post("/api/ui-requests/wait") +async def ui_request_wait(request: Request): + """AskUI's blocking half: parks until the user answers the interactive component in the + transcript. Called by the show_ui_mcp_server stdio subprocess.""" + body = await request.json() + session_id = str(body.get("session_id", "")) + component_id = str(body.get("component_id", "")) + timeout_s = float(body.get("timeout_s", 600) or 600) + if not session_id or not component_id: + return JSONResponse({"error": "session_id and component_id are required"}, status_code=400) + try: + from backend.apps.agents.ui_request_bridge import wait_for_ui_response + response = await wait_for_ui_response(session_id, component_id, timeout_s) + return JSONResponse({"ok": response is not None, "response": response}) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=429) + except Exception as e: + logger.exception("ui_request_wait failed") + return JSONResponse({"error": str(e)}, status_code=500) + + +@app.post("/api/ui-requests/respond") +async def ui_request_respond(request: Request): + """The user's answer from the rendered component; releases the matching parked wait.""" + body = await request.json() + session_id = str(body.get("session_id", "")) + component_id = str(body.get("component_id", "")) + response = body.get("response") + if not session_id or not component_id or not isinstance(response, dict): + return JSONResponse({"error": "session_id, component_id and response object are required"}, status_code=400) + from backend.apps.agents.ui_request_bridge import respond_to_ui_request + delivered = respond_to_ui_request(session_id, component_id, response) + if not delivered: + return JSONResponse({"error": "no pending request for that component"}, status_code=404) + return JSONResponse({"ok": True}) + + @app.post("/api/invoke-agent/run") async def invoke_agent_run(request: Request): """Fork an existing agent session and send it a new message. diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py index c2546db9..ad39a74c 100644 --- a/backend/tests/test_onboarding.py +++ b/backend/tests/test_onboarding.py @@ -114,14 +114,27 @@ def test_parse_prep_carries_reasons(): def test_summarize_chatgpt_usage_leads_with_memory_and_caps(): - from backend.apps.onboarding.usage.chatgpt_usage import summarize_chatgpt_usage + from backend.apps.onboarding.usage.chatgpt_usage import TOTAL_CONVO_CHARS, summarize_chatgpt_usage - s = summarize_chatgpt_usage(812, ["Has an Akita", "Squats 495"], ["Swift concurrency", "Deadlift form"]) + s = summarize_chatgpt_usage( + 812, + ["Has an Akita", "Squats 495"], + ["Swift concurrency", "Deadlift form"], + ["User: fix my squat form?\nAssistant: brace harder."], + ) assert "812 past AI conversations" in s assert "Has an Akita; Squats 495" in s assert "Swift concurrency; Deadlift form" in s - big = summarize_chatgpt_usage(1000, [], [f"topic number {i} about something" for i in range(1000)]) - assert len(big) <= 4000 + assert "fix my squat form?" in s + big = summarize_chatgpt_usage( + 1000, + [], + [f"t{i}x" for i in range(1000)], + ["c" * 60000 for _ in range(10)], + ) + assert "t149x" in big and "t150x" not in big + convo_block = big.split("real asks + the exchange")[1] + assert len(convo_block) <= TOTAL_CONVO_CHARS + 10000 @pytest.mark.asyncio @@ -183,13 +196,20 @@ def test_read_google_session_records_scopes_to_named_auth_cookies(monkeypatch): def test_summarize_claude_usage_counts_and_caps(): - from backend.apps.onboarding.usage.claude_usage import summarize_claude_usage + from backend.apps.onboarding.usage.claude_usage import TOTAL_CONVO_CHARS, summarize_claude_usage - s = summarize_claude_usage(490, ["Yuji Itadori and Buddhism", "B2B SaaS Startup Ideas"]) + s = summarize_claude_usage( + 490, + ["Yuji Itadori and Buddhism", "B2B SaaS Startup Ideas"], + ["User: pitch me a startup\nAssistant: sure."], + ) assert "490 past Claude conversations" in s assert "Yuji Itadori and Buddhism; B2B SaaS Startup Ideas" in s - big = summarize_claude_usage(1000, [f"topic number {i} about something specific" for i in range(1000)]) - assert len(big) <= 4000 + assert "pitch me a startup" in s + big = summarize_claude_usage(1000, [f"t{i}x" for i in range(1000)], ["c" * 60000 for _ in range(10)]) + assert "t149x" in big and "t150x" not in big + convo_block = big.split("real asks + the exchange")[1] + assert len(convo_block) <= TOTAL_CONVO_CHARS + 10000 @pytest.mark.asyncio diff --git a/backend/tests/test_tool_result_hook.py b/backend/tests/test_tool_result_hook.py index e5d52398..7175f9d5 100644 --- a/backend/tests/test_tool_result_hook.py +++ b/backend/tests/test_tool_result_hook.py @@ -50,11 +50,16 @@ async def test_view_builder_dep_install_broadcasts_app_deps_changed(): registry: dict = {} ctx = p_ctx(registry) ctx.session.mode = "view-builder" - with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send: + # The broadcast gates on an attached preview runtime (not the mode), so agent-mode CreateApp builds get it too. + fake_runtime_manager = MagicMock() + fake_runtime_manager.get.return_value = object() + with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send, \ + patch("backend.apps.outputs.runtime.manager", fake_runtime_manager): await tool_result_hook.post_tool_hook( ctx, {"tool_name": "Bash", "tool_response": "added 3 packages", "tool_input": {"command": "npm install recharts"}}, "tu1", None ) + view_builder_state.view_builder_dirty_sessions.discard(ctx.session_id) events = [c.args[1] for c in send.await_args_list] assert "agent:app_deps_changed" in events diff --git a/electron/main.js b/electron/main.js index e9d74162..2c23248f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1239,7 +1239,7 @@ function createWindow() { }); if (isDev) { - // Dev only: a worktree's Electron can point at its own webpack-dev-server via OPENSWARM_DEV_URL (full URL) or OPENSWARM_DEV_PORT (port), instead of colliding on the shared :3000. Packaged builds never hit this branch. + // Dev only: OPENSWARM_DEV_URL (full override) or OPENSWARM_DEV_PORT lets a second worktree's Electron point at its own webpack-dev-server instead of colliding on the shared :3000. Packaged builds never hit this branch. mainWindow.loadURL(process.env.OPENSWARM_DEV_URL || `http://localhost:${process.env.OPENSWARM_DEV_PORT || 3000}`); } else if (frontendServerPort) { mainWindow.loadURL(`http://127.0.0.1:${frontendServerPort}/index.html`); @@ -3079,6 +3079,69 @@ ipcMain.handle('open-external', (_event, url) => { } }); +// Applications launcher support. Names are bare .app basenames from the local scan; both +// handlers hard-validate the name and resolve strictly inside /Applications so a hostile +// renderer string can't traverse anywhere else. +const APP_NAME_RE = /^[\w .&'()+-]{1,80}$/; +const appIconCache = new Map(); +function resolveApplicationPath(name) { + if (typeof name !== 'string' || !APP_NAME_RE.test(name) || name.includes('..')) return null; + const path = require('path'); + const resolved = path.join('/Applications', `${name}.app`); + if (path.dirname(resolved) !== '/Applications') return null; + return resolved; +} + +ipcMain.handle('get-app-icon', async (_event, name) => { + const target = resolveApplicationPath(name); + if (!target) return null; + if (appIconCache.has(name)) return appIconCache.get(name); + try { + let dataUrl = null; + if (process.platform === 'darwin') { + // NEVER app.getFileIcon here: a corrupt .icns raises a native ObjC exception no JS try/catch + // can contain and SIGTRAPs the whole app (reproduced 2026-07-20: last IPC get-app-icon, + // crashpad in_range_cast warning, death). sips does the decode in a disposable child instead. + const { execFile } = require('child_process'); + const os = require('os'); + const run = (cmd, args) => new Promise((resolve, reject) => { + execFile(cmd, args, { timeout: 5000 }, (err, stdout) => (err ? reject(err) : resolve(String(stdout).trim()))); + }); + const resources = path.join(target, 'Contents', 'Resources'); + let icnsName = await run('/usr/bin/defaults', ['read', path.join(target, 'Contents', 'Info'), 'CFBundleIconFile']).catch(() => ''); + if (icnsName && !icnsName.endsWith('.icns')) icnsName += '.icns'; + let icns = icnsName ? path.join(resources, icnsName) : ''; + if (!icns || !fs.existsSync(icns)) { + const alt = fs.existsSync(resources) ? fs.readdirSync(resources).find((f) => f.endsWith('.icns')) : null; + icns = alt ? path.join(resources, alt) : ''; + } + if (icns && fs.existsSync(icns)) { + const outPng = path.join(os.tmpdir(), `osw-icon-${process.pid}-${Date.now()}.png`); + await run('/usr/bin/sips', ['-s', 'format', 'png', '-z', '128', '128', icns, '--out', outPng]).catch(() => ''); + if (fs.existsSync(outPng)) { + dataUrl = `data:image/png;base64,${fs.readFileSync(outPng).toString('base64')}`; + fs.rmSync(outPng, { force: true }); + } + } + } else { + const icon = await app.getFileIcon(target, { size: 'large' }); + dataUrl = icon && !icon.isEmpty() ? icon.toDataURL() : null; + } + appIconCache.set(name, dataUrl); + return dataUrl; + } catch (_) { + appIconCache.set(name, null); + return null; + } +}); + +ipcMain.handle('open-application', (_event, name) => { + const target = resolveApplicationPath(name); + if (!target) return false; + shell.openPath(target); + return true; +}); + // Affiliate install state. Returns the persisted install.json contents so // the renderer can attach the referral code to authenticated cloud calls // (Stripe checkout, sign-in events) for downstream attribution. diff --git a/electron/preload.js b/electron/preload.js index e82275ca..b34f065a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -75,6 +75,8 @@ contextBridge.exposeInMainWorld('openswarm', { cdpRoutesGet: (wcId, originFilter) => ipcRenderer.invoke('cdp-routes-get', wcId, originFilter), getWebviewConsole: (wcId) => ipcRenderer.invoke('get-webview-console', wcId), capturePage: (rect) => ipcRenderer.invoke('capture-page', rect), + getAppIcon: (name) => ipcRenderer.invoke('get-app-icon', name), + openApplication: (name) => ipcRenderer.invoke('open-application', name), getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), getCrashRecoveryInfo: () => ipcRenderer.invoke('get-crash-recovery-info'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92d511d7..e4608790 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,42 +18,75 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", + "@pierre/diffs": "^1.0.11", "@reduxjs/toolkit": "^2.8.2", "@types/react-syntax-highlighter": "^15.5.13", + "ansi-to-react": "^6.2.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "codemirror": "^6.0.2", "framer-motion": "^12.35.2", "html-to-image": "^1.11.13", + "leaflet": "^1.9.4", "lucide-react": "^1.17.0", + "radix-ui": "^1.6.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "react-syntax-highlighter": "^16.1.1", - "remark-gfm": "^4.0.1" + "recharts": "^2.15.4", + "remark-gfm": "^4.0.1", + "shiki": "^3.23.0", + "supercluster": "^8.0.1", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", + "@tailwindcss/postcss": "^4.3.3", + "@types/leaflet": "^1.9.21", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/react-redux": "^7.1.34", + "@types/supercluster": "^7.1.3", "babel-loader": "^9.2.1", "copy-webpack-plugin": "^14.0.0", "css-loader": "^6.8.0", "css-modules-types-loader": "^0.6.10", "html-webpack-plugin": "^5.5.0", + "postcss": "^8.5.20", + "postcss-loader": "^8.2.1", "sass": "^1.89.2", "sass-loader": "^16.0.5", "style-loader": "^3.3.0", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.1", + "tw-animate-css": "^1.4.0", "typescript": "^5.0.0", "webpack": "^5.88.0", "webpack-cli": "^5.1.0", "webpack-dev-server": "^4.15.0" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2052,6 +2085,486 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3067,6 +3580,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@pierre/diffs": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.0.11.tgz", + "integrity": "sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw==", + "license": "apache-2.0", + "dependencies": { + "@shikijs/core": "^3.0.0", + "@shikijs/engine-javascript": "^3.0.0", + "@shikijs/transformers": "^3.0.0", + "diff": "8.0.3", + "hast-util-to-html": "9.0.5", + "lru_map": "0.4.1", + "shiki": "^3.0.0" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3077,6 +3609,1513 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.6.tgz", + "integrity": "sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.12.tgz", + "integrity": "sha512-Y0zhCQ/XUdTom5hAxvE8RlXqR4hZmKGK6g2//LfgHmb88PJFOpXSh9B/7FlfYXezVY5FKGjRYWCYz5FXxZ9WZQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.17.tgz", + "integrity": "sha512-l3Dmp+qPPc3SqT8+SPnxIgoWBEU2MMBxcQ7BsoRgak2UT75xY83SFvFcrUkUAWukOV3LFF+BQ9aBIFtZsIG8yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collapsible": "1.1.17", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.20.tgz", + "integrity": "sha512-Ft1W+jPqSh5BKfSTe4dpq6UYQKKQJ5Tvq3wfux+WVlg7nPwFK/3pIlHTb3Rbe+b/tNurx8YGXD9em91ujmgwuQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dialog": "1.1.20", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.12.tgz", + "integrity": "sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.12.tgz", + "integrity": "sha512-Sok2IBJxA1XO4pU3ldzZMwUBMumIt64EY8zOUlVq5CdS+i0FrEbajVslfDB+YGWLMsrjY2kZQB0DgkrZXLZvcg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.3.tgz", + "integrity": "sha512-peavtnApRB1tABx42tHw+rPU83GSg5tXicMYO/Xi1/lqNcRsF6jkr6L7Njo7gj4q/xtDRDKBkqJvbMtoOMYWtA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.8.tgz", + "integrity": "sha512-wfN60IGuxynWK7rP4Ks2p7u9G7gqirzkAiFptuzVbsR1ot2/K+PavNUAtxiKxyRfLOvSbVfvvm9m3rFqLEXz7A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.17.tgz", + "integrity": "sha512-DJgqGsNXa0df3ifz9PFNgvgj/bzIu5QTVWCt5nQWaUkM6y0EarUv4QG4s6mCoeQdOIyVOT/Q1osFuEGub2TDXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", + "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", + "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.4.tgz", + "integrity": "sha512-eO9tkvHvo4dNwb+lytEcKWjy8c8To+ttLwNt0f9XzzsVFIaspqt3i1/c0JaaksxBB5G//zPo9CCgn39huWQyBA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-menu": "2.1.21", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.20.tgz", + "integrity": "sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.16.tgz", + "integrity": "sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-effect-event": "0.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.21.tgz", + "integrity": "sha512-gavFM1iWLmWdxWNdGJHVeWeSQul5WE/0pxfvWWt1QnD71hyyujyMCDVacqBomaSOjdxwDzYB+Ng4+MxOvrFB1A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.21", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.13.tgz", + "integrity": "sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.13.tgz", + "integrity": "sha512-PopvWqiutoZh5TJXk9EV9Wh+khbp+LQ+A0H4uHocIjVcKIi6gMlBy4sAaW15thwUSc6PrR8J62nB2uM+htqrcg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-label": "2.1.12", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.20.tgz", + "integrity": "sha512-UPmdiR8NsngWjG/y9mClzFg+Rbbpy8u0p0SKM+t7mfH4V07TiLsuylqR0RhJiRibopsawoTtMQudm/TxwHWa9w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.12.tgz", + "integrity": "sha512-dxioNQ7VOrYKKWJIxMRmJPDSWQN0gNCUy3zaqUSBwsuFAiFzI0yLGJr2q3ml07k/HlOk55N8KEfwa1ZgfprJ3w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.21.tgz", + "integrity": "sha512-2BHtaJHvvoWTECyrja1mOjN6z2dWdpeHL6b8PxqZYgex8J8xakT2KAchpZIaMwNPauIRHH/VlPJYhSSKe8lz2g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.21.tgz", + "integrity": "sha512-uQONG1qM4D8FSEt0xRs5yDpzeSWggf8lOKqHa84NvqoVoc1qJ6XN+gdkrJuQCyEY55793gLlQI3wjgWO5A/Oqg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.21", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.19.tgz", + "integrity": "sha512-58OVQUrpWx/zGVV3lxGUyAtjX4n0305Z8xIdUAq2QlFO2m2hd1eBS4x1yIVtV8bzCQJja0TJttWcwiPI6y6tmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.13.tgz", + "integrity": "sha512-reLtbZtEBsMcqXkjd/wOga4e8t9uxzFHdX9W/j/ZfGznTNJxLGjRrDNGnGOOWcBazMH1BI/b7Cx+hblSWSD7aw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.8.tgz", + "integrity": "sha512-NH9puF7Es5Loh8vFELm+SyayzV27nyBw8kiP/uD9wbkwgq359FfbkKEvccrNk75z0LiSqC4REWk1iL9xdeWJkQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.20.tgz", + "integrity": "sha512-/PYqbsyuDkNj+IxMcRx71qNt6GelnuNulMwdCV7AtFEhUyK6XkbwreEN6CCLydMeTiDozBV4uv5aF5d12dDH7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.4.tgz", + "integrity": "sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.14.tgz", + "integrity": "sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.8.tgz", + "integrity": "sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.13.tgz", + "integrity": "sha512-1dUdKDd63Tz9FfbTw20MVr28ohG4v7HOJ1dsavGBBPBS3KGzLOyLKiMJAC1OdgiY18nTSHpD4fULGK5gsLY/ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.4.tgz", + "integrity": "sha512-OpbUmp/korY+tjEQmHwGyQ+QQ3LBlCPC70z03Q/NSqGaHf2EijuwpjQPnswrH6cZLWyT2J6FmB+kzRoMUtPBig==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.16.tgz", + "integrity": "sha512-w7lLsTSd3940vFYEshKkHw+NGf7H0QDJPHYsy8NRjDCVbO6ZdKW1X/xoJSYHZtttnrdZiYqbN2O/2uHGB0zasw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.15.tgz", + "integrity": "sha512-JVBHNfTBbGd9hhq/xZZOgmVnBCXhLs8PJJ8vMzgwI0pLZNsKckW9pkoqHyxokUCt1hoxbwDNvF9DItEeZsG68g==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.4.tgz", + "integrity": "sha512-E2JxqAvaTUEhWtBptWo02g8FnLYPymv9ahEvW/cZQPPV4ySeyo0M8n3sXccsLUAIfMbexnfXt91qF7UjTbTMMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.12.tgz", + "integrity": "sha512-2hezgFBBR5jU3S9L9bIZ9Uag6LnvxuFBNsLCfTR8qx+NshuvFmpL4C72+5zMS3Z6UgHNSU1thOw2UaBBPEDpsQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.4.tgz", + "integrity": "sha512-8dUytW34KoJaB22ctfP7hqUCuyYa8xn2w7H8kCneeOtS5oM7UBivcnZtR8P4kPYMgdoAZlkMhE9/qkYZ5MlRzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.4.tgz", + "integrity": "sha512-7iGMj1SfZBAc6xRiy0Y3Wr/v52viQeDhOmaM3fNRyNf2nbYooZA3kKoEDGLPtrDv8JitpzcueqdZLusVMojLdQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.18.tgz", + "integrity": "sha512-1zq2XkQkK/KfbZn84edytYpOLquhNalra5LXc3NAMKhNRSGtyXqjMv6OyC9jlSuNKpqvQtsb57WKoICNk1v/sQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.20.tgz", + "integrity": "sha512-S28OtO1IvYSpWfaUBtiYCTTwRLF8doafj+a+uQw8rc8dLINS52uuG3CIPCeZc3Jfdb/S7o7HhlQxLoXlIYRu6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.15.tgz", + "integrity": "sha512-tyCejFjhJ51UKFVIG8jh9nTdRIsFPxrgrI4IdlxuJeP+AKTfTko+0gBueyBFLHqsyE71Aj9PKHjMnG+YRPyKhA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.16.tgz", + "integrity": "sha512-uil+A0Um3LaZQJkMap4nIg0VgqWc0j3iNU4AXf9a/zHOgPHNYWfVk5WVsG2296Y8HLv1bxiN7uQJblHc1+00tw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-toggle": "1.1.15", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.16.tgz", + "integrity": "sha512-ZnvUAH+ftoRYzUzFQ8gqKnQ1lUFYb3amguGu+BXpfjvLIkjmXCcHCJlQeBLBlJCOtGNVtP+wHrZaUCC/zYKQMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-separator": "1.1.12", + "@radix-ui/react-toggle-group": "1.1.16" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.13.tgz", + "integrity": "sha512-56XPNYGMnGBcPyiBTaEXB7IGPybbsdNkFgSv90SCrHkXnu2Av1HhsyZMegzXlTu/QHA3V6/l22GZCv9iEoiqmQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.4.tgz", + "integrity": "sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", + "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.8.tgz", + "integrity": "sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -3103,6 +5142,83 @@ } } }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz", + "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3115,6 +5231,277 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3157,6 +5544,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3242,6 +5692,13 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -3295,6 +5752,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3383,7 +5850,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -3490,6 +5957,16 @@ "@types/node": "*" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3851,6 +6328,12 @@ "ajv": "^8.8.2" } }, + "node_modules/anser": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz", + "integrity": "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==", + "license": "MIT" + }, "node_modules/ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", @@ -3874,6 +6357,21 @@ "node": ">=8" } }, + "node_modules/ansi-to-react": { + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/ansi-to-react/-/ansi-to-react-6.2.6.tgz", + "integrity": "sha512-Eqi0iaMK5OZ3jsVFxWvU2B74UZBnGuHlkflKMX6wTOeH+luy9KE2O0gUkc2PxhIP1R4IO0xohv62UMFInQOSeg==", + "license": "BSD-3-Clause", + "dependencies": { + "anser": "^2.3.2", + "escape-carriage": "^1.3.1", + "linkify-it": "^3.0.3" + }, + "peerDependencies": { + "react": "^16.3.2 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.3.2 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3901,6 +6399,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -4316,6 +6833,18 @@ "node": ">=6.0" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -4733,6 +7262,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4750,6 +7400,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4822,7 +7478,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -4834,6 +7489,12 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -4847,6 +7508,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -4990,14 +7660,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -5013,6 +7683,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/envinfo": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", @@ -5074,6 +7754,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5084,6 +7806,12 @@ "node": ">=6" } }, + "node_modules/escape-carriage": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz", + "integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==", + "license": "MIT" + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -5184,7 +7912,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, "license": "MIT" }, "node_modules/events": { @@ -5308,6 +8035,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -5627,6 +8363,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -5771,6 +8516,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -5969,6 +8737,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/html-webpack-plugin": { "version": "5.6.7", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz", @@ -6147,9 +8925,9 @@ } }, "node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", "license": "MIT", "funding": { "type": "opencollective", @@ -6303,6 +9081,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/interpret": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", @@ -6540,12 +9327,45 @@ "node": ">= 10.13.0" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "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/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6584,6 +9404,12 @@ "node": ">=6" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -6605,12 +9431,288 @@ "shell-quote": "^1.8.3" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/loader-runner": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", @@ -6645,7 +9747,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -6701,6 +9802,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lru_map": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", + "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6720,6 +9827,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -7774,9 +10891,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -7959,6 +11076,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -8221,9 +11355,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", "dev": true, "funding": [ { @@ -8241,7 +11375,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8249,6 +11383,78 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-loader": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.2.1.tgz", + "integrity": "sha512-k98jtRzthjj3f76MYTs9JTpRqV1RaaMhEU0Lpw9OTmQZQdppg4B30VZ74BojuBHt3F4KyubHJoXCMUeM8Bqeow==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^9.0.0", + "jiti": "^2.5.1", + "semver": "^7.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/postcss-loader/node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss-loader/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss-modules-extract-imports": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", @@ -8427,6 +11633,83 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/radix-ui": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.3.tgz", + "integrity": "sha512-KmhSq0NfxIwN9q6ZpEaZ+J0hiVFQcGyrPYYhbxg34q9B8CIrQoccLJ3mJ9znLRslLoaogsP2ml8JKOVoKMXgvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-accessible-icon": "1.1.12", + "@radix-ui/react-accordion": "1.2.17", + "@radix-ui/react-alert-dialog": "1.1.20", + "@radix-ui/react-arrow": "1.1.12", + "@radix-ui/react-aspect-ratio": "1.1.12", + "@radix-ui/react-avatar": "1.2.3", + "@radix-ui/react-checkbox": "1.3.8", + "@radix-ui/react-collapsible": "1.1.17", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-context-menu": "2.3.4", + "@radix-ui/react-dialog": "1.1.20", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-dropdown-menu": "2.1.21", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-form": "0.1.13", + "@radix-ui/react-hover-card": "1.1.20", + "@radix-ui/react-label": "2.1.12", + "@radix-ui/react-menu": "2.1.21", + "@radix-ui/react-menubar": "1.1.21", + "@radix-ui/react-navigation-menu": "1.2.19", + "@radix-ui/react-one-time-password-field": "0.1.13", + "@radix-ui/react-password-toggle-field": "0.1.8", + "@radix-ui/react-popover": "1.1.20", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-progress": "1.1.13", + "@radix-ui/react-radio-group": "1.4.4", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-scroll-area": "1.2.15", + "@radix-ui/react-select": "2.3.4", + "@radix-ui/react-separator": "1.1.12", + "@radix-ui/react-slider": "1.4.4", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-switch": "1.3.4", + "@radix-ui/react-tabs": "1.1.18", + "@radix-ui/react-toast": "1.2.20", + "@radix-ui/react-toggle": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.16", + "@radix-ui/react-toolbar": "1.1.16", + "@radix-ui/react-tooltip": "1.2.13", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-escape-keydown": "1.1.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8484,6 +11767,20 @@ "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -8534,6 +11831,53 @@ } } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.14.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", @@ -8572,6 +11916,43 @@ "react-dom": ">=18" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", @@ -8637,6 +12018,45 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -8701,6 +12121,30 @@ "node": ">=4" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexpu-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", @@ -8847,9 +12291,9 @@ "license": "MIT" }, "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, "node_modules/resolve": { @@ -9289,6 +12733,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -9570,6 +13030,15 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -9598,6 +13067,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -9679,6 +13165,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -9745,6 +13237,35 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -9773,6 +13294,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -9952,6 +13479,49 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -10033,6 +13603,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -10446,6 +14038,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7eacee24..5bfcd358 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,36 +19,56 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", + "@pierre/diffs": "^1.0.11", "@reduxjs/toolkit": "^2.8.2", "@types/react-syntax-highlighter": "^15.5.13", + "ansi-to-react": "^6.2.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "codemirror": "^6.0.2", "framer-motion": "^12.35.2", "html-to-image": "^1.11.13", + "leaflet": "^1.9.4", "lucide-react": "^1.17.0", + "radix-ui": "^1.6.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "react-syntax-highlighter": "^16.1.1", - "remark-gfm": "^4.0.1" + "recharts": "^2.15.4", + "remark-gfm": "^4.0.1", + "shiki": "^3.23.0", + "supercluster": "^8.0.1", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", + "@tailwindcss/postcss": "^4.3.3", + "@types/leaflet": "^1.9.21", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/react-redux": "^7.1.34", + "@types/supercluster": "^7.1.3", "babel-loader": "^9.2.1", "copy-webpack-plugin": "^14.0.0", "css-loader": "^6.8.0", "css-modules-types-loader": "^0.6.10", "html-webpack-plugin": "^5.5.0", + "postcss": "^8.5.20", + "postcss-loader": "^8.2.1", "sass": "^1.89.2", "sass-loader": "^16.0.5", "style-loader": "^3.3.0", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.1", + "tw-animate-css": "^1.4.0", "typescript": "^5.0.0", "webpack": "^5.88.0", "webpack-cli": "^5.1.0", diff --git a/frontend/scripts/gen-toolui-hints.ts b/frontend/scripts/gen-toolui-hints.ts new file mode 100644 index 00000000..75f42631 --- /dev/null +++ b/frontend/scripts/gen-toolui-hints.ts @@ -0,0 +1,72 @@ +/* Generates terse per-component prop hints for the ShowUI tool description straight from the + vendored tool-ui zod contracts, so the server spec can never drift from what validates. + Run: npx tsx --tsconfig tsconfig.json scripts/gen-toolui-hints.ts */ +import { z } from 'zod'; + +const TARGETS: Record = { + 'approval-card': ['../src/toolui/components/approval-card/schema', 'SerializableApprovalCardSchema'], + 'audio': ['../src/toolui/components/audio/schema', 'SerializableAudioSchema'], + 'chart': ['../src/toolui/components/chart/schema', 'SerializableChartSchema'], + 'citation': ['../src/toolui/components/citation/schema', 'SerializableCitationSchema'], + 'code-block': ['../src/toolui/components/code-block/schema', 'SerializableCodeBlockSchema'], + 'code-diff': ['../src/toolui/components/code-diff/schema', 'SerializableCodeDiffSchema'], + 'data-table': ['../src/toolui/components/data-table/schema', 'SerializableDataTableSchema'], + 'geo-map': ['../src/toolui/components/geo-map/schema', 'SerializableGeoMapSchema'], + 'image': ['../src/toolui/components/image/schema', 'SerializableImageSchema'], + 'image-gallery': ['../src/toolui/components/image-gallery/schema', 'SerializableImageGallerySchema'], + 'instagram-post': ['../src/toolui/components/instagram-post/schema', 'SerializableInstagramPostSchema'], + 'item-carousel': ['../src/toolui/components/item-carousel/schema', 'SerializableItemCarouselSchema'], + 'link-preview': ['../src/toolui/components/link-preview/schema', 'SerializableLinkPreviewSchema'], + 'linkedin-post': ['../src/toolui/components/linkedin-post/schema', 'SerializableLinkedInPostSchema'], + 'message-draft': ['../src/toolui/components/message-draft/schema', 'SerializableEmailDraftSchema'], + 'option-list': ['../src/toolui/components/option-list/schema', 'SerializableOptionListSchema'], + 'order-summary': ['../src/toolui/components/order-summary/schema', 'SerializableOrderSummarySchema'], + 'parameter-slider': ['../src/toolui/components/parameter-slider/schema', 'SerializableParameterSliderSchema'], + 'plan': ['../src/toolui/components/plan/schema', 'SerializablePlanSchema'], + 'preferences-panel': ['../src/toolui/components/preferences-panel/schema', 'SerializablePreferencesPanelSchema'], + 'progress-tracker': ['../src/toolui/components/progress-tracker/schema', 'SerializableProgressTrackerSchema'], + 'question-flow': ['../src/toolui/components/question-flow/schema', 'SerializableProgressiveModeSchema'], + 'stats-display': ['../src/toolui/components/stats-display/schema', 'SerializableStatsDisplaySchema'], + 'terminal': ['../src/toolui/components/terminal/schema', 'SerializableTerminalSchema'], + 'video': ['../src/toolui/components/video/schema', 'SerializableVideoSchema'], + 'x-post': ['../src/toolui/components/x-post/schema', 'SerializableXPostSchema'], +}; + +function describe(node: any, depth: number): string { + if (!node || typeof node !== 'object') return 'any'; + if (Array.isArray(node.enum)) return node.enum.map((v: unknown) => `'${v}'`).join('|'); + if (Array.isArray(node.anyOf)) return node.anyOf.map((n: any) => describe(n, depth)).join('|'); + const t = node.type; + if (t === 'array') return `[${describe(node.items, depth)}]`; + if (t === 'object') { + if (depth >= 2) return 'obj'; + const req = new Set(node.required || []); + const props = node.properties || {}; + // Required props FIRST so tail truncation can only ever cost optional detail, never a required field. + const keys = Object.keys(props).sort((a, b) => Number(req.has(b)) - Number(req.has(a))); + const parts = keys.map((k) => `${k}${req.has(k) ? '' : '?'}: ${describe(props[k], depth + 1)}`); + return `{${parts.join(', ')}}`; + } + if (t === 'string') return 'str'; + if (t === 'number' || t === 'integer') return 'num'; + if (t === 'boolean') return 'bool'; + return 'any'; +} + +async function main(): Promise { + const fs = await import('fs'); + const out: Record = {}; + for (const [name, [path, exportName]] of Object.entries(TARGETS)) { + const mod = await import(path); + const schema = mod[exportName]; + const js = z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' } as any) as any; + let hint = describe(js, 0); + if (hint.length > 420) hint = hint.slice(0, 417) + '...'; + out[name] = { hint: `props: ${hint.replace(/"/g, "'")}`, schema: js }; + } + const dest = new URL('../../backend/apps/agents/toolui_schemas.json', import.meta.url).pathname; + fs.writeFileSync(dest, JSON.stringify(out, null, 1)); + console.log(`wrote ${Object.keys(out).length} component schemas to ${dest}`); +} + +void main(); diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 211ddb74..53d94009 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -82,9 +82,9 @@ const AppShell: React.FC = () => { const canGoForward = historyIdx < maxHistoryIdx.current; const [dashboardsExpanded, setDashboardsExpanded] = useState(true); const [appsExpanded, setAppsExpanded] = useState(true); - // Starts collapsed so a fresh boot lands on a clean canvas; the toggle brings it back. - // Arc/Zen: the sidebar is the primary chrome (search + nav live here), shown by default. - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + // Desktop shell: the wallpaper canvas IS the home surface, so the sidebar starts docked away + // (left-edge hover peeks it; the pin toggle brings it back full-time). + const [sidebarCollapsed, setSidebarCollapsed] = useState(true); const [renamingDashboardId, setRenamingDashboardId] = useState(null); const [renamingAppId, setRenamingAppId] = useState(null); const [renameValue, setRenameValue] = useState(''); diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index de84e70d..6883d901 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -57,7 +57,10 @@ import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/ import CompactionMarker from './bubbles/CompactionMarker'; import MessageActionBar from './shell/MessageActionBar'; import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble'; -import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble'; +import ToolGroupBubble, { RenderItem, ToolGroup, ToolGroupEntry, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble'; +import ToolUiBubble from './tool-ui/ToolUiBubble'; +import AskUiBubble from './tool-ui/AskUiBubble'; +import { isShowUiPair, isAskUiPair } from './tool-ui/showUiPayload'; import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar'; import ForceStopAgentBar from './ForceStopAgentBar'; import { RateLimitPill } from './shell/RateLimitPill'; @@ -1046,28 +1049,97 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const renderItems: RenderItem[] = useMemo(() => { const items: RenderItem[] = []; let i = 0; + // Live-updating cards: repeated ShowUI calls with the SAME component+props.id are one card that + // UPDATES IN PLACE at its first position (progress advances, data refreshes), never a stack of + // stale snapshots. Pre-scan maps each id key to its first slot and its latest call+result. + const firstCallIdByKey = new Map(); + const latestByKey = new Map(); + const keyByCallId = new Map(); + for (let s = 0; s < activeBranchMessages.length; s++) { + const m = activeBranchMessages[s]; + const mc = m.content; + if (m.role !== 'tool_call' || typeof mc !== 'object' || !/(^|__)ShowUI$/.test(String(mc?.tool || ''))) continue; + const input = mc?.input as { component?: unknown; props?: { id?: unknown } } | undefined; + const compId = input?.props?.id; + if (!input?.component || typeof compId !== 'string' || !compId) continue; + const key = `${input.component}:${compId}`; + keyByCallId.set(m.id, key); + if (!firstCallIdByKey.has(key)) firstCallIdByKey.set(key, m.id); + const next = activeBranchMessages[s + 1]; + latestByKey.set(key, { call: m, result: next && next.role === 'tool_result' ? next : null }); + } + // Narration that led INTO a tool phase; folds into that phase's group on a finished session. + let leadNotes: typeof activeBranchMessages = []; while (i < activeBranchMessages.length) { const msg = activeBranchMessages[i]; if (msg.role === 'tool_call' || msg.role === 'tool_result') { const group: typeof activeBranchMessages = []; - while ( - i < activeBranchMessages.length && - (activeBranchMessages[i].role === 'tool_call' || - activeBranchMessages[i].role === 'tool_result') - ) { - group.push(activeBranchMessages[i]); - i++; + // On a finished session the whole tool PHASE folds into one quiet row: short narration + // LEADING INTO or BETWEEN tool runs is absorbed (readable on expand), only the final + // answer stays out. While running, narration streams visibly, so the phase never folds live. + const noteMarks: Array<{ afterCall: number; msg: (typeof activeBranchMessages)[number] }> = + leadNotes.map((m) => ({ afterCall: 0, msg: m })); + leadNotes = []; + let callsSoFar = 0; + while (i < activeBranchMessages.length) { + const m = activeBranchMessages[i]; + if (m.role === 'tool_call' || m.role === 'tool_result') { + group.push(m); + if (m.role === 'tool_call') callsSoFar++; + i++; + continue; + } + if (!sessionRunning && m.role === 'assistant') { + let j = i; + while (j < activeBranchMessages.length && activeBranchMessages[j].role === 'assistant') j++; + const next = activeBranchMessages[j]; + if (next && (next.role === 'tool_call' || next.role === 'tool_result')) { + for (let k = i; k < j; k++) { + if (!activeBranchMessages[k].hidden) noteMarks.push({ afterCall: callsSoFar, msg: activeBranchMessages[k] }); + } + i = j; + continue; + } + } + break; } - const calls = group.filter((m) => m.role === 'tool_call'); + const allCalls = group.filter((m) => m.role === 'tool_call'); const results = group.filter((m) => m.role === 'tool_result'); - const pairs: ToolPair[] = calls.map((call, idx) => ({ + const allPairs: ToolPair[] = allCalls.map((call, idx) => ({ type: 'tool_pair' as const, id: `pair-${call.id}`, call, result: results[idx] || null, })); + // ShowUI/AskUI calls render as inline components, never buried inside a collapsed group. + // They typically cap a run of work, so the quiet group row stays above the widget. + const showUiPairs = allPairs.filter((p) => isShowUiPair(p) || isAskUiPair(p)); + const pairs = allPairs.filter((p) => !isShowUiPair(p) && !isAskUiPair(p)); + const calls = pairs.map((p) => p.call); + + // Folded narration goes back at its original position among the visible pairs. + const groupEntries: ToolGroupEntry[] | undefined = (() => { + if (noteMarks.length === 0) return undefined; + const entries: ToolGroupEntry[] = []; + let noteIdx = 0; + const noteText = (m: (typeof activeBranchMessages)[number]) => + typeof m.content === 'string' ? m.content : ''; + allPairs.forEach((pair, idx) => { + while (noteIdx < noteMarks.length && noteMarks[noteIdx].afterCall <= idx) { + entries.push({ kind: 'note', id: `note-${noteMarks[noteIdx].msg.id}`, text: noteText(noteMarks[noteIdx].msg) }); + noteIdx++; + } + if (!isShowUiPair(pair) && !isAskUiPair(pair)) entries.push({ kind: 'pair', pair }); + }); + while (noteIdx < noteMarks.length) { + entries.push({ kind: 'note', id: `note-${noteMarks[noteIdx].msg.id}`, text: noteText(noteMarks[noteIdx].msg) }); + noteIdx++; + } + return entries; + })(); + const mcpServers = new Set( calls.map((m) => { const tool = typeof m.content === 'object' ? m.content.tool || '' : ''; @@ -1092,8 +1164,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose label, callCount: calls.length, mcpServer, + entries: groupEntries, } satisfies ToolGroup); - } else if (pairs.length <= 2) { + } else if (sessionRunning && pairs.length <= 2 && !groupEntries) { + // Live turns keep bare rows for streaming detail; finished transcripts always rest as the quiet group row. items.push(...pairs); } else if (pairs.length > 0) { const toolNames = new Set( @@ -1107,9 +1181,40 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose pairs, label, callCount: calls.length, + entries: groupEntries, } satisfies ToolGroup); + } else if (noteMarks.length > 0) { + // Phase held only ShowUI/AskUI pairs: narration has no group to fold into, keep it visible. + for (const nm of noteMarks) items.push(nm.msg); + } + for (const p of showUiPairs) { + const key = keyByCallId.get(p.call.id); + if (!key || isAskUiPair(p)) { + items.push(p); + continue; + } + // Later updates render nowhere themselves; the first slot always shows the latest call + // under a STABLE key so React updates the mounted component instead of remounting it. + if (p.call.id !== firstCallIdByKey.get(key)) continue; + const latest = latestByKey.get(key); + items.push({ + type: 'tool_pair' as const, + id: `showui-${key}`, + call: latest ? latest.call : p.call, + result: latest ? latest.result : p.result, + }); } } else { + if (!sessionRunning && msg.role === 'assistant') { + let j = i; + while (j < activeBranchMessages.length && activeBranchMessages[j].role === 'assistant') j++; + const next = activeBranchMessages[j]; + if (next && (next.role === 'tool_call' || next.role === 'tool_result')) { + leadNotes = activeBranchMessages.slice(i, j).filter((m) => !m.hidden); + i = j; + continue; + } + } if (!msg.hidden) { items.push(msg); } @@ -1117,7 +1222,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } } return items; - }, [activeBranchMessages]); + }, [activeBranchMessages, sessionRunning]); React.useLayoutEffect(() => { const total = renderItems.length; @@ -1589,6 +1694,22 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } if (isToolPair(item)) { const isPending = item.result === null && sessionRunning; + if (isAskUiPair(item)) { + return ( + + + {compactionChip} + + ); + } + if (isShowUiPair(item)) { + return ( + + + {compactionChip} + + ); + } return ( @@ -2253,7 +2374,8 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose sessionId={id} autoFocus={autoFocus} prefillPrompt={prefillPrompt} - placeholderOverride={runContext ? 'Ask about this run...' : undefined} + placeholderOverride={runContext ? 'Ask about this run...' : embedded ? 'Send a message...' : undefined} + quietComposer={embedded} runContext={runContext} onClearRunContext={onClearRunContext} thinkingLevel={session?.thinking_level ?? 'auto'} diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 6027f1cb..28b2477a 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -48,12 +48,14 @@ interface Props { prefillPrompt?: string; // Replaces the default "Agent, @ for context..." placeholder (e.g. "Ask about this run..."). placeholderOverride?: string; + // Desktop-card composer: rest as input + attach/mic; pickers return on focus. + quietComposer?: boolean; // A workflow run shown as a small removable chip inside the composer. runContext?: WorkflowsRunContext; onClearRunContext?: () => void; } -const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, runContext, onClearRunContext }, ref) => { +const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, quietComposer, runContext, onClearRunContext }, ref) => { const c = useClaudeTokens(); const editorRef = useRef(null); const containerRef = useRef(null); @@ -320,6 +322,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, editorRef={editorRef} generalFileInputRef={generalFileInputRef} embedded={embedded} + quietComposer={quietComposer} isDragOver={isDragOver} isUploading={isUploading} handleDragOver={handleDragOver} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx index 045e199f..4d1100fe 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx @@ -50,6 +50,8 @@ interface Props { isRunning?: boolean; onStop?: () => void; handleSend: () => void; + /** Embedded-card resting look: only attach + mic; pickers come back on focus. */ + restMode?: boolean; } export const ChatInputToolbar: React.FC = (p) => { @@ -58,7 +60,7 @@ export const ChatInputToolbar: React.FC = (p) => { allModelFlat, model, onModelChange, onProviderChange, picker, pendingKinds, pendingPayloadEstimate, thinkingLevel, onThinkingLevelChange, contextEstimate, elementSelection, autoRunMode, ownerId, sessionId, generalFileInputRef, addImageFiles, uploadAndAttachFiles, - hasContent, disabled, isRunning, onStop, handleSend, + hasContent, disabled, isRunning, onStop, handleSend, restMode, } = p; const menuPaperProps = { @@ -94,12 +96,14 @@ export const ChatInputToolbar: React.FC = (p) => { pt: 0, }} > - + {!restMode && ( + + )} = (p) => { pendingPayloadEstimate={pendingPayloadEstimate} /> - {!hideForTrial && ( + {!hideForTrial && !restMode && ( = (p) => { - {contextEstimate && ( + {contextEstimate && !restMode && ( = (p) => { void; handleSend: () => void; + restMode?: boolean; } export const ToolbarActions: React.FC = ({ c, elementSelection, autoRunMode, ownerId, sessionId, generalFileInputRef, - addImageFiles, uploadAndAttachFiles, hasContent, disabled, isRunning, onStop, handleSend, + addImageFiles, uploadAndAttachFiles, hasContent, disabled, isRunning, onStop, handleSend, restMode, }) => { return ( <> - {elementSelection && !autoRunMode && (() => { + {elementSelection && !autoRunMode && !restMode && (() => { const isMySelectMode = elementSelection.selectMode && elementSelection.activeOwnerId === ownerId; return ( diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx index 069f6035..fa317fb5 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx @@ -1,4 +1,4 @@ -import React, { RefObject } from 'react'; +import React, { RefObject, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; @@ -28,6 +28,7 @@ interface Props { editorRef: RefObject; generalFileInputRef: RefObject; embedded?: boolean; + quietComposer?: boolean; isDragOver: boolean; isUploading: boolean; handleDragOver: (e: React.DragEvent) => void; @@ -106,9 +107,18 @@ interface Props { export const ChatInputView: React.FC = (p) => { const { c } = p; + // Embedded card composers rest as just the input + attach/mic (the frame look); the pickers return on focus, draft text, or any open menu. + const [focusWithin, setFocusWithin] = useState(false); + const restMode = Boolean( + p.quietComposer && !focusWithin && !p.hasContent && !p.modelAnchor && !p.thinkingAnchor && !p.modeAnchor, + ); return ( setFocusWithin(true)} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false); + }} onDragOver={p.handleDragOver} onDragLeave={p.handleDragLeave} onDrop={p.handleDrop} @@ -246,6 +256,7 @@ export const ChatInputView: React.FC = (p) => { = React.memo(({ message, editing = false, o ...(isOversized ? { width: '85%' } : {}), bgcolor: isUser ? c.user.bubble : c.bg.surface, border: isUser ? (isFailed ? `1px solid ${c.status.error}` : 'none') : `1px solid ${c.border.subtle}`, - borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px', + borderRadius: isUser ? '18px' : '16px 16px 16px 4px', px: 2, py: 1.25, boxShadow: isUser ? 'none' : c.shadow.sm, diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx index 8da2895a..84e91dc3 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx @@ -13,6 +13,10 @@ import { sanitizeSvgString } from '@/shared/sanitizeSvg'; import { parseMcpToolName, getWorkflowToolLabel } from '@/shared/mcpToolMeta'; import ToolCallBubble, { ToolPair } from './ToolCallBubble'; +export type ToolGroupEntry = + | { kind: 'pair'; pair: ToolPair } + | { kind: 'note'; id: string; text: string }; + export interface ToolGroup { type: 'tool_group'; id: string; @@ -20,6 +24,8 @@ export interface ToolGroup { label: string; callCount: number; mcpServer?: string; + /** Pairs interleaved with the folded mid-phase narration; present only when narration was absorbed. */ + entries?: ToolGroupEntry[]; } export type RenderItem = AgentMessage | ToolGroup | ToolPair; @@ -75,7 +81,12 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = const c = useClaudeTokens(); const reveal = useMountReveal(); // JS-driven slide-in; see useMountReveal (was a fragile mount keyframe) const isMcp = !!group.mcpServer; - const [expanded, setExpanded] = useState(isMcp); + // MCP groups auto-expand only WHILE the run is live; a finished transcript rests as the quiet row. + const [expanded, setExpanded] = useState(isMcp && isSessionRunning); + const userToggledRef = React.useRef(false); + React.useEffect(() => { + if (!isSessionRunning && !userToggledRef.current) setExpanded(false); + }, [isSessionRunning]); const completedCount = group.pairs.filter((p) => p.result !== null).length; const pendingCount = group.pairs.filter((p) => p.result === null).length; @@ -98,7 +109,6 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = })(); const displayName = workflowGroupLabel || meta?.name || group.label; const hasSvg = !!meta?.svg && !workflowGroupLabel; - const canToggleGroup = group.pairs.length > 1; return ( = React.memo(({ group, isSessionRunning = > + {/* Collapsed = the quiet "N tool calls ›" line; the detail card only materializes on expand. */} + {!expanded ? ( + { userToggledRef.current = true; setExpanded(true); }} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + py: 0.4, + cursor: 'pointer', + color: c.text.tertiary, + '&:hover': { color: c.text.secondary }, + }} + > + + {group.callCount} tool call{group.callCount === 1 ? '' : 's'} + + {!allDone && ( + + {completedCount}/{group.callCount} + + )} + {deniedCount > 0 && ( + + {deniedCount} denied + + )} + + + ) : ( setExpanded(!expanded) : undefined} + onClick={() => { userToggledRef.current = true; setExpanded(false); }} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.7, - cursor: canToggleGroup ? 'pointer' : 'default', - '&:hover': canToggleGroup ? { bgcolor: 'rgba(0,0,0,0.02)' } : undefined, + cursor: 'pointer', + '&:hover': { bgcolor: 'rgba(0,0,0,0.02)' }, }} > {!meta ? ( @@ -178,12 +220,11 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = {completedCount}/{group.callCount} )} - {canToggleGroup && ( - {expanded ? : } + - )} + )} = React.memo(({ group, isSessionRunning = }, }} > - {group.pairs.map((pair) => ( - - ))} + {(group.entries ?? group.pairs.map((pair) => ({ kind: 'pair' as const, pair }))).map((entry) => + entry.kind === 'pair' ? ( + + ) : ( + + {entry.text} + + ), + )} diff --git a/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx b/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx new file mode 100644 index 00000000..ff3fa3ee --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import InputBase from '@mui/material/InputBase'; +import IconButton from '@mui/material/IconButton'; +import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded'; +import ToolCallBubble from '../tool-bubbles/ToolCallBubble'; +import type { ToolPair } from '../tool-bubbles/ToolCallBubble'; +import { parseShowUiPayload } from './showUiPayload'; +import VendoredToolUi from '@toolui/VendoredToolUi'; +import { API_BASE, getAuthToken } from '@/shared/config'; + +// The choice components that replaced AskUserQuestion, which always had an "Other" escape hatch. +const FREE_TEXT_COMPONENTS = new Set(['option-list', 'question-flow']); + +interface AskUiBubbleProps { + pair: ToolPair; + sessionId: string; + isPending: boolean; + suppressReveal: boolean; +} + +function parseResultResponse(pair: ToolPair): Record | null { + const rc = pair.result?.content; + const text = typeof rc === 'string' ? rc : typeof rc === 'object' && rc?.text ? String(rc.text) : ''; + if (!text.startsWith('{')) return null; + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +/** An AskUI call: the live interactive component while the agent waits; its answered state after. */ +function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubbleProps): React.ReactElement { + const payload = parseShowUiPayload(pair); + const [submitted, setSubmitted] = useState(false); + const [orphaned, setOrphaned] = useState(false); + const [freeText, setFreeText] = useState(''); + const answered = parseResultResponse(pair); + const freeTextAnswer = + answered?.action === 'free_text' && answered.value && typeof answered.value === 'object' + ? String((answered.value as Record).text ?? '') + : null; + + const componentId = payload && payload.component === 'vendored' ? String(payload.props.id || '') : ''; + + const respond = useCallback( + (response: Record) => { + if (submitted) return; + setSubmitted(true); + void fetch(`${API_BASE}/ui-requests/respond`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getAuthToken()}` }, + body: JSON.stringify({ session_id: sessionId, component_id: componentId, response }), + }) + .then((r) => { + if (!r.ok) { + // Nothing parked server-side (agent gone or this is a replayed transcript): say so instead of silently swallowing the click. + setSubmitted(false); + setOrphaned(true); + } + }) + .catch(() => setSubmitted(false)); + }, + [submitted, sessionId, componentId], + ); + + const waiting = pair.result === null && !submitted; + + // Their embedded-actions contract: onAction(actionId, state) delivers the component's full state, + // and the components ship their own footer actions (Clear/Confirm), so we only wire the callback. + // 'cancel' is a local clear, never an answer; approval-card uses onConfirm/onCancel instead. + const extraProps = useMemo(() => { + if (!payload || payload.component !== 'vendored') return {}; + if (payload.name === 'approval-card') { + return waiting + ? { + onConfirm: () => respond({ action: 'confirm', choice: 'approved' }), + onCancel: () => respond({ action: 'cancel', choice: 'denied' }), + } + : { choice: (answered?.choice as string) || undefined }; + } + if (waiting) { + return { + onAction: (actionId: string, state: unknown) => { + if (actionId === 'cancel') return; + respond({ action: actionId, value: state ?? null }); + }, + }; + } + // A free-text answer isn't an option id; passing it as `choice` would fail their contract. + if (freeTextAnswer !== null) return {}; + return answered && 'value' in answered ? { choice: answered.value } : {}; + }, [payload, waiting, respond, answered, freeTextAnswer]); + + const submitFreeText = useCallback(() => { + const text = freeText.trim(); + if (!text) return; + respond({ action: 'free_text', value: { text } }); + }, [freeText, respond]); + + if (!payload || payload.component !== 'vendored' || !componentId) { + return ( + + ); + } + + return ( + + + {waiting && FREE_TEXT_COMPONENTS.has(payload.name) && ( + { e.preventDefault(); submitFreeText(); }} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.5, + mt: 0.75, + px: 1.25, + py: 0.25, + borderRadius: 999, + background: 'rgba(127,127,127,0.08)', + border: '1px solid rgba(127,127,127,0.14)', + maxWidth: 420, + }} + > + setFreeText(e.target.value)} + placeholder="Or type your own answer..." + inputProps={{ 'aria-label': 'Type your own answer' }} + sx={{ flex: 1, fontSize: '0.8rem' }} + /> + + + + + )} + {freeTextAnswer !== null && ( + + ✓ Answered: {freeTextAnswer} + + )} + {submitted && pair.result === null && ( + Sent to the agent... + )} + {orphaned && ( + + No agent is waiting for this answer (the request expired or this is an old transcript). + + )} + + ); +} + +export default AskUiBubble; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/LinksWidget.tsx b/frontend/src/app/pages/AgentChat/tool-ui/LinksWidget.tsx new file mode 100644 index 00000000..8d980491 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/LinksWidget.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { LinksProps } from './showUiPayload'; + +function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } +} + +/** Tool-UI-style link previews: domain, title, description; opens like any transcript link. */ +function LinksWidget({ props }: { props: LinksProps }): React.ReactElement { + const c = useClaudeTokens(); + return ( + + {props.links.map((l, i) => ( + + + {hostOf(l.url)} + + + {l.title} + + {l.description && ( + + {l.description} + + )} + + ))} + + ); +} + +export default LinksWidget; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/PlanWidget.tsx b/frontend/src/app/pages/AgentChat/tool-ui/PlanWidget.tsx new file mode 100644 index 00000000..92b6fd8a --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/PlanWidget.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; +import CircularProgress from '@mui/material/CircularProgress'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { PlanProps } from './showUiPayload'; + +const MAX_VISIBLE = 6; + +/** Tool-UI-style plan card: progress summary bar + step checklist. */ +function PlanWidget({ props }: { props: PlanProps }): React.ReactElement { + const c = useClaudeTokens(); + const done = props.steps.filter((s) => s.status === 'completed').length; + const visible = props.steps.slice(0, MAX_VISIBLE); + const hidden = props.steps.length - visible.length; + + return ( + + {props.title && ( + + {props.title} + + )} + + {done} of {props.steps.length} complete + + + + + {visible.map((step, i) => ( + + {step.status === 'completed' ? ( + + ) : step.status === 'in_progress' ? ( + + ) : ( + + )} + + {step.label} + + + ))} + {hidden > 0 && ( + + ... {hidden} more + + )} + + ); +} + +export default PlanWidget; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx new file mode 100644 index 00000000..d4b78c07 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import WeatherWidget from './WeatherWidget'; +import PlanWidget from './PlanWidget'; +import StatsWidget from './StatsWidget'; +import LinksWidget from './LinksWidget'; +import VendoredToolUi from '@toolui/VendoredToolUi'; +import type { ShowUiPayload } from './showUiPayload'; + +/** One switch for every surface that renders a ShowUI payload (chat bubble, pill artifact); ambient = low-cost render for resting surfaces. */ +function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambient?: boolean }): React.ReactElement | null { + if (payload.component === 'weather') return ; + if (payload.component === 'plan') return ; + if (payload.component === 'stats') return ; + if (payload.component === 'links') return ; + if (payload.component === 'vendored') return ; + return null; +} + +export default ShowUiWidgetView; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/StatsWidget.tsx b/frontend/src/app/pages/AgentChat/tool-ui/StatsWidget.tsx new file mode 100644 index 00000000..22ddaf15 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/StatsWidget.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { StatsProps } from './showUiPayload'; + +/** Tool-UI-style stat tiles: label, value, optional signed delta. */ +function StatsWidget({ props }: { props: StatsProps }): React.ReactElement { + const c = useClaudeTokens(); + return ( + + {props.title && ( + + {props.title} + + )} + + {props.stats.map((s, i) => ( + + + {s.label} + + + {s.value} + + {s.delta && ( + + {s.direction === 'down' ? ( + + ) : ( + + )} + + {s.delta} + + + )} + + ))} + + + ); +} + +export default StatsWidget; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx new file mode 100644 index 00000000..e43acc62 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import Box from '@mui/material/Box'; +import ToolCallBubble from '../tool-bubbles/ToolCallBubble'; +import type { ToolPair } from '../tool-bubbles/ToolCallBubble'; +import { parseShowUiPayload, freezeIfDone } from './showUiPayload'; +import ShowUiWidgetView from './ShowUiWidgetView'; + +interface ToolUiBubbleProps { + pair: ToolPair; + sessionId: string; + isPending: boolean; + suppressReveal: boolean; + sessionRunning?: boolean; +} + +/** Renders a ShowUI call as its inline component; any schema mismatch falls back to the plain tool bubble. */ +function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunning = false }: ToolUiBubbleProps): React.ReactElement { + const rawPayload = parseShowUiPayload(pair); + const payload = useMemo( + () => (rawPayload ? freezeIfDone(rawPayload, sessionRunning) : null), + [rawPayload, sessionRunning], + ); + if (!payload) { + return ( + + ); + } + return ( + + + + ); +} + +export default ToolUiBubble; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx b/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx new file mode 100644 index 00000000..541410ba --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { WeatherWidget as AnimatedWeatherWidget } from '@toolui/components/weather-widget/weather-widget-container'; +import type { WeatherConditionCode, ForecastDay } from '@toolui/components/weather-widget/schema-runtime'; +import { useThemeMode } from '@/shared/styles/ThemeContext'; +import type { WeatherProps } from './showUiPayload'; + +function toConditionCode(condition: string | undefined): WeatherConditionCode { + // "Partly Cloudy with Slight Chance of Showers" is a partly-cloudy scene, not a rain scene: drop the chance-of qualifiers so the leading descriptor wins. + const cond = (condition || '').toLowerCase().replace(/(slight |small )?chance( of)? (showers?|rain|snow|thunderstorms?)/g, ''); + if (/thunder|storm/.test(cond)) return 'thunderstorm'; + if (/heavy rain|downpour/.test(cond)) return 'heavy-rain'; + if (/drizzle/.test(cond)) return 'drizzle'; + if (/rain|shower/.test(cond)) return 'rain'; + if (/sleet/.test(cond)) return 'sleet'; + if (/hail/.test(cond)) return 'hail'; + if (/snow/.test(cond)) return 'snow'; + if (/fog|mist|haze/.test(cond)) return 'fog'; + if (/overcast/.test(cond)) return 'overcast'; + if (/partly|part sun|some cloud/.test(cond)) return 'partly-cloudy'; + if (/cloud/.test(cond)) return 'cloudy'; + if (/wind/.test(cond)) return 'windy'; + return 'clear'; +} + +/** Agent-facing 'weather' shape adapted onto the vendored animated (WebGL) weather widget. */ +function WeatherWidget({ props, ambient }: { props: WeatherProps; ambient?: boolean }): React.ReactElement { + const { mode } = useThemeMode(); + const forecast: ForecastDay[] = (props.forecast || []).slice(0, 7).map((d) => ({ + label: d.day, + conditionCode: toConditionCode(d.condition), + tempMin: Math.round(d.low ?? (d.high ?? props.temp) - 8), + tempMax: Math.round(d.high ?? (d.low ?? props.temp) + 8), + })); + + return ( + // 4:3 card; the vendored strip reveals at 245px height and its day icons at 280px, so width must be >= 374 for the full frame look. +
+ +
+ ); +} + +export default WeatherWidget; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts new file mode 100644 index 00000000..42f4d832 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -0,0 +1,199 @@ +import type { ToolPair } from '../tool-bubbles/ToolCallBubble'; +import { isToolUiComponent } from '@toolui/registry'; + +export interface WeatherForecastDay { + day: string; + condition?: string; + high?: number; + low?: number; +} + +export interface WeatherProps { + location: string; + temp: number; + unit?: 'F' | 'C'; + high?: number; + low?: number; + condition?: string; + forecast?: WeatherForecastDay[]; +} + +export interface PlanStep { + label: string; + status: 'pending' | 'in_progress' | 'completed'; +} + +export interface PlanProps { + title?: string; + steps: PlanStep[]; +} + +export interface StatItem { + label: string; + value: string; + delta?: string; + direction?: 'up' | 'down'; +} + +export interface StatsProps { + title?: string; + stats: StatItem[]; +} + +export interface LinkItem { + title: string; + url: string; + description?: string; +} + +export interface LinksProps { + links: LinkItem[]; +} + +export type ShowUiPayload = + | { component: 'weather'; props: WeatherProps } + | { component: 'plan'; props: PlanProps } + | { component: 'stats'; props: StatsProps } + | { component: 'links'; props: LinksProps } + | { component: 'vendored'; name: string; props: Record }; + +function num(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v); +} + +function str(v: unknown): v is string { + return typeof v === 'string' && v.length > 0; +} + +export function isShowUiPair(pair: ToolPair): boolean { + const tool = typeof pair.call.content === 'object' ? String(pair.call.content?.tool || '') : ''; + return /(^|__)ShowUI$/.test(tool); +} + +export function isAskUiPair(pair: ToolPair): boolean { + const tool = typeof pair.call.content === 'object' ? String(pair.call.content?.tool || '') : ''; + return /(^|__)AskUI$/.test(tool); +} + +/** Latest ShowUI payload anywhere in a transcript; the collapsed card pins this artifact under its pill. */ +export function extractLatestShowUi(messages: Array<{ role: string; content: any }>): ShowUiPayload | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== 'tool_call') continue; + const tool = typeof msg.content === 'object' ? String(msg.content?.tool || '') : ''; + if (!/(^|__)ShowUI$/.test(tool)) continue; + const parsed = parseShowUiInput(msg.content?.input); + if (parsed) return parsed; + } + return null; +} + +/** Strict parse of a ShowUI tool_call's input; null on any mismatch so the caller falls back to the plain bubble. */ +export function parseShowUiPayload(pair: ToolPair): ShowUiPayload | null { + const content = typeof pair.call.content === 'object' ? pair.call.content : null; + return parseShowUiInput(content?.input); +} + +function parseShowUiInput(input: unknown): ShowUiPayload | null { + if (!input || typeof input !== 'object') return null; + { + const name = String((input as { component?: unknown }).component || ''); + const rawProps = (input as { props?: unknown }).props; + if (isToolUiComponent(name) && rawProps && typeof rawProps === 'object') { + // Vendored components carry their own zod contract; deep validation happens at render. + return { component: 'vendored', name, props: rawProps as Record }; + } + } + const component = String((input as { component?: unknown }).component || ''); + const props = (input as { props?: unknown }).props; + if (!props || typeof props !== 'object') return null; + const p = props as Record; + + if (component === 'weather') { + if (!str(p.location) || !num(p.temp)) return null; + const forecast = Array.isArray(p.forecast) + ? (p.forecast as Array>) + // Either bound is enough; a "Tonight" entry legitimately has only a low. + .filter((d) => str(d.day) && (num(d.high) || num(d.low))) + .slice(0, 7) + .map((d) => ({ + day: d.day as string, + condition: str(d.condition) ? d.condition : undefined, + high: num(d.high) ? d.high : undefined, + low: num(d.low) ? d.low : undefined, + })) + : undefined; + return { + component: 'weather', + props: { + location: p.location, + temp: p.temp, + unit: p.unit === 'C' ? 'C' : 'F', + high: num(p.high) ? p.high : undefined, + low: num(p.low) ? p.low : undefined, + condition: str(p.condition) ? p.condition : undefined, + forecast, + }, + }; + } + + if (component === 'plan') { + if (!Array.isArray(p.steps)) return null; + const steps = (p.steps as Array>) + .filter((s) => str(s.label)) + .slice(0, 20) + .map((s) => ({ + label: s.label as string, + status: (s.status === 'completed' || s.status === 'in_progress' ? s.status : 'pending') as PlanStep['status'], + })); + if (steps.length === 0) return null; + return { component: 'plan', props: { title: str(p.title) ? p.title : undefined, steps } }; + } + + if (component === 'stats') { + if (!Array.isArray(p.stats)) return null; + const stats = (p.stats as Array>) + .filter((s) => str(s.label) && str(s.value)) + .slice(0, 8) + .map((s) => ({ + label: s.label as string, + value: s.value as string, + delta: str(s.delta) ? s.delta : undefined, + direction: (s.direction === 'up' || s.direction === 'down' ? s.direction : undefined) as StatItem['direction'], + })); + if (stats.length === 0) return null; + return { component: 'stats', props: { title: str(p.title) ? p.title : undefined, stats } }; + } + + if (component === 'links') { + if (!Array.isArray(p.links)) return null; + const links = (p.links as Array>) + .filter((l) => str(l.title) && str(l.url) && /^https?:\/\//i.test(l.url as string)) + .slice(0, 10) + .map((l) => ({ + title: l.title as string, + url: l.url as string, + description: str(l.description) ? l.description : undefined, + })); + if (links.length === 0) return null; + return { component: 'links', props: { links } }; + } + + return null; +} + +// A dead turn must not keep spinners alive: once the agent stops, any step still marked +// in-progress is work that is NOT happening, so it renders as its truthful stalled state. +export function freezeIfDone(payload: ShowUiPayload, running: boolean): ShowUiPayload { + if (running || payload.component !== 'vendored') return payload; + if (payload.name !== 'progress-tracker' && payload.name !== 'plan') return payload; + const steps = payload.props.steps ?? payload.props.todos; + if (!Array.isArray(steps)) return payload; + const liveKey = payload.name === 'plan' ? 'in_progress' : 'in-progress'; + if (!steps.some((s) => (s as { status?: string })?.status === liveKey)) return payload; + const frozen = steps.map((s) => + (s as { status?: string })?.status === liveKey ? { ...(s as object), status: 'pending' } : s, + ); + const key = payload.name === 'plan' ? 'todos' : 'steps'; + return { ...payload, props: { ...payload.props, [key]: frozen } }; +} diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 1b385459..92c4558a 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -3,27 +3,10 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import InputBase from '@mui/material/InputBase'; import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip, { tooltipClasses } from '@mui/material/Tooltip'; import Snackbar from '@mui/material/Snackbar'; import Icon from '@mui/material/Icon'; -import { styled } from '@mui/material/styles'; -import AddRounded from '@mui/icons-material/AddRounded'; -import ChatBubbleTeardrop from './ChatBubbleTeardrop'; - -// Collapsed-row buttons hop up one after another when the toolbar appears. -const popIn = (i: number) => ({ - animation: `toolbar-pop 0.4s cubic-bezier(0.2, 1.4, 0.4, 1) ${i * 55}ms both`, - '@keyframes toolbar-pop': { - from: { opacity: 0, transform: 'translateY(14px)' }, - to: { opacity: 1, transform: 'translateY(0)' }, - }, -}); -import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; -import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined'; -import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded'; -import EventRepeatIcon from '@mui/icons-material/EventRepeat'; -import LanguageIcon from '@mui/icons-material/Language'; +import DesktopSpawnPill from './desktop/DesktopSpawnPill'; import SearchIcon from '@mui/icons-material/Search'; import { motion } from 'framer-motion'; import ChatInput from '@/app/pages/AgentChat/ChatInput'; @@ -32,12 +15,11 @@ import SchedulePopover from '@/app/pages/Workflows/SchedulePopover'; import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice'; import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice'; import { updateSettingsPatch, AppSettings } from '@/shared/state/settingsSlice'; import { store } from '@/shared/state/store'; -import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import type { Output } from '@/shared/state/outputsSlice'; interface Props { @@ -69,28 +51,6 @@ interface Props { } const TOOLBAR_OWNER_ID = '__toolbar__'; -const BTN = 44; - -const WarmTooltip = styled( - ({ className, ...props }: React.ComponentProps & { className?: string }) => ( - - ) -)<{ tokens: ClaudeTokens }>(({ tokens: c }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: c.bg.inverse, - color: c.text.inverse, - fontFamily: c.font.sans, - fontSize: '0.78rem', - fontWeight: 500, - padding: '6px 12px', - borderRadius: c.radius.md, - boxShadow: c.shadow.md, - letterSpacing: '0.01em', - }, - [`& .${tooltipClasses.arrow}`]: { - color: c.bg.inverse, - }, -})); const MotionBox = motion.div; @@ -178,7 +138,6 @@ const DashboardToolbar = React.forwardRef( const [historyQuery, setHistoryQuery] = useState(''); const [popoverMode, setPopoverMode] = useState<'search' | 'runs' | 'schedule'>('search'); const [expandToast, setExpandToast] = useState(null); - const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut); const outputs = useAppSelector((s) => s.outputs.items); const historySearch = useAppSelector((s) => s.agents.historySearch); const allRuns = useAppSelector((s) => s.workflows.allRuns); @@ -195,16 +154,6 @@ const DashboardToolbar = React.forwardRef( ); }, [outputList, viewSearch]); - const shortcutLabel = (shortcut || '') - .split('+') - .map((p) => { - if (p === 'Meta') return '⌘'; - if (p === 'Ctrl') return 'Ctrl'; - if (p === 'Alt') return '⌥'; - if (p === 'Shift') return '⇧'; - return p.toUpperCase(); - }) - .join(''); React.useImperativeHandle(ref, () => containerRef.current!, []); @@ -390,8 +339,14 @@ const DashboardToolbar = React.forwardRef( onAddBrowser(); } }; + // The desktop dock's History tile opens the same popover as Cmd+O. + const handleOpenHistoryEvent = () => handleOpenHistory(); window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); + window.addEventListener('openswarm:open-history', handleOpenHistoryEvent); + return () => { + window.removeEventListener('keydown', handleKey); + window.removeEventListener('openswarm:open-history', handleOpenHistoryEvent); + }; }, [handleOpenViewPicker, handleOpenHistory, onAddBrowser]); useEffect(() => { @@ -416,7 +371,6 @@ const DashboardToolbar = React.forwardRef( } }, [handleHistoryLoadMore]); - const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = []; return ( <> @@ -427,12 +381,16 @@ const DashboardToolbar = React.forwardRef( style={{ display: 'flex', flexDirection: 'column', - // Drop toolbar card chrome when popover is open so we don't double-card; popover supplies its own surface. - background: historyOpen ? 'transparent' : c.bg.surface, - border: historyOpen ? '1px solid transparent' : `1px solid ${c.border.subtle}`, + // Drop toolbar card chrome when popover is open (popover supplies its own surface) and when + // collapsed (the spawn pill carries its own dark glass). The open composer wears the same + // desktop dark glass as the rest of the shell. + background: historyOpen ? 'transparent' : viewPickerOpen ? c.bg.surface : inputOpen ? 'rgba(22,12,34,0.82)' : 'transparent', + backdropFilter: inputOpen && !historyOpen && !viewPickerOpen ? 'blur(20px) saturate(160%)' : undefined, + WebkitBackdropFilter: inputOpen && !historyOpen && !viewPickerOpen ? 'blur(20px) saturate(160%)' : undefined, + border: viewPickerOpen ? `1px solid ${c.border.subtle}` : '1px solid transparent', borderRadius: `${c.radius.xl}px`, - boxShadow: historyOpen ? 'none' : c.shadow.lg, - padding: isExpanded ? '6px' : '5px', + boxShadow: historyOpen || !isExpanded ? 'none' : '0 12px 32px rgba(0,0,0,0.4)', + padding: isExpanded ? '6px' : '0px', userSelect: 'none' as const, overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden', // historyOpen: width owned by SchedulePopover; leave undefined so framer-motion measures intrinsic size. @@ -445,19 +403,22 @@ const DashboardToolbar = React.forwardRef( data-onboarding-scope="dock" style={{ width: '100%', minHeight: 56, paddingBottom: 0, marginBottom: -4 }} > - + + + ) : historyOpen ? (
@@ -613,259 +574,17 @@ const DashboardToolbar = React.forwardRef(
) : ( -
- - { - if (newAgentBounce) onNewAgentBounceEnd?.(); - onNewAgent(); - }} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: BTN, - height: BTN, - borderRadius: `${c.radius.lg}px`, - bgcolor: c.accent.primary, - color: '#fff', - cursor: 'pointer', - transition: 'background-color 0.15s', - '&:hover': { bgcolor: c.accent.hover }, - '&:active': { bgcolor: c.accent.pressed }, - // Pop in first; the empty-canvas bounce takes over once the row has settled. - animation: `toolbar-pop 0.4s cubic-bezier(0.2, 1.4, 0.4, 1) both${newAgentBounce ? ', new-agent-bounce 1.6s ease-out 0.6s infinite' : ''}`, - '@keyframes toolbar-pop': { - from: { opacity: 0, transform: 'translateY(14px)' }, - to: { opacity: 1, transform: 'translateY(0)' }, - }, - '@keyframes new-agent-bounce': { - '0%': { transform: 'translateY(0)' }, - '15%': { transform: 'translateY(-10px)' }, - '30%': { transform: 'translateY(0)' }, - '42%': { transform: 'translateY(-4px)' }, - '55%': { transform: 'translateY(0)' }, - '100%': { transform: 'translateY(0)' }, - }, - }} - > - - - - - - Add App ⌘M - - } - > - - - - - - - Browser ⌘N - - } - > - - - - - - - Workflows - Schedule and calendar - - } - > - dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: BTN, - height: BTN, - borderRadius: `${c.radius.md}px`, - color: workflowsHubOpen ? c.accent.primary : c.text.tertiary, - bgcolor: workflowsHubOpen ? c.bg.secondary : 'transparent', - cursor: 'pointer', - transition: 'opacity 0.15s, background-color 0.15s', - '&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary }, - ...popIn(3), - }} - > - - - - - - Add note - Sticky note on the canvas - - } - > - - - - - - - History ⌘O - - } - > - - - - - - {placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => ( - - {label} - {sub} - - } - > - - - - - ))} -
+ { + if (newAgentBounce) onNewAgentBounceEnd?.(); + onNewAgent(); + }} + onAddNote={onAddNote} + onAddBrowser={onAddBrowser} + onAddApp={handleOpenViewPicker} + onWorkflows={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())} + onHistory={handleOpenHistory} + /> )} ; @@ -167,6 +172,8 @@ const DashboardCanvas: React.FC = ({ // macOS full screen: one card owns the whole window, every piece of chrome steps aside; Esc exits. const dispatch = useAppDispatch(); const fullscreenCardId = useAppSelector(selectFullscreenCardId); + const [headerRevealed, setHeaderRevealed] = React.useState(false); + const [appsWindowOpen, setAppsWindowOpen] = React.useState(false); useEffect(() => { if (!fullscreenCardId) return undefined; const onKey = (e: KeyboardEvent): void => { @@ -177,6 +184,7 @@ const DashboardCanvas: React.FC = ({ window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [fullscreenCardId, dispatch]); + // Gestures write the transform imperatively (no React commit per frame), so a foreign render mid-gesture would paint the stale committed transform for a frame. Re-applying live after EVERY render seals that; do not remove. React.useLayoutEffect(() => { canvas.actions.syncTransform(); @@ -185,8 +193,14 @@ const DashboardCanvas: React.FC = ({ return ( <> + {/* Top-edge hover strip: the desktop shell keeps the top chromeless; grazing it reveals the header. */} + setHeaderRevealed(true)} + sx={{ position: 'absolute', top: 0, left: 0, right: 0, height: 22, zIndex: 9 }} + /> {/* Floating header overlay */} setHeaderRevealed(false)} sx={{ display: fullscreenCardId ? 'none' : undefined, position: 'absolute', @@ -194,7 +208,10 @@ const DashboardCanvas: React.FC = ({ left: 0, right: 0, zIndex: 10, - pointerEvents: 'none', + pointerEvents: headerRevealed ? undefined : 'none', + opacity: headerRevealed ? 1 : 0, + transform: headerRevealed ? 'translateY(0)' : 'translateY(-6px)', + transition: 'opacity 0.18s ease, transform 0.18s ease', // p: 3 (24px) was leaving a chunky air gap between the sidebar edge and the dashboard header that read as "two disconnected panels" rather than one continuous surface. 0.75 (6px) tightens the inset so the header floats just inside the content area without losing its breathing room from the top-most pixel. pt: 0.75, pr: 0.75, @@ -224,6 +241,41 @@ const DashboardCanvas: React.FC = ({ + {!fullscreenCardId && ( + { + canvas.actions.fitToCards([rect], 1.15, true); + onHighlightCard?.(cardId); + }} + /> + )} + + {!fullscreenCardId && ( + { + canvas.actions.fitToCards([rect], 1.15, true); + onHighlightCard?.(cardId); + }} + onApplications={() => setAppsWindowOpen((v) => !v)} + onNewAgent={onNewAgent} + onAddBrowser={onAddBrowser} + onAddNote={onAddNote} + /> + )} + + {appsWindowOpen && !fullscreenCardId && ( + setAppsWindowOpen(false)} /> + )} + {/* Canvas viewport */} = ({ onNewAgentBounceEnd={onNewAgentBounceEnd} onFitToView={onFitToView} onTidy={onTidy} + onDeleteSelected={() => { + deleteSelectedCards(selection.selectedIds, dispatch); + selection.deselectAll(); + }} + hasSelection={selection.selectedIds.size > 0} onSearchPaletteClose={onSearchPaletteClose} toolbarPrefill={toolbarPrefill} toolbarPrefillMode={toolbarPrefillMode} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx index df5d8dd1..e2581403 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx @@ -8,7 +8,7 @@ import { Music, Video, Image, Camera, ShoppingCart, Bot, Gamepad2, Home, Shield, Users, Scale, Building2, Newspaper, Briefcase, Rocket, Globe, Database, Wrench, Lightbulb, Target, Trophy, Bell, Folder, Package, Truck, - LayoutDashboard, + LayoutDashboard, CloudSun, } from 'lucide-react'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -62,6 +62,7 @@ const KEYWORDS: Record = { archive: Folder, collection: Folder, library: Folder, inventory: Package, stock: Package, package: Package, supply: Package, delivery: Truck, shipping: Truck, logistics: Truck, truck: Truck, fleet: Truck, + weather: CloudSun, forecast: CloudSun, temperature: CloudSun, }; function pickIcon(title: string): LucideIcon | null { @@ -76,21 +77,23 @@ function pickIcon(title: string): LucideIcon | null { interface DashboardGlyphProps { name: string | undefined; size?: number; + color?: string; } -const DashboardGlyph: React.FC = ({ name, size = 16 }) => { +const DashboardGlyph: React.FC = ({ name, size = 16, color }) => { const c = useClaudeTokens(); + const glyphColor = color || c.accent.primary; const title = (name || '').trim(); const Icon = useMemo(() => (title ? pickIcon(title) : null), [title]); if (Icon) { - return ; + return ; } // No keyword hit: a tinted monogram of the first letter. Honest identity, never a misleading icon. A title with no latin letters falls back to the glyph. const letter = title.match(/[a-z0-9]/i)?.[0]?.toUpperCase(); if (!letter) { - return ; + return ; } return ( = ({ name, size = 16 }) => { width: size, height: size, borderRadius: '4px', - bgcolor: `${c.accent.primary}1F`, - color: c.accent.primary, + bgcolor: color ? 'rgba(255,255,255,0.16)' : `${c.accent.primary}1F`, + color: glyphColor, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index f0593fc6..abf9f1b6 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -2,6 +2,7 @@ import React, { type RefObject } from 'react'; import Box from '@mui/material/Box'; import DashboardToolbar from '../DashboardToolbar'; import CanvasControls from '../controls/CanvasControls'; +import HelpPill from '../desktop/HelpPill'; import CardSearchPalette from '../controls/CardSearchPalette'; import DirectionHints from '../controls/DirectionHints'; import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast'; @@ -49,6 +50,8 @@ interface DashboardOverlaysProps { onNewAgentBounceEnd: () => void; onFitToView: () => void; onTidy: () => void; + onDeleteSelected: () => void; + hasSelection: boolean; onSearchPaletteClose: () => void; toolbarPrefill?: string; toolbarPrefillMode?: string; @@ -80,6 +83,8 @@ const DashboardOverlays: React.FC = ({ onNewAgentBounceEnd, onFitToView, onTidy, + onDeleteSelected, + hasSelection, onSearchPaletteClose, toolbarPrefill, toolbarPrefillMode, @@ -106,6 +111,11 @@ const DashboardOverlays: React.FC = ({ /> + {/* Desktop help pill */} + + + + {/* Arrow navigation hints when zoomed in on a card */} {focusedCardId && canvas.zoom >= 0.4 && ( = ({ actions={canvas.actions} onFitToView={onFitToView} onTidy={onTidy} + onDeleteSelected={onDeleteSelected} + hasSelection={hasSelection} minimapProps={{ panX: canvas.panX, panY: canvas.panY, diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index a5079876..57b24feb 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -18,6 +18,7 @@ import { handleApproval, collapseSession, closeSession, + fetchSession, renameSession, } from '@/shared/state/agentsSlice'; import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay'; @@ -35,11 +36,15 @@ import { } from '@/shared/state/dashboardLayoutSlice'; import WindowControls from './WindowControls'; import { useTiledStyle } from './tileZones'; +import AgentNarratorPill from '../desktop/AgentNarratorPill'; +import { extractLatestTodos } from '../desktop/agentTodos'; +import { extractLatestShowUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; +import { getWebview } from '@/shared/browserRegistry'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; import { parseMcpToolName, getMcpShortAction } from '@/shared/mcpToolMeta'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext'; import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; import { useStreamingMessage } from '@/shared/state/streamingSlice'; @@ -689,6 +694,55 @@ const AgentCard: React.FC = ({ const hasPending = session.pending_approvals.length > 0; const pendingReq = session.pending_approvals[0]; + // Desktop-shell narrator pill: a collapsed card with nothing to ask renders as the minimal pill + // (live turn label + plan checklist); approvals and drafts keep the full card so their UI has a home. + const todos = useMemo(() => extractLatestTodos(session.messages || []), [session.messages]); + const pillArtifact = useMemo(() => { + const artifact = extractLatestShowUi(session.messages || []); + return artifact ? freezeIfDone(artifact, session.status === 'running') : null; + }, [session.messages, session.status]); + const pillMode = !expanded && !hasPending && !isDraft && !tileZone; + const pillLabel = session.turn_label?.label || displayChatTitle(session); + const pillRunning = session.status === 'running'; + + // Cold-loaded collapsed cards carry no transcript (status frames are slim), so the pill can't pin + // its widget/checklist artifact; hydrate ONCE per card actually on this dashboard, never in a loop. + const pillHydratedRef = React.useRef(false); + React.useEffect(() => { + if (!pillMode || pillHydratedRef.current) return; + pillHydratedRef.current = true; + if ((session.messages || []).length === 0) dispatch(fetchSession(session.id)); + }, [pillMode, session.messages, session.id, dispatch]); + + // f7's collapsed state: a session that spawned a browser shows that window under the pill. + const spawnedBrowserId = useAppSelector((s) => { + for (const bc of Object.values(s.dashboardLayout.browserCards)) { + if (bc.spawned_by === session.id) return bc.browser_id; + } + return null; + }); + const [browserShot, setBrowserShot] = useState(null); + useEffect(() => { + if (!pillMode || pillArtifact || !spawnedBrowserId) { + setBrowserShot(null); + return undefined; + } + let cancelled = false; + const capture = (): void => { + const wv = getWebview(spawnedBrowserId); + const p = wv?.capturePage?.(); + if (p && typeof (p as Promise).then === 'function') { + (p as Promise<{ toDataURL(): string }>) + .then((img) => { if (!cancelled) setBrowserShot(img.toDataURL()); }) + .catch(() => undefined); + } + }; + capture(); + // Refresh while the agent is driving so the shot tracks the page; parked cards keep the last frame. + const timer = pillRunning ? window.setInterval(capture, 5000) : null; + return () => { cancelled = true; if (timer) window.clearInterval(timer); }; + }, [pillMode, pillArtifact, spawnedBrowserId, pillRunning]); + const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; @@ -760,7 +814,7 @@ const AgentCard: React.FC = ({ contain: 'layout style', // Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas. willChange: 'transform', - width: tiledStyle ? tiledStyle.width : (localResize ? activeW : Math.max(cardWidth, MIN_W)), + width: pillMode ? 'fit-content' : tiledStyle ? tiledStyle.width : (localResize ? activeW : Math.max(cardWidth, MIN_W)), height: tiledStyle ? tiledStyle.height : (localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto')), transform: tiledStyle ? tiledStyle.transform : undefined, transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, @@ -849,9 +903,28 @@ const AgentCard: React.FC = ({ borderColor: hasPending ? c.status.warning : c.border.strong, }, }), + // Narrator pill sheds every bit of card chrome; the pill body draws its own glass + ring. + ...(pillMode && { + bgcolor: 'transparent', + border: 'none', + boxShadow: 'none', + p: 0, + overflow: 'visible', + cursor: isDragging ? 'grabbing' : 'grab', + '&:hover': {}, + }), + // Expanded chat wears the desktop dark glass; the header only surfaces on hover. + ...(expanded && !tiledStyle && { + bgcolor: 'rgba(26,16,34,0.85)', + backdropFilter: 'blur(24px) saturate(150%)', + WebkitBackdropFilter: 'blur(24px) saturate(150%)', + border: isSelected ? '2px solid #3b82f6' : '1px solid rgba(255,255,255,0.08)', + borderRadius: '20px', + boxShadow: '0 18px 48px rgba(0,0,0,0.4)', + }), }} > - {HANDLE_DEFS.map(({ dir, sx }) => ( + {!pillMode && HANDLE_DEFS.map(({ dir, sx }) => ( = ({ /> )} - {/* Drag zone: header + metadata , entire region above separator is draggable */} + {pillMode && ( + + + + )} + + {/* Drag zone: header + metadata , entire region above separator is draggable. + Expanded desktop cards float it as a hover-reveal overlay so the chat reads chromeless. */} + {!pillMode && ( = ({ + )} {expanded && ( = ({ sx={{ mx: -2, mb: -2, + mt: -2, flex: 1, minHeight: 0, - borderTop: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column', overflow: 'hidden', + borderRadius: tiledStyle ? undefined : '20px', }} > - dispatch(collapseSession(session.id))} - embedded - autoFocus={autoFocusInput} - isGlowing={isGlowingRedux && !glowFading} - onDismissGlow={dismissGlow} - onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined} - /> + + dispatch(collapseSession(session.id))} + embedded + autoFocus={autoFocusInput} + isGlowing={isGlowingRedux && !glowFading} + onDismissGlow={dismissGlow} + onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined} + /> + )} - {!expanded && ( + {!expanded && !pillMode && ( <> {previewContent && ( diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 13254693..93af934d 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -37,8 +37,10 @@ import { reorderBrowserTab, moveBrowserTab, recordClosedCard, + toggleMinimizeCard, type BrowserTab, } from '@/shared/state/dashboardLayoutSlice'; +import { saveMinimizedShot } from '../desktop/minimizedShots'; import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -60,6 +62,26 @@ import { getActionLabel } from '@/shared/browserCommandHandler'; import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl'; import BrowserAgentOverlay from './BrowserAgentOverlay'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; + +// Fixed light chrome for the macOS-window look; deliberately theme-independent, like a real browser window. +const CHROME_BG = '#f2eff5'; +const CHROME_SURFACE = '#ffffff'; +const CHROME_PAGE = '#faf9fc'; +const CHROME_BORDER = 'rgba(0,0,0,0.08)'; +const CHROME_TEXT = '#3c3744'; +const CHROME_TEXT_MUTED = '#8a8494'; + +const browserLightSx = (color: string): Record => ({ + width: 12, + height: 12, + p: 0, + borderRadius: '50%', + border: '0.5px solid rgba(0,0,0,0.08)', + background: '#d6d3cd', + cursor: 'pointer', + transition: 'background 150ms', + '.osw-card:hover &': { background: color }, +}); import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -206,6 +228,7 @@ const BrowserCard: React.FC = ({ [browserId], ); const browserAgentSession = useAppSelector(selectBrowserAgentSession); + const isMinimized = useAppSelector((s) => Boolean(s.dashboardLayout.minimizedCards[browserId])); const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]); const endingState = useAppSelector((state) => state.dashboardLayout.endingBrowserCards[browserId]); @@ -518,6 +541,22 @@ const BrowserCard: React.FC = ({ dispatch(addBrowserTab({ browserId, url: browserHomepage })); }, [dispatch, browserId, browserHomepage]); + // Yellow light: snapshot the live page first so the right-edge stack shows a real thumbnail, + // then park the card (webContents stays mounted, same as the keep-alive off-screen park). + const handleMinimize = useCallback(() => { + const wv = webviewMap.current.get(activeTabId); + const capture = wv?.capturePage?.(); + const park = (): void => { dispatch(toggleMinimizeCard({ cardId: browserId })); }; + if (capture && typeof (capture as Promise).then === 'function') { + (capture as Promise<{ toDataURL(): string }>) + .then((img) => { saveMinimizedShot(browserId, img.toDataURL()); }) + .catch(() => undefined) + .finally(park); + } else { + park(); + } + }, [dispatch, browserId, activeTabId]); + const handleCloseTab = useCallback((tabId: string, e: React.MouseEvent) => { e.stopPropagation(); // Closing the last tab destroys the whole card, so record it as a browser-card close (reopen brings the card back), not a tab close. @@ -851,11 +890,12 @@ const BrowserCard: React.FC = ({ return ( { onBringToFront?.(browserId, 'browser'); // Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path. Pass the target so URL-bar/tab presses select without yanking the camera. @@ -872,12 +912,12 @@ const BrowserCard: React.FC = ({ sx={{ position: 'absolute', // Kept-alive card from another dashboard: parked far off-screen so its webview surface can't bleed onto the dashboard you're viewing; click-through, webContents stays mounted. - pointerEvents: keepAliveHidden ? 'none' : undefined, + pointerEvents: keepAliveHidden || isMinimized ? 'none' : undefined, // contain: webview repaints don't shake neighbor cards. contain: 'layout style', // Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale. willChange: 'transform', - left: keepAliveHidden ? -100000 : (dragging ? cardX : displayX), + left: keepAliveHidden || isMinimized ? -100000 : (dragging ? cardX : displayX), top: dragging ? cardY : displayY, transform: dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined, width: displayW, @@ -926,8 +966,9 @@ const BrowserCard: React.FC = ({ zIndex: 16, display: 'flex', alignItems: 'stretch', - bgcolor: agentActive ? `${accentColor}0a` : c.bg.secondary, - borderBottom: `1px solid ${agentActive ? `${accentColor}30` : c.border.subtle}`, + // Real-browser-window chrome stays light in both app themes, like the window it imitates. + bgcolor: agentActive ? `${accentColor}14` : CHROME_BG, + borderBottom: `1px solid ${agentActive ? `${accentColor}30` : CHROME_BORDER}`, cursor: isDragging ? 'grabbing' : 'grab', flexShrink: 0, minHeight: 34, @@ -936,6 +977,25 @@ const BrowserCard: React.FC = ({ overflow: 'hidden', }} > + e.stopPropagation()} + sx={{ display: 'flex', alignItems: 'center', gap: '7px', pl: 1.25, pr: 0.75, flexShrink: 0 }} + > + + { e.stopPropagation(); handleMinimize(); }} + sx={{ ...browserLightSx('#febc2e'), }} + /> + = ({ maxWidth: 180, flex: '0 1 180px', position: 'relative', - borderRight: `1px solid ${c.border.subtle}`, - bgcolor: isActive ? c.bg.surface : 'transparent', + borderRight: `1px solid ${CHROME_BORDER}`, + bgcolor: isActive ? CHROME_SURFACE : 'transparent', cursor: isBeingDragged ? 'grabbing' : 'pointer', transform: isBeingDragged ? `translateX(${dragTabOffset}px)` : 'none', transition: isBeingDragged ? 'none' : 'background 0.15s ease, transform 0.2s ease', zIndex: isBeingDragged ? 10 : 1, - '&:hover': { bgcolor: isActive ? c.bg.surface : c.bg.secondary }, + '&:hover': { bgcolor: isActive ? CHROME_SURFACE : 'rgba(0,0,0,0.04)' }, '&:hover .tab-close': { opacity: 1 }, ...(isActive && { '&::after': { @@ -1001,7 +1061,7 @@ const BrowserCard: React.FC = ({ onError={(e: any) => { e.target.style.display = 'none'; }} /> ) : ( - + )} @@ -1010,7 +1070,7 @@ const BrowserCard: React.FC = ({ flex: 1, fontSize: '0.7rem', fontWeight: isActive ? 600 : 400, - color: isActive ? c.text.primary : c.text.muted, + color: isActive ? CHROME_TEXT : CHROME_TEXT_MUTED, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -1036,10 +1096,10 @@ const BrowserCard: React.FC = ({ opacity: isActive ? 0.6 : 0, cursor: 'pointer', transition: 'opacity 0.15s, background 0.15s', - '&:hover': { bgcolor: `${c.text.muted}25`, opacity: 1 }, + '&:hover': { bgcolor: 'rgba(0,0,0,0.09)', opacity: 1 }, }} > - + ); @@ -1059,10 +1119,10 @@ const BrowserCard: React.FC = ({ mx: 0.25, my: 0.5, transition: 'background 0.15s', - '&:hover': { bgcolor: `${c.text.muted}15` }, + '&:hover': { bgcolor: 'rgba(0,0,0,0.06)' }, }} > - + @@ -1105,16 +1165,6 @@ const BrowserCard: React.FC = ({ )} - - e.stopPropagation()} - sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }} - > - - -
@@ -1126,8 +1176,8 @@ const BrowserCard: React.FC = ({ gap: 0.25, px: 0.5, py: 0.25, - bgcolor: c.bg.page, - borderBottom: `1px solid ${c.border.subtle}`, + bgcolor: CHROME_PAGE, + borderBottom: `1px solid ${CHROME_BORDER}`, flexShrink: 0, }} > @@ -1138,7 +1188,7 @@ const BrowserCard: React.FC = ({ onClick={handleBack} onPointerDown={(e) => e.stopPropagation()} disabled={!activeLocal.canGoBack} - sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} + sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }} > @@ -1152,7 +1202,7 @@ const BrowserCard: React.FC = ({ onClick={handleForward} onPointerDown={(e) => e.stopPropagation()} disabled={!activeLocal.canGoForward} - sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} + sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }} > @@ -1164,7 +1214,7 @@ const BrowserCard: React.FC = ({ size="small" onClick={handleRefresh} onPointerDown={(e) => e.stopPropagation()} - sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} + sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }} > @@ -1180,13 +1230,13 @@ const BrowserCard: React.FC = ({ ml: 0.5, px: 1, py: 0.2, - bgcolor: c.bg.secondary, + bgcolor: '#eceaf1', borderRadius: `${c.radius.md}px`, - border: `1px solid ${c.border.subtle}`, + border: `1px solid ${CHROME_BORDER}`, }} > {isSearch ? ( - + ) : isSecure ? ( ) : null} @@ -1202,10 +1252,10 @@ const BrowserCard: React.FC = ({ flex: 1, fontSize: '0.74rem', fontFamily: c.font.mono, - color: c.text.secondary, + color: CHROME_TEXT, py: 0, - '& input': { py: '2px' }, - '& input::placeholder': { color: c.text.ghost, opacity: 1 }, + '& input': { py: '2px', textAlign: 'center' }, + '& input::placeholder': { color: CHROME_TEXT_MUTED, opacity: 1 }, }} />
diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index b1e94bb7..b13941dd 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -139,8 +139,7 @@ const DashboardViewCard: React.FC = ({ const interactive = activeViewCardId === cardKey; const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[cardKey]); const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[cardKey]); - // Fullscreen pins the card to the viewport, so while tiled the geometry must track canvas pan/zoom. - // The camera lives outside React (getCanvasState), so subscribe to pan ticks ONLY while tiled and read fresh. + // Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter. const [tileTick, setTileTick] = useState(0); useEffect(() => { if (!tileZone) return undefined; @@ -464,10 +463,10 @@ const DashboardViewCard: React.FC = ({ willChange: 'transform', left: tiledStyle ? tiledStyle.left : (dragging ? cardX : displayX), top: tiledStyle ? tiledStyle.top : (dragging ? cardY : displayY), - transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined), - transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, width: tiledStyle ? tiledStyle.width : (isMinimized ? 220 : displayW), height: tiledStyle ? tiledStyle.height : (isMinimized ? 44 : displayH), + transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined), + transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, borderRadius: isFullscreen ? '12px' : `${c.radius.lg}px`, border: isHighlighted ? `2px solid ${c.accent.primary}` diff --git a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx index 64901fb3..8a8f599c 100644 --- a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx @@ -259,8 +259,7 @@ const NoteCard: React.FC = ({ if (zone === 'restore') dispatch(clearTiledCard(noteId)); else dispatch(setTiledCard({ cardId: noteId, zone })); }; - // Fullscreen pins the card to the viewport, so while tiled the geometry must track canvas pan/zoom. - // The camera lives outside React (getCanvasState), so subscribe to pan ticks ONLY while tiled and read fresh. + // Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter. const [tileTick, setTileTick] = useState(0); useEffect(() => { if (!tileZone) return undefined; diff --git a/frontend/src/app/pages/Dashboard/controls/CanvasControls.tsx b/frontend/src/app/pages/Dashboard/controls/CanvasControls.tsx index 84ff7152..db8fab0e 100644 --- a/frontend/src/app/pages/Dashboard/controls/CanvasControls.tsx +++ b/frontend/src/app/pages/Dashboard/controls/CanvasControls.tsx @@ -1,14 +1,12 @@ import React, { useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; import RemoveIcon from '@mui/icons-material/Remove'; import AddIcon from '@mui/icons-material/Add'; -import FitScreenIcon from '@mui/icons-material/FitScreen'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import SpaceDashboardOutlinedIcon from '@mui/icons-material/SpaceDashboardOutlined'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import MapIcon from '@mui/icons-material/Map'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import type { CanvasActions } from '../hooks/interaction/useCanvasControls'; import Minimap from './Minimap'; import type { MinimapProps } from './Minimap'; @@ -18,6 +16,8 @@ interface Props { actions: CanvasActions; onFitToView: () => void; onTidy: () => void; + onDeleteSelected: () => void; + hasSelection: boolean; minimapProps: Omit; onMinimapPan: (panX: number, panY: number) => void; } @@ -33,8 +33,27 @@ function readMinimapPref(): boolean { } } -const CanvasControls: React.FC = ({ zoom, actions, onFitToView, onTidy, minimapProps, onMinimapPan }) => { - const c = useClaudeTokens(); +const GLASS = 'rgba(22,12,34,0.66)'; +const GLASS_BLUR = 'blur(20px) saturate(160%)'; + +const circleSx = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 30, + height: 30, + borderRadius: '50%', + background: GLASS, + backdropFilter: GLASS_BLUR, + WebkitBackdropFilter: GLASS_BLUR, + boxShadow: '0 6px 20px rgba(0,0,0,0.3)', + color: 'rgba(255,255,255,0.72)', + cursor: 'pointer', + transition: 'color 0.15s', + '&:hover': { color: '#fff' }, +}; + +const CanvasControls: React.FC = ({ zoom, actions, onFitToView, onTidy, onDeleteSelected, hasSelection, minimapProps, onMinimapPan }) => { const pct = Math.round(zoom * 100); const [minimapOpen, setMinimapOpen] = useState(() => readMinimapPref()); const setAndPersistMinimap = (next: boolean) => { @@ -53,10 +72,11 @@ const CanvasControls: React.FC = ({ zoom, actions, onFitToView, onTidy, m sx={{ width: 200, height: 140, - bgcolor: c.bg.surface, - border: `1px solid ${c.border.medium}`, - borderRadius: `${c.radius.lg}px`, - boxShadow: c.shadow.md, + background: GLASS, + backdropFilter: GLASS_BLUR, + WebkitBackdropFilter: GLASS_BLUR, + borderRadius: '12px', + boxShadow: '0 8px 28px rgba(0,0,0,0.35)', overflow: 'hidden', }} > @@ -64,87 +84,82 @@ const CanvasControls: React.FC = ({ zoom, actions, onFitToView, onTidy, m
)} - - - - - - - - - - {pct}% - - - - - - - - - - - - - - - - + + setAndPersistMinimap(!minimapOpen)} + data-onboarding="canvas-minimap-toggle" + sx={{ ...circleSx, width: 26, height: 26, borderRadius: '8px', ...(minimapOpen && { color: '#fff' }) }} + > + + + + - - - + + + - - - - setAndPersistMinimap(!minimapOpen)} - sx={{ color: minimapOpen ? c.accent.primary : c.text.muted }} - data-onboarding="canvas-minimap-toggle" + + { if (hasSelection) onDeleteSelected(); }} + sx={{ ...circleSx, ...(!hasSelection && { color: 'rgba(255,255,255,0.35)', cursor: 'default', '&:hover': { color: 'rgba(255,255,255,0.35)' } }) }} > - - + + + + + + + + + + + + + {pct}% + + + + + + + + + ); diff --git a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx new file mode 100644 index 00000000..9c942d4f --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CheckIcon from '@mui/icons-material/Check'; +import DashboardGlyph from '../canvas/DashboardGlyph'; +import ShowUiWidgetView from '@/app/pages/AgentChat/tool-ui/ShowUiWidgetView'; +import type { ShowUiPayload } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; +import type { AgentTodoItem } from './agentTodos'; + +interface AgentNarratorPillProps { + label: string; + running: boolean; + todos: AgentTodoItem[] | null; + artifact: ShowUiPayload | null; + browserShot: string | null; + selected: boolean; + highlighted: boolean; +} + +const GLASS = 'rgba(24,14,32,0.8)'; +const GLASS_BLUR = 'blur(18px) saturate(150%)'; +const MAX_VISIBLE_TODOS = 4; + +/** Collapsed agent as the desktop narrator pill; below it, the best artifact wins: widget > browser shot > plan > Thinking. */ +function AgentNarratorPill({ label, running, todos, artifact, browserShot, selected, highlighted }: AgentNarratorPillProps): React.ReactElement { + const visibleTodos = (todos || []).slice(0, MAX_VISIBLE_TODOS); + const hiddenCount = (todos?.length || 0) - visibleTodos.length; + const ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined; + // One key per ladder state so a state CHANGE remounts the artifact and replays the one-shot entrance; nothing loops. + const artifactKey = artifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : running ? 'thinking' : 'none'; + + return ( + + + + + {label} + + + + {artifact ? ( + + + + ) : browserShot ? ( + + ) : visibleTodos.length > 0 ? ( + + + {visibleTodos.length > 1 && ( + + )} + {visibleTodos.map((todo, i) => { + const done = todo.status === 'completed'; + const active = todo.status === 'in_progress'; + return ( + + + {done && } + + + {todo.content} + + + ); + })} + + {hiddenCount > 0 && ( + + ... {hiddenCount} more + + )} + + ) : running ? ( + + + Thinking... + + + ) : null} + + ); +} + +export default AgentNarratorPill; diff --git a/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx b/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx new file mode 100644 index 00000000..8a21d6be --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; +import { API_BASE } from '@/shared/config'; + +interface ApplicationsWindowProps { + onClose: () => void; +} + +const CATEGORY_RULES: Array<{ label: string; re: RegExp }> = [ + { label: 'Developer Tools', re: /code|cursor|docker|terminal|xcode|git|iterm|studio|postman|figma|utm|dev/i }, + { label: 'Productivity & Finance', re: /notion|calendar|mail|numbers|pages|keynote|excel|word|slides|office|linear|wallet|slack|zoom|meet|drive|todo|remind/i }, + { label: 'Social', re: /message|discord|telegram|whatsapp|signal|wechat|facetime|x\b|instagram/i }, + { label: 'Entertainment', re: /spotify|music|tv|netflix|youtube|game|steam|chess|vlc|iina|podcast/i }, + { label: 'Utilities', re: /calculator|clock|settings|finder|preview|utility|cleaner|monitor|keychain|archive|font/i }, + { label: 'Travel', re: /maps|weather|flight|uber|airbnb/i }, + { label: 'Creativity', re: /photo|imovie|garageband|final cut|logic|premiere|illustrator|sketch|blender|procreate|paint|davinci/i }, + { label: 'Information', re: /news|books|stocks|dictionary|wiki|safari|chrome|edge|firefox|arc|browser/i }, +]; + +function categorize(name: string): string { + for (const rule of CATEGORY_RULES) if (rule.re.test(name)) return rule.label; + return 'Other'; +} + +function LetterTile({ name }: { name: string }): React.ReactElement { + const letter = name.match(/[a-z0-9]/i)?.[0]?.toUpperCase() || '?'; + return ( + + {letter} + + ); +} + +/** Launchpad-style window over the canvas: the user's real /Applications, categorized. */ +function ApplicationsWindow({ onClose }: ApplicationsWindowProps): React.ReactElement { + const [apps, setApps] = useState(null); + const [error, setError] = useState(false); + const [icons, setIcons] = useState>({}); + const [category, setCategory] = useState('All'); + + useEffect(() => { + let cancelled = false; + fetch(`${API_BASE}/onboarding/scan`, { method: 'POST' }) + .then((r) => r.json()) + .then((d) => { + if (cancelled) return; + const names: string[] = Array.isArray(d?.apps) ? d.apps : []; + setApps(names); + }) + .catch(() => { if (!cancelled) setError(true); }); + return () => { cancelled = true; }; + }, []); + + const getIcon = (window as unknown as { openswarm?: { getAppIcon?: (n: string) => Promise } }).openswarm?.getAppIcon; + useEffect(() => { + if (!apps || !getIcon) return; + let cancelled = false; + (async () => { + for (const name of apps.slice(0, 60)) { + if (cancelled) return; + try { + const dataUrl = await getIcon(name); + if (cancelled) return; + if (dataUrl) setIcons((prev) => (prev[name] ? prev : { ...prev, [name]: dataUrl })); + } catch { + /* icon-less tile falls back to the letter */ + } + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apps]); + + const categories = useMemo(() => { + if (!apps) return []; + const present = new Set(apps.map(categorize)); + return ['All', ...CATEGORY_RULES.map((r) => r.label).filter((l) => present.has(l)), ...(present.has('Other') ? ['Other'] : [])]; + }, [apps]); + + const visible = useMemo(() => { + if (!apps) return []; + return category === 'All' ? apps : apps.filter((a) => categorize(a) === category); + }, [apps, category]); + + const openApp = (window as unknown as { openswarm?: { openApplication?: (n: string) => Promise } }).openswarm?.openApplication; + + return ( + <> + + + + 🐙 + + Applications + + + + {categories.length > 1 && ( + + {categories.map((cat) => ( + setCategory(cat)} + sx={{ + px: 1.25, + py: 0.4, + borderRadius: 999, + flexShrink: 0, + cursor: 'pointer', + fontSize: '0.72rem', + fontWeight: 500, + color: category === cat ? '#fff' : 'rgba(255,255,255,0.6)', + background: category === cat ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', + '&:hover': { background: 'rgba(255,255,255,0.16)' }, + }} + > + {cat} + + ))} + + )} + + + {!apps && !error && ( + + + + )} + {error && ( + + Could not read /Applications. + + )} + {apps && ( + + {visible.map((name) => ( + { if (openApp) void openApp(name); }} + title={name} + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 0.75, + py: 0.75, + borderRadius: '10px', + cursor: openApp ? 'pointer' : 'default', + '&:hover': openApp ? { background: 'rgba(255,255,255,0.08)' } : undefined, + }} + > + {icons[name] ? ( + + ) : ( + + )} + + {name} + + + ))} + + )} + + + + ); +} + +export default ApplicationsWindow; diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx new file mode 100644 index 00000000..364abac4 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -0,0 +1,369 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import LanguageIcon from '@mui/icons-material/Language'; +import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline'; +import EventRepeatIcon from '@mui/icons-material/EventRepeat'; +import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined'; +import HistoryIcon from '@mui/icons-material/History'; +import DashboardGlyph from '../canvas/DashboardGlyph'; +import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; +import SettingsIcon from '@mui/icons-material/Settings'; +import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; +import EditNoteIcon from '@mui/icons-material/EditNote'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import { useAppDispatch } from '@/shared/hooks'; +import { openSettingsModal } from '@/shared/state/settingsSlice'; +import { getWebview } from '@/shared/browserRegistry'; +import { displayChatTitle } from '@/shared/state/sessionDisplay'; +import type { AgentSession } from '@/shared/state/agentsSlice'; +import type { + CardPosition, + ViewCardPosition, + BrowserCardPosition, + NotePosition, + WorkflowCardPosition, +} from '@/shared/state/dashboardLayoutSlice'; +import type { Output } from '@/shared/state/outputsSlice'; + +interface CardRect { + x: number; + y: number; + width: number; + height: number; +} + +interface DockEntry { + id: string; + label: string; + rect: CardRect; + tileBg: string; + icon: React.ReactNode; + faviconUrl?: string; + thumbnail?: string | null; + browserId?: string; + snippet?: string; +} + +interface DesktopDockProps { + sessions: Record; + cards: Record; + viewCards: Record; + browserCards: Record; + notes: Record; + workflowCards: Record; + outputs: Record; + selectedIds: string[]; + onFocusCard: (id: string, rect: CardRect) => void; + onApplications: () => void; + onNewAgent: () => void; + onAddBrowser: () => void; + onAddNote: () => void; +} + +const TILE = 30; +const PREVIEW_W = 190; + +// Frames show a colorful per-card dock, not uniform tiles; hues rotate by name so two agents rarely match. +const AGENT_TILE_HUES = [ + 'linear-gradient(135deg, #4a7dd6, #2b4fa8)', + 'linear-gradient(135deg, #8a5bd6, #5b34a8)', + 'linear-gradient(135deg, #3aa88f, #1f7a64)', + 'linear-gradient(135deg, #d6754a, #a8492b)', + 'linear-gradient(135deg, #c94a7d, #96305c)', +]; + +function hueFor(name: string): string { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0; + return AGENT_TILE_HUES[Math.abs(h) % AGENT_TILE_HUES.length]; +} + +/** Left-edge desktop dock: one tile per open card, hover previews, click focuses the window. */ +function DesktopDock({ + sessions, + cards, + viewCards, + browserCards, + notes, + workflowCards, + outputs, + selectedIds, + onFocusCard, + onApplications, + onNewAgent, + onAddBrowser, + onAddNote, +}: DesktopDockProps): React.ReactElement | null { + const dispatch = useAppDispatch(); + const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null); + const [liveShot, setLiveShot] = useState<{ id: string; dataUrl: string } | null>(null); + const hoverTimer = useRef(null); + + const entries = useMemo(() => { + const list: DockEntry[] = []; + for (const card of Object.values(cards)) { + const session = sessions[card.session_id]; + if (!session) continue; + const title = displayChatTitle(session); + list.push({ + id: card.session_id, + label: title, + rect: card, + tileBg: hueFor(title), + icon: , + snippet: session.turn_label?.label || undefined, + }); + } + for (const bc of Object.values(browserCards)) { + const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0]; + list.push({ + id: bc.browser_id, + label: activeTab?.title || 'Browser', + rect: bc, + tileBg: 'linear-gradient(135deg, #4f9fe8, #2f6ed4)', + icon: , + faviconUrl: activeTab?.favicon, + browserId: bc.browser_id, + }); + } + for (const [cardKey, vc] of Object.entries(viewCards)) { + const output = outputs[vc.output_id]; + const appName = output?.name || 'App'; + list.push({ + id: cardKey, + label: appName, + rect: vc, + tileBg: 'linear-gradient(135deg, #ef9552, #d96a2b)', + icon: ( + + {appName.charAt(0).toUpperCase()} + + ), + thumbnail: output?.thumbnail, + }); + } + for (const note of Object.values(notes)) { + const firstLine = (note.content || '').split('\n')[0].trim(); + list.push({ + id: note.note_id, + label: firstLine || 'Note', + rect: note, + tileBg: 'linear-gradient(135deg, #f2d270, #e0b23e)', + icon: , + snippet: (note.content || '').slice(0, 140), + }); + } + for (const [cardKey, wf] of Object.entries(workflowCards)) { + list.push({ + id: cardKey, + label: 'Workflow', + rect: wf, + tileBg: 'linear-gradient(135deg, #ef7a70, #d94f45)', + icon: , + }); + } + return list; + }, [sessions, cards, viewCards, browserCards, notes, workflowCards, outputs]); + + const beginHover = useCallback( + (entry: DockEntry, target: HTMLElement) => { + if (hoverTimer.current) window.clearTimeout(hoverTimer.current); + const top = target.offsetTop; + hoverTimer.current = window.setTimeout(() => { + setHovered({ id: entry.id, top }); + if (entry.browserId) { + const wv = getWebview(entry.browserId); + const capture = wv?.capturePage?.(); + if (capture && typeof (capture as Promise).then === 'function') { + (capture as Promise<{ toDataURL(): string }>) + .then((img) => setLiveShot({ id: entry.id, dataUrl: img.toDataURL() })) + .catch(() => undefined); + } + } + }, 220); + }, + [], + ); + + const endHover = useCallback(() => { + if (hoverTimer.current) window.clearTimeout(hoverTimer.current); + setHovered(null); + setLiveShot(null); + }, []); + + const hoveredEntry = hovered ? entries.find((e) => e.id === hovered.id) : undefined; + const previewImage = hoveredEntry + ? (liveShot?.id === hoveredEntry.id ? liveShot.dataUrl : hoveredEntry.thumbnail || undefined) + : undefined; + + return ( + + {entries.map((entry) => { + const isActive = selectedIds.includes(entry.id); + return ( + beginHover(entry, e.currentTarget as HTMLElement)} + onClick={() => { + endHover(); + onFocusCard(entry.id, entry.rect); + }} + sx={{ + width: TILE, + height: TILE, + borderRadius: '9px', + background: entry.tileBg, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + overflow: 'hidden', + flexShrink: 0, + transition: 'transform 0.15s ease', + '&:hover': { transform: 'scale(1.12)' }, + ...(isActive && { outline: '2px solid #6aa2ff', outlineOffset: '2px' }), + }} + > + {entry.faviconUrl ? ( + + ) : ( + entry.icon + )} + + ); + })} + + {entries.length > 0 && ( + + )} + {/* The og toolbar's actions, dock-resident: chat, browser, workflow, note, history. */} + {([ + { label: 'New chat', icon: , act: onNewAgent }, + { label: 'New browser', icon: , act: onAddBrowser }, + { label: 'Workflows', icon: , act: () => dispatch(openWorkflowsApp()) }, + { label: 'New note', icon: , act: onAddNote }, + { label: 'History', icon: , act: () => window.dispatchEvent(new CustomEvent('openswarm:open-history')) }, + ] as const).map((a) => ( + + + {a.icon} + + + ))} + + dispatch(openSettingsModal(undefined))} + onMouseEnter={endHover} + sx={{ + width: TILE, + height: TILE, + borderRadius: '9px', + background: 'linear-gradient(135deg, #5a5a62, #34343c)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + flexShrink: 0, + transition: 'transform 0.15s ease', + '&:hover': { transform: 'scale(1.12)' }, + }} + > + + + + + + + {hoveredEntry && ( + + {previewImage ? ( + + ) : ( + + + {hoveredEntry.label} + + {hoveredEntry.snippet && ( + + {hoveredEntry.snippet} + + )} + + )} + + )} + + ); +} + +export default DesktopDock; diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopSpawnPill.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopSpawnPill.tsx new file mode 100644 index 00000000..b0af5641 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopSpawnPill.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import AddRounded from '@mui/icons-material/AddRounded'; +import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; +import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; +import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined'; +import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded'; +import EventRepeatIcon from '@mui/icons-material/EventRepeat'; +import LanguageIcon from '@mui/icons-material/Language'; + +interface DesktopSpawnPillProps { + onOpenComposer: () => void; + onAddNote: () => void; + onAddBrowser: () => void; + onAddApp: () => void; + onWorkflows: () => void; + onHistory: () => void; +} + +const MENU_ITEMS: Array<{ key: string; label: string; icon: React.ElementType }> = [ + { key: 'note', label: 'Add note', icon: StickyNote2OutlinedIcon }, + { key: 'browser', label: 'Browser', icon: LanguageIcon }, + { key: 'app', label: 'Add app', icon: GridViewRoundedIcon }, + { key: 'workflows', label: 'Workflows', icon: EventRepeatIcon }, + { key: 'history', label: 'History', icon: HistoryRoundedIcon }, +]; + +/** Collapsed desktop composer: one dark pill that spawns an agent; + tucks the add actions away. */ +function DesktopSpawnPill({ + onOpenComposer, + onAddNote, + onAddBrowser, + onAddApp, + onWorkflows, + onHistory, +}: DesktopSpawnPillProps): React.ReactElement { + const [menuOpen, setMenuOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return undefined; + const onDown = (e: MouseEvent): void => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false); + }; + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + }, [menuOpen]); + + const actions: Record void> = { + note: onAddNote, + browser: onAddBrowser, + app: onAddApp, + workflows: onWorkflows, + history: onHistory, + }; + + return ( + + {menuOpen && ( + + {MENU_ITEMS.map(({ key, label, icon: ItemIcon }) => ( + { + setMenuOpen(false); + actions[key](); + }} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1.25, + px: 1.25, + py: 0.75, + borderRadius: '9px', + cursor: 'pointer', + '&:hover': { background: 'rgba(255,255,255,0.1)' }, + }} + > + + + {label} + + + ))} + + )} + + + + Spawn an agent... + + { + e.stopPropagation(); + setMenuOpen((v) => !v); + }} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 22, + height: 22, + borderRadius: '50%', + cursor: 'pointer', + color: 'rgba(255,255,255,0.6)', + '&:hover': { color: '#fff', background: 'rgba(255,255,255,0.12)' }, + }} + > + + + + + + + + + + ); +} + +export default DesktopSpawnPill; diff --git a/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx b/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx new file mode 100644 index 00000000..eba61551 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; +import { useAppDispatch } from '@/shared/hooks'; +import { addBrowserCard } from '@/shared/state/dashboardLayoutSlice'; + +const HELP_URL = 'https://openswarm.com'; + +/** Top-right desktop help pill: opens the docs site in an in-app browser card. */ +function HelpPill(): React.ReactElement { + const dispatch = useAppDispatch(); + return ( + dispatch(addBrowserCard({ url: HELP_URL }))} + > + + Help + + + e.stopPropagation()}> + + + + + ); +} + +export default HelpPill; diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx new file mode 100644 index 00000000..abe96e3b --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import LanguageIcon from '@mui/icons-material/Language'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { toggleMinimizeCard } from '@/shared/state/dashboardLayoutSlice'; +import { getMinimizedShot, dropMinimizedShot } from './minimizedShots'; +import type { BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; + +interface CardRect { + x: number; + y: number; + width: number; + height: number; +} + +interface MinimizedStackProps { + browserCards: Record; + onRestore: (id: string, rect: CardRect) => void; +} + +const THUMB_W = 96; + +/** Right-edge stack of minimized browser windows; click restores the card where it was. */ +function MinimizedStack({ browserCards, onRestore }: MinimizedStackProps): React.ReactElement | null { + const dispatch = useAppDispatch(); + const minimized = useAppSelector((s) => s.dashboardLayout.minimizedCards); + const entries = Object.values(browserCards).filter((bc) => minimized[bc.browser_id]); + if (entries.length === 0) return null; + + return ( + + {entries.map((bc) => { + const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0]; + const shot = getMinimizedShot(bc.browser_id); + return ( + { + dropMinimizedShot(bc.browser_id); + dispatch(toggleMinimizeCard({ cardId: bc.browser_id })); + onRestore(bc.browser_id, bc); + }} + title={activeTab?.title || 'Browser'} + sx={{ + width: THUMB_W, + borderRadius: '8px', + overflow: 'hidden', + cursor: 'pointer', + boxShadow: '0 6px 20px rgba(0,0,0,0.3)', + background: '#fff', + transition: 'transform 0.15s ease, box-shadow 0.15s ease', + '&:hover': { transform: 'scale(1.06)', boxShadow: '0 10px 28px rgba(0,0,0,0.4)' }, + }} + > + {shot ? ( + + ) : ( + + {activeTab?.favicon ? ( + + ) : ( + + )} + + {activeTab?.title || 'Browser'} + + + )} + + ); + })} + + ); +} + +export default MinimizedStack; diff --git a/frontend/src/app/pages/Dashboard/desktop/agentTodos.ts b/frontend/src/app/pages/Dashboard/desktop/agentTodos.ts new file mode 100644 index 00000000..34fff2f2 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/agentTodos.ts @@ -0,0 +1,28 @@ +export interface AgentTodoItem { + content: string; + status: 'pending' | 'in_progress' | 'completed'; +} + +function isTodoStatus(v: unknown): v is AgentTodoItem['status'] { + return v === 'pending' || v === 'in_progress' || v === 'completed'; +} + +/** Latest TodoWrite payload in the transcript = the agent's live plan; null when it never wrote one. */ +export function extractLatestTodos(messages: Array<{ role: string; content: any }>): AgentTodoItem[] | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== 'tool_call') continue; + const tool = typeof msg.content === 'object' ? String(msg.content?.tool || '') : ''; + if (!/todowrite$/i.test(tool)) continue; + const raw = msg.content?.input?.todos; + if (!Array.isArray(raw)) continue; + const items: AgentTodoItem[] = []; + for (const t of raw) { + const content = typeof t?.content === 'string' ? t.content : (typeof t?.activeForm === 'string' ? t.activeForm : ''); + if (!content) continue; + items.push({ content, status: isTodoStatus(t?.status) ? t.status : 'pending' }); + } + if (items.length > 0) return items; + } + return null; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/desktop.css b/frontend/src/app/pages/Dashboard/desktop/desktop.css new file mode 100644 index 00000000..814e3a74 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/desktop.css @@ -0,0 +1,6 @@ +/* While any card/marquee drag is live, glass stops sampling its backdrop: backdrop-filter re-blurs + a card-sized region EVERY frame under motion and is the single biggest drag-jank source. */ +body.dashboard-marquee-active * { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts b/frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts new file mode 100644 index 00000000..8a086620 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts @@ -0,0 +1,19 @@ +/** Last visual of a card captured at minimize time; in-memory only, keyed by card id. */ +const shots = new Map(); +const CAP = 40; + +export function saveMinimizedShot(cardId: string, dataUrl: string): void { + if (shots.size >= CAP && !shots.has(cardId)) { + const oldest = shots.keys().next().value; + if (oldest) shots.delete(oldest); + } + shots.set(cardId, dataUrl); +} + +export function getMinimizedShot(cardId: string): string | undefined { + return shots.get(cardId); +} + +export function dropMinimizedShot(cardId: string): void { + shots.delete(cardId); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts new file mode 100644 index 00000000..64e5a844 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts @@ -0,0 +1,35 @@ +import { closeSession } from '@/shared/state/agentsSlice'; +import { removeNote, removeWorkflowCard, closeWorkflowsHub, recordClosedCard } from '@/shared/state/dashboardLayoutSlice'; +import { closeWorkflowCard } from '@/shared/state/workflowsSlice'; +import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; +import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import type { AppDispatch } from '@/shared/state/store'; +import type { CardType } from '../state/useDashboardSelection'; + +/** Close every selected card, recording each so Cmd+Shift+T can bring it back. */ +export function deleteSelectedCards(selectedIds: Map, dispatch: AppDispatch): void { + const viewIds: string[] = []; + for (const [id, type] of selectedIds) { + if (type === 'agent') { + dispatch(recordClosedCard({ kind: 'agent', id })); + dispatch(closeSession({ sessionId: id })); + } else if (type === 'view') { + dispatch(recordClosedCard({ kind: 'view', id })); + viewIds.push(id); + } else if (type === 'browser') { + dispatch(recordClosedCard({ kind: 'browser', id })); + removeBrowserCardCleanly(id, dispatch); + } else if (type === 'note') { + dispatch(recordClosedCard({ kind: 'note', id })); + dispatch(removeNote(id)); + } else if (type === 'workflow') { + dispatch(recordClosedCard({ kind: 'workflow', id })); + dispatch(removeWorkflowCard(id)); + dispatch(closeWorkflowCard(id)); + } else if (type === 'workflows-hub') { + dispatch(closeWorkflowsHub()); + } + } + // Tear view cards down ONE AT A TIME (each quiesces its GPU surface first); ripping several large app webviews out in one frame is what piles up "non-existent mailbox" errors and kills the GPU process. + void (async () => { for (const id of viewIds) await removeViewCardCleanly(id, dispatch); })(); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts index 96d5da6f..83b40876 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts @@ -1,11 +1,9 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react'; import { report } from '@/shared/serviceClient'; import { useAppDispatch } from '@/shared/hooks'; -import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice'; -import { removeNote, removeWorkflowCard, closeWorkflowsHub, recordClosedCard, reopenLastClosed } from '@/shared/state/dashboardLayoutSlice'; -import { closeWorkflowCard } from '@/shared/state/workflowsSlice'; -import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; -import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import { toggleExpandSession } from '@/shared/state/agentsSlice'; +import { reopenLastClosed } from '@/shared/state/dashboardLayoutSlice'; +import { deleteSelectedCards } from './deleteSelectedCards'; import { getLastInteractedBrowser } from '@/shared/browserFocus'; import { getWebview } from '@/shared/browserRegistry'; import type { useDashboardSelection } from '../state/useDashboardSelection'; @@ -76,30 +74,7 @@ export function useDashboardShortcuts({ if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return; if (selection.selectedIds.size === 0) return; e.preventDefault(); - const viewIds: string[] = []; - for (const [id, type] of selection.selectedIds) { - if (type === 'agent') { - dispatch(recordClosedCard({ kind: 'agent', id })); - dispatch(closeSession({ sessionId: id })); - } else if (type === 'view') { - dispatch(recordClosedCard({ kind: 'view', id })); - viewIds.push(id); - } else if (type === 'browser') { - dispatch(recordClosedCard({ kind: 'browser', id })); - removeBrowserCardCleanly(id, dispatch); - } else if (type === 'note') { - dispatch(recordClosedCard({ kind: 'note', id })); - dispatch(removeNote(id)); - } else if (type === 'workflow') { - dispatch(recordClosedCard({ kind: 'workflow', id })); - dispatch(removeWorkflowCard(id)); - dispatch(closeWorkflowCard(id)); - } else if (type === 'workflows-hub') { - dispatch(closeWorkflowsHub()); - } - } - // Tear view cards down ONE AT A TIME (each quiesces its GPU surface first); ripping several large app webviews out in one frame is what piles up "non-existent mailbox" errors and kills the GPU process. - void (async () => { for (const id of viewIds) await removeViewCardCleanly(id, dispatch); })(); + deleteSelectedCards(selection.selectedIds, dispatch); selection.deselectAll(); }; window.addEventListener('keydown', handleDelete); diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index fed118c8..670e919b 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -152,6 +152,8 @@ export interface DashboardLayoutState { nextZOrder: number; loading: boolean; initialized: boolean; + /** True only after a SUCCESSFUL layout fetch for the current dashboard; saveLayout is a no-op until then (a failed boot fetch must never wipe the server layout). */ + saveArmed: boolean; /** Transient: new browser card id; Dashboard pans/zooms to it then clears via clearPendingFocusBrowserId. */ pendingFocusBrowserId: string | null; // Set when a view card is opened from outside the canvas (sidebar app click / toolbar picker) so the dashboard fits+highlights it on arrival; holds the card key. @@ -203,6 +205,7 @@ const initialState: DashboardLayoutState = { nextZOrder: 1, loading: false, initialized: false, + saveArmed: false, pendingFocusBrowserId: null, pendingFocusViewCardId: null, pendingFocusNoteId: null, @@ -237,6 +240,8 @@ export const fetchLayout = createAsyncThunk( // isReconnect distinguishes a socket-reconnect recovery refetch (merge, keep live positions) from a fresh mount/switch load (replace, snapshot is the user's saved layout). Passed explicitly, not inferred from state, so a stale in-flight fetch from a previous dashboard can't be misread as a merge. async ({ dashboardId }: { dashboardId: string; isReconnect?: boolean }) => { const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`); + // A non-2xx body silently parsing to "no layout" is how a healthy dashboard gets wiped: the empty result gets marked initialized and the next debounced save persists it. + if (!res.ok) throw new Error(`layout fetch failed: ${res.status}`); const data = await res.json(); const layout = data.layout ?? {}; const browserCards = (layout.browser_cards ?? {}) as Record; @@ -271,7 +276,10 @@ interface SaveLayoutPayload extends LayoutPayload { export const saveLayout = createAsyncThunk( 'dashboardLayout/save', - async (payload: SaveLayoutPayload) => { + async (payload: SaveLayoutPayload, { getState }) => { + // Never persist a layout this client never successfully loaded; a failed boot fetch otherwise saves the pristine empty store over the server's real layout (the wipe class). + const armed = (getState() as { dashboardLayout: { saveArmed: boolean } }).dashboardLayout.saveArmed; + if (!armed) return payload; await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -1626,6 +1634,7 @@ const dashboardLayoutSlice = createSlice({ state.persistedExpandedSessionIds = []; state.nextZOrder = 1; state.initialized = false; + state.saveArmed = false; state.pendingFocusNoteId = null; state.suspendedBrowserCards = keptSuspended; state.endingBrowserCards = {}; @@ -1643,6 +1652,7 @@ const dashboardLayoutSlice = createSlice({ // A fresh mount/switch load replaces (the snapshot is the user's saved layout, authoritative). A reconnect refetch (useDashboardLifecycle line ~90) recovers cards lost in a socket gap and must MERGE, blind- replacing there clobbered the live, collision-placed positions of cards already on canvas (the overlap / vanish under load while many browsers spawn). The caller says which; never inferred from state. const isReconnectRefetch = action.meta.arg.isReconnect === true; state.initialized = true; + state.saveArmed = true; const ownerDashboardId = action.meta.arg.dashboardId; if (!isReconnectRefetch) { state.cards = action.payload.cards; @@ -1701,6 +1711,7 @@ const dashboardLayoutSlice = createSlice({ }) .addCase(fetchLayout.rejected, (state) => { state.loading = false; + // Fail-open for RENDERING only; saveArmed stays false so this client can never persist the empty layout it booted with over the server's real one (the wipe that hit 2026-07-20). state.initialized = true; }) .addCase(fetchSessionRejectedAction, (state, action) => { diff --git a/frontend/src/shared/styles/ThemeContext.tsx b/frontend/src/shared/styles/ThemeContext.tsx index f047f6e2..e6ecb367 100644 --- a/frontend/src/shared/styles/ThemeContext.tsx +++ b/frontend/src/shared/styles/ThemeContext.tsx @@ -131,6 +131,17 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre }; export const useClaudeTokens = (): ClaudeTokens => useContext(ThemeContext).tokens; + +/** Forces dark tokens for a subtree: desktop-shell glass panels stay dark even in light mode. */ +export const DarkTokensScope: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const ctx = useContext(ThemeContext); + const value = useMemo(() => ({ + ...ctx, + mode: 'dark' as ThemeMode, + tokens: withAccent(darkTokens, ctx.accent, 'dark'), + }), [ctx]); + return {children}; +}; export const useThemeMode = () => { const { mode, toggleMode, setMode } = useContext(ThemeContext); return { mode, toggleMode, setMode }; diff --git a/frontend/src/toolui/LICENSE.md b/frontend/src/toolui/LICENSE.md new file mode 100644 index 00000000..1be0da05 --- /dev/null +++ b/frontend/src/toolui/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 AgentbaseAI Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx new file mode 100644 index 00000000..fdbbd0c3 --- /dev/null +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -0,0 +1,104 @@ +import React, { Suspense, useEffect, useState } from 'react'; +import { useThemeMode } from '@/shared/styles/ThemeContext'; +import { TOOL_UI_REGISTRY } from './registry'; + +interface GuardProps { name: string; children: React.ReactNode } + +// A component render throwing must cost exactly one quiet line, never the app: the top-level +// ErrorBoundary unmounts the whole shell for any uncaught child throw (the linkedin-post {post} +// mismatch took down the dashboard until this wall existed). +class ComponentGuard extends React.Component { + constructor(props: GuardProps) { + super(props); + this.state = { failed: false }; + } + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true }; + } + + render(): React.ReactNode { + if (this.state.failed) { + return ( +
+ {this.props.name} failed to render +
+ ); + } + return this.props.children; + } +} + +interface VendoredToolUiProps { + name: string; + props: Record; + /** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */ + extraProps?: Record; +} + +type Gate = + | { state: 'pending' } + | { state: 'ok'; parsed: Record } + | { state: 'bad'; problem: string }; + +/** Models pad payloads with invented keys; strip ONLY unrecognized-key issues and retry once, so + sloppiness self-heals while genuinely wrong shapes still fall back loudly. */ +function parseLeniently(schema: { safeParse: (v: unknown) => any }, props: Record): Gate { + let result = schema.safeParse(props); + if (!result.success) { + const issues: Array<{ code: string; keys?: string[]; path: Array; message: string }> = result.error.issues; + if (issues.every((i) => i.code === 'unrecognized_keys')) { + const cleaned: Record = { ...props }; + for (const issue of issues) { + for (const key of issue.keys || []) delete cleaned[key]; + } + result = schema.safeParse(cleaned); + } + } + if (result.success) return { state: 'ok', parsed: result.data as Record }; + const issues = result.error.issues.slice(0, 2).map((i: { path: Array; message: string }) => `${i.path.join('.')}: ${i.message}`).join('; '); + return { state: 'bad', problem: issues }; +} + +/** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */ +function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React.ReactElement | null { + const { mode } = useThemeMode(); + const entry = TOOL_UI_REGISTRY[name]; + const [gate, setGate] = useState({ state: 'pending' }); + + useEffect(() => { + let cancelled = false; + if (!entry) return undefined; + entry + .loadSchema() + .then((schema) => { + if (!cancelled) setGate(parseLeniently(schema, props)); + }) + .catch(() => { if (!cancelled) setGate({ state: 'bad', problem: 'component failed to load' }); }); + return () => { cancelled = true; }; + }, [entry, props]); + + if (!entry) return null; + if (gate.state === 'bad') { + return ( +
+ {name} payload didn't validate ({gate.problem}) +
+ ); + } + if (gate.state === 'pending') { + return
; + } + const Component = entry.Component; + return ( +
+ + }> + + + +
+ ); +} + +export default VendoredToolUi; diff --git a/frontend/src/toolui/components/approval-card/README.md b/frontend/src/toolui/components/approval-card/README.md new file mode 100644 index 00000000..070bdded --- /dev/null +++ b/frontend/src/toolui/components/approval-card/README.md @@ -0,0 +1,19 @@ +# Approval Card + +Implementation for the "approval-card" Tool UI surface. + +## Files + +- public exports: components/tool-ui/approval-card/index.tsx +- serializable schema + parse helpers: components/tool-ui/approval-card/schema.ts + +## Companion assets + +- Docs page: app/docs/approval-card/content.mdx +- Preset payload: lib/presets/approval-card.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/approval-card/_adapter.tsx b/frontend/src/toolui/components/approval-card/_adapter.tsx new file mode 100644 index 00000000..770d1920 --- /dev/null +++ b/frontend/src/toolui/components/approval-card/_adapter.tsx @@ -0,0 +1,11 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + */ + +export { cn } from "@toolui/lib/utils"; +export { Separator } from "@toolui/ui/separator"; diff --git a/frontend/src/toolui/components/approval-card/approval-card.tsx b/frontend/src/toolui/components/approval-card/approval-card.tsx new file mode 100644 index 00000000..f0ea1ed9 --- /dev/null +++ b/frontend/src/toolui/components/approval-card/approval-card.tsx @@ -0,0 +1,212 @@ +"use client"; + +import * as React from "react"; +import { cn, Separator } from "./_adapter"; +import type { ApprovalCardProps, ApprovalDecision } from "./schema"; +import { ActionButtons } from "../shared/action-buttons"; +import { type Action } from "../shared/schema"; + +import { icons, Check, X } from "lucide-react"; + +type LucideIcon = React.ComponentType<{ className?: string }>; + +function getLucideIcon(name: string): LucideIcon | null { + const pascalName = name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + + const Icon = icons[pascalName as keyof typeof icons]; + return Icon ?? null; +} + +interface ApprovalCardReceiptProps { + id: string; + title: string; + choice: ApprovalDecision; + actionLabel?: string; + className?: string; +} + +function ApprovalCardReceipt({ + id, + title, + choice, + actionLabel, + className, +}: ApprovalCardReceiptProps) { + const isApproved = choice === "approved"; + const displayLabel = actionLabel ?? (isApproved ? "Approved" : "Denied"); + + return ( +
+
+ + {isApproved ? : } + +
+ {displayLabel} + {title} +
+
+
+ ); +} + +export function ApprovalCard({ + id, + title, + description, + icon, + metadata, + variant, + confirmLabel, + cancelLabel, + className, + choice, + onConfirm, + onCancel, +}: ApprovalCardProps) { + const resolvedVariant = variant ?? "default"; + const resolvedConfirmLabel = confirmLabel ?? "Approve"; + const resolvedCancelLabel = cancelLabel ?? "Deny"; + const Icon = icon ? getLucideIcon(icon) : null; + + const handleAction = React.useCallback( + async (actionId: string) => { + if (actionId === "confirm") { + await onConfirm?.(); + } else if (actionId === "cancel") { + await onCancel?.(); + } + }, + [onConfirm, onCancel], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel?.(); + } + }, + [onCancel], + ); + + const isDestructive = resolvedVariant === "destructive"; + + const actions: Action[] = [ + { + id: "cancel", + label: resolvedCancelLabel, + variant: "ghost", + }, + { + id: "confirm", + label: resolvedConfirmLabel, + variant: isDestructive ? "destructive" : "default", + }, + ]; + + const viewKey = choice ? `receipt-${choice}` : "interactive"; + + return ( +
+ {choice ? ( + + ) : ( +
+
+
+ {Icon && ( + + + + )} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ + {metadata && metadata.length > 0 && ( + <> + +
+ {metadata.map((item, index) => ( +
+
+ {item.key} +
+
{item.value}
+
+ ))} +
+ + )} +
+
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/toolui/components/approval-card/index.tsx b/frontend/src/toolui/components/approval-card/index.tsx new file mode 100644 index 00000000..1bbfb6a6 --- /dev/null +++ b/frontend/src/toolui/components/approval-card/index.tsx @@ -0,0 +1,7 @@ +export { ApprovalCard } from "./approval-card"; +export { + type SerializableApprovalCard, + type ApprovalCardProps, + type ApprovalDecision, + type MetadataItem, +} from "./schema"; diff --git a/frontend/src/toolui/components/approval-card/schema.ts b/frontend/src/toolui/components/approval-card/schema.ts new file mode 100644 index 00000000..9371e726 --- /dev/null +++ b/frontend/src/toolui/components/approval-card/schema.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema"; +import { defineToolUiContract } from "../shared/contract"; + +export const MetadataItemSchema = z.object({ + key: z.string().min(1), + value: z.string(), +}); + +export type MetadataItem = z.infer; + +export const ApprovalDecisionSchema = z.enum(["approved", "denied"]); + +export type ApprovalDecision = z.infer; + +export const SerializableApprovalCardSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + + title: z.string().min(1), + description: z.string().optional(), + icon: z.string().optional(), + metadata: z.array(MetadataItemSchema).optional(), + + variant: z.enum(["default", "destructive"]).optional(), + + confirmLabel: z.string().optional(), + cancelLabel: z.string().optional(), + + choice: ApprovalDecisionSchema.optional(), +}); + +export type SerializableApprovalCard = z.infer< + typeof SerializableApprovalCardSchema +>; + +const SerializableApprovalCardSchemaContract = defineToolUiContract( + "ApprovalCard", + SerializableApprovalCardSchema, +); + +export const parseSerializableApprovalCard: ( + input: unknown, +) => SerializableApprovalCard = SerializableApprovalCardSchemaContract.parse; + +export const safeParseSerializableApprovalCard: ( + input: unknown, +) => SerializableApprovalCard | null = + SerializableApprovalCardSchemaContract.safeParse; +export interface ApprovalCardProps extends SerializableApprovalCard { + className?: string; + onConfirm?: () => void | Promise; + onCancel?: () => void | Promise; +} diff --git a/frontend/src/toolui/components/audio/README.md b/frontend/src/toolui/components/audio/README.md new file mode 100644 index 00000000..da6a00e6 --- /dev/null +++ b/frontend/src/toolui/components/audio/README.md @@ -0,0 +1,19 @@ +# Audio + +Implementation for the "audio" Tool UI surface. + +## Files + +- public exports: components/tool-ui/audio/index.ts +- serializable schema + parse helpers: components/tool-ui/audio/schema.ts + +## Companion assets + +- Docs page: app/docs/audio/content.mdx +- Preset payload: lib/presets/audio.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/audio/_adapter.tsx b/frontend/src/toolui/components/audio/_adapter.tsx new file mode 100644 index 00000000..ae01af74 --- /dev/null +++ b/frontend/src/toolui/components/audio/_adapter.tsx @@ -0,0 +1,8 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + */ +"use client"; + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Slider } from "@toolui/ui/slider"; diff --git a/frontend/src/toolui/components/audio/audio.tsx b/frontend/src/toolui/components/audio/audio.tsx new file mode 100644 index 00000000..785c3c9f --- /dev/null +++ b/frontend/src/toolui/components/audio/audio.tsx @@ -0,0 +1,341 @@ +"use client"; + +import * as React from "react"; +import { Pause, Play } from "lucide-react"; +import { cn, Button, Slider } from "./_adapter"; + +import { AudioProvider, useAudio } from "./context"; +import type { SerializableAudio, AudioVariant } from "./schema"; + +const FALLBACK_LOCALE = "en-US"; + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds)) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export interface AudioProps extends SerializableAudio { + variant?: AudioVariant; + className?: string; + onMediaEvent?: (type: "play" | "pause" | "mute" | "unmute") => void; +} + +export function Audio(props: AudioProps) { + return ( + + + + ); +} + +interface PlayerControls { + isPlaying: boolean; + currentTime: number; + duration: number; + onPlayPause: () => void; + onSeek: (value: number[]) => void; + onSeekStart: () => void; + onSeekEnd: () => void; +} + +interface FullPlayerProps { + artwork?: string; + title?: string; + description?: string; + controls: PlayerControls; +} + +function FullPlayer({ + artwork, + title, + description, + controls, +}: FullPlayerProps) { + return ( +
+ {artwork && ( +
+ +
+ )} +
+ {(title || description) && ( +
+ {title && ( +
+ {title} +
+ )} + {description && ( +
+ {description} +
+ )} +
+ )} +
+
+ +
+ {formatTime(controls.currentTime)} + {formatTime(controls.duration)} +
+
+ +
+
+
+ ); +} + +interface CompactPlayerProps { + artwork?: string; + title?: string; + description?: string; + controls: PlayerControls; +} + +function CompactPlayer({ + artwork, + title, + description, + controls, +}: CompactPlayerProps) { + const progress = + controls.duration > 0 + ? (controls.currentTime / controls.duration) * 100 + : 0; + + return ( +
+ {artwork && ( + <> + +
+ + )} + {artwork && ( +
+ +
+ )} +
+ {title && ( +
+ {title} +
+ )} + {description && ( +
+ {description} +
+ )} + {controls.duration > 0 && ( +
+
+
+
+ + {formatTime(controls.currentTime)} + +
+ )} +
+ +
+ ); +} + +function AudioInner(props: AudioProps) { + const { variant = "full", className, onMediaEvent, ...serializable } = props; + + const { + id, + src, + title, + description, + artwork, + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + + const { state, setState, setAudioElement } = useAudio(); + const audioRef = React.useRef(null); + const [currentTime, setCurrentTime] = React.useState(0); + const [duration, setDuration] = React.useState(0); + const [isSeeking, setIsSeeking] = React.useState(false); + + React.useEffect(() => { + setAudioElement(audioRef.current); + return () => setAudioElement(null); + }, [setAudioElement]); + + React.useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + if (state.playing && audio.paused) { + void audio.play().catch(() => undefined); + } else if (!state.playing && !audio.paused) { + audio.pause(); + } + }, [state.playing]); + + const handlePlayPause = () => { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) { + void audio.play().catch(() => undefined); + } else { + audio.pause(); + } + }; + + const handleSeek = (value: number[]) => { + const audio = audioRef.current; + if (!audio) return; + const newTime = value[0]; + audio.currentTime = newTime; + setCurrentTime(newTime); + }; + + const handleSeekStart = () => { + setIsSeeking(true); + }; + + const handleSeekEnd = () => { + setIsSeeking(false); + }; + + const controls: PlayerControls = { + isPlaying: state.playing, + currentTime, + duration, + onPlayPause: handlePlayPause, + onSeek: handleSeek, + onSeekStart: handleSeekStart, + onSeekEnd: handleSeekEnd, + }; + + const isCompact = variant === "compact"; + + return ( +
+
+ {isCompact ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/frontend/src/toolui/components/audio/context.tsx b/frontend/src/toolui/components/audio/context.tsx new file mode 100644 index 00000000..18e37ef8 --- /dev/null +++ b/frontend/src/toolui/components/audio/context.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; + +export interface AudioPlaybackState { + playing: boolean; + muted: boolean; +} + +export interface AudioContextValue { + state: AudioPlaybackState; + setState: (patch: Partial) => void; + audioElement: HTMLAudioElement | null; + setAudioElement: (node: HTMLAudioElement | null) => void; +} + +const AudioContext = React.createContext(null); + +export function useAudio() { + const ctx = React.useContext(AudioContext); + if (!ctx) { + throw new Error("useAudio must be used within an "); + } + return ctx; +} + +export interface AudioProviderProps { + children: React.ReactNode; + defaultState?: Partial; +} + +export function AudioProvider({ children, defaultState }: AudioProviderProps) { + const [state, setStateInternal] = React.useState({ + playing: defaultState?.playing ?? false, + muted: defaultState?.muted ?? false, + }); + + const [audioElement, setAudioElement] = + React.useState(null); + + const setState = React.useCallback((patch: Partial) => { + setStateInternal((prev) => ({ ...prev, ...patch })); + }, []); + + const value = React.useMemo( + () => ({ state, setState, audioElement, setAudioElement }), + [state, setState, audioElement], + ); + + return ( + {children} + ); +} diff --git a/frontend/src/toolui/components/audio/index.ts b/frontend/src/toolui/components/audio/index.ts new file mode 100644 index 00000000..9b588b80 --- /dev/null +++ b/frontend/src/toolui/components/audio/index.ts @@ -0,0 +1,5 @@ +export { Audio } from "./audio"; +export type { AudioProps } from "./audio"; +export { AudioProvider, useAudio } from "./context"; +export type { AudioPlaybackState, AudioContextValue } from "./context"; +export type { SerializableAudio, Source, AudioVariant } from "./schema"; diff --git a/frontend/src/toolui/components/audio/schema.ts b/frontend/src/toolui/components/audio/schema.ts new file mode 100644 index 00000000..b4fdcfcf --- /dev/null +++ b/frontend/src/toolui/components/audio/schema.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const SourceSchema = z.object({ + label: z.string(), + iconUrl: z.url().optional(), + url: z.url().optional(), +}); + +export type Source = z.infer; + +export const SerializableAudioSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + assetId: z.string(), + src: z.url(), + title: z.string().optional(), + description: z.string().optional(), + artwork: z.url().optional(), + durationMs: z.number().int().positive().optional(), + fileSizeBytes: z.number().int().positive().optional(), + createdAt: z.string().datetime().optional(), + locale: z.string().optional(), + source: SourceSchema.optional(), +}); + +export type SerializableAudio = z.infer; + +const SerializableAudioSchemaContract = defineToolUiContract( + "Audio", + SerializableAudioSchema, +); + +export const parseSerializableAudio: (input: unknown) => SerializableAudio = + SerializableAudioSchemaContract.parse; + +export const safeParseSerializableAudio: ( + input: unknown, +) => SerializableAudio | null = SerializableAudioSchemaContract.safeParse; +export type AudioVariant = "full" | "compact"; diff --git a/frontend/src/toolui/components/chart/README.md b/frontend/src/toolui/components/chart/README.md new file mode 100644 index 00000000..eb421cd2 --- /dev/null +++ b/frontend/src/toolui/components/chart/README.md @@ -0,0 +1,19 @@ +# Chart + +Implementation for the "chart" Tool UI surface. + +## Files + +- public exports: components/tool-ui/chart/index.tsx +- serializable schema + parse helpers: components/tool-ui/chart/schema.ts + +## Companion assets + +- Docs page: app/docs/chart/content.mdx +- Preset payload: lib/presets/chart.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/chart/_adapter.tsx b/frontend/src/toolui/components/chart/_adapter.tsx new file mode 100644 index 00000000..52cd2e17 --- /dev/null +++ b/frontend/src/toolui/components/chart/_adapter.tsx @@ -0,0 +1,27 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Chart → shadcn/ui Chart (recharts wrapper) + * Card → shadcn/ui Card + */ + +export { cn } from "@toolui/lib/utils"; +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + type ChartConfig, +} from "@toolui/ui/chart"; +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@toolui/ui/card"; diff --git a/frontend/src/toolui/components/chart/chart.tsx b/frontend/src/toolui/components/chart/chart.tsx new file mode 100644 index 00000000..97a7b44b --- /dev/null +++ b/frontend/src/toolui/components/chart/chart.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useMemo, useCallback, memo } from "react"; +import { + BarChart, + LineChart, + Bar, + Line, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; + +import { + cn, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + type ChartConfig, +} from "./_adapter"; +import type { ChartProps } from "./schema"; + +const DEFAULT_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +]; + +export const Chart = memo(function Chart({ + id, + type, + title, + description, + data, + xKey, + series, + colors, + showLegend = false, + showGrid = true, + className, + onDataPointClick, +}: ChartProps) { + const palette = colors?.length ? colors : DEFAULT_COLORS; + + const seriesColors = useMemo( + () => + series.map( + (seriesItem, index) => + seriesItem.color ?? palette[index % palette.length], + ), + [series, palette], + ); + + const chartConfig: ChartConfig = useMemo( + () => + Object.fromEntries( + series.map((seriesItem, index) => [ + seriesItem.key, + { + label: seriesItem.label, + color: seriesColors[index], + }, + ]), + ), + [series, seriesColors], + ); + + const handleDataPointClick = useCallback( + ( + seriesKey: string, + seriesLabel: string, + payload: Record, + index: number, + ) => { + onDataPointClick?.({ + seriesKey, + seriesLabel, + xValue: payload[xKey], + yValue: payload[seriesKey], + index, + payload, + }); + }, + [onDataPointClick, xKey], + ); + + const ChartComponent = type === "bar" ? BarChart : LineChart; + + const chartContent = ( + + + {showGrid && } + + + } /> + {showLegend && } />} + + {type === "bar" && + series.map((s, i) => ( + + handleDataPointClick(s.key, s.label, data.payload, data.index) + } + cursor={onDataPointClick ? "pointer" : undefined} + /> + ))} + + {type === "line" && + series.map((s, i) => ( + ; index: number }, + ) => { + handleDataPointClick( + s.key, + s.label, + dotData.payload, + dotData.index, + ); + }) as unknown as React.MouseEventHandler, + }} + /> + ))} + + + ); + + return ( + + {(title || description) && ( + + {title && {title}} + {description && ( + + {description} + + )} + + )} + {chartContent} + + ); +}); diff --git a/frontend/src/toolui/components/chart/index.tsx b/frontend/src/toolui/components/chart/index.tsx new file mode 100644 index 00000000..aeebab8f --- /dev/null +++ b/frontend/src/toolui/components/chart/index.tsx @@ -0,0 +1,8 @@ +export { Chart } from "./chart"; +export { + type ChartProps, + type ChartSeries, + type ChartDataPoint, + type ChartClientProps, + type SerializableChart, +} from "./schema"; diff --git a/frontend/src/toolui/components/chart/schema.ts b/frontend/src/toolui/components/chart/schema.ts new file mode 100644 index 00000000..77a5b7f1 --- /dev/null +++ b/frontend/src/toolui/components/chart/schema.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const ChartSeriesSchema = z.object({ + key: z.string().min(1), + label: z.string().min(1), + color: z.string().optional(), +}); + +export type ChartSeries = z.infer; + +export const ChartPropsSchema = z + .object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + type: z.enum(["bar", "line"]), + title: z.string().optional(), + description: z.string().optional(), + data: z.array(z.record(z.string(), z.unknown())).min(1), + xKey: z.string().min(1), + series: z.array(ChartSeriesSchema).min(1), + /** Color palette applied to series in order. Individual series.color takes precedence. */ + colors: z.array(z.string().min(1)).min(1).optional(), + showLegend: z.boolean().optional(), + showGrid: z.boolean().optional(), + }) + .superRefine((value, ctx) => { + const seenSeriesKeys = new Set(); + value.series.forEach((series, index) => { + if (seenSeriesKeys.has(series.key)) { + ctx.addIssue({ + code: "custom", + path: ["series", index, "key"], + message: `Duplicate series key "${series.key}".`, + }); + return; + } + seenSeriesKeys.add(series.key); + }); + + value.data.forEach((row, rowIndex) => { + if (!(value.xKey in row)) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, value.xKey], + message: `Missing xKey "${value.xKey}" in data row.`, + }); + } else { + const xVal = row[value.xKey]; + const isValidX = typeof xVal === "string" || typeof xVal === "number"; + if (!isValidX) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, value.xKey], + message: `Expected "${value.xKey}" to be a string or number.`, + }); + } + } + + value.series.forEach((series) => { + if (!(series.key in row)) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, series.key], + message: `Missing series key "${series.key}" in data row.`, + }); + return; + } + + const yVal = row[series.key]; + if (yVal === null) { + return; + } + if (typeof yVal !== "number" || !Number.isFinite(yVal)) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, series.key], + message: `Expected "${series.key}" to be a finite number (or null).`, + }); + } + }); + }); + }); + +export type ChartDataPoint = { + seriesKey: string; + seriesLabel: string; + xValue: unknown; + yValue: unknown; + index: number; + payload: Record; +}; + +export type ChartClientProps = { + className?: string; + onDataPointClick?: (point: ChartDataPoint) => void; +}; + +export type ChartProps = z.infer & ChartClientProps; + +export const SerializableChartSchema = ChartPropsSchema; + +export type SerializableChart = z.infer; + +const SerializableChartSchemaContract = defineToolUiContract( + "Chart", + SerializableChartSchema, +); + +export const parseSerializableChart: (input: unknown) => SerializableChart = + SerializableChartSchemaContract.parse; + +export const safeParseSerializableChart: ( + input: unknown, +) => SerializableChart | null = SerializableChartSchemaContract.safeParse; diff --git a/frontend/src/toolui/components/citation/README.md b/frontend/src/toolui/components/citation/README.md new file mode 100644 index 00000000..e248e34d --- /dev/null +++ b/frontend/src/toolui/components/citation/README.md @@ -0,0 +1,19 @@ +# Citation + +Implementation for the "citation" Tool UI surface. + +## Files + +- public exports: components/tool-ui/citation/index.ts +- serializable schema + parse helpers: components/tool-ui/citation/schema.ts + +## Companion assets + +- Docs page: app/docs/citation/content.mdx +- Preset payload: lib/presets/citation.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/citation/_adapter.tsx b/frontend/src/toolui/components/citation/_adapter.tsx new file mode 100644 index 00000000..33f4cdc4 --- /dev/null +++ b/frontend/src/toolui/components/citation/_adapter.tsx @@ -0,0 +1,18 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Tooltip → shadcn/ui Tooltip (only needed for variant="inline") + * Popover → shadcn/ui Popover (only needed for CitationList) + */ +"use client"; + +export { cn } from "@toolui/lib/utils"; +export { + Popover, + PopoverContent, + PopoverTrigger, +} from "@toolui/ui/popover"; diff --git a/frontend/src/toolui/components/citation/citation-list.tsx b/frontend/src/toolui/components/citation/citation-list.tsx new file mode 100644 index 00000000..db586c7e --- /dev/null +++ b/frontend/src/toolui/components/citation/citation-list.tsx @@ -0,0 +1,460 @@ +"use client"; + +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { + FileText, + Globe, + Code2, + Newspaper, + Database, + File, + ExternalLink, +} from "lucide-react"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; +import { Citation } from "./citation"; +import type { + SerializableCitation, + CitationType, + CitationVariant, +} from "./schema"; +import { + openSafeNavigationHref, + resolveSafeNavigationHref, +} from "../shared/media"; + +const TYPE_ICONS: Record = { + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, +}; + +function useHoverPopover(delay = 100) { + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + const containerRef = React.useRef(null); + + const handleMouseEnter = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(true), delay); + }, [delay]); + + const handleMouseLeave = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, [delay]); + + const handleFocus = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setOpen(true); + }, []); + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null; + if (containerRef.current?.contains(relatedTarget)) { + return; + } + if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) { + return; + } + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, + [delay], + ); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { + open, + setOpen, + containerRef, + handleMouseEnter, + handleMouseLeave, + handleFocus, + handleBlur, + }; +} + +export interface CitationListProps { + id: string; + citations: SerializableCitation[]; + variant?: CitationVariant; + maxVisible?: number; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +export function CitationList(props: CitationListProps) { + const { + id, + citations, + variant = "default", + maxVisible, + className, + onNavigate, + } = props; + + const shouldTruncate = + maxVisible !== undefined && citations.length > maxVisible; + const visibleCitations = shouldTruncate + ? citations.slice(0, maxVisible) + : citations; + const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : []; + const overflowCount = overflowCitations.length; + + const wrapperClass = + variant === "inline" + ? "flex flex-wrap items-center gap-1.5" + : "flex flex-col gap-2"; + + // Stacked variant: overlapping favicons with popover + if (variant === "stacked") { + return ( + + ); + } + + if (variant === "default") { + return ( +
+ {visibleCitations.map((citation) => ( + + ))} + {shouldTruncate && ( + + )} +
+ ); + } + + return ( +
+ {visibleCitations.map((citation) => ( + + ))} + {shouldTruncate && ( + + )} +
+ ); +} + +interface OverflowIndicatorProps { + citations: SerializableCitation[]; + count: number; + variant: CitationVariant; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +function OverflowIndicator({ + citations, + count, + variant, + onNavigate, +}: OverflowIndicatorProps) { + const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover(); + + const handleClick = (citation: SerializableCitation) => { + const href = resolveSafeNavigationHref(citation.href); + if (!href) return; + if (onNavigate) { + onNavigate(href, citation); + } else { + openSafeNavigationHref(href); + } + }; + + const popoverContent = ( +
+ {citations.map((citation) => ( + handleClick(citation)} + /> + ))} +
+ ); + + if (variant === "inline") { + return ( + + + + + e.preventDefault()} + > + {popoverContent} + + + ); + } + + // Default variant + return ( + + + + + e.preventDefault()} + > + {popoverContent} + + + ); +} + +interface OverflowItemProps { + citation: SerializableCitation; + onClick: () => void; +} + +function OverflowItem({ citation, onClick }: OverflowItemProps) { + const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe; + + return ( + + ); +} + +interface StackedCitationsProps { + id: string; + citations: SerializableCitation[]; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +function StackedCitations({ + id, + citations, + className, + onNavigate, +}: StackedCitationsProps) { + const { + open, + setOpen, + containerRef, + handleMouseEnter, + handleMouseLeave, + handleBlur, + } = useHoverPopover(); + const maxIcons = 4; + const visibleCitations = citations.slice(0, maxIcons); + const remainingCount = Math.max(0, citations.length - maxIcons); + + const handleClick = (citation: SerializableCitation) => { + const href = resolveSafeNavigationHref(citation.href); + if (!href) return; + if (onNavigate) { + onNavigate(href, citation); + } else { + openSafeNavigationHref(href); + } + }; + + return ( +
+ + + + + setOpen(false)} + > +
+ {citations.map((citation) => ( + handleClick(citation)} + /> + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/toolui/components/citation/citation.tsx b/frontend/src/toolui/components/citation/citation.tsx new file mode 100644 index 00000000..551f82a7 --- /dev/null +++ b/frontend/src/toolui/components/citation/citation.tsx @@ -0,0 +1,259 @@ +"use client"; + +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { + FileText, + Globe, + Code2, + Newspaper, + Database, + File, + ExternalLink, +} from "lucide-react"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; + +import { openSafeNavigationHref, sanitizeHref } from "../shared/media"; +import type { + SerializableCitation, + CitationType, + CitationVariant, +} from "./schema"; + +const FALLBACK_LOCALE = "en-US"; + +const TYPE_ICONS: Record = { + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, +}; + +function extractDomain(url: string): string | undefined { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +function formatDate(isoString: string, locale: string): string { + try { + const date = new Date(isoString); + return date.toLocaleDateString(locale, { + year: "numeric", + month: "short", + }); + } catch { + return isoString; + } +} + +function useHoverPopover(delay = 100) { + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + + const handleMouseEnter = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(true), delay); + }, [delay]); + + const handleMouseLeave = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, [delay]); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { open, setOpen, handleMouseEnter, handleMouseLeave }; +} + +export interface CitationProps extends SerializableCitation { + variant?: CitationVariant; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +export function Citation(props: CitationProps) { + const { variant = "default", className, onNavigate, ...serializable } = props; + + const { + id, + href: rawHref, + title, + snippet, + domain: providedDomain, + favicon, + author, + publishedAt, + type = "webpage", + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + const sanitizedHref = sanitizeHref(rawHref); + const domain = providedDomain ?? extractDomain(rawHref); + + const citationData: SerializableCitation = { + ...serializable, + href: sanitizedHref ?? rawHref, + domain, + locale, + }; + + const TypeIcon = TYPE_ICONS[type] ?? Globe; + + const handleClick = () => { + if (!sanitizedHref) return; + if (onNavigate) { + onNavigate(sanitizedHref, citationData); + } else { + openSafeNavigationHref(sanitizedHref); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (sanitizedHref && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleClick(); + } + }; + + const iconElement = favicon ? ( + + ) : ( +
+ + ); +} diff --git a/frontend/src/toolui/components/item-carousel/item-carousel.tsx b/frontend/src/toolui/components/item-carousel/item-carousel.tsx new file mode 100644 index 00000000..6c0b601b --- /dev/null +++ b/frontend/src/toolui/components/item-carousel/item-carousel.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { useRef, useState, useEffect, useCallback } from "react"; +import { cn, Button, Card, ChevronLeft, ChevronRight } from "./_adapter"; +import { ItemCard } from "./item-card"; +import { prefersReducedMotion } from "../shared/utils"; +import type { ItemCarouselProps } from "./schema"; + +const SCROLL_PADDING_STYLE = { scrollPaddingInline: "1rem" }; + +const SCROLL_EDGE_THRESHOLD_PX = 8; +const SNAP_EPSILON_PX = 5; +const SCROLL_ANIMATION_DURATION_MS = 300; +const PAGE_SCROLL_RATIO = 0.8; +const PAGE_SCROLL_BREAKPOINT_PX = 640; + +type ScrollDirection = "left" | "right"; + +interface ScrollAnimationState { + target: number; + start: number; + startTime: number; + duration: number; + onComplete?: () => void; +} + +function useSmoothScroll() { + const animationRef = useRef(null); + const frameRef = useRef(null); + + const cancelAnimation = useCallback(() => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + animationRef.current = null; + }, []); + + useEffect(() => cancelAnimation, [cancelAnimation]); + + const scrollTo = useCallback( + ( + element: HTMLElement, + target: number, + duration = SCROLL_ANIMATION_DURATION_MS, + onComplete?: () => void, + ) => { + if (prefersReducedMotion() || duration <= 0) { + element.scrollLeft = target; + onComplete?.(); + return; + } + + cancelAnimation(); + + animationRef.current = { + target, + start: element.scrollLeft, + startTime: performance.now(), + duration, + onComplete, + }; + + element.style.scrollSnapType = "none"; + + const step = () => { + const anim = animationRef.current; + if (!anim) return; + + const elapsed = performance.now() - anim.startTime; + const progress = Math.min(elapsed / anim.duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + + element.scrollLeft = anim.start + (anim.target - anim.start) * eased; + + if (progress < 1) { + frameRef.current = requestAnimationFrame(step); + return; + } + + element.scrollLeft = anim.target; + const callback = anim.onComplete; + cancelAnimation(); + + requestAnimationFrame(() => { + element.style.scrollSnapType = ""; + callback?.(); + }); + }; + + frameRef.current = requestAnimationFrame(step); + }, + [cancelAnimation], + ); + + const isAnimating = useCallback( + () => animationRef.current !== null && frameRef.current !== null, + [], + ); + + return { scrollTo, isAnimating, cancelAnimation }; +} + +function useScrollEdgeState( + scrollRef: React.RefObject, + itemCount: number, +) { + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateState = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + + const scrollLeft = Math.round(container.scrollLeft); + const maxScroll = Math.max( + 0, + Math.round(container.scrollWidth - container.clientWidth), + ); + + setCanScrollLeft(scrollLeft > SCROLL_EDGE_THRESHOLD_PX); + setCanScrollRight(scrollLeft < maxScroll - SCROLL_EDGE_THRESHOLD_PX); + }, [scrollRef]); + + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + + let rafId: number | null = null; + + const scheduleUpdate = () => { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + rafId = null; + updateState(); + }); + }; + + scheduleUpdate(); + + container.addEventListener("scroll", scheduleUpdate, { passive: true }); + const resizeObserver = new ResizeObserver(scheduleUpdate); + resizeObserver.observe(container); + + return () => { + container.removeEventListener("scroll", scheduleUpdate); + resizeObserver.disconnect(); + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }, [scrollRef, updateState, itemCount]); + + return { canScrollLeft, canScrollRight }; +} + +function CarouselNavButton({ + direction, + visible, + onClick, +}: { + direction: ScrollDirection; + visible: boolean; + onClick: () => void; +}) { + const isLeft = direction === "left"; + const Icon = isLeft ? ChevronLeft : ChevronRight; + + return ( + + ); +} + +interface ItemCarouselHeaderProps { + title?: string; + description?: string; +} + +function ItemCarouselHeader({ title, description }: ItemCarouselHeaderProps) { + if (!title && !description) return null; + + return ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ ); +} + +interface EmptyStateProps { + id: string; + className?: string; +} + +function EmptyState({ id, className }: EmptyStateProps) { + return ( + +

No items to display

+
+ ); +} + +function ItemCarouselRoot({ + id, + title, + description, + items, + className, + onItemClick, + onItemAction, +}: ItemCarouselProps) { + const scrollRef = useRef(null); + const targetIndexRef = useRef(null); + + const { scrollTo, isAnimating } = useSmoothScroll(); + const { canScrollLeft, canScrollRight } = useScrollEdgeState( + scrollRef, + items.length, + ); + + const scroll = useCallback( + (direction: ScrollDirection) => { + const container = scrollRef.current; + if (!container) return; + + const paddingValue = window.getComputedStyle(container).scrollPaddingLeft; + const scrollPaddingLeft = Number.isFinite(Number.parseFloat(paddingValue)) + ? Number.parseFloat(paddingValue) + : 0; + + const itemElements = Array.from( + container.querySelectorAll("[data-carousel-item]"), + ); + if (itemElements.length === 0) return; + + const snapPositions = itemElements.map((el) => + Math.max(0, el.offsetLeft - scrollPaddingLeft), + ); + + const scrollLeft = Math.round(container.scrollLeft); + let currentIndex: number; + if (isAnimating()) { + currentIndex = Math.min( + targetIndexRef.current ?? 0, + snapPositions.length - 1, + ); + } else { + currentIndex = snapPositions.length - 1; + for (let i = 0; i < snapPositions.length; i++) { + const snap = snapPositions[i]; + if (Math.abs(snap - scrollLeft) < SNAP_EPSILON_PX) { + currentIndex = i; + break; + } + if (snap > scrollLeft) { + currentIndex = Math.max(0, i - 1); + break; + } + } + } + + const itemStep = + itemElements.length > 1 + ? itemElements[1].offsetLeft - itemElements[0].offsetLeft + : 0; + const safeStep = + itemStep > 0 ? itemStep : itemElements[0].offsetWidth || 1; + + const pageIndexStep = + container.clientWidth >= PAGE_SCROLL_BREAKPOINT_PX + ? Math.max( + 1, + Math.floor( + (container.clientWidth * PAGE_SCROLL_RATIO) / safeStep, + ), + ) + : 1; + + const targetIndex = + direction === "right" + ? Math.min(currentIndex + pageIndexStep, itemElements.length - 1) + : Math.max(currentIndex - pageIndexStep, 0); + + targetIndexRef.current = targetIndex; + const targetScrollLeft = snapPositions[targetIndex]; + + if (Math.abs(targetScrollLeft - container.scrollLeft) > 1) { + scrollTo( + container, + targetScrollLeft, + SCROLL_ANIMATION_DURATION_MS, + () => { + targetIndexRef.current = null; + }, + ); + } + }, + [scrollTo, isAnimating], + ); + + const handleScrollLeft = useCallback(() => scroll("left"), [scroll]); + const handleScrollRight = useCallback(() => scroll("right"), [scroll]); + + if (items.length === 0) { + return ; + } + + return ( +
+ + +
+ + + +
+ {items.map((item) => ( +
+ +
+ ))} +
+
+
+ ); +} + +type ItemCarouselComponent = typeof ItemCarouselRoot & { + Root: typeof ItemCarouselRoot; + Header: typeof ItemCarouselHeader; + EmptyState: typeof EmptyState; + NavButton: typeof CarouselNavButton; + Card: typeof ItemCard; +}; + +export const ItemCarousel = Object.assign(ItemCarouselRoot, { + Root: ItemCarouselRoot, + Header: ItemCarouselHeader, + EmptyState, + NavButton: CarouselNavButton, + Card: ItemCard, +}) as ItemCarouselComponent; diff --git a/frontend/src/toolui/components/item-carousel/schema.ts b/frontend/src/toolui/components/item-carousel/schema.ts new file mode 100644 index 00000000..d20a72b5 --- /dev/null +++ b/frontend/src/toolui/components/item-carousel/schema.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ActionSchema, + SerializableActionSchema, + ToolUIIdSchema, +} from "../shared/schema"; + +export const ItemSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + subtitle: z.string().optional(), + image: z.url().optional(), + color: z.string().optional(), + actions: z.array(ActionSchema).optional(), +}); + +export const ItemCarouselPropsSchema = z.object({ + id: ToolUIIdSchema, + title: z.string().optional(), + description: z.string().optional(), + items: z.array(ItemSchema), + className: z.string().optional(), +}); + +export type Item = z.infer; + +export type ItemCarouselProps = z.infer & { + onItemClick?: (itemId: string) => void; + onItemAction?: (itemId: string, actionId: string) => void; +}; + +export const SerializableItemSchema = ItemSchema.extend({ + actions: z.array(SerializableActionSchema).optional(), +}); + +export const SerializableItemCarouselSchema = ItemCarouselPropsSchema.omit({ + className: true, +}) + .extend({ + items: z.array(SerializableItemSchema), + }) + .superRefine((payload, ctx) => { + const seenItemIds = new Map(); + + payload.items.forEach((item, index) => { + const firstSeenAt = seenItemIds.get(item.id); + if (firstSeenAt !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["items", index, "id"], + message: `duplicate item id '${item.id}' (first seen at index ${firstSeenAt})`, + }); + return; + } + seenItemIds.set(item.id, index); + }); + }); + +export type SerializableItem = z.infer; +export type SerializableItemCarousel = z.infer< + typeof SerializableItemCarouselSchema +>; + +const SerializableItemCarouselSchemaContract = defineToolUiContract( + "ItemCarousel", + SerializableItemCarouselSchema, +); + +export const parseSerializableItemCarousel: ( + input: unknown, +) => SerializableItemCarousel = SerializableItemCarouselSchemaContract.parse; + +export const safeParseSerializableItemCarousel: ( + input: unknown, +) => SerializableItemCarousel | null = + SerializableItemCarouselSchemaContract.safeParse; diff --git a/frontend/src/toolui/components/link-preview/README.md b/frontend/src/toolui/components/link-preview/README.md new file mode 100644 index 00000000..0fab3dbb --- /dev/null +++ b/frontend/src/toolui/components/link-preview/README.md @@ -0,0 +1,19 @@ +# Link Preview + +Implementation for the "link-preview" Tool UI surface. + +## Files + +- public exports: components/tool-ui/link-preview/index.ts +- serializable schema + parse helpers: components/tool-ui/link-preview/schema.ts + +## Companion assets + +- Docs page: app/docs/link-preview/content.mdx +- Preset payload: lib/presets/link-preview.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/link-preview/_adapter.tsx b/frontend/src/toolui/components/link-preview/_adapter.tsx new file mode 100644 index 00000000..ac928498 --- /dev/null +++ b/frontend/src/toolui/components/link-preview/_adapter.tsx @@ -0,0 +1,6 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + */ +"use client"; + +export { cn } from "@toolui/lib/utils"; diff --git a/frontend/src/toolui/components/link-preview/index.ts b/frontend/src/toolui/components/link-preview/index.ts new file mode 100644 index 00000000..cea9fe77 --- /dev/null +++ b/frontend/src/toolui/components/link-preview/index.ts @@ -0,0 +1,3 @@ +export { LinkPreview } from "./link-preview"; +export type { LinkPreviewProps } from "./link-preview"; +export type { SerializableLinkPreview } from "./schema"; diff --git a/frontend/src/toolui/components/link-preview/link-preview.tsx b/frontend/src/toolui/components/link-preview/link-preview.tsx new file mode 100644 index 00000000..9d2ae161 --- /dev/null +++ b/frontend/src/toolui/components/link-preview/link-preview.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { Globe } from "lucide-react"; +import { cn } from "./_adapter"; + +import { + RATIO_CLASS_MAP, + getFitClass, + openSafeNavigationHref, + sanitizeHref, +} from "../shared/media"; +import type { SerializableLinkPreview } from "./schema"; + +const FALLBACK_LOCALE = "en-US"; +const CONTENT_SPACING = "px-5 py-4 gap-2"; + +export interface LinkPreviewProps extends SerializableLinkPreview { + className?: string; + onNavigate?: (href: string, preview: SerializableLinkPreview) => void; +} + +export function LinkPreview(props: LinkPreviewProps) { + const { className, onNavigate, ...serializable } = props; + + const { + id, + href: rawHref, + title, + description, + image, + domain, + favicon, + ratio = "16:9", + fit = "cover", + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + const sanitizedHref = sanitizeHref(rawHref); + + const previewData: SerializableLinkPreview = { + ...serializable, + href: sanitizedHref ?? rawHref, + locale, + }; + + const handleClick = () => { + if (!sanitizedHref) return; + if (onNavigate) { + onNavigate(sanitizedHref, previewData); + } else { + openSafeNavigationHref(sanitizedHref); + } + }; + + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(); + } + } + : undefined + } + > +
+ {image && ( +
+ +
+ )} +
+ {domain && ( +
+ {favicon ? ( + + ) : ( +
+
+ )} + {domain} +
+ )} + {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/toolui/components/link-preview/schema.ts b/frontend/src/toolui/components/link-preview/schema.ts new file mode 100644 index 00000000..3bde91b5 --- /dev/null +++ b/frontend/src/toolui/components/link-preview/schema.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +import { AspectRatioSchema, MediaFitSchema } from "../shared/media"; + +export const SerializableLinkPreviewSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + href: z.url(), + title: z.string().optional(), + description: z.string().optional(), + image: z.url().optional(), + domain: z.string().optional(), + favicon: z.url().optional(), + ratio: AspectRatioSchema.optional(), + fit: MediaFitSchema.optional(), + createdAt: z.string().datetime().optional(), + locale: z.string().optional(), +}); + +export type SerializableLinkPreview = z.infer< + typeof SerializableLinkPreviewSchema +>; + +const SerializableLinkPreviewSchemaContract = defineToolUiContract( + "LinkPreview", + SerializableLinkPreviewSchema, +); + +export const parseSerializableLinkPreview: ( + input: unknown, +) => SerializableLinkPreview = SerializableLinkPreviewSchemaContract.parse; + +export const safeParseSerializableLinkPreview: ( + input: unknown, +) => SerializableLinkPreview | null = + SerializableLinkPreviewSchemaContract.safeParse; diff --git a/frontend/src/toolui/components/linkedin-post/README.md b/frontend/src/toolui/components/linkedin-post/README.md new file mode 100644 index 00000000..1869eff8 --- /dev/null +++ b/frontend/src/toolui/components/linkedin-post/README.md @@ -0,0 +1,19 @@ +# Linkedin Post + +Implementation for the "linkedin-post" Tool UI surface. + +## Files + +- public exports: components/tool-ui/linkedin-post/index.ts +- serializable schema + parse helpers: components/tool-ui/linkedin-post/schema.ts + +## Companion assets + +- Docs page: app/docs/social-post/content.mdx +- Preset payload: lib/presets/linkedin-post.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/linkedin-post/_adapter.tsx b/frontend/src/toolui/components/linkedin-post/_adapter.tsx new file mode 100644 index 00000000..c314b96c --- /dev/null +++ b/frontend/src/toolui/components/linkedin-post/_adapter.tsx @@ -0,0 +1,19 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + * Tooltip → shadcn/ui Tooltip + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@toolui/ui/tooltip"; diff --git a/frontend/src/toolui/components/linkedin-post/index.ts b/frontend/src/toolui/components/linkedin-post/index.ts new file mode 100644 index 00000000..2fd27172 --- /dev/null +++ b/frontend/src/toolui/components/linkedin-post/index.ts @@ -0,0 +1,9 @@ +export { LinkedInPost } from "./linkedin-post"; +export type { LinkedInPostProps } from "./linkedin-post"; +export type { + LinkedInPostData, + LinkedInPostAuthor, + LinkedInPostMedia, + LinkedInPostLinkPreview, + LinkedInPostStats, +} from "./schema"; diff --git a/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx b/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx new file mode 100644 index 00000000..51fcd221 --- /dev/null +++ b/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx @@ -0,0 +1,283 @@ +"use client"; + +import * as React from "react"; +import { ThumbsUp, Share } from "lucide-react"; +import { + cn, + Button, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./_adapter"; +import { formatCount, formatRelativeTime, getDomain } from "../shared/utils"; + +import { resolveSafeNavigationHref } from "../shared/media"; +import type { + LinkedInPostData, + LinkedInPostMedia, + LinkedInPostLinkPreview, +} from "./schema"; + +const TEXT_PREVIEW_LENGTH = 280; + +export interface LinkedInPostProps { + post: LinkedInPostData; + className?: string; + onAction?: (action: string, post: LinkedInPostData) => void; +} + +function LinkedInLogo({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function Header({ + author, + createdAt, +}: { + author: LinkedInPostData["author"]; + createdAt?: string; +}) { + return ( +
+ {`${author.name} +
+ {author.name} + {author.headline && ( + + {author.headline} + + )} + {createdAt && ( +
+ {formatRelativeTime(createdAt)} + · + Edited +
+ )} +
+ +
+ ); +} + +function PostBody({ text }: { text?: string }) { + const [isExpanded, setIsExpanded] = React.useState(false); + const shouldTruncate = text && text.length > TEXT_PREVIEW_LENGTH; + + if (!text) return null; + + return ( +
+ {shouldTruncate && !isExpanded ? ( + <> + {text.slice(0, TEXT_PREVIEW_LENGTH)} + ... + + + ) : ( + text + )} +
+ ); +} + +function PostMedia({ media }: { media: LinkedInPostMedia }) { + return ( +
+ {media.type === "image" ? ( + {media.alt} + ) : ( +
+ ); +} + +function PostLinkPreview({ preview }: { preview: LinkedInPostLinkPreview }) { + const href = resolveSafeNavigationHref(preview.url); + const domain = preview.domain ?? getDomain(preview.url); + const content = ( + <> + {preview.imageUrl && ( + + )} +
+ {preview.title && ( +
+ {preview.title} +
+ )} + {domain && ( +
{domain}
+ )} +
+ + ); + + if (!href) { + return ( +
{content}
+ ); + } + + return ( + + {content} + + ); +} + +function ActionButton({ + icon: Icon, + label, + count, + active, + hoverColor, + activeColor, + onClick, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + count?: number; + active?: boolean; + hoverColor: string; + activeColor?: string; + onClick: () => void; +}) { + return ( + + + + + {label} + + ); +} + +function PostActions({ + stats, + onAction, +}: { + stats?: LinkedInPostData["stats"]; + onAction: (action: string) => void; +}) { + return ( + +
+ onAction("like")} + /> + onAction("share")} + /> +
+
+ ); +} + +export function LinkedInPost({ post, className, onAction }: LinkedInPostProps) { + return ( +
+
+
+ + + {post.media && } + + {post.linkPreview && !post.media && ( + + )} + + onAction?.(action, post)} + /> +
+
+ ); +} diff --git a/frontend/src/toolui/components/linkedin-post/schema.ts b/frontend/src/toolui/components/linkedin-post/schema.ts new file mode 100644 index 00000000..177f5b4c --- /dev/null +++ b/frontend/src/toolui/components/linkedin-post/schema.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; + +export const LinkedInPostAuthorSchema = z.object({ + name: z.string(), + avatarUrl: z.string(), + headline: z.string().optional(), +}); + +export const LinkedInPostMediaSchema = z.object({ + type: z.enum(["image", "video"]), + url: z.string(), + alt: z.string(), +}); + +export const LinkedInPostLinkPreviewSchema = z.object({ + url: z.string(), + title: z.string().optional(), + description: z.string().optional(), + imageUrl: z.string().optional(), + domain: z.string().optional(), +}); + +export const LinkedInPostStatsSchema = z.object({ + likes: z.number().optional(), + isLiked: z.boolean().optional(), +}); + +export const SerializableLinkedInPostSchema = z.object({ + id: z.string(), + author: LinkedInPostAuthorSchema, + text: z.string().optional(), + media: LinkedInPostMediaSchema.optional(), + linkPreview: LinkedInPostLinkPreviewSchema.optional(), + stats: LinkedInPostStatsSchema.optional(), + createdAt: z.string().optional(), +}); + +export type LinkedInPostData = z.infer; + +export type LinkedInPostAuthor = z.infer; +export type LinkedInPostMedia = z.infer; +export type LinkedInPostLinkPreview = z.infer< + typeof LinkedInPostLinkPreviewSchema +>; +export type LinkedInPostStats = z.infer; + +const SerializableLinkedInPostSchemaContract = defineToolUiContract( + "LinkedInPost", + SerializableLinkedInPostSchema, +); + +export const parseSerializableLinkedInPost: ( + input: unknown, +) => LinkedInPostData = SerializableLinkedInPostSchemaContract.parse; + +export const safeParseSerializableLinkedInPost: ( + input: unknown, +) => LinkedInPostData | null = SerializableLinkedInPostSchemaContract.safeParse; diff --git a/frontend/src/toolui/components/message-draft/README.md b/frontend/src/toolui/components/message-draft/README.md new file mode 100644 index 00000000..48ed63e2 --- /dev/null +++ b/frontend/src/toolui/components/message-draft/README.md @@ -0,0 +1,19 @@ +# Message Draft + +Implementation for the "message-draft" Tool UI surface. + +## Files + +- public exports: components/tool-ui/message-draft/index.tsx +- serializable schema + parse helpers: components/tool-ui/message-draft/schema.ts + +## Companion assets + +- Docs page: app/docs/message-draft/content.mdx +- Preset payload: lib/presets/message-draft.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/message-draft/_adapter.tsx b/frontend/src/toolui/components/message-draft/_adapter.tsx new file mode 100644 index 00000000..4d2303fd --- /dev/null +++ b/frontend/src/toolui/components/message-draft/_adapter.tsx @@ -0,0 +1,12 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; diff --git a/frontend/src/toolui/components/message-draft/index.tsx b/frontend/src/toolui/components/message-draft/index.tsx new file mode 100644 index 00000000..9970ea34 --- /dev/null +++ b/frontend/src/toolui/components/message-draft/index.tsx @@ -0,0 +1,10 @@ +export { MessageDraft } from "./message-draft"; +export { + type SerializableMessageDraft, + type SerializableEmailDraft, + type SerializableSlackDraft, + type MessageDraftChannel, + type MessageDraftOutcome, + type SlackTarget, + type MessageDraftProps, +} from "./schema"; diff --git a/frontend/src/toolui/components/message-draft/message-draft.tsx b/frontend/src/toolui/components/message-draft/message-draft.tsx new file mode 100644 index 00000000..1753b795 --- /dev/null +++ b/frontend/src/toolui/components/message-draft/message-draft.tsx @@ -0,0 +1,511 @@ +"use client"; + +import * as React from "react"; +import { cn, Button } from "./_adapter"; +import type { + MessageDraftProps, + SerializableEmailDraft, + SerializableSlackDraft, +} from "./schema"; +import { ActionButtons } from "../shared/action-buttons"; +import type { Action } from "../shared/schema"; +import { Check, ChevronDown } from "lucide-react"; + +type DraftState = "review" | "sending" | "sent" | "cancelled"; +type DraftOutcome = MessageDraftProps["outcome"]; + +const DEFAULT_GRACE_PERIOD = 5000; +const COLLAPSED_BODY_HEIGHT = 280; + +interface RecipientRowProps { + label: string; + recipients: string[]; + maxVisible?: number; + muted?: boolean; +} + +function RecipientRow({ + label, + recipients, + maxVisible = 3, + muted = false, +}: RecipientRowProps) { + const visibleRecipients = recipients.slice(0, maxVisible); + const overflowCount = recipients.length - maxVisible; + + return ( + + + {label} + + + {visibleRecipients.join(", ")} + {overflowCount > 0 && ( + +{overflowCount} more + )} + + + ); +} + +interface SingleFieldRowProps { + label: string; + value: string; +} + +function SingleFieldRow({ label, value }: SingleFieldRowProps) { + return ( + + + {label} + + {value} + + ); +} + +interface ExpandableBodyProps { + body: string; + isExpanded: boolean; + onNeedsExpansionChange?: (needsExpansion: boolean) => void; +} + +function ExpandableBody({ + body, + isExpanded, + onNeedsExpansionChange, +}: ExpandableBodyProps) { + const [needsExpansion, setNeedsExpansion] = React.useState( + null, + ); + const contentRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (contentRef.current) { + const needs = contentRef.current.scrollHeight > COLLAPSED_BODY_HEIGHT; + setNeedsExpansion(needs); + onNeedsExpansionChange?.(needs); + } + }, [body, onNeedsExpansionChange]); + + return ( +
+
+

{body}

+
+ {needsExpansion && ( +
+ )} +
+ ); +} + +interface EmailDraftContentProps { + draft: SerializableEmailDraft; + titleId: string; + isExpanded: boolean; + onNeedsExpansionChange?: (needsExpansion: boolean) => void; +} + +function EmailDraftContent({ + draft, + titleId, + isExpanded, + onNeedsExpansionChange, +}: EmailDraftContentProps) { + return ( + <> +

+ {draft.subject} +

+ + + + {draft.from && } + + {draft.cc && draft.cc.length > 0 && ( + + )} + {draft.bcc && draft.bcc.length > 0 && ( + + )} + +
+ +
+ + + + ); +} + +interface SlackDraftContentProps { + draft: SerializableSlackDraft; + titleId: string; + isExpanded: boolean; + onNeedsExpansionChange?: (needsExpansion: boolean) => void; +} + +function SlackLogo({ className }: { className?: string }) { + return ( + + ); +} + +function SlackDraftContent({ + draft, + titleId, + isExpanded, + onNeedsExpansionChange, +}: SlackDraftContentProps) { + const { target } = draft; + const isChannel = target.type === "channel"; + const targetDisplay = isChannel + ? `#${target.name}` + : `Message to @${target.name}`; + const memberCount = isChannel ? target.memberCount : undefined; + + return ( + <> +
+ + {targetDisplay} + {memberCount !== undefined && ( + + {memberCount.toLocaleString()} members + + )} +
+ +
+ + + + ); +} + +function formatSentTime(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); +} + +export function resolveStateFromOutcome(outcome: DraftOutcome): DraftState { + if (outcome === "sent") return "sent"; + if (outcome === "cancelled") return "cancelled"; + return "review"; +} + +export function resolveOutcomeTransition( + previousOutcome: DraftOutcome, + nextOutcome: DraftOutcome, +): DraftState | null { + if (previousOutcome === nextOutcome) { + return null; + } + + return resolveStateFromOutcome(nextOutcome); +} + +interface SentConfirmationProps { + sentAt: Date; +} + +function SentConfirmation({ sentAt }: SentConfirmationProps) { + return ( +
+ + Sent at {formatSentTime(sentAt)} + + + + +
+ ); +} + +export function MessageDraft(props: MessageDraftProps) { + const { + id, + className, + outcome, + undoGracePeriod = DEFAULT_GRACE_PERIOD, + onSend, + onUndo, + onCancel, + } = props; + + const [state, setState] = React.useState(() => + resolveStateFromOutcome(outcome), + ); + const [countdown, setCountdown] = React.useState( + Math.ceil(undoGracePeriod / 1000), + ); + const [sentAt, setSentAt] = React.useState(() => + outcome === "sent" ? new Date() : null, + ); + const [isExpanded, setIsExpanded] = React.useState(false); + const [needsExpansion, setNeedsExpansion] = React.useState(false); + const undoButtonRef = React.useRef(null); + const timerRef = React.useRef | null>(null); + const countdownRef = React.useRef | null>( + null, + ); + const previousOutcomeRef = React.useRef(outcome); + + const clearTimers = React.useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + }, []); + + React.useEffect(() => { + return clearTimers; + }, [clearTimers]); + + React.useEffect(() => { + const nextState = resolveOutcomeTransition( + previousOutcomeRef.current, + outcome, + ); + + previousOutcomeRef.current = outcome; + + if (nextState === null) { + return; + } + + clearTimers(); + setState(nextState); + setCountdown(Math.ceil(undoGracePeriod / 1000)); + setSentAt(nextState === "sent" ? new Date() : null); + }, [outcome, undoGracePeriod, clearTimers]); + + React.useEffect(() => { + if (state === "sending") { + undoButtonRef.current?.focus(); + + setCountdown(Math.ceil(undoGracePeriod / 1000)); + + countdownRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + return 0; + } + return prev - 1; + }); + }, 1000); + + timerRef.current = setTimeout(async () => { + clearTimers(); + await onSend?.(); + setSentAt(new Date()); + setState("sent"); + }, undoGracePeriod); + } + }, [state, undoGracePeriod, onSend, clearTimers]); + + const handleSend = React.useCallback(() => { + setState("sending"); + }, []); + + const handleUndo = React.useCallback(() => { + clearTimers(); + setState("review"); + onUndo?.(); + }, [clearTimers, onUndo]); + + const handleCancel = React.useCallback(() => { + clearTimers(); + setState("cancelled"); + onCancel?.(); + }, [clearTimers, onCancel]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape" && state === "review") { + event.preventDefault(); + handleCancel(); + } + }, + [state, handleCancel], + ); + + const handleNeedsExpansionChange = React.useCallback((needs: boolean) => { + setNeedsExpansion(needs); + }, []); + + const handleToggleExpand = React.useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + const handleAction = React.useCallback( + async (actionId: string) => { + if (actionId === "send") { + handleSend(); + } else if (actionId === "cancel") { + handleCancel(); + } + }, + [handleSend, handleCancel], + ); + + const actions: Action[] = [ + { + id: "cancel", + label: "Cancel", + variant: "ghost", + }, + { + id: "send", + label: "Send", + variant: "default", + }, + ]; + + const expandButton = needsExpansion ? ( + + ) : null; + + const renderActions = () => { + switch (state) { + case "sending": + return ( +
+ + Sending in {countdown}s + + +
+ ); + case "sent": + return ; + case "cancelled": + return null; + default: + return ; + } + }; + + if (state === "cancelled") { + return null; + } + + return ( +
+
+ {props.channel === "email" ? ( + + ) : ( + + )} + + {expandButton} +
+ +
{renderActions()}
+
+ ); +} diff --git a/frontend/src/toolui/components/message-draft/schema.ts b/frontend/src/toolui/components/message-draft/schema.ts new file mode 100644 index 00000000..8cf14d5c --- /dev/null +++ b/frontend/src/toolui/components/message-draft/schema.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; +import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema"; +import { defineToolUiContract } from "../shared/contract"; + +export const MessageDraftChannelSchema = z.enum(["email", "slack"]); + +export type MessageDraftChannel = z.infer; + +export const MessageDraftOutcomeSchema = z.enum(["sent", "cancelled"]); + +export type MessageDraftOutcome = z.infer; + +const SlackTargetSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("channel"), + name: z.string().min(1), + memberCount: z.number().optional(), + }), + z.object({ type: z.literal("dm"), name: z.string().min(1) }), +]); + +export type SlackTarget = z.infer; + +export const SerializableEmailDraftSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + body: z.string().min(1), + outcome: MessageDraftOutcomeSchema.optional(), + channel: z.literal("email"), + subject: z.string().min(1), + from: z.string().optional(), + to: z.array(z.string()).min(1), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), +}); + +export const SerializableSlackDraftSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + body: z.string().min(1), + outcome: MessageDraftOutcomeSchema.optional(), + channel: z.literal("slack"), + target: SlackTargetSchema, +}); + +export const SerializableMessageDraftSchema = z.discriminatedUnion("channel", [ + SerializableEmailDraftSchema, + SerializableSlackDraftSchema, +]); + +export type SerializableMessageDraft = z.infer< + typeof SerializableMessageDraftSchema +>; + +export type SerializableEmailDraft = z.infer< + typeof SerializableEmailDraftSchema +>; + +export type SerializableSlackDraft = z.infer< + typeof SerializableSlackDraftSchema +>; + +const SerializableMessageDraftSchemaContract = defineToolUiContract( + "MessageDraft", + SerializableMessageDraftSchema, +); + +export const parseSerializableMessageDraft: ( + input: unknown, +) => SerializableMessageDraft = SerializableMessageDraftSchemaContract.parse; + +export const safeParseSerializableMessageDraft: ( + input: unknown, +) => SerializableMessageDraft | null = + SerializableMessageDraftSchemaContract.safeParse; + +export type MessageDraftProps = SerializableMessageDraft & { + className?: string; + undoGracePeriod?: number; + onSend?: () => void | Promise; + onUndo?: () => void; + onCancel?: () => void; +}; diff --git a/frontend/src/toolui/components/option-list/README.md b/frontend/src/toolui/components/option-list/README.md new file mode 100644 index 00000000..6d8d4574 --- /dev/null +++ b/frontend/src/toolui/components/option-list/README.md @@ -0,0 +1,19 @@ +# Option List + +Implementation for the "option-list" Tool UI surface. + +## Files + +- public exports: components/tool-ui/option-list/index.tsx +- serializable schema + parse helpers: components/tool-ui/option-list/schema.ts + +## Companion assets + +- Docs page: app/docs/option-list/content.mdx +- Preset payload: lib/presets/option-list.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/option-list/_adapter.tsx b/frontend/src/toolui/components/option-list/_adapter.tsx new file mode 100644 index 00000000..a7873bcc --- /dev/null +++ b/frontend/src/toolui/components/option-list/_adapter.tsx @@ -0,0 +1,14 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + * Separator → shadcn/ui Separator + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Separator } from "@toolui/ui/separator"; diff --git a/frontend/src/toolui/components/option-list/index.tsx b/frontend/src/toolui/components/option-list/index.tsx new file mode 100644 index 00000000..7ae9d2cb --- /dev/null +++ b/frontend/src/toolui/components/option-list/index.tsx @@ -0,0 +1,7 @@ +export { OptionList } from "./option-list"; +export type { + OptionListProps, + OptionListOption, + OptionListSelection, + SerializableOptionList, +} from "./schema"; diff --git a/frontend/src/toolui/components/option-list/option-list.tsx b/frontend/src/toolui/components/option-list/option-list.tsx new file mode 100644 index 00000000..34df143b --- /dev/null +++ b/frontend/src/toolui/components/option-list/option-list.tsx @@ -0,0 +1,625 @@ +"use client"; + +import { + useMemo, + useState, + useCallback, + useEffect, + useRef, + Fragment, +} from "react"; +import type { KeyboardEvent } from "react"; +import type { + OptionListProps, + OptionListSelection, + OptionListOption, +} from "./schema"; +import { + normalizeSelectionForOptions, + parseSelectionToIdSet, +} from "./selection"; +import { ActionButtons } from "../shared/action-buttons"; +import { normalizeActionsConfig } from "../shared/actions-config"; +import type { Action } from "../shared/schema"; +import { cn, Button, Separator } from "./_adapter"; +import { Check } from "lucide-react"; + +function convertIdSetToSelection( + selected: Set, + mode: "multi" | "single", +): OptionListSelection { + if (mode === "single") { + const [first] = selected; + return first ?? null; + } + return Array.from(selected); +} + +function areSetsEqual(a: Set, b: Set) { + if (a.size !== b.size) return false; + for (const val of a) { + if (!b.has(val)) return false; + } + return true; +} + +interface SelectionIndicatorProps { + mode: "multi" | "single"; + isSelected: boolean; + disabled?: boolean; +} + +function SelectionIndicator({ + mode, + isSelected, + disabled, +}: SelectionIndicatorProps) { + const shape = mode === "single" ? "rounded-full" : "rounded"; + + return ( +
+ {mode === "multi" && isSelected && } + {mode === "single" && isSelected && ( + + )} +
+ ); +} + +interface OptionItemProps { + option: OptionListOption; + isSelected: boolean; + isDisabled: boolean; + selectionMode: "multi" | "single"; + isFirst: boolean; + isLast: boolean; + onToggle: () => void; + tabIndex?: number; + onFocus?: () => void; + buttonRef?: (el: HTMLButtonElement | null) => void; +} + +function OptionItem({ + option, + isSelected, + isDisabled, + selectionMode, + isFirst, + isLast, + onToggle, + tabIndex, + onFocus, + buttonRef, +}: OptionItemProps) { + const hasAdjacentOptions = !isFirst && !isLast; + + return ( + + ); +} + +interface OptionListConfirmationProps { + id: string; + options: OptionListOption[]; + selectedIds: Set; + className?: string; +} + +function OptionListConfirmation({ + id, + options, + selectedIds, + className, +}: OptionListConfirmationProps) { + const confirmedOptions = options.filter((opt) => selectedIds.has(opt.id)); + + return ( +
+
+ {confirmedOptions.map((option, index) => ( + + {index > 0 && ( + + )} +
+ + + + {option.icon && ( + {option.icon} + )} +
+ + {option.label} + + {option.description && ( + + {option.description} + + )} +
+
+
+ ))} +
+
+ ); +} + +export function OptionList({ + id, + options, + selectionMode = "multi", + minSelections = 1, + maxSelections, + value, + defaultValue, + choice, + onChange, + actions, + onAction, + onBeforeAction, + className, +}: OptionListProps) { + if (process.env["NODE_ENV"] !== "production") { + if (value !== undefined && defaultValue !== undefined) { + console.warn( + "[OptionList] Both `value` (controlled) and `defaultValue` (uncontrolled) were provided. `defaultValue` is ignored when `value` is set.", + ); + } + if (value !== undefined && !onChange) { + console.warn( + "[OptionList] `value` was provided without `onChange`. This makes OptionList controlled; selection will not update unless the parent updates `value`.", + ); + } + } + + const effectiveMaxSelections = selectionMode === "single" ? 1 : maxSelections; + const optionIds = useMemo( + () => new Set(options.map((option) => option.id)), + [options], + ); + + const [uncontrolledSelected, setUncontrolledSelected] = useState>( + () => + normalizeSelectionForOptions( + parseSelectionToIdSet( + defaultValue, + selectionMode, + effectiveMaxSelections, + ), + optionIds, + ), + ); + + const selectedIds = useMemo(() => { + const parsed = + value !== undefined + ? parseSelectionToIdSet(value, selectionMode, effectiveMaxSelections) + : uncontrolledSelected; + return normalizeSelectionForOptions(parsed, optionIds); + }, [ + value, + uncontrolledSelected, + selectionMode, + effectiveMaxSelections, + optionIds, + ]); + + const selectedCount = selectedIds.size; + + const optionStates = useMemo(() => { + return options.map((option) => { + const isSelected = selectedIds.has(option.id); + const isSelectionLocked = + selectionMode === "multi" && + effectiveMaxSelections !== undefined && + selectedCount >= effectiveMaxSelections && + !isSelected; + const isDisabled = option.disabled || isSelectionLocked; + + return { option, isSelected, isDisabled }; + }); + }, [ + options, + selectedIds, + selectionMode, + effectiveMaxSelections, + selectedCount, + ]); + + const optionRefs = useRef>([]); + const [activeIndex, setActiveIndex] = useState(() => { + const firstSelected = optionStates.findIndex( + (s) => s.isSelected && !s.isDisabled, + ); + if (firstSelected >= 0) return firstSelected; + const firstEnabled = optionStates.findIndex((s) => !s.isDisabled); + return firstEnabled >= 0 ? firstEnabled : 0; + }); + + useEffect(() => { + if (optionStates.length === 0) return; + setActiveIndex((prev) => { + if ( + prev < 0 || + prev >= optionStates.length || + optionStates[prev].isDisabled + ) { + const firstEnabled = optionStates.findIndex((s) => !s.isDisabled); + return firstEnabled >= 0 ? firstEnabled : 0; + } + return prev; + }); + }, [optionStates]); + + const updateSelection = useCallback( + (next: Set) => { + const normalizedNext = normalizeSelectionForOptions( + parseSelectionToIdSet( + Array.from(next), + selectionMode, + effectiveMaxSelections, + ), + optionIds, + ); + + if (value === undefined) { + if (!areSetsEqual(uncontrolledSelected, normalizedNext)) { + setUncontrolledSelected(normalizedNext); + } + } + + onChange?.(convertIdSetToSelection(normalizedNext, selectionMode)); + }, + [ + effectiveMaxSelections, + selectionMode, + uncontrolledSelected, + value, + onChange, + optionIds, + ], + ); + + const toggleSelection = useCallback( + (optionId: string) => { + const next = new Set(selectedIds); + const isSelected = next.has(optionId); + + if (selectionMode === "single") { + if (isSelected) { + next.delete(optionId); + } else { + next.clear(); + next.add(optionId); + } + } else { + if (isSelected) { + next.delete(optionId); + } else { + if (effectiveMaxSelections && next.size >= effectiveMaxSelections) { + return; + } + next.add(optionId); + } + } + + updateSelection(next); + }, + [effectiveMaxSelections, selectedIds, selectionMode, updateSelection], + ); + + const toSelectionState = useCallback( + (selected: Set): OptionListSelection => + convertIdSetToSelection(selected, selectionMode), + [selectionMode], + ); + + const handleCancel = useCallback((): OptionListSelection => { + const empty = new Set(); + updateSelection(empty); + return toSelectionState(empty); + }, [toSelectionState, updateSelection]); + + const customActions = useMemo( + () => normalizeActionsConfig(actions), + [actions], + ); + + const handleFooterAction = useCallback( + async (actionId: string) => { + let nextState = toSelectionState(selectedIds); + + if (actionId === "cancel") { + nextState = handleCancel(); + } + + await onAction?.(actionId, nextState); + }, + [handleCancel, onAction, selectedIds, toSelectionState], + ); + + const normalizedFooterActions = useMemo(() => { + if (customActions) return customActions; + return { + items: [ + { id: "cancel", label: "Clear", variant: "ghost" as const }, + { id: "confirm", label: "Confirm", variant: "default" as const }, + ], + align: "right" as const, + } satisfies ReturnType; + }, [customActions]); + + const isConfirmDisabled = + selectedCount < minSelections || selectedCount === 0; + const hasNothingToClear = selectedCount === 0; + + const focusOptionAt = useCallback((index: number) => { + const el = optionRefs.current[index]; + if (el) el.focus(); + setActiveIndex(index); + }, []); + + const findFirstEnabledIndex = useCallback(() => { + const idx = optionStates.findIndex((s) => !s.isDisabled); + return idx >= 0 ? idx : 0; + }, [optionStates]); + + const findLastEnabledIndex = useCallback(() => { + for (let i = optionStates.length - 1; i >= 0; i--) { + if (!optionStates[i].isDisabled) return i; + } + return 0; + }, [optionStates]); + + const findNextEnabledIndex = useCallback( + (start: number, direction: 1 | -1) => { + const len = optionStates.length; + if (len === 0) return 0; + for (let step = 1; step <= len; step++) { + const idx = (start + direction * step + len) % len; + if (!optionStates[idx].isDisabled) return idx; + } + return start; + }, + [optionStates], + ); + + const handleListboxKeyDown = useCallback( + (e: KeyboardEvent) => { + if (optionStates.length === 0) return; + + const key = e.key; + + if (key === "ArrowDown") { + e.preventDefault(); + e.stopPropagation(); + focusOptionAt(findNextEnabledIndex(activeIndex, 1)); + return; + } + + if (key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + focusOptionAt(findNextEnabledIndex(activeIndex, -1)); + return; + } + + if (key === "Home") { + e.preventDefault(); + e.stopPropagation(); + focusOptionAt(findFirstEnabledIndex()); + return; + } + + if (key === "End") { + e.preventDefault(); + e.stopPropagation(); + focusOptionAt(findLastEnabledIndex()); + return; + } + + if (key === "Enter" || key === " ") { + e.preventDefault(); + e.stopPropagation(); + const current = optionStates[activeIndex]; + if (!current || current.isDisabled) return; + toggleSelection(current.option.id); + return; + } + + if (key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + if (!hasNothingToClear) { + handleCancel(); + } + } + }, + [ + activeIndex, + findFirstEnabledIndex, + findLastEnabledIndex, + findNextEnabledIndex, + focusOptionAt, + handleCancel, + hasNothingToClear, + optionStates, + toggleSelection, + ], + ); + + const actionsWithDisabledState = useMemo((): Action[] => { + return normalizedFooterActions.items.map((action) => { + const isDisabledByValidation = + (action.id === "confirm" && isConfirmDisabled) || + (action.id === "cancel" && hasNothingToClear); + return { + ...action, + disabled: action.disabled || isDisabledByValidation, + label: + action.id === "confirm" && + selectionMode === "multi" && + selectedCount > 0 + ? `${action.label} (${selectedCount})` + : action.label, + }; + }); + }, [ + normalizedFooterActions.items, + isConfirmDisabled, + hasNothingToClear, + selectionMode, + selectedCount, + ]); + + const isReceipt = choice !== undefined && choice !== null; + const viewKey = isReceipt ? `receipt-${String(choice)}` : "interactive"; + + return ( +
+ {isReceipt ? ( + + ) : ( +
+
+ {optionStates.map(({ option, isSelected, isDisabled }, index) => { + return ( + + {index > 0 && ( + + )} + setActiveIndex(index)} + buttonRef={(el) => { + optionRefs.current[index] = el; + }} + onToggle={() => toggleSelection(option.id)} + /> + + ); + })} +
+ +
+ + onBeforeAction(actionId, toSelectionState(selectedIds)) + : undefined + } + /> +
+
+ )} +
+ ); +} diff --git a/frontend/src/toolui/components/option-list/schema.ts b/frontend/src/toolui/components/option-list/schema.ts new file mode 100644 index 00000000..76a9745c --- /dev/null +++ b/frontend/src/toolui/components/option-list/schema.ts @@ -0,0 +1,210 @@ +import { z } from "zod"; +import type { ReactNode } from "react"; +import type { ActionsProp } from "../shared/actions-config"; +import type { EmbeddedActionsProps } from "../shared/embedded-actions"; +import { + ActionSchema, + SerializableActionSchema, + SerializableActionsConfigSchema, + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; +import { defineToolUiContract } from "../shared/contract"; + +export const OptionListOptionSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), + icon: z.custom().optional(), + disabled: z.boolean().optional(), +}); + +export type OptionListSelection = string[] | string | null; + +const OptionListSelectionSchema = z + .union([z.array(z.string()), z.string(), z.null()]) + .optional(); + +type OptionListSchemaInvariantInput = { + options: Array<{ id: string }>; + minSelections?: number; + maxSelections?: number; + value?: OptionListSelection; + defaultValue?: OptionListSelection; + choice?: OptionListSelection; +}; + +function selectionToIds(selection: OptionListSelection | undefined): string[] { + if (selection == null) return []; + if (typeof selection === "string") return [selection]; + return Array.isArray(selection) ? selection : []; +} + +function validateOptionListInvariants( + data: OptionListSchemaInvariantInput, + ctx: z.RefinementCtx, +) { + if ( + data.minSelections !== undefined && + data.maxSelections !== undefined && + data.minSelections > data.maxSelections + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["minSelections"], + message: "`minSelections` cannot be greater than `maxSelections`.", + }); + } + + const optionIds = new Set(); + for (let index = 0; index < data.options.length; index++) { + const optionId = data.options[index]?.id; + if (!optionId) continue; + + if (optionIds.has(optionId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["options", index, "id"], + message: `Duplicate option id "${optionId}" is not allowed.`, + }); + } else { + optionIds.add(optionId); + } + } + + const selectionFields: Array< + ["value" | "defaultValue" | "choice", OptionListSelection | undefined] + > = [ + ["value", data.value], + ["defaultValue", data.defaultValue], + ["choice", data.choice], + ]; + + for (const [fieldName, selection] of selectionFields) { + if (selection == null) continue; + + const ids = selectionToIds(selection); + ids.forEach((selectionId, index) => { + if (!optionIds.has(selectionId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: + typeof selection === "string" ? [fieldName] : [fieldName, index], + message: `Selection id "${selectionId}" must exist in options.`, + }); + } + }); + } +} + +const OptionListPropsSchemaBase = z.object({ + /** + * Unique identifier for this tool UI instance in the conversation. + * + * Used for: + * - Assistant referencing ("the options above") + * - Receipt generation (linking selections to their source) + * - Narration context + * + * Should be stable across re-renders, meaningful, and unique within the conversation. + * + * @example "option-list-deploy-target", "format-selection" + */ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + options: z.array(OptionListOptionSchema).min(1), + selectionMode: z.enum(["multi", "single"]).optional(), + /** + * Controlled selection value (advanced / runtime only). + * + * For Tool UI tool payloads, prefer `defaultValue` (initial selection) and + * `choice` (receipt state). Controlled `value` is intentionally excluded + * from `SerializableOptionListSchema` to avoid accidental "controlled but + * non-interactive" states when an LLM includes `value` in args. + */ + value: OptionListSelectionSchema, + defaultValue: OptionListSelectionSchema, + /** + * When set, renders the component in receipt state showing the user's choice. + * + * In receipt state: + * - Only the chosen option(s) are shown + * - Actions are hidden + * - The component is read-only + * + * Use this with assistant-ui's `addResult` to show the outcome of a decision. + * + * @example + * ```tsx + * // In a toolkit render function: + * if (result) { + * return ; + * } + * ``` + */ + choice: OptionListSelectionSchema, + actions: z + .union([z.array(ActionSchema), SerializableActionsConfigSchema]) + .optional(), + minSelections: z.number().min(0).optional(), + maxSelections: z.number().min(1).optional(), +}); + +export const OptionListPropsSchema = OptionListPropsSchemaBase.superRefine( + validateOptionListInvariants, +); + +export type OptionListOption = z.infer; + +export type OptionListProps = Omit< + z.infer, + "value" | "defaultValue" | "choice" | "actions" +> & { + /** @see OptionListPropsSchema.id */ + id: string; + value?: OptionListSelection; + defaultValue?: OptionListSelection; + /** @see OptionListPropsSchema.choice */ + choice?: OptionListSelection; + onChange?: (value: OptionListSelection) => void; + actions?: ActionsProp; + onAction?: EmbeddedActionsProps["onAction"]; + onBeforeAction?: EmbeddedActionsProps["onBeforeAction"]; + className?: string; +}; + +export const SerializableOptionListSchema = OptionListPropsSchemaBase.omit({ + // Exclude controlled selection from tool/LLM payloads. + value: true, +}) + .extend({ + options: z.array(OptionListOptionSchema.omit({ icon: true })), + actions: z + .union([ + z.array(SerializableActionSchema), + SerializableActionsConfigSchema, + ]) + .optional(), + }) + .strict() + .superRefine(validateOptionListInvariants); + +export type SerializableOptionList = z.infer< + typeof SerializableOptionListSchema +>; + +const SerializableOptionListSchemaContract = defineToolUiContract( + "OptionList", + SerializableOptionListSchema, +); + +export const parseSerializableOptionList: ( + input: unknown, +) => SerializableOptionList = SerializableOptionListSchemaContract.parse; + +export const safeParseSerializableOptionList: ( + input: unknown, +) => SerializableOptionList | null = + SerializableOptionListSchemaContract.safeParse; diff --git a/frontend/src/toolui/components/option-list/selection.ts b/frontend/src/toolui/components/option-list/selection.ts new file mode 100644 index 00000000..48ff8ece --- /dev/null +++ b/frontend/src/toolui/components/option-list/selection.ts @@ -0,0 +1,35 @@ +import type { OptionListSelection } from "./schema"; + +export function parseSelectionToIdSet( + value: OptionListSelection | undefined, + mode: "multi" | "single", + maxSelections?: number, +): Set { + if (mode === "single") { + const single = + typeof value === "string" + ? value + : Array.isArray(value) + ? value[0] + : null; + return single ? new Set([single]) : new Set(); + } + + const arr = + typeof value === "string" ? [value] : Array.isArray(value) ? value : []; + + return new Set(maxSelections ? arr.slice(0, maxSelections) : arr); +} + +export function normalizeSelectionForOptions( + selection: Set, + optionIds: Set, +): Set { + const normalized = new Set(); + for (const id of selection) { + if (optionIds.has(id)) { + normalized.add(id); + } + } + return normalized; +} diff --git a/frontend/src/toolui/components/order-summary/README.md b/frontend/src/toolui/components/order-summary/README.md new file mode 100644 index 00000000..6f80a24d --- /dev/null +++ b/frontend/src/toolui/components/order-summary/README.md @@ -0,0 +1,19 @@ +# Order Summary + +Implementation for the "order-summary" Tool UI surface. + +## Files + +- public exports: components/tool-ui/order-summary/index.tsx +- serializable schema + parse helpers: components/tool-ui/order-summary/schema.ts + +## Companion assets + +- Docs page: app/docs/order-summary/content.mdx +- Preset payload: lib/presets/order-summary.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/order-summary/_adapter.tsx b/frontend/src/toolui/components/order-summary/_adapter.tsx new file mode 100644 index 00000000..b111ed52 --- /dev/null +++ b/frontend/src/toolui/components/order-summary/_adapter.tsx @@ -0,0 +1,16 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + * Separator → shadcn/ui Separator + * Skeleton → shadcn/ui Skeleton + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Separator } from "@toolui/ui/separator"; +export { Skeleton } from "@toolui/ui/skeleton"; diff --git a/frontend/src/toolui/components/order-summary/index.tsx b/frontend/src/toolui/components/order-summary/index.tsx new file mode 100644 index 00000000..d8024df9 --- /dev/null +++ b/frontend/src/toolui/components/order-summary/index.tsx @@ -0,0 +1,14 @@ +export { OrderSummary } from "./order-summary"; +export type { + OrderSummaryDisplayProps, + OrderSummaryReceiptProps, + OrderSummaryCompoundComponent, +} from "./order-summary"; +export { + type SerializableOrderSummary, + type OrderSummaryProps, + type OrderSummaryVariant, + type OrderItem, + type Pricing, + type OrderDecision, +} from "./schema"; diff --git a/frontend/src/toolui/components/order-summary/order-summary.tsx b/frontend/src/toolui/components/order-summary/order-summary.tsx new file mode 100644 index 00000000..5583dd55 --- /dev/null +++ b/frontend/src/toolui/components/order-summary/order-summary.tsx @@ -0,0 +1,296 @@ +import { CheckCircle, Package } from "lucide-react"; +import type { ReactElement } from "react"; +import { cn, Separator } from "./_adapter"; +import type { + OrderSummaryProps, + OrderItem, + Pricing, + OrderDecision, + OrderSummaryVariant, +} from "./schema"; + +function formatCurrency(amount: number, currency: string): string { + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + }).format(amount); + } catch { + return `${currency} ${amount.toFixed(2)}`; + } +} + +function formatQuantity(quantity: number): string { + return quantity === 1 ? "" : `Qty: ${quantity}`; +} + +function ItemImage({ src, alt }: { src?: string; alt: string }) { + if (!src) { + return ( +
+
+ ); + } + + return ( + {alt} + ); +} + +function OrderItemRow({ + item, + currency, +}: { + item: OrderItem; + currency: string; +}) { + const quantity = item.quantity ?? 1; + const quantityText = formatQuantity(quantity); + const hasDescription = item.description || quantityText; + const lineTotal = item.unitPrice * quantity; + + return ( +
+ +
+
+
+ {item.name} + + {formatCurrency(lineTotal, currency)} + +
+ {hasDescription && ( +
+ {[item.description, quantityText].filter(Boolean).join(" · ")} +
+ )} +
+
+
+ ); +} + +function PricingBreakdown({ + pricing, + className, +}: { + pricing: Pricing; + className?: string; +}) { + const currency = pricing.currency ?? "USD"; + + return ( +
+
+
Subtotal
+
+ {formatCurrency(pricing.subtotal, currency)} +
+
+ + {pricing.discount !== undefined && pricing.discount > 0 && ( +
+
{pricing.discountLabel || "Discount"}
+
+ -{formatCurrency(pricing.discount, currency)} +
+
+ )} + + {pricing.shipping !== undefined && ( +
+
Shipping
+
+ {pricing.shipping === 0 + ? "Free" + : formatCurrency(pricing.shipping, currency)} +
+
+ )} + + {pricing.tax !== undefined && ( +
+
{pricing.taxLabel || "Tax"}
+
+ {formatCurrency(pricing.tax, currency)} +
+
+ )} + +
+
Total
+
+ {formatCurrency(pricing.total, currency)} +
+
+
+ ); +} + +function formatDate(isoString: string): string | undefined { + try { + const date = new Date(isoString); + if (isNaN(date.getTime())) return undefined; + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return undefined; + } +} + +function ReceiptBadge({ + orderId, + confirmedAt, +}: { + orderId?: string; + confirmedAt?: string; +}) { + const formattedDate = confirmedAt ? formatDate(confirmedAt) : undefined; + + const parts = [orderId && `#${orderId}`, formattedDate].filter(Boolean); + if (parts.length === 0) return null; + + return ( +

{parts.join(" · ")}

+ ); +} + +function OrderSummaryRoot({ + id, + title = "Order Summary", + variant, + items, + pricing, + choice, + className, +}: OrderSummaryProps) { + const titleId = `${id}-title`; + const resolvedVariant: OrderSummaryVariant = + variant ?? (choice === undefined ? "summary" : "receipt"); + const isReceipt = resolvedVariant === "receipt"; + const isMalformedPayload = + !Array.isArray(items) || + items.length === 0 || + pricing == null || + (isReceipt && choice === undefined); + + if (isMalformedPayload) { + return ( +
+
+

+ {title} +

+

+ Unable to render order summary +

+
+
+ ); + } + + return ( +
+
+
+
+

+ {isReceipt && ( +

+ {isReceipt && choice && ( + + )} +
+ +
+ {items.map((item) => ( + + ))} +
+ + + + +
+
+
+ ); +} + +export type OrderSummaryDisplayProps = OrderSummaryProps; + +function OrderSummaryDisplay(props: OrderSummaryDisplayProps) { + return ; +} + +export interface OrderSummaryReceiptProps extends Omit< + OrderSummaryProps, + "choice" +> { + choice: OrderDecision; +} + +function OrderSummaryReceipt(props: OrderSummaryReceiptProps) { + return ; +} + +export interface OrderSummaryCompoundComponent { + (props: OrderSummaryProps): ReactElement; + Display: (props: OrderSummaryDisplayProps) => ReactElement; + Receipt: (props: OrderSummaryReceiptProps) => ReactElement; +} + +export const OrderSummary: OrderSummaryCompoundComponent = Object.assign( + OrderSummaryRoot, + { + Display: OrderSummaryDisplay, + Receipt: OrderSummaryReceipt, + }, +); diff --git a/frontend/src/toolui/components/order-summary/schema.ts b/frontend/src/toolui/components/order-summary/schema.ts new file mode 100644 index 00000000..9e9c1d0a --- /dev/null +++ b/frontend/src/toolui/components/order-summary/schema.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema"; + +export const OrderItemSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + imageUrl: z.string().url().optional(), + quantity: z.number().int().positive().optional(), + unitPrice: z.number(), +}); + +export type OrderItem = z.infer; + +const OrderItemsSchema = z + .array(OrderItemSchema) + .min(1) + .superRefine((items, ctx) => { + const seenIds = new Set(); + + for (const [index, item] of items.entries()) { + if (seenIds.has(item.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate item id: "${item.id}"`, + path: [index, "id"], + }); + } + + seenIds.add(item.id); + } + }); + +export const PricingSchema = z.object({ + subtotal: z.number(), + tax: z.number().optional(), + taxLabel: z.string().optional(), + shipping: z.number().optional(), + discount: z.number().nonnegative().optional(), + discountLabel: z.string().optional(), + total: z.number(), + currency: z.string().optional(), +}); + +export type Pricing = z.infer; + +export const OrderSummaryVariantSchema = z.enum(["summary", "receipt"]); +export type OrderSummaryVariant = z.infer; + +export const OrderDecisionSchema = z.object({ + action: z.literal("confirm"), + orderId: z.string().optional(), + confirmedAt: z.string().datetime().optional(), +}); + +export type OrderDecision = z.infer; + +export const SerializableOrderSummarySchema = z + .object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + title: z.string().optional(), + variant: OrderSummaryVariantSchema.optional(), + items: OrderItemsSchema, + pricing: PricingSchema, + choice: OrderDecisionSchema.optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (value.variant === "receipt" && value.choice === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Receipt variant requires "choice".', + path: ["choice"], + }); + } + + if (value.variant === "summary" && value.choice !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Summary variant cannot include "choice".', + path: ["choice"], + }); + } + }); + +export type SerializableOrderSummary = z.infer< + typeof SerializableOrderSummarySchema +>; + +const SerializableOrderSummarySchemaContract = defineToolUiContract( + "OrderSummary", + SerializableOrderSummarySchema, +); + +export const parseSerializableOrderSummary: ( + input: unknown, +) => SerializableOrderSummary = SerializableOrderSummarySchemaContract.parse; + +export const safeParseSerializableOrderSummary: ( + input: unknown, +) => SerializableOrderSummary | null = + SerializableOrderSummarySchemaContract.safeParse; + +export interface OrderSummaryProps extends SerializableOrderSummary { + className?: string; +} diff --git a/frontend/src/toolui/components/parameter-slider/README.md b/frontend/src/toolui/components/parameter-slider/README.md new file mode 100644 index 00000000..04d1be58 --- /dev/null +++ b/frontend/src/toolui/components/parameter-slider/README.md @@ -0,0 +1,19 @@ +# Parameter Slider + +Implementation for the "parameter-slider" Tool UI surface. + +## Files + +- public exports: components/tool-ui/parameter-slider/index.tsx +- serializable schema + parse helpers: components/tool-ui/parameter-slider/schema.ts + +## Companion assets + +- Docs page: app/docs/parameter-slider/content.mdx +- Preset payload: lib/presets/parameter-slider.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/parameter-slider/_adapter.tsx b/frontend/src/toolui/components/parameter-slider/_adapter.tsx new file mode 100644 index 00000000..52a1b4d7 --- /dev/null +++ b/frontend/src/toolui/components/parameter-slider/_adapter.tsx @@ -0,0 +1,16 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + * Separator → shadcn/ui Separator + * Slider → shadcn/ui Slider + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Separator } from "@toolui/ui/separator"; +export { Slider } from "@toolui/ui/slider"; diff --git a/frontend/src/toolui/components/parameter-slider/index.tsx b/frontend/src/toolui/components/parameter-slider/index.tsx new file mode 100644 index 00000000..8f2ecf3e --- /dev/null +++ b/frontend/src/toolui/components/parameter-slider/index.tsx @@ -0,0 +1,7 @@ +export { ParameterSlider } from "./parameter-slider"; +export type { + ParameterSliderProps, + SliderConfig, + SliderValue, + SerializableParameterSlider, +} from "./schema"; diff --git a/frontend/src/toolui/components/parameter-slider/math.ts b/frontend/src/toolui/components/parameter-slider/math.ts new file mode 100644 index 00000000..80df94e3 --- /dev/null +++ b/frontend/src/toolui/components/parameter-slider/math.ts @@ -0,0 +1,42 @@ +import type { SliderConfig, SliderValue } from "./schema"; + +type SliderPercentInput = { + value: number; + min: number; + max: number; +}; + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + +export function sliderRangeToPercent({ + value, + min, + max, +}: SliderPercentInput): number { + const range = max - min; + if (!Number.isFinite(range) || range <= 0) return 0; + return clampPercent(((value - min) / range) * 100); +} + +export function createSliderValueSnapshot( + sliders: SliderConfig[], +): SliderValue[] { + return sliders.map((slider) => ({ id: slider.id, value: slider.value })); +} + +export function createSliderSignature(sliders: SliderConfig[]): string { + return JSON.stringify( + sliders.map(({ id, min, max, step, value, unit, precision }) => ({ + id, + min, + max, + step: step ?? 1, + value, + unit: unit ?? "", + precision: precision ?? null, + })), + ); +} diff --git a/frontend/src/toolui/components/parameter-slider/parameter-slider.tsx b/frontend/src/toolui/components/parameter-slider/parameter-slider.tsx new file mode 100644 index 00000000..4bbaa0df --- /dev/null +++ b/frontend/src/toolui/components/parameter-slider/parameter-slider.tsx @@ -0,0 +1,821 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; +import type { ParameterSliderProps, SliderConfig, SliderValue } from "./schema"; +import { ActionButtons } from "../shared/action-buttons"; +import { normalizeActionsConfig } from "../shared/actions-config"; +import { useControllableState } from "../shared/use-controllable-state"; +import { useSignatureReset } from "../shared/use-signature-reset"; + +import { cn } from "./_adapter"; +import { + createSliderSignature, + createSliderValueSnapshot, + sliderRangeToPercent, +} from "./math"; + +function formatSignedValue( + value: number, + min: number, + max: number, + precision?: number, + unit?: string, +): string { + const crossesZero = min < 0 && max > 0; + const fixed = + precision !== undefined ? value.toFixed(precision) : String(value); + const numericPart = crossesZero && value >= 0 ? `+${fixed}` : fixed; + return unit ? `${numericPart} ${unit}` : numericPart; +} + +function getAriaValueText( + value: number, + min: number, + max: number, + unit?: string, +): string { + const crossesZero = min < 0 && max > 0; + if (crossesZero) { + if (value > 0) { + return unit ? `plus ${value} ${unit}` : `plus ${value}`; + } else if (value < 0) { + return unit + ? `minus ${Math.abs(value)} ${unit}` + : `minus ${Math.abs(value)}`; + } + } + return unit ? `${value} ${unit}` : String(value); +} + +const TICK_COUNT = 16; +const TEXT_PADDING_X = 4; +const TEXT_PADDING_X_OUTER = 0; // Less inset on outer-facing side (near edges) +const TEXT_PADDING_Y = 2; +const DETECTION_MARGIN_X = 12; +const DETECTION_MARGIN_X_OUTER = 4; // Small margin at edges for steep falloff - segments fully close at terminal positions +const DETECTION_MARGIN_Y = 12; +const TRACK_HEIGHT = 48; +const TEXT_RELEASE_INSET = 8; +const TRACK_EDGE_INSET = 4; // px from track edge - keeps elements visible at extremes +const THUMB_WIDTH = 12; // w-3 +// Text vertical offset: raised slightly from center +// Positive = raised, negative = lowered +const TEXT_VERTICAL_OFFSET = 0.5; + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + +// Convert a percentage (0-100) to an inset position string +// At 0%: 4px from left edge; at 100%: 4px from right edge +function toInsetPosition(percent: number): string { + const safePercent = clampPercent(percent); + return `calc(${TRACK_EDGE_INSET}px + (100% - ${TRACK_EDGE_INSET * 2}px) * ${safePercent / 100})`; +} + +// Radix keeps the thumb in bounds by applying a percent-dependent px offset. +// Matching this for fill clipping prevents handle/fill drift near extremes. +function getRadixThumbInBoundsOffsetPx(percent: number): number { + const safePercent = clampPercent(percent); + const halfWidth = THUMB_WIDTH / 2; + return halfWidth - (safePercent * halfWidth) / 50; +} + +function toRadixThumbPosition(percent: number): string { + const safePercent = clampPercent(percent); + const offsetPx = getRadixThumbInBoundsOffsetPx(safePercent); + return `calc(${safePercent}% + ${offsetPx}px)`; +} + +function signedDistanceToRoundedRect( + px: number, + py: number, + left: number, + right: number, + top: number, + bottom: number, + radiusLeft: number, + radiusRight: number, +): number { + const innerLeft = left + radiusLeft; + const innerRight = right - radiusRight; + const innerTop = top + Math.max(radiusLeft, radiusRight); + const innerBottom = bottom - Math.max(radiusLeft, radiusRight); + + const inLeftCorner = px < innerLeft; + const inRightCorner = px > innerRight; + const inCornerY = py < innerTop || py > innerBottom; + + if ((inLeftCorner || inRightCorner) && inCornerY) { + const radius = inLeftCorner ? radiusLeft : radiusRight; + const cornerX = inLeftCorner ? innerLeft : innerRight; + const cornerY = py < innerTop ? top + radius : bottom - radius; + const distToCornerCenter = Math.hypot(px - cornerX, py - cornerY); + return distToCornerCenter - radius; + } + + const dx = Math.max(left - px, px - right, 0); + const dy = Math.max(top - py, py - bottom, 0); + + if (dx === 0 && dy === 0) { + return -Math.min(px - left, right - px, py - top, bottom - py); + } + + return Math.max(dx, dy); +} + +const OUTER_EDGE_RADIUS_FACTOR = 0.3; // Reduced radius on outer-facing sides for steeper falloff + +function calculateGap( + thumbCenterX: number, + textRect: { left: number; right: number; height: number; centerY: number }, + isLeftAligned: boolean, +): number { + const { left, right, height, centerY } = textRect; + // Asymmetric padding/margin: outer-facing side has less padding, more margin + const paddingLeft = isLeftAligned ? TEXT_PADDING_X_OUTER : TEXT_PADDING_X; + const paddingRight = isLeftAligned ? TEXT_PADDING_X : TEXT_PADDING_X_OUTER; + const marginLeft = isLeftAligned + ? DETECTION_MARGIN_X_OUTER + : DETECTION_MARGIN_X; + const marginRight = isLeftAligned + ? DETECTION_MARGIN_X + : DETECTION_MARGIN_X_OUTER; + const paddingY = TEXT_PADDING_Y; + const marginY = DETECTION_MARGIN_Y; + const thumbCenterY = centerY; + + // Inner boundary (where max gap occurs) + const innerLeft = left - paddingLeft; + const innerRight = right + paddingRight; + const innerTop = centerY - height / 2 - paddingY; + const innerBottom = centerY + height / 2 + paddingY; + const innerHeight = height + paddingY * 2; + const innerRadius = innerHeight / 2; + // Smaller radius on outer-facing side (left for label, right for value) + const innerRadiusLeft = isLeftAligned + ? innerRadius * OUTER_EDGE_RADIUS_FACTOR + : innerRadius; + const innerRadiusRight = isLeftAligned + ? innerRadius + : innerRadius * OUTER_EDGE_RADIUS_FACTOR; + + // Outer boundary (where effect starts) - proportionally larger + const outerLeft = left - paddingLeft - marginLeft; + const outerRight = right + paddingRight + marginRight; + const outerTop = centerY - height / 2 - paddingY - marginY; + const outerBottom = centerY + height / 2 + paddingY + marginY; + const outerHeight = height + paddingY * 2 + marginY * 2; + const outerRadius = outerHeight / 2; + const outerRadiusLeft = isLeftAligned + ? outerRadius * OUTER_EDGE_RADIUS_FACTOR + : outerRadius; + const outerRadiusRight = isLeftAligned + ? outerRadius + : outerRadius * OUTER_EDGE_RADIUS_FACTOR; + + const outerDist = signedDistanceToRoundedRect( + thumbCenterX, + thumbCenterY, + outerLeft, + outerRight, + outerTop, + outerBottom, + outerRadiusLeft, + outerRadiusRight, + ); + + // Outside outer boundary - no gap + if (outerDist > 0) return 0; + + const innerDist = signedDistanceToRoundedRect( + thumbCenterX, + thumbCenterY, + innerLeft, + innerRight, + innerTop, + innerBottom, + innerRadiusLeft, + innerRadiusRight, + ); + + // Inside inner boundary - max gap + const maxGap = height + paddingY * 2; + if (innerDist <= 0) return maxGap; + + // Between boundaries - linear interpolation + // outerDist is negative (inside outer), innerDist is positive (outside inner) + const totalDist = Math.abs(outerDist) + innerDist; + const t = Math.abs(outerDist) / totalDist; + + return maxGap * t; +} + +interface SliderRowProps { + config: SliderConfig; + value: number; + onChange: (value: number) => void; + trackClassName?: string; + fillClassName?: string; + handleClassName?: string; +} + +function SliderRow({ + config, + value, + onChange, + trackClassName, + fillClassName, + handleClassName, +}: SliderRowProps) { + const { id, label, min, max, step = 1, unit, precision, disabled } = config; + // Per-slider theming overrides component-level theming + const resolvedTrackClassName = config.trackClassName ?? trackClassName; + const resolvedFillClassName = config.fillClassName ?? fillClassName; + const resolvedHandleClassName = config.handleClassName ?? handleClassName; + const crossesZero = min < 0 && max > 0; + const [isDragging, setIsDragging] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const trackRef = useRef(null); + const labelRef = useRef(null); + const valueRef = useRef(null); + + const [dragGap, setDragGap] = useState(0); + const [fullGap, setFullGap] = useState(0); + const [intersectsText, setIntersectsText] = useState(false); + const [layoutVersion, setLayoutVersion] = useState(0); + + useEffect(() => { + if (!isDragging) return; + const handlePointerUp = () => setIsDragging(false); + document.addEventListener("pointerup", handlePointerUp); + return () => document.removeEventListener("pointerup", handlePointerUp); + }, [isDragging]); + + useEffect(() => { + const track = trackRef.current; + const labelEl = labelRef.current; + const valueEl = valueRef.current; + if (!track || !labelEl || !valueEl) return; + + const bumpLayoutVersion = () => setLayoutVersion((v) => v + 1); + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(() => { + bumpLayoutVersion(); + }); + observer.observe(track); + observer.observe(labelEl); + observer.observe(valueEl); + return () => observer.disconnect(); + } + + window.addEventListener("resize", bumpLayoutVersion); + return () => window.removeEventListener("resize", bumpLayoutVersion); + }, []); + + useLayoutEffect(() => { + const track = trackRef.current; + const labelEl = labelRef.current; + const valueEl = valueRef.current; + + if (!track || !labelEl || !valueEl) return; + + const trackRect = track.getBoundingClientRect(); + const labelRect = labelEl.getBoundingClientRect(); + const valueRect = valueEl.getBoundingClientRect(); + + const trackWidth = trackRect.width; + const valuePercent = sliderRangeToPercent({ value, min, max }); + // Use same inset coordinate system as visual elements + const thumbCenterPx = + (trackWidth * clampPercent(valuePercent)) / 100 + + getRadixThumbInBoundsOffsetPx(valuePercent); + const thumbHalfWidth = THUMB_WIDTH / 2; + + // Text is raised by TEXT_VERTICAL_OFFSET from center + const trackCenterY = TRACK_HEIGHT / 2 - TEXT_VERTICAL_OFFSET; + + const labelGap = calculateGap( + thumbCenterPx, + { + left: labelRect.left - trackRect.left, + right: labelRect.right - trackRect.left, + height: labelRect.height, + centerY: trackCenterY, + }, + true, + ); // label is left-aligned + + const valueGap = calculateGap( + thumbCenterPx, + { + left: valueRect.left - trackRect.left, + right: valueRect.right - trackRect.left, + height: valueRect.height, + centerY: trackCenterY, + }, + false, + ); // value is right-aligned + + setDragGap(Math.max(labelGap, valueGap)); + + // Tight intersection check for release state + // Inset by px-2 (8px) padding to check against actual text, not padded container + const labelLeft = labelRect.left - trackRect.left + TEXT_RELEASE_INSET; + const labelRight = labelRect.right - trackRect.left - TEXT_RELEASE_INSET; + const valueLeft = valueRect.left - trackRect.left + TEXT_RELEASE_INSET; + const valueRight = valueRect.right - trackRect.left - TEXT_RELEASE_INSET; + + const thumbLeft = thumbCenterPx - thumbHalfWidth; + const thumbRight = thumbCenterPx + thumbHalfWidth; + + const hitsLabel = thumbRight > labelLeft && thumbLeft < labelRight; + const hitsValue = thumbRight > valueLeft && thumbLeft < valueRight; + + setIntersectsText(hitsLabel || hitsValue); + + // Calculate full separation gap for release state + // Use the max gap of whichever text element(s) the handle intersects + const labelFullGap = labelRect.height + TEXT_PADDING_Y * 2; + const valueFullGap = valueRect.height + TEXT_PADDING_Y * 2; + const releaseGap = + hitsLabel && hitsValue + ? Math.max(labelFullGap, valueFullGap) + : hitsLabel + ? labelFullGap + : hitsValue + ? valueFullGap + : 0; + setFullGap(releaseGap); + }, [value, min, max, layoutVersion]); + + // While dragging: use distance-based separation, but never collapse below + // the release split when the thumb still intersects text. + const gap = isDragging + ? Math.max(dragGap, intersectsText ? fullGap : 0) + : intersectsText + ? fullGap + : 0; + + const ticks = useMemo(() => { + // Generate equidistant ticks regardless of step value + const majorTickCount = TICK_COUNT; + const result: { percent: number; isCenter: boolean; isSubtick: boolean }[] = + []; + + for (let i = 0; i <= majorTickCount; i++) { + const percent = (i / majorTickCount) * 100; + const isCenter = !crossesZero && percent === 50; + + // Skip the center tick (50%) for crossesZero sliders + if (crossesZero && percent === 50) continue; + + // Add subtick at midpoint before this tick (except for first) + if (i > 0) { + const prevPercent = ((i - 1) / majorTickCount) * 100; + // Don't add subtick if it would be at 50% for crossesZero + const midPercent = (prevPercent + percent) / 2; + if (!(crossesZero && midPercent === 50)) { + result.push({ + percent: midPercent, + isCenter: false, + isSubtick: true, + }); + } + } + + result.push({ percent, isCenter, isSubtick: false }); + } + + return result; + }, [crossesZero]); + + const zeroPercent = crossesZero + ? sliderRangeToPercent({ value: 0, min, max }) + : 0; + const valuePercent = sliderRangeToPercent({ value, min, max }); + + // Fill clip-path uses the same inset coordinate system as the handle. + // This keeps the collapsed stroke aligned with the fill edge near extremes. + const fillClipPath = useMemo(() => { + const toClipFromRightInset = (percent: number) => + `calc(100% - ${toRadixThumbPosition(percent)})`; + const toClipFromLeftInset = (percent: number) => + toRadixThumbPosition(percent); + const TERMINAL_EPSILON = 1e-6; + const snapLeftInset = (percent: number) => { + if (percent <= TERMINAL_EPSILON) return "0"; + if (percent >= 100 - TERMINAL_EPSILON) return "100%"; + return toClipFromLeftInset(percent); + }; + const snapRightInset = (percent: number) => { + if (percent <= TERMINAL_EPSILON) return "100%"; + if (percent >= 100 - TERMINAL_EPSILON) return "0"; + return toClipFromRightInset(percent); + }; + + if (crossesZero) { + // Keep center anchor stable by always clipping the low/high pair, + // independent of sign branch, then snapping at terminal edges. + const lowPercent = Math.min(valuePercent, zeroPercent); + const highPercent = Math.max(valuePercent, zeroPercent); + return `inset(0 ${snapRightInset(highPercent)} 0 ${snapLeftInset(lowPercent)})`; + } + // Non-crossing: fill starts at left edge; snap right inset at terminals. + return `inset(0 ${snapRightInset(valuePercent)} 0 0)`; + }, [crossesZero, zeroPercent, valuePercent]); + + const fillMaskImage = crossesZero + ? "linear-gradient(to right, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.35) 50%, rgba(0,0,0,0.7) 100%)" + : "linear-gradient(to right, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%)"; + + // Metallic reflection gradient that follows the handle position + // Visible while dragging OR when resting at edges (0%/100%) + const reflectionStyle = useMemo(() => { + const edgeThreshold = 3; + const nearEdge = + valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold; + + // Narrower spread when stationary at edges (~35% narrower) + const spreadPercent = nearEdge && !isDragging ? 6.5 : 10; + const handlePos = toRadixThumbPosition(valuePercent); + const start = `clamp(0%, calc(${handlePos} - ${spreadPercent}%), 100%)`; + const end = `clamp(0%, calc(${handlePos} + ${spreadPercent}%), 100%)`; + + const gradient = `linear-gradient(to right, + transparent ${start}, + white ${handlePos}, + transparent ${end})`; + + return { + background: gradient, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + padding: "1px", + }; + }, [valuePercent, isDragging]); + + // Opacity scales with handle size: rest → hover → drag + const reflectionOpacity = useMemo(() => { + const edgeThreshold = 3; + const atEdge = + valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold; + + if (isDragging || atEdge) { + return 1; + } + if (isHovered) { + return 0.6; + } + return 0; + }, [valuePercent, isDragging, isHovered]); + + const handleValueChange = useCallback( + (values: number[]) => { + if (values[0] !== undefined) { + onChange(values[0]); + } + }, + [onChange], + ); + + return ( +
+ span]:transition-[left,transform] [&>span]:duration-45 [&>span]:ease-linear" + : "[&>span]:transition-[left,transform] [&>span]:duration-90 [&>span]:ease-[cubic-bezier(0.22,1,0.36,1)]", + "[&>span]:will-change-[left,transform]", + "motion-reduce:[&>span]:transition-none", + disabled && "pointer-events-none opacity-50", + )} + value={[value]} + onValueChange={handleValueChange} + onPointerDown={() => setIsDragging(true)} + onPointerUp={() => setIsDragging(false)} + onPointerEnter={() => setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + min={min} + max={max} + step={step} + disabled={disabled} + aria-valuetext={getAriaValueText(value, min, max, unit)} + > + +
+ + {ticks.map((tick, i) => { + const isEdge = + !tick.isSubtick && (tick.percent === 0 || tick.percent === 100); + return ( + + ); + })} + + + {/* Metallic reflection overlay - follows handle, brightness scales with interaction */} +
+ + + {(() => { + // Calculate morph state + const isActive = isHovered || isDragging; + + // Indicator stays centered on the real thumb while CSS transitions + // smooth thumb wrapper and fill movement together. + const fillEdgeOffset = 0; + + // Hide rest-state indicator at edges (0% or 100%) - the reflection gradient handles this + const edgeThreshold = 3; + const atEdge = + valuePercent <= edgeThreshold || + valuePercent >= 100 - edgeThreshold; + const restOpacity = atEdge ? 0 : 0.25; + + // Asymmetric segment heights: gap is shifted up to match raised text position + // Top segment is shorter, bottom segment is taller + const topHeight = + isActive && gap > 0 + ? `calc(50% - ${gap / 2 + TEXT_VERTICAL_OFFSET}px)` + : "50%"; + const bottomHeight = + isActive && gap > 0 + ? `calc(50% - ${gap / 2 - TEXT_VERTICAL_OFFSET}px)` + : "50%"; + + return ( + <> + 0 + ? "rounded-full" + : "rounded-t-full" + : "rounded-t-sm", + isDragging ? "w-2" : isActive ? "w-1.5" : "w-px", + resolvedHandleClassName ?? "bg-primary", + )} + style={{ + transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`, + height: topHeight, + opacity: isActive ? 1 : restOpacity, + }} + /> + 0 + ? "rounded-full" + : "rounded-b-full" + : "rounded-b-sm", + isDragging ? "w-2" : isActive ? "w-1.5" : "w-px", + resolvedHandleClassName ?? "bg-primary", + )} + style={{ + transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`, + height: bottomHeight, + opacity: isActive ? 1 : restOpacity, + }} + /> + + ); + })()} + + +
+ + {label} + + + {formatSignedValue(value, min, max, precision, unit)} + +
+ +
+ ); +} + +export function ParameterSlider({ + id, + sliders, + values: controlledValues, + onChange, + actions, + onAction, + onBeforeAction, + className, + trackClassName, + fillClassName, + handleClassName, +}: ParameterSliderProps) { + const slidersSignature = useMemo( + () => createSliderSignature(sliders), + [sliders], + ); + const sliderSnapshot = useMemo( + () => createSliderValueSnapshot(sliders), + [sliders], + ); + const { + value: currentValues, + isControlled, + setValue, + setUncontrolledValue, + } = useControllableState({ + value: controlledValues, + defaultValue: sliderSnapshot, + onChange, + }); + + useSignatureReset(slidersSignature, () => { + if (!isControlled) { + setUncontrolledValue(sliderSnapshot); + } + }); + + const valueMap = useMemo(() => { + const map = new Map(); + for (const v of currentValues) { + map.set(v.id, v.value); + } + return map; + }, [currentValues]); + + const updateValue = useCallback( + (sliderId: string, newValue: number) => { + setValue((prev) => + prev.map((v) => (v.id === sliderId ? { ...v, value: newValue } : v)), + ); + }, + [setValue], + ); + + const handleReset = useCallback(() => { + setValue(sliderSnapshot); + }, [setValue, sliderSnapshot]); + + const handleAction = useCallback( + async (actionId: string) => { + let nextValues = currentValues; + if (actionId === "reset") { + handleReset(); + nextValues = sliderSnapshot; + } + + await onAction?.(actionId, nextValues); + }, + [currentValues, handleReset, onAction, sliderSnapshot], + ); + + const normalizedActions = useMemo(() => { + const normalized = normalizeActionsConfig(actions); + if (normalized) return normalized; + return { + items: [ + { id: "reset", label: "Reset", variant: "ghost" as const }, + { id: "apply", label: "Apply", variant: "default" as const }, + ], + align: "right" as const, + }; + }, [actions]); + + return ( +
+
+ {sliders.map((slider) => ( + updateValue(slider.id, v)} + trackClassName={trackClassName} + fillClassName={fillClassName} + handleClassName={handleClassName} + /> + ))} +
+ +
+ onBeforeAction(actionId, currentValues) + : undefined + } + /> +
+
+ ); +} diff --git a/frontend/src/toolui/components/parameter-slider/schema.ts b/frontend/src/toolui/components/parameter-slider/schema.ts new file mode 100644 index 00000000..86673967 --- /dev/null +++ b/frontend/src/toolui/components/parameter-slider/schema.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import { type ActionsProp } from "../shared/actions-config"; +import type { EmbeddedActionsProps } from "../shared/embedded-actions"; +import { defineToolUiContract } from "../shared/contract"; +import { + SerializableActionSchema, + SerializableActionsConfigSchema, + ToolUIIdSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const SliderConfigSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + min: z.number().finite(), + max: z.number().finite(), + step: z.number().finite().positive().optional(), + value: z.number().finite(), + unit: z.string().optional(), + precision: z.number().int().min(0).optional(), + disabled: z.boolean().optional(), + trackClassName: z.string().optional(), + fillClassName: z.string().optional(), + handleClassName: z.string().optional(), + }) + .superRefine((slider, ctx) => { + if (slider.max <= slider.min) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["max"], + message: "max must be greater than min", + }); + } + + if (slider.value < slider.min || slider.value > slider.max) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["value"], + message: "value must be between min and max", + }); + } + }); + +export type SliderConfig = z.infer; + +export const SerializableParameterSliderSchema = z + .object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + sliders: z.array(SliderConfigSchema).min(1), + actions: z + .union([ + z.array(SerializableActionSchema), + SerializableActionsConfigSchema, + ]) + .optional(), + }) + .strict() + .superRefine((payload, ctx) => { + const seenIds = new Map(); + + payload.sliders.forEach((slider, index) => { + const firstSeenAt = seenIds.get(slider.id); + if (firstSeenAt !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sliders", index, "id"], + message: `duplicate slider id '${slider.id}' (first seen at index ${firstSeenAt})`, + }); + return; + } + seenIds.set(slider.id, index); + }); + }); + +export type SerializableParameterSlider = z.infer< + typeof SerializableParameterSliderSchema +>; + +const SerializableParameterSliderSchemaContract = defineToolUiContract( + "ParameterSlider", + SerializableParameterSliderSchema, +); + +export const parseSerializableParameterSlider: ( + input: unknown, +) => SerializableParameterSlider = + SerializableParameterSliderSchemaContract.parse; + +export const safeParseSerializableParameterSlider: ( + input: unknown, +) => SerializableParameterSlider | null = + SerializableParameterSliderSchemaContract.safeParse; + +export interface SliderValue { + id: string; + value: number; +} + +export interface ParameterSliderProps extends Omit< + SerializableParameterSlider, + "actions" +> { + className?: string; + values?: SliderValue[]; + onChange?: (values: SliderValue[]) => void; + actions?: ActionsProp; + onAction?: EmbeddedActionsProps["onAction"]; + onBeforeAction?: EmbeddedActionsProps["onBeforeAction"]; + trackClassName?: string; + fillClassName?: string; + handleClassName?: string; +} diff --git a/frontend/src/toolui/components/plan/README.md b/frontend/src/toolui/components/plan/README.md new file mode 100644 index 00000000..2e7b7db7 --- /dev/null +++ b/frontend/src/toolui/components/plan/README.md @@ -0,0 +1,19 @@ +# Plan + +Implementation for the "plan" Tool UI surface. + +## Files + +- public exports: components/tool-ui/plan/index.tsx +- serializable schema + parse helpers: components/tool-ui/plan/schema.ts + +## Companion assets + +- Docs page: app/docs/plan/content.mdx +- Preset payload: lib/presets/plan.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/plan/_adapter.tsx b/frontend/src/toolui/components/plan/_adapter.tsx new file mode 100644 index 00000000..48ea8dc9 --- /dev/null +++ b/frontend/src/toolui/components/plan/_adapter.tsx @@ -0,0 +1,32 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Accordion → shadcn/ui Accordion + * Card → shadcn/ui Card + * Collapsible → shadcn/ui Collapsible + */ + +export { cn } from "@toolui/lib/utils"; +export { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@toolui/ui/accordion"; +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from "@toolui/ui/card"; +export { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from "@toolui/ui/collapsible"; diff --git a/frontend/src/toolui/components/plan/index.tsx b/frontend/src/toolui/components/plan/index.tsx new file mode 100644 index 00000000..de5021c2 --- /dev/null +++ b/frontend/src/toolui/components/plan/index.tsx @@ -0,0 +1,7 @@ +export { Plan, PlanCompact } from "./plan"; +export type { + PlanProps, + PlanTodo, + PlanTodoStatus, + SerializablePlan, +} from "./schema"; diff --git a/frontend/src/toolui/components/plan/plan.tsx b/frontend/src/toolui/components/plan/plan.tsx new file mode 100644 index 00000000..a62032bb --- /dev/null +++ b/frontend/src/toolui/components/plan/plan.tsx @@ -0,0 +1,428 @@ +"use client"; + +import * as React from "react"; +import { useMemo, useState, useEffect, useRef, memo } from "react"; +import { Loader2, Check, X, MoreHorizontal, ChevronRight } from "lucide-react"; +import type { PlanProps, PlanTodo, PlanTodoStatus } from "./schema"; +import { + cn, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from "./_adapter"; +import { calculatePlanProgress, shouldCelebrateProgress } from "./progress"; + +const INITIAL_VISIBLE_TODO_COUNT = 4; + +const TodoIcon = memo(function TodoIcon({ + status, +}: { + status: PlanTodoStatus; +}) { + if (status === "pending") { + return ( +