mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
Merge remote-tracking branch 'origin/eric/redesign' into eric/onboarding
# Conflicts: # backend/apps/agents/manager/run/RunOptions.py # electron/main.js # frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx # frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx # frontend/src/app/pages/Dashboard/cards/NoteCard.tsx
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -62,7 +62,9 @@ def compose_turn_system_prompt(
|
||||
"<current_time>\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"
|
||||
"</current_time>"
|
||||
)
|
||||
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 = (
|
||||
"<rich_ui>\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"
|
||||
"</rich_ui>"
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+64
-1
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Generated
+3623
-22
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -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",
|
||||
|
||||
@@ -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<string, [string, string]> = {
|
||||
'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<void> {
|
||||
const fs = await import('fs');
|
||||
const out: Record<string, { hint: string; schema: unknown }> = {};
|
||||
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();
|
||||
@@ -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<string | null>(null);
|
||||
const [renamingAppId, setRenamingAppId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
@@ -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<AgentChatProps> = ({ 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<string, string>();
|
||||
const latestByKey = new Map<string, { call: (typeof activeBranchMessages)[number]; result: (typeof activeBranchMessages)[number] | null }>();
|
||||
const keyByCallId = new Map<string, string>();
|
||||
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<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [activeBranchMessages]);
|
||||
}, [activeBranchMessages, sessionRunning]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const total = renderItems.length;
|
||||
@@ -1589,6 +1694,22 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
}
|
||||
if (isToolPair(item)) {
|
||||
const isPending = item.result === null && sessionRunning;
|
||||
if (isAskUiPair(item)) {
|
||||
return (
|
||||
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
|
||||
<AskUiBubble pair={item} sessionId={session.id} isPending={isPending} suppressReveal={item.call.id === justStreamedId} />
|
||||
{compactionChip}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (isShowUiPair(item)) {
|
||||
return (
|
||||
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
|
||||
<ToolUiBubble pair={item} sessionId={session.id} isPending={isPending} suppressReveal={item.call.id === justStreamedId} sessionRunning={sessionRunning} />
|
||||
{compactionChip}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
|
||||
<ToolCallBubble call={item.call} result={item.result} isPending={isPending} sessionId={session.id} suppressReveal={item.call.id === justStreamedId} />
|
||||
@@ -2253,7 +2374,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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'}
|
||||
|
||||
@@ -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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -320,6 +322,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
editorRef={editorRef}
|
||||
generalFileInputRef={generalFileInputRef}
|
||||
embedded={embedded}
|
||||
quietComposer={quietComposer}
|
||||
isDragOver={isDragOver}
|
||||
isUploading={isUploading}
|
||||
handleDragOver={handleDragOver}
|
||||
|
||||
@@ -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<Props> = (p) => {
|
||||
@@ -58,7 +60,7 @@ export const ChatInputToolbar: React.FC<Props> = (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<Props> = (p) => {
|
||||
pt: 0,
|
||||
}}
|
||||
>
|
||||
<ModelControl
|
||||
c={c}
|
||||
setModelAnchor={setModelAnchor}
|
||||
allModelFlat={allModelFlat}
|
||||
model={model}
|
||||
/>
|
||||
{!restMode && (
|
||||
<ModelControl
|
||||
c={c}
|
||||
setModelAnchor={setModelAnchor}
|
||||
allModelFlat={allModelFlat}
|
||||
model={model}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelPickerMenu
|
||||
c={c}
|
||||
@@ -134,7 +138,7 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
|
||||
pendingPayloadEstimate={pendingPayloadEstimate}
|
||||
/>
|
||||
|
||||
{!hideForTrial && (
|
||||
{!hideForTrial && !restMode && (
|
||||
<ThinkingLevelControl
|
||||
c={c}
|
||||
model={model}
|
||||
@@ -149,7 +153,7 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{contextEstimate && (
|
||||
{contextEstimate && !restMode && (
|
||||
<ContextRing
|
||||
used={contextEstimate.used}
|
||||
limit={contextEstimate.limit}
|
||||
@@ -160,6 +164,7 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
|
||||
|
||||
<ToolbarActions
|
||||
c={c}
|
||||
restMode={restMode}
|
||||
elementSelection={elementSelection}
|
||||
autoRunMode={autoRunMode}
|
||||
ownerId={ownerId}
|
||||
|
||||
@@ -24,15 +24,16 @@ interface Props {
|
||||
isRunning?: boolean;
|
||||
onStop?: () => void;
|
||||
handleSend: () => void;
|
||||
restMode?: boolean;
|
||||
}
|
||||
|
||||
export const ToolbarActions: React.FC<Props> = ({
|
||||
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 (
|
||||
<Tooltip title={isMySelectMode ? 'Exit select mode' : 'Select UI element'}>
|
||||
|
||||
@@ -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<HTMLDivElement>;
|
||||
generalFileInputRef: RefObject<HTMLInputElement>;
|
||||
embedded?: boolean;
|
||||
quietComposer?: boolean;
|
||||
isDragOver: boolean;
|
||||
isUploading: boolean;
|
||||
handleDragOver: (e: React.DragEvent) => void;
|
||||
@@ -106,9 +107,18 @@ interface Props {
|
||||
|
||||
export const ChatInputView: React.FC<Props> = (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 (
|
||||
<Box
|
||||
ref={p.containerRef}
|
||||
onFocusCapture={() => 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<Props> = (p) => {
|
||||
|
||||
<ChatInputToolbar
|
||||
c={c}
|
||||
restMode={restMode}
|
||||
modeConf={p.modeConf}
|
||||
modesArr={p.modesArr}
|
||||
mode={p.mode}
|
||||
|
||||
@@ -1059,7 +1059,7 @@ const MessageBubble: React.FC<Props> = 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,
|
||||
|
||||
@@ -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<Props> = 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<Props> = React.memo(({ group, isSessionRunning =
|
||||
})();
|
||||
const displayName = workflowGroupLabel || meta?.name || group.label;
|
||||
const hasSvg = !!meta?.svg && !workflowGroupLabel;
|
||||
const canToggleGroup = group.pairs.length > 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -116,22 +126,54 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
...(expanded && {
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Collapsed = the quiet "N tool calls ›" line; the detail card only materializes on expand. */}
|
||||
{!expanded ? (
|
||||
<Box
|
||||
onClick={() => { 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 },
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.8rem', fontWeight: 500, color: 'inherit' }}>
|
||||
{group.callCount} tool call{group.callCount === 1 ? '' : 's'}
|
||||
</Typography>
|
||||
{!allDone && (
|
||||
<Typography sx={{ fontSize: '0.7rem', color: 'inherit', fontFamily: c.font.mono, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{completedCount}/{group.callCount}
|
||||
</Typography>
|
||||
)}
|
||||
{deniedCount > 0 && (
|
||||
<Typography sx={{ color: c.status.error, fontSize: '0.68rem' }}>
|
||||
{deniedCount} denied
|
||||
</Typography>
|
||||
)}
|
||||
<ExpandMoreIcon sx={{ fontSize: 15, transform: 'rotate(-90deg)' }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={canToggleGroup ? () => 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<Props> = React.memo(({ group, isSessionRunning =
|
||||
{completedCount}/{group.callCount}
|
||||
</Typography>
|
||||
)}
|
||||
{canToggleGroup && (
|
||||
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15 }}>
|
||||
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
<ExpandLessIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Box
|
||||
@@ -198,16 +239,25 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
},
|
||||
}}
|
||||
>
|
||||
{group.pairs.map((pair) => (
|
||||
<ToolCallBubble
|
||||
key={pair.id}
|
||||
call={pair.call}
|
||||
result={pair.result}
|
||||
isPending={pair.result === null && isSessionRunning}
|
||||
mcpCompact
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
))}
|
||||
{(group.entries ?? group.pairs.map((pair) => ({ kind: 'pair' as const, pair }))).map((entry) =>
|
||||
entry.kind === 'pair' ? (
|
||||
<ToolCallBubble
|
||||
key={entry.pair.id}
|
||||
call={entry.pair.call}
|
||||
result={entry.pair.result}
|
||||
isPending={entry.pair.result === null && isSessionRunning}
|
||||
mcpCompact
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
key={entry.id}
|
||||
sx={{ px: 1.5, py: 0.5, fontSize: '0.78rem', color: c.text.tertiary }}
|
||||
>
|
||||
{entry.text}
|
||||
</Typography>
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>).text ?? '')
|
||||
: null;
|
||||
|
||||
const componentId = payload && payload.component === 'vendored' ? String(payload.props.id || '') : '';
|
||||
|
||||
const respond = useCallback(
|
||||
(response: Record<string, unknown>) => {
|
||||
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 (
|
||||
<ToolCallBubble call={pair.call} result={pair.result} isPending={isPending} sessionId={sessionId} suppressReveal={suppressReveal} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ my: 1, contain: 'layout style' }} data-select-type="tool-ui-ask" data-select-id={pair.id} data-select-meta={JSON.stringify({ component: payload.name })}>
|
||||
<VendoredToolUi name={payload.name} props={payload.props} extraProps={extraProps} />
|
||||
{waiting && FREE_TEXT_COMPONENTS.has(payload.name) && (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={(e: React.FormEvent) => { 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,
|
||||
}}
|
||||
>
|
||||
<InputBase
|
||||
value={freeText}
|
||||
onChange={(e) => setFreeText(e.target.value)}
|
||||
placeholder="Or type your own answer..."
|
||||
inputProps={{ 'aria-label': 'Type your own answer' }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
<IconButton type="submit" size="small" disabled={!freeText.trim()} aria-label="Send answer">
|
||||
<ArrowUpwardRoundedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
{freeTextAnswer !== null && (
|
||||
<Box sx={{ fontSize: '0.78rem', opacity: 0.75, pt: 0.75 }}>
|
||||
✓ Answered: {freeTextAnswer}
|
||||
</Box>
|
||||
)}
|
||||
{submitted && pair.result === null && (
|
||||
<Box sx={{ fontSize: '0.72rem', opacity: 0.55, pt: 0.5 }}>Sent to the agent...</Box>
|
||||
)}
|
||||
{orphaned && (
|
||||
<Box sx={{ fontSize: '0.72rem', opacity: 0.55, pt: 0.5 }}>
|
||||
No agent is waiting for this answer (the request expired or this is an old transcript).
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default AskUiBubble;
|
||||
@@ -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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, maxWidth: 420 }}>
|
||||
{props.links.map((l, i) => (
|
||||
<Box
|
||||
key={`${i}-${l.url.slice(0, 40)}`}
|
||||
component="a"
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{
|
||||
display: 'block',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '12px',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.elevated,
|
||||
px: 1.75,
|
||||
py: 1.25,
|
||||
transition: 'border-color 0.12s',
|
||||
'&:hover': { borderColor: c.border.strong },
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary, mb: 0.25 }}>
|
||||
{hostOf(l.url)}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.88rem', fontWeight: 600, color: c.text.primary }}>
|
||||
{l.title}
|
||||
</Typography>
|
||||
{l.description && (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mt: 0.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{l.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinksWidget;
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
width: 320,
|
||||
borderRadius: '14px',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.elevated,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
{props.title && (
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary, mb: 1.5 }}>
|
||||
{props.title}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, mb: 0.5 }}>
|
||||
{done} of {props.steps.length} complete
|
||||
</Typography>
|
||||
<Box sx={{ height: 4, borderRadius: 2, bgcolor: c.border.subtle, mb: 1.5, overflow: 'hidden' }}>
|
||||
<Box sx={{ height: '100%', width: `${(done / props.steps.length) * 100}%`, bgcolor: c.text.primary, transition: 'width 0.3s ease' }} />
|
||||
</Box>
|
||||
{visible.map((step, i) => (
|
||||
<Box key={`${i}-${step.label.slice(0, 24)}`} sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6 }}>
|
||||
{step.status === 'completed' ? (
|
||||
<CheckCircleIcon sx={{ fontSize: 17, color: c.text.primary }} />
|
||||
) : step.status === 'in_progress' ? (
|
||||
<CircularProgress size={14} thickness={5} sx={{ color: c.text.secondary }} />
|
||||
) : (
|
||||
<RadioButtonUncheckedIcon sx={{ fontSize: 17, color: c.border.strong }} />
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: step.status === 'in_progress' ? 600 : 500,
|
||||
color: step.status === 'pending' ? c.text.muted : c.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{step.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, pt: 0.5 }}>
|
||||
... {hidden} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlanWidget;
|
||||
@@ -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 <WeatherWidget props={payload.props} ambient={ambient} />;
|
||||
if (payload.component === 'plan') return <PlanWidget props={payload.props} />;
|
||||
if (payload.component === 'stats') return <StatsWidget props={payload.props} />;
|
||||
if (payload.component === 'links') return <LinksWidget props={payload.props} />;
|
||||
if (payload.component === 'vendored') return <VendoredToolUi name={payload.name} props={payload.props} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default ShowUiWidgetView;
|
||||
@@ -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 (
|
||||
<Box sx={{ maxWidth: 460 }}>
|
||||
{props.title && (
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary, mb: 1 }}>
|
||||
{props.title}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{props.stats.map((s, i) => (
|
||||
<Box
|
||||
key={`${i}-${s.label.slice(0, 16)}`}
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
flex: '1 1 120px',
|
||||
borderRadius: '12px',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.elevated,
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', color: c.text.tertiary }}>
|
||||
{s.label}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '1.15rem', fontWeight: 700, color: c.text.primary, mt: 0.25, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{s.value}
|
||||
</Typography>
|
||||
{s.delta && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
|
||||
{s.direction === 'down' ? (
|
||||
<ArrowDownwardIcon sx={{ fontSize: 12, color: c.status.error }} />
|
||||
) : (
|
||||
<ArrowUpwardIcon sx={{ fontSize: 12, color: c.status.success }} />
|
||||
)}
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: s.direction === 'down' ? c.status.error : c.status.success }}>
|
||||
{s.delta}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatsWidget;
|
||||
@@ -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 (
|
||||
<ToolCallBubble call={pair.call} result={pair.result} isPending={isPending} sessionId={sessionId} suppressReveal={suppressReveal} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box sx={{ my: 1, contain: 'layout style' }} data-select-type="tool-ui" data-select-id={pair.id} data-select-meta={JSON.stringify({ component: payload.component })}>
|
||||
<ShowUiWidgetView payload={payload} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolUiBubble;
|
||||
@@ -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.
|
||||
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`} style={{ width: 384, maxWidth: '100%' }}>
|
||||
<AnimatedWeatherWidget
|
||||
version="3.1"
|
||||
id={`weather-${props.location}`}
|
||||
location={{ name: props.location }}
|
||||
units={{ temperature: props.unit === 'C' ? 'celsius' : 'fahrenheit' }}
|
||||
current={{
|
||||
conditionCode: toConditionCode(props.condition),
|
||||
temperature: Math.round(props.temp),
|
||||
tempMin: Math.round(props.low ?? props.temp - 4),
|
||||
tempMax: Math.round(props.high ?? props.temp + 4),
|
||||
}}
|
||||
forecast={forecast}
|
||||
time={{ localTimeOfDay: new Date().getHours() + new Date().getMinutes() / 60 }}
|
||||
effects={{ enabled: true, quality: ambient ? 'low' : 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeatherWidget;
|
||||
@@ -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<string, unknown> };
|
||||
|
||||
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<string, unknown> };
|
||||
}
|
||||
}
|
||||
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<string, unknown>;
|
||||
|
||||
if (component === 'weather') {
|
||||
if (!str(p.location) || !num(p.temp)) return null;
|
||||
const forecast = Array.isArray(p.forecast)
|
||||
? (p.forecast as Array<Record<string, unknown>>)
|
||||
// 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<Record<string, unknown>>)
|
||||
.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<Record<string, unknown>>)
|
||||
.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<Record<string, unknown>>)
|
||||
.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 } };
|
||||
}
|
||||
@@ -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<typeof Tooltip> & { className?: string }) => (
|
||||
<Tooltip {...props} classes={{ popper: className }} />
|
||||
)
|
||||
)<{ 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<HTMLDivElement, Props>(
|
||||
const [historyQuery, setHistoryQuery] = useState('');
|
||||
const [popoverMode, setPopoverMode] = useState<'search' | 'runs' | 'schedule'>('search');
|
||||
const [expandToast, setExpandToast] = useState<string | null>(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<HTMLDivElement, Props>(
|
||||
);
|
||||
}, [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<HTMLDivElement, Props>(
|
||||
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<HTMLDivElement, Props>(
|
||||
}
|
||||
}, [handleHistoryLoadMore]);
|
||||
|
||||
const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -427,12 +381,16 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
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<HTMLDivElement, Props>(
|
||||
data-onboarding-scope="dock"
|
||||
style={{ width: '100%', minHeight: 56, paddingBottom: 0, marginBottom: -4 }}
|
||||
>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
embedded
|
||||
autoFocus
|
||||
sessionId={TOOLBAR_OWNER_ID}
|
||||
thinkingLevel={thinkingLevel}
|
||||
onThinkingLevelChange={handleThinkingLevelChange}
|
||||
prefillPrompt={prefillPrompt}
|
||||
/>
|
||||
<DarkTokensScope>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
embedded
|
||||
autoFocus
|
||||
sessionId={TOOLBAR_OWNER_ID}
|
||||
thinkingLevel={thinkingLevel}
|
||||
onThinkingLevelChange={handleThinkingLevelChange}
|
||||
prefillPrompt={prefillPrompt}
|
||||
placeholderOverride="What should I do sir..."
|
||||
/>
|
||||
</DarkTokensScope>
|
||||
</div>
|
||||
) : historyOpen ? (
|
||||
<div style={{ width: '100%' }}>
|
||||
@@ -613,259 +574,17 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
</Box>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
|
||||
<WarmTooltip tokens={c} title={`New Agent ${shortcutLabel}`} placement="top" arrow enterDelay={400}>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="New Agent"
|
||||
data-onboarding="new-agent-button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
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)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ChatBubbleTeardrop sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
<WarmTooltip
|
||||
tokens={c}
|
||||
placement="top"
|
||||
arrow
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>Add App ⌘M</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Add App"
|
||||
tabIndex={0}
|
||||
onClick={handleOpenViewPicker}
|
||||
data-onboarding="dashboard-toolbar-apps"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: BTN,
|
||||
height: BTN,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
color: c.text.tertiary,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
|
||||
...popIn(1),
|
||||
}}
|
||||
>
|
||||
<GridViewRoundedIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
<WarmTooltip
|
||||
tokens={c}
|
||||
placement="top"
|
||||
arrow
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>Browser ⌘N</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Browser"
|
||||
data-onboarding="browser-button"
|
||||
tabIndex={0}
|
||||
onClick={onAddBrowser}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: BTN,
|
||||
height: BTN,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
color: c.text.tertiary,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
|
||||
...popIn(2),
|
||||
}}
|
||||
>
|
||||
<LanguageIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
<WarmTooltip
|
||||
tokens={c}
|
||||
placement="top"
|
||||
arrow
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>Workflows</Box>
|
||||
<Box sx={{ opacity: 0.6, fontSize: '0.7rem', mt: '1px' }}>Schedule and calendar</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Workflows"
|
||||
tabIndex={0}
|
||||
onClick={() => 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),
|
||||
}}
|
||||
>
|
||||
<EventRepeatIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
<WarmTooltip
|
||||
tokens={c}
|
||||
placement="top"
|
||||
arrow
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>Add note</Box>
|
||||
<Box sx={{ opacity: 0.6, fontSize: '0.7rem', mt: '1px' }}>Sticky note on the canvas</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Add note"
|
||||
tabIndex={0}
|
||||
onClick={onAddNote}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: BTN,
|
||||
height: BTN,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
color: c.text.tertiary,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
|
||||
...popIn(4),
|
||||
}}
|
||||
>
|
||||
<StickyNote2OutlinedIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
<WarmTooltip
|
||||
tokens={c}
|
||||
placement="top"
|
||||
arrow
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>History ⌘O</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="History"
|
||||
tabIndex={0}
|
||||
onClick={handleOpenHistory}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: BTN,
|
||||
height: BTN,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
color: c.text.tertiary,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
|
||||
...popIn(5),
|
||||
}}
|
||||
>
|
||||
<HistoryRoundedIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
|
||||
<WarmTooltip
|
||||
key={label}
|
||||
tokens={c}
|
||||
placement="top"
|
||||
arrow
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>{label}</Box>
|
||||
<Box sx={{ opacity: 0.6, fontSize: '0.7rem', mt: '1px' }}>{sub}</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: BTN,
|
||||
height: BTN,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
color: c.text.tertiary,
|
||||
opacity: 0.45,
|
||||
cursor: 'default',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover': { opacity: 0.65, bgcolor: c.bg.secondary },
|
||||
}}
|
||||
>
|
||||
<PlaceholderIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
))}
|
||||
</div>
|
||||
<DesktopSpawnPill
|
||||
onOpenComposer={() => {
|
||||
if (newAgentBounce) onNewAgentBounceEnd?.();
|
||||
onNewAgent();
|
||||
}}
|
||||
onAddNote={onAddNote}
|
||||
onAddBrowser={onAddBrowser}
|
||||
onAddApp={handleOpenViewPicker}
|
||||
onWorkflows={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())}
|
||||
onHistory={handleOpenHistory}
|
||||
/>
|
||||
)}
|
||||
</MotionBox>
|
||||
<Snackbar
|
||||
|
||||
@@ -7,6 +7,10 @@ import TetherLayer from './TetherLayer';
|
||||
import DashboardCardLayer from './DashboardCardLayer';
|
||||
import DashboardOverlays from './DashboardOverlays';
|
||||
import DashboardEmptyState from './DashboardEmptyState';
|
||||
import '../desktop/desktop.css';
|
||||
import DesktopDock from '../desktop/DesktopDock';
|
||||
import MinimizedStack from '../desktop/MinimizedStack';
|
||||
import ApplicationsWindow from '../desktop/ApplicationsWindow';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
|
||||
import { GRAIN_URL } from '@/shared/styles/grainTexture';
|
||||
@@ -23,6 +27,7 @@ import type { Output } from '@/shared/state/outputsSlice';
|
||||
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
|
||||
import type { useCanvasControls } from '../hooks/interaction/useCanvasControls';
|
||||
import { useWebviewSuspend } from '../hooks/interaction/useWebviewSuspend';
|
||||
import { deleteSelectedCards } from '../hooks/interaction/deleteSelectedCards';
|
||||
import type { Tether } from '../geometry/dashboardTethers';
|
||||
|
||||
type Selection = ReturnType<typeof useDashboardSelection>;
|
||||
@@ -167,6 +172,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
// 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<DashboardCanvasProps> = ({
|
||||
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<DashboardCanvasProps> = ({
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Top-edge hover strip: the desktop shell keeps the top chromeless; grazing it reveals the header. */}
|
||||
<Box
|
||||
onMouseEnter={() => setHeaderRevealed(true)}
|
||||
sx={{ position: 'absolute', top: 0, left: 0, right: 0, height: 22, zIndex: 9 }}
|
||||
/>
|
||||
{/* Floating header overlay */}
|
||||
<Box
|
||||
onMouseLeave={() => setHeaderRevealed(false)}
|
||||
sx={{
|
||||
display: fullscreenCardId ? 'none' : undefined,
|
||||
position: 'absolute',
|
||||
@@ -194,7 +208,10 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
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<DashboardCanvasProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{!fullscreenCardId && (
|
||||
<MinimizedStack
|
||||
browserCards={browserCards}
|
||||
onRestore={(cardId, rect) => {
|
||||
canvas.actions.fitToCards([rect], 1.15, true);
|
||||
onHighlightCard?.(cardId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!fullscreenCardId && (
|
||||
<DesktopDock
|
||||
sessions={sessions}
|
||||
cards={cards}
|
||||
viewCards={viewCards}
|
||||
browserCards={browserCards}
|
||||
notes={notes}
|
||||
workflowCards={workflowCards}
|
||||
outputs={outputs}
|
||||
selectedIds={Array.from(selection.selectedIds.keys())}
|
||||
onFocusCard={(cardId, rect) => {
|
||||
canvas.actions.fitToCards([rect], 1.15, true);
|
||||
onHighlightCard?.(cardId);
|
||||
}}
|
||||
onApplications={() => setAppsWindowOpen((v) => !v)}
|
||||
onNewAgent={onNewAgent}
|
||||
onAddBrowser={onAddBrowser}
|
||||
onAddNote={onAddNote}
|
||||
/>
|
||||
)}
|
||||
|
||||
{appsWindowOpen && !fullscreenCardId && (
|
||||
<ApplicationsWindow onClose={() => setAppsWindowOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Canvas viewport */}
|
||||
<Box
|
||||
ref={canvas.viewportRef}
|
||||
@@ -362,6 +414,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
onNewAgentBounceEnd={onNewAgentBounceEnd}
|
||||
onFitToView={onFitToView}
|
||||
onTidy={onTidy}
|
||||
onDeleteSelected={() => {
|
||||
deleteSelectedCards(selection.selectedIds, dispatch);
|
||||
selection.deselectAll();
|
||||
}}
|
||||
hasSelection={selection.selectedIds.size > 0}
|
||||
onSearchPaletteClose={onSearchPaletteClose}
|
||||
toolbarPrefill={toolbarPrefill}
|
||||
toolbarPrefillMode={toolbarPrefillMode}
|
||||
|
||||
@@ -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<string, LucideIcon> = {
|
||||
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<DashboardGlyphProps> = ({ name, size = 16 }) => {
|
||||
const DashboardGlyph: React.FC<DashboardGlyphProps> = ({ 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 <Icon size={size} strokeWidth={1.75} color={c.accent.primary} />;
|
||||
return <Icon size={size} strokeWidth={1.75} color={glyphColor} />;
|
||||
}
|
||||
|
||||
// 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 <LayoutDashboard size={size} strokeWidth={1.75} color={c.accent.primary} />;
|
||||
return <LayoutDashboard size={size} strokeWidth={1.75} color={glyphColor} />;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
@@ -98,8 +101,8 @@ const DashboardGlyph: React.FC<DashboardGlyphProps> = ({ 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',
|
||||
|
||||
@@ -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<DashboardOverlaysProps> = ({
|
||||
onNewAgentBounceEnd,
|
||||
onFitToView,
|
||||
onTidy,
|
||||
onDeleteSelected,
|
||||
hasSelection,
|
||||
onSearchPaletteClose,
|
||||
toolbarPrefill,
|
||||
toolbarPrefillMode,
|
||||
@@ -106,6 +111,11 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Desktop help pill */}
|
||||
<Box sx={{ position: 'absolute', top: 14, right: 16, zIndex: 10 }}>
|
||||
<HelpPill />
|
||||
</Box>
|
||||
|
||||
{/* Arrow navigation hints when zoomed in on a card */}
|
||||
{focusedCardId && canvas.zoom >= 0.4 && (
|
||||
<DirectionHints
|
||||
@@ -124,6 +134,8 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
actions={canvas.actions}
|
||||
onFitToView={onFitToView}
|
||||
onTidy={onTidy}
|
||||
onDeleteSelected={onDeleteSelected}
|
||||
hasSelection={hasSelection}
|
||||
minimapProps={{
|
||||
panX: canvas.panX,
|
||||
panY: canvas.panY,
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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<string | null>(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<unknown>).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<Props> = ({
|
||||
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<Props> = ({
|
||||
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 }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
onPointerDown={handleResizeDown(dir)}
|
||||
@@ -890,19 +963,61 @@ const AgentCard: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drag zone: header + metadata , entire region above separator is draggable */}
|
||||
{pillMode && (
|
||||
<Box
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
sx={{ touchAction: 'none', userSelect: 'none' }}
|
||||
>
|
||||
<AgentNarratorPill
|
||||
label={pillLabel}
|
||||
running={pillRunning}
|
||||
todos={todos}
|
||||
artifact={pillArtifact}
|
||||
browserShot={browserShot}
|
||||
selected={isSelected}
|
||||
highlighted={isHighlighted}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<Box
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 16,
|
||||
mx: -2,
|
||||
mt: -2,
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 1.5,
|
||||
...(expanded
|
||||
? {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 17,
|
||||
px: 2,
|
||||
pt: 1.5,
|
||||
pb: 2,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.15s ease',
|
||||
'&:hover': { opacity: 1 },
|
||||
background: 'linear-gradient(to bottom, rgba(20,12,28,0.92) 0%, rgba(20,12,28,0.65) 60%, rgba(20,12,28,0) 100%)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
// Header text must read over the dark scrim regardless of app theme.
|
||||
'& .MuiTypography-root': { color: 'rgba(255,255,255,0.92)' },
|
||||
'& input': { color: 'rgba(255,255,255,0.92)' },
|
||||
}
|
||||
: {
|
||||
position: 'relative',
|
||||
zIndex: 16,
|
||||
mx: -2,
|
||||
mt: -2,
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 1.5,
|
||||
}),
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
@@ -1018,6 +1133,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<Box
|
||||
@@ -1025,28 +1141,31 @@ const AgentCard: React.FC<Props> = ({
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<AgentChat
|
||||
key={session.id}
|
||||
sessionId={session.id}
|
||||
onClose={() => dispatch(collapseSession(session.id))}
|
||||
embedded
|
||||
autoFocus={autoFocusInput}
|
||||
isGlowing={isGlowingRedux && !glowFading}
|
||||
onDismissGlow={dismissGlow}
|
||||
onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined}
|
||||
/>
|
||||
<DarkTokensScope>
|
||||
<AgentChat
|
||||
key={session.id}
|
||||
sessionId={session.id}
|
||||
onClose={() => dispatch(collapseSession(session.id))}
|
||||
embedded
|
||||
autoFocus={autoFocusInput}
|
||||
isGlowing={isGlowingRedux && !glowFading}
|
||||
onDismissGlow={dismissGlow}
|
||||
onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined}
|
||||
/>
|
||||
</DarkTokensScope>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!expanded && (
|
||||
{!expanded && !pillMode && (
|
||||
<>
|
||||
{previewContent && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: hasPending ? 1.5 : 0 }}>
|
||||
|
||||
@@ -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<string, unknown> => ({
|
||||
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<Props> = ({
|
||||
[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<Props> = ({
|
||||
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<unknown>).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<Props> = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="osw-card"
|
||||
data-select-type="browser-card"
|
||||
data-select-id={browserId}
|
||||
data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })}
|
||||
// Marks a kept-alive card parked off-screen (it belongs to another dashboard); fit-to-view must skip it or it pans the canvas to chase it and the card bleeds onto the dashboard you're viewing.
|
||||
data-keepalive-hidden={keepAliveHidden ? '1' : undefined}
|
||||
data-keepalive-hidden={keepAliveHidden || isMinimized ? '1' : undefined}
|
||||
onPointerDownCapture={(e: React.PointerEvent) => {
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: '7px', pl: 1.25, pr: 0.75, flexShrink: 0 }}
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
aria-label="Close browser"
|
||||
onClick={handleRemove}
|
||||
sx={{ ...browserLightSx('#ff5f57'), }}
|
||||
/>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
aria-label="Minimize browser"
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleMinimize(); }}
|
||||
sx={{ ...browserLightSx('#febc2e'), }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -969,13 +1029,13 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
onError={(e: any) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<LanguageIcon sx={{ fontSize: 13, color: isActive ? accentColor : c.text.ghost }} />
|
||||
<LanguageIcon sx={{ fontSize: 13, color: isActive ? accentColor : CHROME_TEXT_MUTED }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1010,7 +1070,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
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 },
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 10, color: c.text.muted }} />
|
||||
<CloseIcon sx={{ fontSize: 10, color: CHROME_TEXT_MUTED }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -1059,10 +1119,10 @@ const BrowserCard: React.FC<Props> = ({
|
||||
mx: 0.25,
|
||||
my: 0.5,
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': { bgcolor: `${c.text.muted}15` },
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.06)' },
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 15, color: c.text.muted }} />
|
||||
<AddIcon sx={{ fontSize: 15, color: CHROME_TEXT_MUTED }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1105,16 +1165,6 @@ const BrowserCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Tooltip title="Close browser" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1126,8 +1176,8 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
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 } }}
|
||||
>
|
||||
<ArrowBackIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
@@ -1152,7 +1202,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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 } }}
|
||||
>
|
||||
<ArrowForwardIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
@@ -1164,7 +1214,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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 } }}
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
@@ -1180,13 +1230,13 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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 ? (
|
||||
<SearchIcon sx={{ fontSize: 13, color: c.text.muted, flexShrink: 0 }} />
|
||||
<SearchIcon sx={{ fontSize: 13, color: CHROME_TEXT_MUTED, flexShrink: 0 }} />
|
||||
) : isSecure ? (
|
||||
<LockIcon sx={{ fontSize: 12, color: c.status.success, flexShrink: 0 }} />
|
||||
) : null}
|
||||
@@ -1202,10 +1252,10 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -139,8 +139,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
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}`
|
||||
|
||||
@@ -259,8 +259,7 @@ const NoteCard: React.FC<Props> = ({
|
||||
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;
|
||||
|
||||
@@ -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<MinimapProps, 'onPan'>;
|
||||
onMinimapPan: (panX: number, panY: number) => void;
|
||||
}
|
||||
@@ -33,8 +33,27 @@ function readMinimapPref(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const CanvasControls: React.FC<Props> = ({ 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<Props> = ({ zoom, actions, onFitToView, onTidy, onDeleteSelected, hasSelection, minimapProps, onMinimapPan }) => {
|
||||
const pct = Math.round(zoom * 100);
|
||||
const [minimapOpen, setMinimapOpen] = useState<boolean>(() => readMinimapPref());
|
||||
const setAndPersistMinimap = (next: boolean) => {
|
||||
@@ -53,10 +72,11 @@ const CanvasControls: React.FC<Props> = ({ 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<Props> = ({ zoom, actions, onFitToView, onTidy, m
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
boxShadow: c.shadow.sm,
|
||||
py: 0.25,
|
||||
px: 0.5,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
data-onboarding="canvas-controls"
|
||||
>
|
||||
<Tooltip title="Zoom out" placement="top">
|
||||
<IconButton size="small" onClick={actions.zoomOut} sx={{ color: c.text.muted }}>
|
||||
<RemoveIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Reset to 100%" placement="top">
|
||||
<Typography
|
||||
onClick={actions.resetZoom}
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
color: c.text.secondary,
|
||||
minWidth: 40,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Zoom in" placement="top">
|
||||
<IconButton size="small" onClick={actions.zoomIn} sx={{ color: c.text.muted }}>
|
||||
<AddIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
|
||||
|
||||
<Tooltip title="Fit to view" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onFitToView}
|
||||
sx={{ color: c.text.muted }}
|
||||
data-onboarding="canvas-fit-to-view"
|
||||
>
|
||||
<FitScreenIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={minimapOpen ? 'Hide minimap' : 'Show minimap'} placement="left">
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Toggle minimap"
|
||||
onClick={() => setAndPersistMinimap(!minimapOpen)}
|
||||
data-onboarding="canvas-minimap-toggle"
|
||||
sx={{ ...circleSx, width: 26, height: 26, borderRadius: '8px', ...(minimapOpen && { color: '#fff' }) }}
|
||||
>
|
||||
<MapIcon sx={{ fontSize: 14 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }} data-onboarding="canvas-controls">
|
||||
<Tooltip title="Tidy layout" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onTidy}
|
||||
sx={{ color: c.text.muted }}
|
||||
data-onboarding="canvas-tidy-layout"
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
<Box role="button" aria-label="Tidy layout" onClick={onTidy} data-onboarding="canvas-tidy-layout" sx={circleSx}>
|
||||
<SpaceDashboardOutlinedIcon sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Box sx={{ width: 1, height: 16, bgcolor: c.border.medium, mx: 0.5 }} />
|
||||
|
||||
<Tooltip title={minimapOpen ? 'Hide minimap' : 'Show minimap'} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setAndPersistMinimap(!minimapOpen)}
|
||||
sx={{ color: minimapOpen ? c.accent.primary : c.text.muted }}
|
||||
data-onboarding="canvas-minimap-toggle"
|
||||
<Tooltip title={hasSelection ? 'Close selected' : 'Select a card to close it'} placement="top">
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Close selected"
|
||||
onClick={() => { if (hasSelection) onDeleteSelected(); }}
|
||||
sx={{ ...circleSx, ...(!hasSelection && { color: 'rgba(255,255,255,0.35)', cursor: 'default', '&:hover': { color: 'rgba(255,255,255,0.35)' } }) }}
|
||||
>
|
||||
<MapIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
<DeleteOutlineIcon sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
height: 30,
|
||||
px: 1,
|
||||
borderRadius: 999,
|
||||
background: GLASS,
|
||||
backdropFilter: GLASS_BLUR,
|
||||
WebkitBackdropFilter: GLASS_BLUR,
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Zoom out" placement="top">
|
||||
<Box role="button" aria-label="Zoom out" onClick={actions.zoomOut} sx={{ display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.6)', cursor: 'pointer', '&:hover': { color: '#fff' } }}>
|
||||
<RemoveIcon sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Fit to view" placement="top">
|
||||
<Typography
|
||||
onClick={onFitToView}
|
||||
data-onboarding="canvas-fit-to-view"
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255,255,255,0.78)',
|
||||
minWidth: 38,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1,
|
||||
'&:hover': { color: '#fff' },
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Zoom in" placement="top">
|
||||
<Box role="button" aria-label="Zoom in" onClick={actions.zoomIn} sx={{ display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.6)', cursor: 'pointer', '&:hover': { color: '#fff' } }}>
|
||||
<AddIcon sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
'@keyframes osw-artifact-in': {
|
||||
from: { opacity: 0, transform: 'translateY(8px) scale(0.98)' },
|
||||
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
|
||||
},
|
||||
'& .osw-artifact': {
|
||||
animation: 'osw-artifact-in 320ms cubic-bezier(0.2, 0.8, 0.2, 1) both',
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
'& .osw-artifact': { animation: 'none' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
height: 34,
|
||||
pl: 1.25,
|
||||
pr: 1.75,
|
||||
borderRadius: 999,
|
||||
background: GLASS,
|
||||
backdropFilter: GLASS_BLUR,
|
||||
WebkitBackdropFilter: GLASS_BLUR,
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
|
||||
whiteSpace: 'nowrap',
|
||||
...ring,
|
||||
}}
|
||||
>
|
||||
<DashboardGlyph name={label} size={15} color="rgba(255,255,255,0.85)" />
|
||||
<Typography sx={{ fontSize: '0.82rem', fontWeight: 500, color: 'rgba(255,255,255,0.92)' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{artifact ? (
|
||||
<Box key={artifactKey} className="osw-artifact">
|
||||
<ShowUiWidgetView payload={artifact} ambient />
|
||||
</Box>
|
||||
) : browserShot ? (
|
||||
<Box
|
||||
key={artifactKey}
|
||||
className="osw-artifact"
|
||||
component="img"
|
||||
src={browserShot}
|
||||
alt=""
|
||||
sx={{ width: 300, display: 'block', borderRadius: '10px', boxShadow: '0 10px 30px rgba(0,0,0,0.35)' }}
|
||||
/>
|
||||
) : visibleTodos.length > 0 ? (
|
||||
<Box
|
||||
key={artifactKey}
|
||||
className="osw-artifact"
|
||||
sx={{
|
||||
borderRadius: '16px',
|
||||
background: GLASS,
|
||||
backdropFilter: GLASS_BLUR,
|
||||
WebkitBackdropFilter: GLASS_BLUR,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.32)',
|
||||
px: 1.75,
|
||||
py: 1.5,
|
||||
minWidth: 200,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{visibleTodos.length > 1 && (
|
||||
<Box sx={{ position: 'absolute', left: 10, top: 12, bottom: 12, width: '2px', background: 'rgba(214,170,203,0.4)' }} />
|
||||
)}
|
||||
{visibleTodos.map((todo, i) => {
|
||||
const done = todo.status === 'completed';
|
||||
const active = todo.status === 'in_progress';
|
||||
return (
|
||||
<Box key={`${i}-${todo.content.slice(0, 24)}`} sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.75 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: done ? '#ecd2e4' : active ? '#cf9fc4' : 'rgba(207,159,196,0.35)',
|
||||
}}
|
||||
>
|
||||
{done && <CheckIcon sx={{ fontSize: 14, color: '#3c2035' }} />}
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: done || active ? 500 : 400,
|
||||
color: done || active ? 'rgba(255,255,255,0.92)' : 'rgba(255,255,255,0.45)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 260,
|
||||
}}
|
||||
>
|
||||
{todo.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{hiddenCount > 0 && (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.4)', pl: '2px', pt: 0.5 }}>
|
||||
... {hiddenCount} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : running ? (
|
||||
<Box
|
||||
key={artifactKey}
|
||||
className="osw-artifact"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
height: 28,
|
||||
px: 1.5,
|
||||
borderRadius: 999,
|
||||
background: GLASS,
|
||||
backdropFilter: GLASS_BLUR,
|
||||
WebkitBackdropFilter: GLASS_BLUR,
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: 'rgba(255,255,255,0.6)' }}>
|
||||
Thinking...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentNarratorPill;
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: '12px',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.22), rgba(255,255,255,0.08))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.3rem',
|
||||
fontWeight: 700,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Launchpad-style window over the canvas: the user's real /Applications, categorized. */
|
||||
function ApplicationsWindow({ onClose }: ApplicationsWindowProps): React.ReactElement {
|
||||
const [apps, setApps] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [icons, setIcons] = useState<Record<string, string>>({});
|
||||
const [category, setCategory] = useState<string>('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<string | null> } }).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<boolean> } }).openswarm?.openApplication;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box onClick={onClose} sx={{ position: 'absolute', inset: 0, zIndex: 19 }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 20,
|
||||
width: 620,
|
||||
maxWidth: 'calc(100% - 80px)',
|
||||
maxHeight: 'calc(100% - 120px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '18px',
|
||||
background: 'rgba(22,12,34,0.82)',
|
||||
backdropFilter: 'blur(28px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(28px) saturate(160%)',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
||||
p: 2.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 1.5 }}>
|
||||
<Typography sx={{ fontSize: '1.15rem' }}>🐙</Typography>
|
||||
<Typography sx={{ fontSize: '1.05rem', fontWeight: 600, color: 'rgba(255,255,255,0.75)' }}>
|
||||
Applications
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{categories.length > 1 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 2, overflowX: 'auto', pb: 0.5, scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none' } }}>
|
||||
{categories.map((cat) => (
|
||||
<Box
|
||||
key={cat}
|
||||
onClick={() => 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}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ overflowY: 'auto', flex: 1, minHeight: 120 }}>
|
||||
{!apps && !error && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={22} sx={{ color: 'rgba(255,255,255,0.5)' }} />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.55)', fontSize: '0.85rem', textAlign: 'center', py: 5 }}>
|
||||
Could not read /Applications.
|
||||
</Typography>
|
||||
)}
|
||||
{apps && (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(78px, 1fr))', gap: 1.5 }}>
|
||||
{visible.map((name) => (
|
||||
<Box
|
||||
key={name}
|
||||
onClick={() => { 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] ? (
|
||||
<Box component="img" src={icons[name]} alt="" sx={{ width: 52, height: 52, borderRadius: '12px' }} />
|
||||
) : (
|
||||
<LetterTile name={name} />
|
||||
)}
|
||||
<Typography sx={{ fontSize: '0.66rem', color: 'rgba(255,255,255,0.82)', textAlign: 'center', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApplicationsWindow;
|
||||
@@ -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<string, AgentSession>;
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
notes: Record<string, NotePosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
outputs: Record<string, Output>;
|
||||
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<number | null>(null);
|
||||
|
||||
const entries = useMemo<DockEntry[]>(() => {
|
||||
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: <DashboardGlyph name={title} size={16} color="#fff" />,
|
||||
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: <LanguageIcon sx={{ fontSize: 17, color: '#fff' }} />,
|
||||
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: (
|
||||
<Typography sx={{ fontSize: 14, fontWeight: 700, color: '#fff', lineHeight: 1 }}>
|
||||
{appName.charAt(0).toUpperCase()}
|
||||
</Typography>
|
||||
),
|
||||
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: <EditNoteIcon sx={{ fontSize: 18, color: '#7a5d10' }} />,
|
||||
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: <CalendarMonthIcon sx={{ fontSize: 16, color: '#fff' }} />,
|
||||
});
|
||||
}
|
||||
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<unknown>).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 (
|
||||
<Box
|
||||
onMouseLeave={endHover}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 11,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '9px',
|
||||
p: '7px',
|
||||
borderRadius: '14px',
|
||||
background: 'rgba(22,12,34,0.66)',
|
||||
backdropFilter: 'blur(20px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(160%)',
|
||||
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const isActive = selectedIds.includes(entry.id);
|
||||
return (
|
||||
<Box
|
||||
key={entry.id}
|
||||
onMouseEnter={(e) => 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 ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={entry.faviconUrl}
|
||||
alt=""
|
||||
sx={{ width: 18, height: 18, borderRadius: '4px' }}
|
||||
/>
|
||||
) : (
|
||||
entry.icon
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{entries.length > 0 && (
|
||||
<Box sx={{ width: TILE - 8, height: '1px', background: 'rgba(255,255,255,0.14)' }} />
|
||||
)}
|
||||
{/* The og toolbar's actions, dock-resident: chat, browser, workflow, note, history. */}
|
||||
{([
|
||||
{ label: 'New chat', icon: <ChatBubbleOutlineIcon sx={{ fontSize: 16, color: '#e8e8ee' }} />, act: onNewAgent },
|
||||
{ label: 'New browser', icon: <LanguageIcon sx={{ fontSize: 17, color: '#e8e8ee' }} />, act: onAddBrowser },
|
||||
{ label: 'Workflows', icon: <EventRepeatIcon sx={{ fontSize: 16, color: '#e8e8ee' }} />, act: () => dispatch(openWorkflowsApp()) },
|
||||
{ label: 'New note', icon: <StickyNote2OutlinedIcon sx={{ fontSize: 16, color: '#e8e8ee' }} />, act: onAddNote },
|
||||
{ label: 'History', icon: <HistoryIcon sx={{ fontSize: 17, color: '#e8e8ee' }} />, act: () => window.dispatchEvent(new CustomEvent('openswarm:open-history')) },
|
||||
] as const).map((a) => (
|
||||
<Tooltip key={a.label} title={a.label} placement="right">
|
||||
<Box
|
||||
onClick={a.act}
|
||||
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)' },
|
||||
}}
|
||||
>
|
||||
{a.icon}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Box sx={{ width: TILE - 8, height: '1px', background: 'rgba(255,255,255,0.14)' }} />
|
||||
<Box
|
||||
onClick={() => 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)' },
|
||||
}}
|
||||
>
|
||||
<SettingsIcon sx={{ fontSize: 18, color: '#e8e8ee' }} />
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onApplications}
|
||||
onMouseEnter={endHover}
|
||||
sx={{
|
||||
width: TILE,
|
||||
height: TILE,
|
||||
borderRadius: '9px',
|
||||
background: 'linear-gradient(135deg, #3d3d46, #232329)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'transform 0.15s ease',
|
||||
'&:hover': { transform: 'scale(1.12)' },
|
||||
}}
|
||||
>
|
||||
<AppsRoundedIcon sx={{ fontSize: 18, color: '#e8e8ee' }} />
|
||||
</Box>
|
||||
|
||||
{hoveredEntry && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 'calc(100% + 10px)',
|
||||
top: Math.max(0, hovered!.top - 34),
|
||||
width: PREVIEW_W,
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
background: previewImage ? '#fff' : 'rgba(22,12,34,0.9)',
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.4)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{previewImage ? (
|
||||
<Box component="img" src={previewImage} alt="" sx={{ width: '100%', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ p: 1.25 }}>
|
||||
<Typography sx={{ color: '#fff', fontSize: '0.78rem', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{hoveredEntry.label}
|
||||
</Typography>
|
||||
{hoveredEntry.snippet && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.7rem', mt: 0.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{hoveredEntry.snippet}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default DesktopDock;
|
||||
@@ -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<HTMLDivElement | null>(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<string, () => void> = {
|
||||
note: onAddNote,
|
||||
browser: onAddBrowser,
|
||||
app: onAddApp,
|
||||
workflows: onWorkflows,
|
||||
history: onHistory,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box ref={rootRef} sx={{ position: 'relative' }}>
|
||||
{menuOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 10px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: 168,
|
||||
p: '6px',
|
||||
borderRadius: '14px',
|
||||
background: 'rgba(22,12,34,0.82)',
|
||||
backdropFilter: 'blur(20px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(160%)',
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
{MENU_ITEMS.map(({ key, label, icon: ItemIcon }) => (
|
||||
<Box
|
||||
key={key}
|
||||
onClick={() => {
|
||||
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)' },
|
||||
}}
|
||||
>
|
||||
<ItemIcon sx={{ fontSize: 17, color: 'rgba(255,255,255,0.75)' }} />
|
||||
<Typography sx={{ fontSize: '0.8rem', color: 'rgba(255,255,255,0.88)', fontWeight: 500 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
height: 34,
|
||||
pl: 1.75,
|
||||
pr: 1.25,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(22,12,34,0.66)',
|
||||
backdropFilter: 'blur(20px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(160%)',
|
||||
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
|
||||
cursor: 'text',
|
||||
}}
|
||||
onClick={onOpenComposer}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.82rem',
|
||||
color: 'rgba(255,255,255,0.55)',
|
||||
fontWeight: 400,
|
||||
userSelect: 'none',
|
||||
mr: 1.5,
|
||||
}}
|
||||
>
|
||||
Spawn an agent...
|
||||
</Typography>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Add to canvas"
|
||||
onClick={(e) => {
|
||||
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)' },
|
||||
}}
|
||||
>
|
||||
<AddRounded sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Tooltip title="Voice input (coming soon)" placement="top" arrow>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<MicNoneOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default DesktopSpawnPill;
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
height: 30,
|
||||
pl: 1.5,
|
||||
pr: 1,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(22,12,34,0.66)',
|
||||
backdropFilter: 'blur(20px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(160%)',
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => dispatch(addBrowserCard({ url: HELP_URL }))}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>
|
||||
Help
|
||||
</Typography>
|
||||
<Tooltip title="Voice help (coming soon)" placement="bottom" arrow>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.45)' }} onClick={(e) => e.stopPropagation()}>
|
||||
<MicNoneOutlinedIcon sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default HelpPill;
|
||||
@@ -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<string, BrowserCardPosition>;
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
top: 120,
|
||||
zIndex: 11,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
{entries.map((bc) => {
|
||||
const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0];
|
||||
const shot = getMinimizedShot(bc.browser_id);
|
||||
return (
|
||||
<Box
|
||||
key={bc.browser_id}
|
||||
onClick={() => {
|
||||
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 ? (
|
||||
<Box component="img" src={shot} alt="" sx={{ width: '100%', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5, py: 1.5, px: 1 }}>
|
||||
{activeTab?.favicon ? (
|
||||
<Box component="img" src={activeTab.favicon} alt="" sx={{ width: 20, height: 20, borderRadius: '4px' }} />
|
||||
) : (
|
||||
<LanguageIcon sx={{ fontSize: 20, color: '#8a8494' }} />
|
||||
)}
|
||||
<Typography sx={{ fontSize: '0.62rem', color: '#3c3744', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' }}>
|
||||
{activeTab?.title || 'Browser'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default MinimizedStack;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Last visual of a card captured at minimize time; in-memory only, keyed by card id. */
|
||||
const shots = new Map<string, string>();
|
||||
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);
|
||||
}
|
||||
@@ -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<string, CardType>, 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); })();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, any>;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
export const useThemeMode = () => {
|
||||
const { mode, toggleMode, setMode } = useContext(ThemeContext);
|
||||
return { mode, toggleMode, setMode };
|
||||
|
||||
@@ -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.
|
||||
@@ -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<GuardProps, { failed: boolean }> {
|
||||
constructor(props: GuardProps) {
|
||||
super(props);
|
||||
this.state = { failed: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): { failed: boolean } {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.55, padding: '4px 0' }}>
|
||||
{this.props.name} failed to render
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface VendoredToolUiProps {
|
||||
name: string;
|
||||
props: Record<string, unknown>;
|
||||
/** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */
|
||||
extraProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type Gate =
|
||||
| { state: 'pending' }
|
||||
| { state: 'ok'; parsed: Record<string, unknown> }
|
||||
| { 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<string, unknown>): Gate {
|
||||
let result = schema.safeParse(props);
|
||||
if (!result.success) {
|
||||
const issues: Array<{ code: string; keys?: string[]; path: Array<string | number>; message: string }> = result.error.issues;
|
||||
if (issues.every((i) => i.code === 'unrecognized_keys')) {
|
||||
const cleaned: Record<string, unknown> = { ...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<string, unknown> };
|
||||
const issues = result.error.issues.slice(0, 2).map((i: { path: Array<string | number>; 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<Gate>({ 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 (
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.55, padding: '4px 0' }}>
|
||||
{name} payload didn't validate ({gate.problem})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (gate.state === 'pending') {
|
||||
return <div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />;
|
||||
}
|
||||
const Component = entry.Component;
|
||||
return (
|
||||
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
|
||||
<ComponentGuard name={name}>
|
||||
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
|
||||
<Component {...gate.parsed} {...(extraProps || {})} />
|
||||
</Suspense>
|
||||
</ComponentGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VendoredToolUi;
|
||||
@@ -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
|
||||
@@ -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";
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-w-64 max-w-md flex-col",
|
||||
"text-foreground",
|
||||
"motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:zoom-in-95 motion-safe:duration-300 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:fill-mode-both",
|
||||
className,
|
||||
)}
|
||||
data-slot="approval-card"
|
||||
data-tool-ui-id={id}
|
||||
data-receipt="true"
|
||||
role="status"
|
||||
aria-label={displayLabel}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card/60 flex w-full items-center gap-3 rounded-2xl border px-4 py-3 shadow-xs",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full bg-muted",
|
||||
isApproved ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isApproved ? <Check className="size-4" /> : <X className="size-4" />}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{displayLabel}</span>
|
||||
<span className="text-muted-foreground text-sm">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div key={viewKey} className="contents">
|
||||
{choice ? (
|
||||
<ApprovalCardReceipt
|
||||
id={id}
|
||||
title={title}
|
||||
choice={choice}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<article
|
||||
className={cn(
|
||||
"flex w-full min-w-64 max-w-md flex-col gap-3",
|
||||
"text-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="approval-card"
|
||||
data-tool-ui-id={id}
|
||||
role="dialog"
|
||||
aria-labelledby={`${id}-title`}
|
||||
aria-describedby={description ? `${id}-description` : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="bg-card flex w-full flex-col gap-4 rounded-2xl border p-5 shadow-xs">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-xl",
|
||||
isDestructive
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<h2
|
||||
id={`${id}-title`}
|
||||
className="text-base font-semibold leading-tight"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
id={`${id}-description`}
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metadata && metadata.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<dl className="flex flex-col gap-2 text-sm">
|
||||
{metadata.map((item, index) => (
|
||||
<div key={index} className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground shrink-0">
|
||||
{item.key}
|
||||
</dt>
|
||||
<dd className="min-w-0 truncate">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="@container/actions">
|
||||
<ActionButtons actions={actions} onAction={handleAction} />
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { ApprovalCard } from "./approval-card";
|
||||
export {
|
||||
type SerializableApprovalCard,
|
||||
type ApprovalCardProps,
|
||||
type ApprovalDecision,
|
||||
type MetadataItem,
|
||||
} from "./schema";
|
||||
@@ -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<typeof MetadataItemSchema>;
|
||||
|
||||
export const ApprovalDecisionSchema = z.enum(["approved", "denied"]);
|
||||
|
||||
export type ApprovalDecision = z.infer<typeof ApprovalDecisionSchema>;
|
||||
|
||||
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<void>;
|
||||
onCancel?: () => void | Promise<void>;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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";
|
||||
@@ -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 (
|
||||
<AudioProvider>
|
||||
<AudioInner {...props} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex w-full flex-col">
|
||||
{artwork && (
|
||||
<div className="bg-muted relative aspect-[4/3] w-full overflow-hidden">
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-5 p-4">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5">
|
||||
{title && (
|
||||
<div className="text-foreground line-clamp-2 font-semibold leading-snug">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Slider
|
||||
value={[controls.currentTime]}
|
||||
max={controls.duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={controls.onSeek}
|
||||
onPointerDown={controls.onSeekStart}
|
||||
onPointerUp={controls.onSeekEnd}
|
||||
className="cursor-pointer [&_[data-slot=range]]:bg-foreground [&_[data-slot=thumb]]:size-3 [&_[data-slot=thumb]]:border-2 [&_[data-slot=thumb]]:border-background [&_[data-slot=thumb]]:bg-foreground"
|
||||
aria-label="Audio progress"
|
||||
/>
|
||||
<div className="text-muted-foreground flex items-center justify-between text-xs tabular-nums">
|
||||
<span>{formatTime(controls.currentTime)}</span>
|
||||
<span>{formatTime(controls.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={controls.onPlayPause}
|
||||
className="-mt-4 size-10 shrink-0 rounded-full"
|
||||
aria-label={controls.isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{controls.isPlaying ? (
|
||||
<Pause className="size-4" fill="currentColor" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" fill="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden p-3">
|
||||
{artwork && (
|
||||
<>
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -left-1/4 top-1/2 h-[200%] w-auto -translate-y-1/2 object-cover opacity-40 blur-2xl saturate-150"
|
||||
/>
|
||||
<div className="from-card/60 to-card/90 pointer-events-none absolute inset-0 bg-gradient-to-r" />
|
||||
</>
|
||||
)}
|
||||
{artwork && (
|
||||
<div className="ring-background/20 relative size-12 shrink-0 overflow-hidden rounded-lg shadow-lg ring-1">
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col justify-center">
|
||||
{title && (
|
||||
<div className="text-foreground truncate text-sm font-semibold leading-tight">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-muted-foreground mt-0.5 truncate text-xs leading-tight">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{controls.duration > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="bg-foreground/20 relative h-1 flex-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-foreground absolute inset-y-0 left-0 rounded-full transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatTime(controls.currentTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={controls.onPlayPause}
|
||||
className="relative size-10 shrink-0 rounded-full shadow-md"
|
||||
aria-label={controls.isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{controls.isPlaying ? (
|
||||
<Pause className="size-4" fill="currentColor" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" fill="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLAudioElement | null>(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 (
|
||||
<article
|
||||
className={cn(
|
||||
"@container/actions relative w-full",
|
||||
isCompact ? "min-w-72 max-w-md" : "min-w-52 max-w-sm",
|
||||
className,
|
||||
)}
|
||||
lang={locale}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="audio"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden",
|
||||
"border-border bg-card border text-sm shadow-xs",
|
||||
"rounded-xl",
|
||||
)}
|
||||
>
|
||||
{isCompact ? (
|
||||
<CompactPlayer
|
||||
artwork={artwork}
|
||||
title={title}
|
||||
description={description}
|
||||
controls={controls}
|
||||
/>
|
||||
) : (
|
||||
<FullPlayer
|
||||
artwork={artwork}
|
||||
title={title}
|
||||
description={description}
|
||||
controls={controls}
|
||||
/>
|
||||
)}
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
preload="metadata"
|
||||
className="hidden"
|
||||
onPlay={() => {
|
||||
setState({ playing: true });
|
||||
onMediaEvent?.("play");
|
||||
}}
|
||||
onPause={() => {
|
||||
setState({ playing: false });
|
||||
onMediaEvent?.("pause");
|
||||
}}
|
||||
onTimeUpdate={(event) => {
|
||||
if (!isSeeking) {
|
||||
setCurrentTime(event.currentTarget.currentTime);
|
||||
}
|
||||
}}
|
||||
onLoadedMetadata={(event) => {
|
||||
setDuration(event.currentTarget.duration);
|
||||
}}
|
||||
onDurationChange={(event) => {
|
||||
setDuration(event.currentTarget.duration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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<AudioPlaybackState>) => void;
|
||||
audioElement: HTMLAudioElement | null;
|
||||
setAudioElement: (node: HTMLAudioElement | null) => void;
|
||||
}
|
||||
|
||||
const AudioContext = React.createContext<AudioContextValue | null>(null);
|
||||
|
||||
export function useAudio() {
|
||||
const ctx = React.useContext(AudioContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAudio must be used within an <AudioProvider />");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export interface AudioProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultState?: Partial<AudioPlaybackState>;
|
||||
}
|
||||
|
||||
export function AudioProvider({ children, defaultState }: AudioProviderProps) {
|
||||
const [state, setStateInternal] = React.useState<AudioPlaybackState>({
|
||||
playing: defaultState?.playing ?? false,
|
||||
muted: defaultState?.muted ?? false,
|
||||
});
|
||||
|
||||
const [audioElement, setAudioElement] =
|
||||
React.useState<HTMLAudioElement | null>(null);
|
||||
|
||||
const setState = React.useCallback((patch: Partial<AudioPlaybackState>) => {
|
||||
setStateInternal((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ state, setState, audioElement, setAudioElement }),
|
||||
[state, setState, audioElement],
|
||||
);
|
||||
|
||||
return (
|
||||
<AudioContext.Provider value={value}>{children}</AudioContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<typeof SourceSchema>;
|
||||
|
||||
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<typeof SerializableAudioSchema>;
|
||||
|
||||
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";
|
||||
@@ -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
|
||||
@@ -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";
|
||||
@@ -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<string, unknown>,
|
||||
index: number,
|
||||
) => {
|
||||
onDataPointClick?.({
|
||||
seriesKey,
|
||||
seriesLabel,
|
||||
xValue: payload[xKey],
|
||||
yValue: payload[seriesKey],
|
||||
index,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
[onDataPointClick, xKey],
|
||||
);
|
||||
|
||||
const ChartComponent = type === "bar" ? BarChart : LineChart;
|
||||
|
||||
const chartContent = (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-[200px] w-full"
|
||||
data-tool-ui-id={id}
|
||||
>
|
||||
<ChartComponent data={data} accessibilityLayer>
|
||||
{showGrid && <CartesianGrid vertical={false} />}
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={10} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
|
||||
|
||||
{type === "bar" &&
|
||||
series.map((s, i) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
fill={seriesColors[i]}
|
||||
radius={4}
|
||||
onClick={(data) =>
|
||||
handleDataPointClick(s.key, s.label, data.payload, data.index)
|
||||
}
|
||||
cursor={onDataPointClick ? "pointer" : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{type === "line" &&
|
||||
series.map((s, i) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
type="monotone"
|
||||
stroke={seriesColors[i]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, cursor: onDataPointClick ? "pointer" : undefined }}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
cursor: onDataPointClick ? "pointer" : undefined,
|
||||
// Recharts types are incorrect - onClick receives (event, dotData) at runtime
|
||||
onClick: ((
|
||||
_: unknown,
|
||||
dotData: { payload: Record<string, unknown>; index: number },
|
||||
) => {
|
||||
handleDataPointClick(
|
||||
s.key,
|
||||
s.label,
|
||||
dotData.payload,
|
||||
dotData.index,
|
||||
);
|
||||
}) as unknown as React.MouseEventHandler,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ChartComponent>
|
||||
</ChartContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn("w-full min-w-80", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="chart"
|
||||
>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle className="text-pretty">{title}</CardTitle>}
|
||||
{description && (
|
||||
<CardDescription className="text-pretty">
|
||||
{description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>{chartContent}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Chart } from "./chart";
|
||||
export {
|
||||
type ChartProps,
|
||||
type ChartSeries,
|
||||
type ChartDataPoint,
|
||||
type ChartClientProps,
|
||||
type SerializableChart,
|
||||
} from "./schema";
|
||||
@@ -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<typeof ChartSeriesSchema>;
|
||||
|
||||
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<string>();
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
export type ChartClientProps = {
|
||||
className?: string;
|
||||
onDataPointClick?: (point: ChartDataPoint) => void;
|
||||
};
|
||||
|
||||
export type ChartProps = z.infer<typeof ChartPropsSchema> & ChartClientProps;
|
||||
|
||||
export const SerializableChartSchema = ChartPropsSchema;
|
||||
|
||||
export type SerializableChart = z.infer<typeof SerializableChartSchema>;
|
||||
|
||||
const SerializableChartSchemaContract = defineToolUiContract(
|
||||
"Chart",
|
||||
SerializableChartSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableChart: (input: unknown) => SerializableChart =
|
||||
SerializableChartSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableChart: (
|
||||
input: unknown,
|
||||
) => SerializableChart | null = SerializableChartSchemaContract.safeParse;
|
||||
@@ -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
|
||||
@@ -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";
|
||||
@@ -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<CitationType, LucideIcon> = {
|
||||
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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(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 (
|
||||
<StackedCitations
|
||||
id={id}
|
||||
citations={citations}
|
||||
className={className}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "default") {
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate flex flex-col gap-4", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation
|
||||
key={citation.id}
|
||||
{...citation}
|
||||
variant="default"
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant="default"
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate", wrapperClass, className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
>
|
||||
{visibleCitations.map((citation) => (
|
||||
<Citation
|
||||
key={citation.id}
|
||||
{...citation}
|
||||
variant={variant}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{shouldTruncate && (
|
||||
<OverflowIndicator
|
||||
citations={overflowCitations}
|
||||
count={overflowCount}
|
||||
variant={variant}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem
|
||||
key={citation.id}
|
||||
citation={citation}
|
||||
onClick={() => handleClick(citation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm tabular-nums",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">+{count} more</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-xl px-4 py-3",
|
||||
"border-border bg-card border border-dashed",
|
||||
"transition-colors duration-150",
|
||||
"hover:border-foreground/25 hover:bg-muted/50",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
+{count} more sources
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{popoverContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverflowItemProps {
|
||||
citation: SerializableCitation;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function OverflowItem({ citation, onClick }: OverflowItemProps) {
|
||||
const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
|
||||
>
|
||||
{citation.favicon ? (
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
className="bg-muted size-4 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
|
||||
{citation.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{citation.domain}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div ref={containerRef} onBlur={handleBlur} className="inline-flex">
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation-list"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
|
||||
"bg-muted/40 outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted/70",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{visibleCitations.map((citation, index) => {
|
||||
const TypeIcon =
|
||||
TYPE_ICONS[citation.type ?? "webpage"] ?? Globe;
|
||||
return (
|
||||
<div
|
||||
key={citation.id}
|
||||
className={cn(
|
||||
"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs",
|
||||
index > 0 && "-ml-2",
|
||||
)}
|
||||
style={{ zIndex: maxIcons - index }}
|
||||
>
|
||||
{citation.favicon ? (
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon
|
||||
className="text-muted-foreground size-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className="border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
<span className="text-muted-foreground text-[10px] font-medium tracking-tight">
|
||||
•••
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{citations.length} source{citations.length !== 1 && "s"}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80 p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onBlur={handleBlur}
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto">
|
||||
{citations.map((citation) => (
|
||||
<OverflowItem
|
||||
key={citation.id}
|
||||
citation={citation}
|
||||
onClick={() => handleClick(citation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<CitationType, LucideIcon> = {
|
||||
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<ReturnType<typeof setTimeout> | 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 ? (
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={14}
|
||||
height={14}
|
||||
className="bg-muted size-3.5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||
);
|
||||
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
|
||||
// Inline variant: compact chip with hover popover
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground">{domain}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-72 cursor-pointer p-0"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground text-xs">{domain}</span>
|
||||
</div>
|
||||
<p className="text-sm leading-snug font-medium">{title}</p>
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant: full card
|
||||
return (
|
||||
<article
|
||||
className={cn("relative w-full max-w-md min-w-72", className)}
|
||||
lang={locale}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
|
||||
"border-border bg-card border text-sm shadow-xs",
|
||||
"transition-colors duration-150",
|
||||
sanitizedHref && [
|
||||
"cursor-pointer",
|
||||
"hover:border-foreground/25",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
],
|
||||
)}
|
||||
onClick={sanitizedHref ? handleClick : undefined}
|
||||
role={sanitizedHref ? "link" : undefined}
|
||||
tabIndex={sanitizedHref ? 0 : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{iconElement}
|
||||
<span className="truncate font-medium">{domain}</span>
|
||||
{(author || publishedAt) && (
|
||||
<span className="opacity-70">
|
||||
<span className="opacity-60"> — </span>
|
||||
{author}
|
||||
{author && publishedAt && ", "}
|
||||
{publishedAt && (
|
||||
<time dateTime={publishedAt} className="tabular-nums">
|
||||
{formatDate(publishedAt, locale)}
|
||||
</time>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sanitizedHref && (
|
||||
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
|
||||
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
|
||||
<span className="line-clamp-3">{snippet}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { Citation } from "./citation";
|
||||
export type { CitationProps } from "./citation";
|
||||
export { CitationList } from "./citation-list";
|
||||
export type { CitationListProps } from "./citation-list";
|
||||
export type {
|
||||
SerializableCitation,
|
||||
CitationType,
|
||||
CitationVariant,
|
||||
} from "./schema";
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
export const CitationTypeSchema = z.enum([
|
||||
"webpage",
|
||||
"document",
|
||||
"article",
|
||||
"api",
|
||||
"code",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export type CitationType = z.infer<typeof CitationTypeSchema>;
|
||||
|
||||
export const CitationVariantSchema = z.enum(["default", "inline", "stacked"]);
|
||||
|
||||
export type CitationVariant = z.infer<typeof CitationVariantSchema>;
|
||||
|
||||
export const SerializableCitationSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
href: z.string().url(),
|
||||
title: z.string(),
|
||||
snippet: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
favicon: z.string().url().optional(),
|
||||
author: z.string().optional(),
|
||||
publishedAt: z.string().datetime().optional(),
|
||||
type: CitationTypeSchema.optional(),
|
||||
locale: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SerializableCitation = z.infer<typeof SerializableCitationSchema>;
|
||||
|
||||
const SerializableCitationSchemaContract = defineToolUiContract(
|
||||
"Citation",
|
||||
SerializableCitationSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableCitation: (
|
||||
input: unknown,
|
||||
) => SerializableCitation = SerializableCitationSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableCitation: (
|
||||
input: unknown,
|
||||
) => SerializableCitation | null = SerializableCitationSchemaContract.safeParse;
|
||||
@@ -0,0 +1,19 @@
|
||||
# Code Block
|
||||
|
||||
Implementation for the "code-block" Tool UI surface.
|
||||
|
||||
## Files
|
||||
|
||||
- public exports: components/tool-ui/code-block/index.tsx
|
||||
- serializable schema + parse helpers: components/tool-ui/code-block/schema.ts
|
||||
|
||||
## Companion assets
|
||||
|
||||
- Docs page: app/docs/code-block/content.mdx
|
||||
- Preset payload: lib/presets/code-block.ts
|
||||
|
||||
## Quick check
|
||||
|
||||
Run this after edits:
|
||||
|
||||
pnpm test
|
||||
@@ -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
|
||||
* Collapsible → shadcn/ui Collapsible
|
||||
*/
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
export { Button } from "@toolui/ui/button";
|
||||
export { Collapsible, CollapsibleTrigger } from "@toolui/ui/collapsible";
|
||||
@@ -0,0 +1,469 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
createContext,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
createHighlighter,
|
||||
createJavaScriptRegexEngine,
|
||||
type Highlighter,
|
||||
} from "shiki";
|
||||
import { Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import pierreDarkTheme from "../shared/pierre-dark-theme.js";
|
||||
import pierreLightTheme from "../shared/pierre-light-theme.js";
|
||||
import type { CodeBlockLineNumbersMode, CodeBlockProps } from "./schema";
|
||||
import { useCopyToClipboard } from "../shared/use-copy-to-clipboard";
|
||||
|
||||
import { Button, cn, Collapsible, CollapsibleTrigger } from "./_adapter";
|
||||
|
||||
const COPY_ID = "codeblock-code";
|
||||
const MAX_HTML_CACHE_ENTRIES = 64;
|
||||
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
function getHighlighter(): Promise<Highlighter> {
|
||||
let pending = highlighterPromise;
|
||||
if (!pending) {
|
||||
pending = createHighlighter({
|
||||
themes: [pierreDarkTheme as never, pierreLightTheme as never],
|
||||
langs: [],
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
});
|
||||
highlighterPromise = pending;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
const htmlCache = new Map<string, string>();
|
||||
|
||||
function getCacheKey(
|
||||
code: string,
|
||||
language: string,
|
||||
theme: string,
|
||||
lineNumbers: CodeBlockLineNumbersMode,
|
||||
highlightLines?: number[],
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
code,
|
||||
language,
|
||||
theme,
|
||||
lineNumbers,
|
||||
highlightLines: highlightLines ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function setCachedHtml(cacheKey: string, html: string): void {
|
||||
if (htmlCache.has(cacheKey)) {
|
||||
htmlCache.set(cacheKey, html);
|
||||
return;
|
||||
}
|
||||
|
||||
if (htmlCache.size >= MAX_HTML_CACHE_ENTRIES) {
|
||||
const oldestKey = htmlCache.keys().next().value;
|
||||
if (typeof oldestKey === "string") {
|
||||
htmlCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
htmlCache.set(cacheKey, html);
|
||||
}
|
||||
|
||||
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
|
||||
typescript: "TypeScript",
|
||||
javascript: "JavaScript",
|
||||
python: "Python",
|
||||
tsx: "TSX",
|
||||
jsx: "JSX",
|
||||
json: "JSON",
|
||||
bash: "Bash",
|
||||
shell: "Shell",
|
||||
css: "CSS",
|
||||
html: "HTML",
|
||||
markdown: "Markdown",
|
||||
sql: "SQL",
|
||||
yaml: "YAML",
|
||||
go: "Go",
|
||||
rust: "Rust",
|
||||
text: "Plain Text",
|
||||
};
|
||||
|
||||
function getLanguageDisplayName(lang: string): string {
|
||||
return LANGUAGE_DISPLAY_NAMES[lang.toLowerCase()] || lang.toUpperCase();
|
||||
}
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function getDocumentTheme(): "light" | "dark" | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const root = document.documentElement;
|
||||
const dataTheme = root.getAttribute("data-theme")?.toLowerCase();
|
||||
if (dataTheme === "dark") return "dark";
|
||||
if (dataTheme === "light") return "light";
|
||||
if (root.classList.contains("dark")) return "dark";
|
||||
if (root.classList.contains("light")) return "light";
|
||||
return null;
|
||||
}
|
||||
|
||||
function useResolvedTheme(): "light" | "dark" {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
return getDocumentTheme() ?? getSystemTheme();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());
|
||||
|
||||
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mql?.addEventListener("change", update);
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
mql?.removeEventListener("change", update);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
export type CodeBlockRootProps = CodeBlockProps & {
|
||||
children: ReactNode;
|
||||
expanded?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
};
|
||||
|
||||
type CodeBlockSharedState = {
|
||||
id: string;
|
||||
code: string;
|
||||
language: string;
|
||||
filename?: string;
|
||||
highlightedHtml: string | null;
|
||||
isCopied: boolean;
|
||||
copyCode: () => void;
|
||||
lineCount: number;
|
||||
isCollapsed: boolean;
|
||||
shouldCollapse: boolean;
|
||||
toggleExpanded: () => void;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockSharedState | null>(null);
|
||||
|
||||
function useCodeBlock(): CodeBlockSharedState {
|
||||
const context = useContext(CodeBlockContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"CodeBlock subcomponents must be used within <CodeBlock.Root>.",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function CodeBlockRoot({
|
||||
id,
|
||||
code,
|
||||
language = "text",
|
||||
lineNumbers = "visible",
|
||||
filename,
|
||||
highlightLines,
|
||||
maxCollapsedLines,
|
||||
className,
|
||||
children,
|
||||
expanded: expandedProp,
|
||||
defaultExpanded = false,
|
||||
onExpandedChange,
|
||||
}: CodeBlockRootProps) {
|
||||
const resolvedTheme = useResolvedTheme();
|
||||
const [expandedState, setExpandedState] = useState(defaultExpanded);
|
||||
const { copiedId, copy } = useCopyToClipboard();
|
||||
const isCopied = copiedId === COPY_ID;
|
||||
|
||||
const expanded = expandedProp ?? expandedState;
|
||||
const setExpanded = useCallback(
|
||||
(nextExpanded: boolean) => {
|
||||
if (expandedProp === undefined) {
|
||||
setExpandedState(nextExpanded);
|
||||
}
|
||||
onExpandedChange?.(nextExpanded);
|
||||
},
|
||||
[expandedProp, onExpandedChange],
|
||||
);
|
||||
|
||||
const theme = resolvedTheme === "dark" ? "pierre-dark" : "pierre-light";
|
||||
const cacheKey = getCacheKey(
|
||||
code,
|
||||
language,
|
||||
theme,
|
||||
lineNumbers,
|
||||
highlightLines,
|
||||
);
|
||||
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(
|
||||
() => htmlCache.get(cacheKey) ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = htmlCache.get(cacheKey);
|
||||
if (cached) {
|
||||
setHighlightedHtml(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const showLineNumbers = lineNumbers === "visible";
|
||||
|
||||
async function highlight() {
|
||||
if (!code) {
|
||||
if (!cancelled) setHighlightedHtml("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const highlighter = await getHighlighter();
|
||||
const loadedLangs = highlighter.getLoadedLanguages();
|
||||
|
||||
if (!loadedLangs.includes(language)) {
|
||||
await highlighter.loadLanguage(
|
||||
language as Parameters<Highlighter["loadLanguage"]>[0],
|
||||
);
|
||||
}
|
||||
|
||||
const lineCount = code.split("\n").length;
|
||||
const lineNumberWidth = `${String(lineCount).length + 0.5}ch`;
|
||||
|
||||
const html = highlighter.codeToHtml(code, {
|
||||
lang: language,
|
||||
theme,
|
||||
transformers: [
|
||||
{
|
||||
line(node: any, line: number) {
|
||||
node.properties["data-line"] = line;
|
||||
if (highlightLines?.includes(line)) {
|
||||
const highlightBg =
|
||||
resolvedTheme === "dark"
|
||||
? "rgba(255,255,255,0.1)"
|
||||
: "rgba(0,0,0,0.05)";
|
||||
node.properties.style = `background:${highlightBg};`;
|
||||
}
|
||||
if (showLineNumbers) {
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
style: `display:inline-block;width:${lineNumberWidth};text-align:right;margin-right:1.5em;user-select:none;opacity:0.5;`,
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
children: [{ type: "text", value: String(line) }],
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!cancelled) {
|
||||
setCachedHtml(cacheKey, html);
|
||||
setHighlightedHtml(html);
|
||||
}
|
||||
} catch {
|
||||
const escaped = code
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
if (!cancelled) {
|
||||
setHighlightedHtml(`<pre><code>${escaped}</code></pre>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
void highlight();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
cacheKey,
|
||||
code,
|
||||
language,
|
||||
lineNumbers,
|
||||
theme,
|
||||
highlightLines,
|
||||
resolvedTheme,
|
||||
]);
|
||||
|
||||
const lineCount = code.split("\n").length;
|
||||
const shouldCollapse = !!maxCollapsedLines && lineCount > maxCollapsedLines;
|
||||
const isCollapsed = shouldCollapse && !expanded;
|
||||
|
||||
const copyCode = useCallback(() => {
|
||||
void copy(code, COPY_ID);
|
||||
}, [code, copy]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded(!expanded);
|
||||
}, [expanded, setExpanded]);
|
||||
|
||||
const state: CodeBlockSharedState = {
|
||||
id,
|
||||
code,
|
||||
language,
|
||||
filename,
|
||||
highlightedHtml,
|
||||
isCopied,
|
||||
copyCode,
|
||||
lineCount,
|
||||
shouldCollapse,
|
||||
isCollapsed,
|
||||
toggleExpanded,
|
||||
};
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={state}>
|
||||
<div
|
||||
className={cn(
|
||||
"@container flex w-full min-w-80 flex-col gap-3",
|
||||
className,
|
||||
)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="code-block"
|
||||
>
|
||||
<div className="border-border bg-card overflow-hidden rounded-lg border shadow-xs">
|
||||
<Collapsible open={!isCollapsed}>{children}</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type CodeBlockSectionProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CodeBlockHeader({ className }: CodeBlockSectionProps) {
|
||||
const { language, filename, isCopied, copyCode } = useCodeBlock();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card flex items-center justify-between border-b px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{getLanguageDisplayName(language)}
|
||||
</span>
|
||||
{filename && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{filename}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyCode}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={isCopied ? "Copied" : "Copy code"}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-green-700 dark:text-green-400" />
|
||||
) : (
|
||||
<Copy className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockContent({ className }: CodeBlockSectionProps) {
|
||||
const { highlightedHtml, isCollapsed } = useCodeBlock();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto overflow-y-clip text-[13px] leading-[1.4] [&_pre]:bg-transparent [&_pre]:py-4",
|
||||
isCollapsed && "max-h-[200px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{highlightedHtml && (
|
||||
<div dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockCollapseToggle({ className }: CodeBlockSectionProps) {
|
||||
const { shouldCollapse, isCollapsed, toggleExpanded, lineCount } =
|
||||
useCodeBlock();
|
||||
|
||||
if (!shouldCollapse) return null;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleExpanded}
|
||||
className={cn(
|
||||
"text-muted-foreground w-full rounded-none border-t font-normal",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<>
|
||||
<ChevronDown className="mr-1 size-4" />
|
||||
Show all {lineCount} lines
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className="mr-2 h-4 w-4" />
|
||||
Collapse
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export type CodeBlockComposedProps = Omit<CodeBlockRootProps, "children">;
|
||||
|
||||
function CodeBlockComposed(props: CodeBlockComposedProps) {
|
||||
return (
|
||||
<CodeBlockRoot {...props}>
|
||||
<CodeBlockHeader />
|
||||
<CodeBlockContent />
|
||||
<CodeBlockCollapseToggle />
|
||||
</CodeBlockRoot>
|
||||
);
|
||||
}
|
||||
|
||||
type CodeBlockComponent = typeof CodeBlockComposed & {
|
||||
Root: typeof CodeBlockRoot;
|
||||
Header: typeof CodeBlockHeader;
|
||||
Content: typeof CodeBlockContent;
|
||||
CollapseToggle: typeof CodeBlockCollapseToggle;
|
||||
};
|
||||
|
||||
export const CodeBlock = Object.assign(CodeBlockComposed, {
|
||||
Root: CodeBlockRoot,
|
||||
Header: CodeBlockHeader,
|
||||
Content: CodeBlockContent,
|
||||
CollapseToggle: CodeBlockCollapseToggle,
|
||||
}) as CodeBlockComponent;
|
||||
@@ -0,0 +1,11 @@
|
||||
export { CodeBlock } from "./code-block";
|
||||
export type {
|
||||
CodeBlockRootProps,
|
||||
CodeBlockComposedProps,
|
||||
CodeBlockSectionProps,
|
||||
} from "./code-block";
|
||||
export type {
|
||||
CodeBlockProps,
|
||||
CodeBlockLineNumbersMode,
|
||||
SerializableCodeBlock,
|
||||
} from "./schema";
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
export const CodeBlockPropsSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
code: z.string(),
|
||||
language: z.string().trim().min(1).default("text"),
|
||||
lineNumbers: z.enum(["visible", "hidden"]).default("visible"),
|
||||
filename: z.string().optional(),
|
||||
highlightLines: z.array(z.number().int().positive()).optional(),
|
||||
maxCollapsedLines: z.number().min(1).optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CodeBlockProps = z.infer<typeof CodeBlockPropsSchema>;
|
||||
export type CodeBlockLineNumbersMode = CodeBlockProps["lineNumbers"];
|
||||
|
||||
export const SerializableCodeBlockSchema = CodeBlockPropsSchema.omit({
|
||||
className: true,
|
||||
});
|
||||
|
||||
export type SerializableCodeBlock = z.infer<typeof SerializableCodeBlockSchema>;
|
||||
|
||||
const SerializableCodeBlockSchemaContract = defineToolUiContract(
|
||||
"CodeBlock",
|
||||
SerializableCodeBlockSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableCodeBlock: (
|
||||
input: unknown,
|
||||
) => SerializableCodeBlock = SerializableCodeBlockSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableCodeBlock: (
|
||||
input: unknown,
|
||||
) => SerializableCodeBlock | null =
|
||||
SerializableCodeBlockSchemaContract.safeParse;
|
||||
@@ -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
|
||||
* Collapsible -> shadcn/ui Collapsible
|
||||
*/
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
export { Button } from "@toolui/ui/button";
|
||||
export { Collapsible, CollapsibleTrigger } from "@toolui/ui/collapsible";
|
||||
@@ -0,0 +1,463 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
createContext,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
FileDiff as PierreFileDiff,
|
||||
PatchDiff as PierrePatchDiff,
|
||||
} from "@pierre/diffs/react";
|
||||
import { parseDiffFromFile, RegisteredCustomThemes } from "@pierre/diffs";
|
||||
import type { FileDiffMetadata, ThemesType } from "@pierre/diffs";
|
||||
import { Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { CodeDiffProps } from "./schema";
|
||||
import { useCopyToClipboard } from "../shared/use-copy-to-clipboard";
|
||||
import { Button, cn, Collapsible, CollapsibleTrigger } from "./_adapter";
|
||||
|
||||
/*
|
||||
* Pierre's shared_highlighter registers custom themes with dynamic imports
|
||||
* (`import("../themes/pierre-dark.js")`) that fail under Turbopack because the
|
||||
* package `exports` field doesn't include those subpaths. We override the
|
||||
* RegisteredCustomThemes map entries with loaders that point to local vendored
|
||||
* theme files in `components/tool-ui/shared`, which Turbopack can resolve.
|
||||
*/
|
||||
RegisteredCustomThemes.set("pierre-dark", () =>
|
||||
import("../shared/pierre-dark-theme.js").then((m) => m.default as never),
|
||||
);
|
||||
RegisteredCustomThemes.set("pierre-light", () =>
|
||||
import("../shared/pierre-light-theme.js").then((m) => m.default as never),
|
||||
);
|
||||
|
||||
const COPY_ID = "codediff-code";
|
||||
|
||||
/* ── Theme detection (mirrors CodeBlock) ────────────────────────── */
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function getDocumentTheme(): "light" | "dark" | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const root = document.documentElement;
|
||||
const dataTheme = root.getAttribute("data-theme")?.toLowerCase();
|
||||
if (dataTheme === "dark") return "dark";
|
||||
if (dataTheme === "light") return "light";
|
||||
if (root.classList.contains("dark")) return "dark";
|
||||
if (root.classList.contains("light")) return "light";
|
||||
return null;
|
||||
}
|
||||
|
||||
function useResolvedTheme(): "light" | "dark" {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
return getDocumentTheme() ?? getSystemTheme();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());
|
||||
|
||||
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mql?.addEventListener("change", update);
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
mql?.removeEventListener("change", update);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
/* ── Language display names (mirrors CodeBlock) ─────────────────── */
|
||||
|
||||
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
|
||||
typescript: "TypeScript",
|
||||
javascript: "JavaScript",
|
||||
python: "Python",
|
||||
tsx: "TSX",
|
||||
jsx: "JSX",
|
||||
json: "JSON",
|
||||
bash: "Bash",
|
||||
shell: "Shell",
|
||||
css: "CSS",
|
||||
html: "HTML",
|
||||
markdown: "Markdown",
|
||||
sql: "SQL",
|
||||
yaml: "YAML",
|
||||
go: "Go",
|
||||
rust: "Rust",
|
||||
text: "Plain Text",
|
||||
};
|
||||
|
||||
function getLanguageDisplayName(lang: string): string {
|
||||
return LANGUAGE_DISPLAY_NAMES[lang.toLowerCase()] || lang.toUpperCase();
|
||||
}
|
||||
|
||||
/* ── Shared context ─────────────────────────────────────────────── */
|
||||
|
||||
type CodeDiffSharedState = {
|
||||
id: string;
|
||||
isPatchMode: boolean;
|
||||
language: string;
|
||||
lineNumbers: "visible" | "hidden";
|
||||
filename?: string;
|
||||
diffStyle: "unified" | "split";
|
||||
copyableCode: string;
|
||||
isCopied: boolean;
|
||||
copyCode: () => void;
|
||||
isCollapsed: boolean;
|
||||
shouldCollapse: boolean;
|
||||
toggleExpanded: () => void;
|
||||
resolvedTheme: "light" | "dark";
|
||||
pierreThemes: ThemesType;
|
||||
fileDiffMetadata: FileDiffMetadata | null;
|
||||
patch: string | null;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
|
||||
const CodeDiffContext = createContext<CodeDiffSharedState | null>(null);
|
||||
|
||||
function useCodeDiff(): CodeDiffSharedState {
|
||||
const context = useContext(CodeDiffContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"CodeDiff subcomponents must be used within <CodeDiff.Root>.",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* ── Subcomponents ──────────────────────────────────────────────── */
|
||||
|
||||
export type CodeDiffRootProps = CodeDiffProps & {
|
||||
children: ReactNode;
|
||||
expanded?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
};
|
||||
|
||||
function CodeDiffRoot({
|
||||
id,
|
||||
oldCode,
|
||||
newCode,
|
||||
patch,
|
||||
language = "text",
|
||||
filename,
|
||||
lineNumbers = "visible",
|
||||
diffStyle = "unified",
|
||||
maxCollapsedLines,
|
||||
className,
|
||||
children,
|
||||
expanded: expandedProp,
|
||||
defaultExpanded = false,
|
||||
onExpandedChange,
|
||||
}: CodeDiffRootProps) {
|
||||
const resolvedTheme = useResolvedTheme();
|
||||
const [expandedState, setExpandedState] = useState(defaultExpanded);
|
||||
const { copiedId, copy } = useCopyToClipboard();
|
||||
const isCopied = copiedId === COPY_ID;
|
||||
|
||||
const expanded = expandedProp ?? expandedState;
|
||||
const setExpanded = useCallback(
|
||||
(nextExpanded: boolean) => {
|
||||
if (expandedProp === undefined) {
|
||||
setExpandedState(nextExpanded);
|
||||
}
|
||||
onExpandedChange?.(nextExpanded);
|
||||
},
|
||||
[expandedProp, onExpandedChange],
|
||||
);
|
||||
|
||||
const pierreThemes: ThemesType = {
|
||||
dark: "pierre-dark",
|
||||
light: "pierre-light",
|
||||
};
|
||||
|
||||
// Auto-detect mode: if `patch` is provided, use patch mode; otherwise files mode
|
||||
const isPatchMode = !!patch;
|
||||
|
||||
const fileDiffMetadata = useMemo(() => {
|
||||
if (isPatchMode) return null;
|
||||
return parseDiffFromFile(
|
||||
{
|
||||
name: filename ?? "file",
|
||||
contents: oldCode ?? "",
|
||||
lang: language as never,
|
||||
},
|
||||
{
|
||||
name: filename ?? "file",
|
||||
contents: newCode ?? "",
|
||||
lang: language as never,
|
||||
},
|
||||
);
|
||||
}, [isPatchMode, oldCode, newCode, filename, language]);
|
||||
|
||||
const copyableCode = isPatchMode ? (patch ?? "") : (newCode ?? oldCode ?? "");
|
||||
|
||||
const lineCount = useMemo(() => {
|
||||
if (isPatchMode) {
|
||||
return (patch ?? "").split("\n").length;
|
||||
}
|
||||
if (fileDiffMetadata) {
|
||||
return fileDiffMetadata.unifiedLineCount;
|
||||
}
|
||||
return 0;
|
||||
}, [isPatchMode, patch, fileDiffMetadata]);
|
||||
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
if (!isPatchMode && fileDiffMetadata) {
|
||||
let add = 0;
|
||||
let del = 0;
|
||||
for (const hunk of fileDiffMetadata.hunks) {
|
||||
add += hunk.additionLines;
|
||||
del += hunk.deletionLines;
|
||||
}
|
||||
return { additions: add, deletions: del };
|
||||
}
|
||||
if (isPatchMode && patch) {
|
||||
let add = 0;
|
||||
let del = 0;
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++ ")) add++;
|
||||
else if (line.startsWith("-") && !line.startsWith("--- ")) del++;
|
||||
}
|
||||
return { additions: add, deletions: del };
|
||||
}
|
||||
return { additions: 0, deletions: 0 };
|
||||
}, [isPatchMode, fileDiffMetadata, patch]);
|
||||
|
||||
const shouldCollapse = !!maxCollapsedLines && lineCount > maxCollapsedLines;
|
||||
const isCollapsed = shouldCollapse && !expanded;
|
||||
|
||||
const copyCode = useCallback(() => {
|
||||
void copy(copyableCode, COPY_ID);
|
||||
}, [copyableCode, copy]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded(!expanded);
|
||||
}, [expanded, setExpanded]);
|
||||
|
||||
const state: CodeDiffSharedState = {
|
||||
id,
|
||||
isPatchMode,
|
||||
language,
|
||||
lineNumbers,
|
||||
filename,
|
||||
diffStyle,
|
||||
copyableCode,
|
||||
isCopied,
|
||||
copyCode,
|
||||
isCollapsed,
|
||||
shouldCollapse,
|
||||
toggleExpanded,
|
||||
resolvedTheme,
|
||||
pierreThemes,
|
||||
fileDiffMetadata,
|
||||
patch: isPatchMode ? (patch ?? null) : null,
|
||||
additions,
|
||||
deletions,
|
||||
};
|
||||
|
||||
return (
|
||||
<CodeDiffContext.Provider value={state}>
|
||||
<div
|
||||
className={cn(
|
||||
"@container flex w-full min-w-80 flex-col gap-3",
|
||||
className,
|
||||
)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="code-diff"
|
||||
>
|
||||
<div className="border-border bg-card overflow-hidden rounded-lg border shadow-xs">
|
||||
<Collapsible open={!isCollapsed}>{children}</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</CodeDiffContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type CodeDiffSectionProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CodeDiffHeader({ className }: CodeDiffSectionProps) {
|
||||
const { language, filename, isCopied, copyCode, additions, deletions } =
|
||||
useCodeDiff();
|
||||
const hasChanges = additions > 0 || deletions > 0;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card flex items-center justify-between gap-2 border-b px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{getLanguageDisplayName(language)}
|
||||
</span>
|
||||
{filename && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{filename}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<span className="ml-auto text-xs font-mono tabular-nums">
|
||||
{additions > 0 && (
|
||||
<span style={{ color: "#00cab1" }}>+{additions}</span>
|
||||
)}
|
||||
{additions > 0 && deletions > 0 && " "}
|
||||
{deletions > 0 && (
|
||||
<span style={{ color: "#ff2e3f" }}>-{deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyCode}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={isCopied ? "Copied" : "Copy code"}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-green-700 dark:text-green-400" />
|
||||
) : (
|
||||
<Copy className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeDiffContent({ className }: CodeDiffSectionProps) {
|
||||
const {
|
||||
isPatchMode,
|
||||
diffStyle,
|
||||
lineNumbers,
|
||||
isCollapsed,
|
||||
resolvedTheme,
|
||||
pierreThemes,
|
||||
fileDiffMetadata,
|
||||
patch,
|
||||
} = useCodeDiff();
|
||||
|
||||
const disableLineNumbers = lineNumbers === "hidden";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto overflow-y-clip text-sm",
|
||||
isCollapsed && "max-h-[200px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!isPatchMode && fileDiffMetadata && (
|
||||
<PierreFileDiff
|
||||
fileDiff={fileDiffMetadata}
|
||||
options={{
|
||||
theme: pierreThemes,
|
||||
themeType: resolvedTheme,
|
||||
diffStyle,
|
||||
disableFileHeader: true,
|
||||
disableLineNumbers,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isPatchMode && patch && (
|
||||
<PierrePatchDiff
|
||||
patch={patch}
|
||||
options={{
|
||||
theme: pierreThemes,
|
||||
themeType: resolvedTheme,
|
||||
diffStyle,
|
||||
disableFileHeader: true,
|
||||
disableLineNumbers,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeDiffCollapseToggle({ className }: CodeDiffSectionProps) {
|
||||
const { shouldCollapse, isCollapsed, toggleExpanded } = useCodeDiff();
|
||||
|
||||
if (!shouldCollapse) return null;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleExpanded}
|
||||
className={cn(
|
||||
"text-muted-foreground w-full rounded-none border-t font-normal",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<>
|
||||
<ChevronDown className="mr-1 size-4" />
|
||||
Show full diff
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className="mr-2 h-4 w-4" />
|
||||
Collapse
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Composed preset (callable as a flat component) ─────────────── */
|
||||
|
||||
export type CodeDiffComposedProps = Omit<CodeDiffRootProps, "children">;
|
||||
|
||||
function CodeDiffComposed(props: CodeDiffComposedProps) {
|
||||
return (
|
||||
<CodeDiffRoot {...props}>
|
||||
<CodeDiffHeader />
|
||||
<CodeDiffContent />
|
||||
<CodeDiffCollapseToggle />
|
||||
</CodeDiffRoot>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Compound export: CodeDiff is callable AND has subcomponents ── */
|
||||
|
||||
type CodeDiffComponent = typeof CodeDiffComposed & {
|
||||
Root: typeof CodeDiffRoot;
|
||||
Header: typeof CodeDiffHeader;
|
||||
Content: typeof CodeDiffContent;
|
||||
CollapseToggle: typeof CodeDiffCollapseToggle;
|
||||
};
|
||||
|
||||
export const CodeDiff = Object.assign(CodeDiffComposed, {
|
||||
Root: CodeDiffRoot,
|
||||
Header: CodeDiffHeader,
|
||||
Content: CodeDiffContent,
|
||||
CollapseToggle: CodeDiffCollapseToggle,
|
||||
}) as CodeDiffComponent;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { CodeDiff } from "./code-diff";
|
||||
export type {
|
||||
CodeDiffRootProps,
|
||||
CodeDiffComposedProps,
|
||||
CodeDiffSectionProps,
|
||||
} from "./code-diff";
|
||||
export type { CodeDiffProps, SerializableCodeDiff } from "./schema";
|
||||
@@ -0,0 +1,71 @@
|
||||
import { z } from "zod";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
const CodeDiffPropsSchemaBase = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
oldCode: z.string().optional(),
|
||||
newCode: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
language: z.string().trim().min(1).default("text"),
|
||||
filename: z.string().optional(),
|
||||
lineNumbers: z.enum(["visible", "hidden"]).default("visible"),
|
||||
diffStyle: z.enum(["unified", "split"]).default("unified"),
|
||||
maxCollapsedLines: z.number().min(1).optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
function validateCodeDiffInputMode(
|
||||
data: { patch?: string; oldCode?: string; newCode?: string },
|
||||
ctx: z.RefinementCtx,
|
||||
) {
|
||||
const hasPatch = !!data.patch;
|
||||
const hasFiles = !!data.oldCode || !!data.newCode;
|
||||
|
||||
if (!hasPatch && !hasFiles) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Provide either a patch string or at least one of oldCode/newCode",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPatch && hasFiles) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Cannot mix patch mode with oldCode/newCode — use one or the other",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const CodeDiffPropsSchema = CodeDiffPropsSchemaBase.superRefine(
|
||||
validateCodeDiffInputMode,
|
||||
);
|
||||
|
||||
export type CodeDiffProps = z.infer<typeof CodeDiffPropsSchema>;
|
||||
|
||||
export const SerializableCodeDiffSchema = CodeDiffPropsSchemaBase.omit({
|
||||
className: true,
|
||||
}).superRefine(validateCodeDiffInputMode);
|
||||
|
||||
export type SerializableCodeDiff = z.infer<typeof SerializableCodeDiffSchema>;
|
||||
|
||||
const SerializableCodeDiffSchemaContract = defineToolUiContract(
|
||||
"CodeDiff",
|
||||
SerializableCodeDiffSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableCodeDiff: (
|
||||
input: unknown,
|
||||
) => SerializableCodeDiff = SerializableCodeDiffSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableCodeDiff: (
|
||||
input: unknown,
|
||||
) => SerializableCodeDiff | null = SerializableCodeDiffSchemaContract.safeParse;
|
||||
@@ -0,0 +1,19 @@
|
||||
# Data Table
|
||||
|
||||
Implementation for the "data-table" Tool UI surface.
|
||||
|
||||
## Files
|
||||
|
||||
- public exports: components/tool-ui/data-table/index.tsx
|
||||
- serializable schema + parse helpers: components/tool-ui/data-table/schema.ts
|
||||
|
||||
## Companion assets
|
||||
|
||||
- Docs page: app/docs/data-table/content.mdx
|
||||
- Preset payload: lib/presets/data-table.ts
|
||||
|
||||
## Quick check
|
||||
|
||||
Run this after edits:
|
||||
|
||||
pnpm test
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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
|
||||
* DropdownMenu → shadcn/ui DropdownMenu
|
||||
* Accordion → shadcn/ui Accordion
|
||||
* Tooltip → shadcn/ui Tooltip
|
||||
* Badge → shadcn/ui Badge
|
||||
* Table → shadcn/ui Table
|
||||
*/
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
export { Button } from "@toolui/ui/button";
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@toolui/ui/dropdown-menu";
|
||||
export {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@toolui/ui/accordion";
|
||||
export {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@toolui/ui/tooltip";
|
||||
export { Badge } from "@toolui/ui/badge";
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@toolui/ui/table";
|
||||
@@ -0,0 +1,936 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
cn,
|
||||
Table,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "./_adapter";
|
||||
import {
|
||||
sortData,
|
||||
createDataTableRowKeys,
|
||||
getDataTableMobileDescriptionId,
|
||||
} from "./utilities";
|
||||
import { renderFormattedValue } from "./formatters";
|
||||
import type {
|
||||
DataTableProps,
|
||||
DataTableContextValue,
|
||||
RowData,
|
||||
DataTableRowData,
|
||||
ColumnKey,
|
||||
Column,
|
||||
} from "./types";
|
||||
import type { FormatConfig } from "./formatters";
|
||||
|
||||
export const DEFAULT_LOCALE = "en-US" as const;
|
||||
|
||||
function isNumericFormat(format?: FormatConfig): boolean {
|
||||
const kind = format?.kind;
|
||||
return (
|
||||
kind === "number" ||
|
||||
kind === "currency" ||
|
||||
kind === "percent" ||
|
||||
kind === "delta"
|
||||
);
|
||||
}
|
||||
|
||||
function getAlignmentClass(
|
||||
align?: "left" | "right" | "center",
|
||||
): string | undefined {
|
||||
if (align === "right") return "text-right";
|
||||
if (align === "center") return "text-center";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const DataTableContext = React.createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
DataTableContextValue<any> | undefined
|
||||
>(undefined);
|
||||
|
||||
export function useDataTable<T extends object = RowData>() {
|
||||
const context = React.useContext(DataTableContext) as
|
||||
| DataTableContextValue<T>
|
||||
| undefined;
|
||||
if (!context) {
|
||||
throw new Error("useDataTable must be used within <DataTable.Provider />");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
type DataTableLayout = "auto" | "table" | "cards";
|
||||
|
||||
type DataTableBaseProps<T extends object = RowData> = DataTableProps<T> & {
|
||||
layout: DataTableLayout;
|
||||
};
|
||||
|
||||
type DataTableProviderProps<T extends object = RowData> = Pick<
|
||||
DataTableProps<T>,
|
||||
| "columns"
|
||||
| "data"
|
||||
| "rowIdKey"
|
||||
| "defaultSort"
|
||||
| "sort"
|
||||
| "onSortChange"
|
||||
| "id"
|
||||
| "locale"
|
||||
> & {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function DataTableProvider<T extends object = RowData>({
|
||||
columns,
|
||||
data: rawData,
|
||||
rowIdKey,
|
||||
defaultSort,
|
||||
sort: controlledSort,
|
||||
id,
|
||||
onSortChange,
|
||||
locale,
|
||||
children,
|
||||
}: DataTableProviderProps<T>) {
|
||||
// Default locale avoids SSR/client formatting mismatches.
|
||||
const resolvedLocale = locale ?? DEFAULT_LOCALE;
|
||||
|
||||
const [internalSortBy, setInternalSortBy] = React.useState<
|
||||
ColumnKey<T> | undefined
|
||||
>(defaultSort?.by);
|
||||
const [internalSortDirection, setInternalSortDirection] = React.useState<
|
||||
"asc" | "desc" | undefined
|
||||
>(defaultSort?.direction);
|
||||
|
||||
const sortBy = controlledSort?.by ?? internalSortBy;
|
||||
const sortDirection = controlledSort?.direction ?? internalSortDirection;
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
if (!sortBy || !sortDirection) return rawData;
|
||||
return sortData(rawData, sortBy, sortDirection, resolvedLocale);
|
||||
}, [rawData, sortBy, sortDirection, resolvedLocale]);
|
||||
|
||||
const handleSort = React.useCallback(
|
||||
(key: ColumnKey<T>) => {
|
||||
let newDirection: "asc" | "desc" | undefined;
|
||||
|
||||
if (sortBy === key) {
|
||||
if (sortDirection === "asc") {
|
||||
newDirection = "desc";
|
||||
} else if (sortDirection === "desc") {
|
||||
newDirection = undefined;
|
||||
} else {
|
||||
newDirection = "asc";
|
||||
}
|
||||
} else {
|
||||
newDirection = "asc";
|
||||
}
|
||||
|
||||
const next = {
|
||||
by: newDirection ? key : undefined,
|
||||
direction: newDirection,
|
||||
} as const;
|
||||
|
||||
if (controlledSort) {
|
||||
onSortChange?.(next);
|
||||
} else {
|
||||
setInternalSortBy(next.by);
|
||||
setInternalSortDirection(next.direction);
|
||||
}
|
||||
},
|
||||
[sortBy, sortDirection, controlledSort, onSortChange],
|
||||
);
|
||||
|
||||
const contextValue: DataTableContextValue<T> = {
|
||||
columns,
|
||||
data,
|
||||
rowIdKey,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
toggleSort: handleSort,
|
||||
id,
|
||||
locale: resolvedLocale,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</DataTableContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableLayoutProps {
|
||||
layout: DataTableLayout;
|
||||
emptyMessage: string;
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DataTableLayout({
|
||||
layout,
|
||||
emptyMessage,
|
||||
maxHeight,
|
||||
className,
|
||||
}: DataTableLayoutProps) {
|
||||
const { columns, data, rowIdKey, sortBy, sortDirection, id } = useDataTable();
|
||||
const rowKeys = React.useMemo(
|
||||
() =>
|
||||
createDataTableRowKeys(
|
||||
data as Array<Record<string, unknown>>,
|
||||
rowIdKey ? String(rowIdKey) : undefined,
|
||||
),
|
||||
[data, rowIdKey],
|
||||
);
|
||||
const mobileDescriptionId = React.useMemo(
|
||||
() => getDataTableMobileDescriptionId(String(id ?? "data-table")),
|
||||
[id],
|
||||
);
|
||||
|
||||
const sortAnnouncement = React.useMemo(() => {
|
||||
const col = columns.find((c) => c.key === sortBy);
|
||||
const label = col?.label ?? sortBy;
|
||||
return sortBy && sortDirection
|
||||
? `Sorted by ${label}, ${sortDirection === "asc" ? "ascending" : "descending"}`
|
||||
: "";
|
||||
}, [columns, sortBy, sortDirection]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("@container w-full min-w-80", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="data-table"
|
||||
data-layout={layout}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
layout === "table"
|
||||
? "block"
|
||||
: layout === "cards"
|
||||
? "hidden"
|
||||
: "hidden @md:block",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card relative w-full overflow-clip overflow-y-auto rounded-lg border",
|
||||
"touch-pan-x",
|
||||
maxHeight && "max-h-[--max-height]",
|
||||
)}
|
||||
style={
|
||||
maxHeight
|
||||
? ({ "--max-height": maxHeight } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
{columns.length > 0 && (
|
||||
<colgroup>
|
||||
{columns.map((col) => (
|
||||
<col
|
||||
key={String(col.key)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
)}
|
||||
{data.length === 0 ? (
|
||||
<DataTableEmpty message={emptyMessage} />
|
||||
) : (
|
||||
<DataTableContent />
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
layout === "cards"
|
||||
? ""
|
||||
: layout === "table"
|
||||
? "hidden"
|
||||
: "@md:hidden",
|
||||
)}
|
||||
role="list"
|
||||
aria-label="Data table (mobile card view)"
|
||||
aria-describedby={mobileDescriptionId}
|
||||
>
|
||||
<div id={mobileDescriptionId} className="sr-only">
|
||||
Table data shown as expandable cards. Each card represents one row.
|
||||
{columns.length > 0 &&
|
||||
` Columns: ${columns.map((c) => c.label).join(", ")}.`}
|
||||
</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-2xl border shadow-xs">
|
||||
{data.map((row, i) => {
|
||||
const rowKey = rowKeys[i];
|
||||
return (
|
||||
<DataTableAccordionCard
|
||||
key={rowKey}
|
||||
row={row as unknown as DataTableRowData}
|
||||
index={i}
|
||||
rowKey={rowKey}
|
||||
isFirst={i === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sortAnnouncement && (
|
||||
<div className="sr-only" aria-live="polite">
|
||||
{sortAnnouncement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableBase<T extends object = RowData>(
|
||||
props: DataTableBaseProps<T>,
|
||||
) {
|
||||
const {
|
||||
columns,
|
||||
data,
|
||||
rowIdKey,
|
||||
defaultSort,
|
||||
sort,
|
||||
onSortChange,
|
||||
id,
|
||||
locale,
|
||||
layout,
|
||||
emptyMessage = "No data available",
|
||||
maxHeight,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<DataTableProvider
|
||||
columns={columns}
|
||||
data={data}
|
||||
rowIdKey={rowIdKey}
|
||||
defaultSort={defaultSort}
|
||||
sort={sort}
|
||||
onSortChange={onSortChange}
|
||||
id={id}
|
||||
locale={locale}
|
||||
>
|
||||
<DataTableLayout
|
||||
layout={layout}
|
||||
emptyMessage={emptyMessage}
|
||||
maxHeight={maxHeight}
|
||||
className={className}
|
||||
/>
|
||||
</DataTableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableRoot<T extends object = RowData>(props: DataTableProps<T>) {
|
||||
return <DataTableBase {...props} layout="auto" />;
|
||||
}
|
||||
|
||||
function DataTableTable<T extends object = RowData>(props: DataTableProps<T>) {
|
||||
return <DataTableBase {...props} layout="table" />;
|
||||
}
|
||||
|
||||
function DataTableCards<T extends object = RowData>(props: DataTableProps<T>) {
|
||||
return <DataTableBase {...props} layout="cards" />;
|
||||
}
|
||||
|
||||
type DataTableComponent = {
|
||||
<T extends object = RowData>(props: DataTableProps<T>): React.ReactElement;
|
||||
Table: typeof DataTableTable;
|
||||
Cards: typeof DataTableCards;
|
||||
Provider: typeof DataTableProvider;
|
||||
};
|
||||
|
||||
export const DataTable = Object.assign(DataTableRoot, {
|
||||
Table: DataTableTable,
|
||||
Cards: DataTableCards,
|
||||
Provider: DataTableProvider,
|
||||
}) as DataTableComponent;
|
||||
|
||||
function DataTableContent() {
|
||||
return (
|
||||
<>
|
||||
<DataTableHeader />
|
||||
<DataTableBody />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableEmpty({ message }: { message: string }) {
|
||||
const { columns } = useDataTable();
|
||||
|
||||
return (
|
||||
<TableBody>
|
||||
<TableRow className="bg-card h-24 text-center">
|
||||
<TableCell colSpan={columns.length} role="status" aria-live="polite">
|
||||
{message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIcon({ state }: { state?: "asc" | "desc" }) {
|
||||
let char = "⇅";
|
||||
let className = "opacity-20";
|
||||
|
||||
if (state === "asc") {
|
||||
char = "↑";
|
||||
className = "";
|
||||
}
|
||||
|
||||
if (state === "desc") {
|
||||
char = "↓";
|
||||
className = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<span aria-hidden className={cn("min-w-4 shrink-0 text-center", className)}>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableHeader() {
|
||||
const { columns } = useDataTable();
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{columns.map((column, columnIndex) => (
|
||||
<DataTableHead
|
||||
key={column.key}
|
||||
column={column}
|
||||
columnIndex={columnIndex}
|
||||
totalColumns={columns.length}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableHeadProps {
|
||||
column: Column;
|
||||
columnIndex?: number;
|
||||
totalColumns?: number;
|
||||
}
|
||||
|
||||
function DataTableHead({
|
||||
column,
|
||||
columnIndex = 0,
|
||||
totalColumns = 1,
|
||||
}: DataTableHeadProps) {
|
||||
const { sortBy, sortDirection, toggleSort } = useDataTable();
|
||||
const isFirstColumn = columnIndex === 0;
|
||||
const isLastColumn = columnIndex === totalColumns - 1;
|
||||
|
||||
const isSortable = column.sortable !== false;
|
||||
|
||||
const isSorted = sortBy === column.key;
|
||||
const direction = isSorted ? sortDirection : undefined;
|
||||
const isDisabled = !isSortable;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isDisabled && toggleSort) {
|
||||
toggleSort(column.key);
|
||||
}
|
||||
};
|
||||
|
||||
const displayText = column.abbr || column.label;
|
||||
const shouldShowTooltip = column.abbr || displayText.length > 15;
|
||||
const isNumericKind = isNumericFormat(column.format);
|
||||
const align =
|
||||
column.align ??
|
||||
(columnIndex === 0 ? "left" : isNumericKind ? "right" : "left");
|
||||
const alignClass = getAlignmentClass(align);
|
||||
const buttonAlignClass = cn(
|
||||
"min-w-0 gap-1 font-normal",
|
||||
align === "right" && "text-right",
|
||||
align === "center" && "text-center",
|
||||
align === "left" && "text-left",
|
||||
);
|
||||
const labelAlignClass =
|
||||
align === "right"
|
||||
? "text-right"
|
||||
: align === "center"
|
||||
? "text-center"
|
||||
: "text-left";
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
scope="col"
|
||||
className={cn(
|
||||
alignClass,
|
||||
isFirstColumn && "pl-1",
|
||||
isLastColumn && "pr-1",
|
||||
)}
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
aria-sort={
|
||||
isSorted
|
||||
? direction === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
buttonAlignClass,
|
||||
"w-fit min-w-10",
|
||||
isFirstColumn && "pl-4",
|
||||
isLastColumn && "pr-4",
|
||||
)}
|
||||
aria-label={
|
||||
`Sort by ${column.label}` +
|
||||
(isSorted && direction
|
||||
? ` (${direction === "asc" ? "ascending" : "descending"})`
|
||||
: "")
|
||||
}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
>
|
||||
{shouldShowTooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("truncate", labelAlignClass)}>
|
||||
{column.abbr ? (
|
||||
<abbr
|
||||
title={column.label}
|
||||
className={cn(
|
||||
"cursor-help border-b border-dotted border-current no-underline",
|
||||
labelAlignClass,
|
||||
)}
|
||||
>
|
||||
{column.abbr}
|
||||
</abbr>
|
||||
) : (
|
||||
<span className={labelAlignClass}>{column.label}</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{column.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={cn("truncate", labelAlignClass)}>
|
||||
{column.label}
|
||||
</span>
|
||||
)}
|
||||
{isSortable && <SortIcon state={direction} />}
|
||||
</Button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableBody() {
|
||||
const { data, rowIdKey } = useDataTable<DataTableRowData>();
|
||||
const rowKeys = React.useMemo(
|
||||
() =>
|
||||
createDataTableRowKeys(
|
||||
data as Array<Record<string, unknown>>,
|
||||
rowIdKey ? String(rowIdKey) : undefined,
|
||||
),
|
||||
[data, rowIdKey],
|
||||
);
|
||||
const hasWarnedRowKeyRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasWarnedRowKeyRef.current) return;
|
||||
if (process.env.NODE_ENV !== "production" && !rowIdKey && data.length > 0) {
|
||||
hasWarnedRowKeyRef.current = true;
|
||||
console.warn(
|
||||
"[DataTable] Missing `rowIdKey` prop. Falling back to inferred/content-derived row keys. " +
|
||||
"Strongly recommended: Pass a `rowIdKey` prop that points to a unique identifier in your row data (e.g., 'id', 'uuid', 'symbol').\n" +
|
||||
'Example: <DataTable rowIdKey="id" columns={...} data={...} />',
|
||||
);
|
||||
}
|
||||
}, [rowIdKey, data.length]);
|
||||
|
||||
return (
|
||||
<TableBody>
|
||||
{data.map((row, index) => {
|
||||
const rowKey = rowKeys[index];
|
||||
return <DataTableRow key={rowKey} row={row} />;
|
||||
})}
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableRowProps {
|
||||
row: DataTableRowData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DataTableRow({ row, className }: DataTableRowProps) {
|
||||
const { columns } = useDataTable();
|
||||
|
||||
return (
|
||||
<TableRow className={className}>
|
||||
{columns.map((column, columnIndex) => (
|
||||
<DataTableCell
|
||||
key={column.key}
|
||||
value={row[column.key]}
|
||||
column={column}
|
||||
row={row}
|
||||
columnIndex={columnIndex}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableCellProps {
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| (string | number | boolean | null)[];
|
||||
column: Column;
|
||||
row: DataTableRowData;
|
||||
className?: string;
|
||||
columnIndex?: number;
|
||||
}
|
||||
|
||||
function DataTableCell({
|
||||
value,
|
||||
column,
|
||||
row,
|
||||
className,
|
||||
columnIndex = 0,
|
||||
}: DataTableCellProps) {
|
||||
const { locale } = useDataTable();
|
||||
const isNumericKind = isNumericFormat(column.format);
|
||||
const isNumericValue = typeof value === "number";
|
||||
const displayValue = renderFormattedValue({ value, column, row, locale });
|
||||
const align =
|
||||
column.align ??
|
||||
(columnIndex === 0
|
||||
? "left"
|
||||
: isNumericKind || isNumericValue
|
||||
? "right"
|
||||
: "left");
|
||||
const alignClass = getAlignmentClass(align);
|
||||
|
||||
return (
|
||||
<TableCell className={cn("px-5 py-3", alignClass, className)}>
|
||||
{displayValue}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
function categorizeColumns(columns: Column[]) {
|
||||
const primary: Column[] = [];
|
||||
const secondary: Column[] = [];
|
||||
|
||||
let visibleColumnCount = 0;
|
||||
columns.forEach((col) => {
|
||||
if (col.hideOnMobile) return;
|
||||
|
||||
if (col.priority === "primary") {
|
||||
primary.push(col);
|
||||
} else if (col.priority === "secondary") {
|
||||
secondary.push(col);
|
||||
} else if (col.priority === "tertiary") {
|
||||
return;
|
||||
} else {
|
||||
if (visibleColumnCount < 2) {
|
||||
primary.push(col);
|
||||
} else {
|
||||
secondary.push(col);
|
||||
}
|
||||
visibleColumnCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return { primary, secondary };
|
||||
}
|
||||
|
||||
interface DataTableAccordionCardProps {
|
||||
row: DataTableRowData;
|
||||
index: number;
|
||||
rowKey: string;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
|
||||
function getDataTableRowDomId(rowKey: string): string {
|
||||
return encodeURIComponent(rowKey).replace(/%/g, "_");
|
||||
}
|
||||
|
||||
function DataTableAccordionCard({
|
||||
row,
|
||||
index,
|
||||
rowKey,
|
||||
isFirst = false,
|
||||
}: DataTableAccordionCardProps) {
|
||||
const { columns, locale } = useDataTable();
|
||||
|
||||
const { primary, secondary } = React.useMemo(
|
||||
() => categorizeColumns(columns),
|
||||
[columns],
|
||||
);
|
||||
|
||||
if (secondary.length === 0) {
|
||||
return (
|
||||
<SimpleCard
|
||||
row={row}
|
||||
columns={primary}
|
||||
index={index}
|
||||
rowKey={rowKey}
|
||||
isFirst={isFirst}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryColumn = primary[0];
|
||||
const remainingPrimaryColumns = primary.slice(1);
|
||||
|
||||
const stableRowId = getDataTableRowDomId(rowKey);
|
||||
|
||||
const headingId = `row-${stableRowId}-heading`;
|
||||
const detailsId = `row-${stableRowId}-details`;
|
||||
const remainingPrimaryDataIds = remainingPrimaryColumns.map(
|
||||
(col) => `row-${stableRowId}-${String(col.key)}`,
|
||||
);
|
||||
|
||||
const primaryValue = primaryColumn
|
||||
? String(row[primaryColumn.key] ?? "")
|
||||
: "";
|
||||
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
|
||||
const accordionItemId = `row-${stableRowId}`;
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className={cn(!isFirst && "border-t")}
|
||||
role="listitem"
|
||||
aria-label={rowLabel}
|
||||
>
|
||||
<AccordionItem value={accordionItemId} className="group border-0">
|
||||
<AccordionTrigger
|
||||
className="group-data-[state=closed]:hover:bg-accent/50 active:bg-accent/50 group-data-[state=open]:bg-muted w-full rounded-none px-4 py-3 hover:no-underline"
|
||||
aria-controls={detailsId}
|
||||
aria-label={`${rowLabel}. ${secondary.length > 0 ? "Expand for details" : ""}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{primaryColumn && (
|
||||
<div
|
||||
id={headingId}
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
className="truncate"
|
||||
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
|
||||
>
|
||||
{renderFormattedValue({
|
||||
value: row[primaryColumn.key],
|
||||
column: primaryColumn,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remainingPrimaryColumns.length > 0 && (
|
||||
<div
|
||||
className="text-muted-foreground flex w-full flex-wrap gap-x-4 gap-y-0.5"
|
||||
role="group"
|
||||
aria-label="Summary information"
|
||||
>
|
||||
{remainingPrimaryColumns.map((col, idx) => (
|
||||
<span
|
||||
key={col.key}
|
||||
id={remainingPrimaryDataIds[idx]}
|
||||
className="flex min-w-0 gap-1 font-normal"
|
||||
role="cell"
|
||||
aria-label={`${col.label}: ${row[col.key]}`}
|
||||
>
|
||||
<span className="sr-only">{col.label}:</span>
|
||||
<span aria-hidden="true">{col.label}:</span>
|
||||
<span className="truncate">
|
||||
{renderFormattedValue({
|
||||
value: row[col.key],
|
||||
column: col,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent
|
||||
className={"flex flex-col gap-4 px-4 pb-4"}
|
||||
id={detailsId}
|
||||
role="region"
|
||||
aria-labelledby={headingId}
|
||||
>
|
||||
{secondary.length > 0 && (
|
||||
<dl
|
||||
className={cn(
|
||||
"flex flex-col gap-2 pt-4",
|
||||
"motion-safe:group-data-[state=open]:animate-in motion-safe:group-data-[state=open]:fade-in-0",
|
||||
"motion-safe:group-data-[state=open]:slide-in-from-top-1",
|
||||
"motion-safe:group-data-[state=closed]:animate-out motion-safe:group-data-[state=closed]:fade-out-0",
|
||||
"motion-safe:group-data-[state=closed]:slide-out-to-top-1",
|
||||
"duration-150",
|
||||
)}
|
||||
role="list"
|
||||
aria-label="Additional data"
|
||||
>
|
||||
{secondary.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="flex items-start justify-between gap-4"
|
||||
role="listitem"
|
||||
>
|
||||
<dt
|
||||
className="text-muted-foreground shrink-0"
|
||||
id={`row-${stableRowId}-${String(col.key)}-label`}
|
||||
>
|
||||
{col.label}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"text-foreground min-w-0 text-pretty wrap-break-word",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center",
|
||||
)}
|
||||
role="cell"
|
||||
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
|
||||
>
|
||||
{renderFormattedValue({
|
||||
value: row[col.key],
|
||||
column: col,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple card with no accordion, for when there are only primary columns
|
||||
*/
|
||||
function SimpleCard({
|
||||
row,
|
||||
columns,
|
||||
index,
|
||||
rowKey,
|
||||
isFirst = false,
|
||||
}: {
|
||||
row: DataTableRowData;
|
||||
columns: Column[];
|
||||
index: number;
|
||||
rowKey: string;
|
||||
isFirst?: boolean;
|
||||
}) {
|
||||
const { locale } = useDataTable();
|
||||
const primaryColumn = columns[0];
|
||||
const otherColumns = columns.slice(1);
|
||||
|
||||
const stableRowId = getDataTableRowDomId(rowKey);
|
||||
|
||||
const primaryValue = primaryColumn
|
||||
? String(row[primaryColumn.key] ?? "")
|
||||
: "";
|
||||
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2 p-4", !isFirst && "border-t")}
|
||||
role="listitem"
|
||||
aria-label={rowLabel}
|
||||
>
|
||||
{primaryColumn && (
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
|
||||
>
|
||||
{renderFormattedValue({
|
||||
value: row[primaryColumn.key],
|
||||
column: primaryColumn,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherColumns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="flex items-start justify-between gap-4"
|
||||
role="group"
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
id={`row-${stableRowId}-${String(col.key)}-label`}
|
||||
>
|
||||
{col.label}:
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 wrap-break-word",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center",
|
||||
)}
|
||||
role="cell"
|
||||
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
|
||||
>
|
||||
{renderFormattedValue({
|
||||
value: row[col.key],
|
||||
column: col,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter";
|
||||
import { resolveSafeNavigationHref } from "../shared/media";
|
||||
|
||||
type Tone = "success" | "warning" | "danger" | "info" | "neutral";
|
||||
|
||||
export type FormatConfig =
|
||||
| { kind: "text" }
|
||||
| {
|
||||
kind: "number";
|
||||
decimals?: number;
|
||||
unit?: string;
|
||||
compact?: boolean;
|
||||
showSign?: boolean;
|
||||
}
|
||||
| { kind: "currency"; currency: string; decimals?: number }
|
||||
| {
|
||||
kind: "percent";
|
||||
decimals?: number;
|
||||
showSign?: boolean;
|
||||
basis?: "fraction" | "unit";
|
||||
}
|
||||
| { kind: "date"; dateFormat?: "short" | "long" | "relative" }
|
||||
| {
|
||||
kind: "delta";
|
||||
decimals?: number;
|
||||
upIsPositive?: boolean;
|
||||
showSign?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "status";
|
||||
statusMap: Record<string, { tone: Tone; label?: string }>;
|
||||
}
|
||||
| { kind: "boolean"; labels?: { true: string; false: string } }
|
||||
| { kind: "link"; hrefKey?: string; external?: boolean }
|
||||
| { kind: "badge"; colorMap?: Record<string, Tone> }
|
||||
| { kind: "array"; maxVisible?: number };
|
||||
|
||||
interface DeltaValueProps {
|
||||
value: number;
|
||||
options?: Extract<FormatConfig, { kind: "delta" }>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function DeltaValue({ value, options, locale }: DeltaValueProps) {
|
||||
const decimals = options?.decimals ?? 2;
|
||||
const upIsPositive = options?.upIsPositive ?? true;
|
||||
const showSign = options?.showSign ?? true;
|
||||
|
||||
const isPositive = value > 0;
|
||||
const isNegative = value < 0;
|
||||
const isNeutral = value === 0;
|
||||
|
||||
const isGood = upIsPositive ? isPositive : isNegative;
|
||||
const isBad = upIsPositive ? isNegative : isPositive;
|
||||
|
||||
const colorClass = isGood
|
||||
? "text-green-700 dark:text-green-500"
|
||||
: isBad
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const formatted = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(absValue);
|
||||
|
||||
const display =
|
||||
showSign && !isNeutral
|
||||
? isNegative
|
||||
? `-${formatted}`
|
||||
: `+${formatted}`
|
||||
: formatted;
|
||||
|
||||
const arrow = isPositive ? "↑" : isNegative ? "↓" : "";
|
||||
|
||||
return (
|
||||
<span className={cn("tabular-nums", colorClass)}>
|
||||
{display}
|
||||
{!isNeutral && <span className="ml-0.5">{arrow}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
value: string;
|
||||
options?: Extract<FormatConfig, { kind: "status" }>;
|
||||
}
|
||||
|
||||
export function StatusBadge({ value, options }: StatusBadgeProps) {
|
||||
const config = options?.statusMap?.[value] ?? {
|
||||
tone: "neutral" as Tone,
|
||||
label: value,
|
||||
};
|
||||
const label = config.label ?? value;
|
||||
|
||||
const variant =
|
||||
config.tone === "danger"
|
||||
? "destructive"
|
||||
: config.tone === "neutral"
|
||||
? "outline"
|
||||
: "secondary";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"border",
|
||||
config.tone === "warning" &&
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
|
||||
config.tone === "success" &&
|
||||
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
|
||||
config.tone === "info" &&
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
|
||||
config.tone === "danger" &&
|
||||
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
interface CurrencyValueProps {
|
||||
value: number;
|
||||
options?: Extract<FormatConfig, { kind: "currency" }>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
|
||||
const currency = options?.currency ?? "USD";
|
||||
const decimals = options?.decimals ?? 2;
|
||||
|
||||
const formatted = new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(value);
|
||||
|
||||
return <span className="tabular-nums">{formatted}</span>;
|
||||
}
|
||||
|
||||
interface PercentValueProps {
|
||||
value: number;
|
||||
options?: Extract<FormatConfig, { kind: "percent" }>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function PercentValue({ value, options, locale }: PercentValueProps) {
|
||||
const decimals = options?.decimals ?? 2;
|
||||
const showSign = options?.showSign ?? false;
|
||||
const basis = options?.basis ?? "fraction";
|
||||
|
||||
const numeric = basis === "fraction" ? value : value / 100;
|
||||
|
||||
const formatted = new Intl.NumberFormat(locale, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
signDisplay: showSign ? "always" : "auto",
|
||||
}).format(numeric);
|
||||
|
||||
return <span className="tabular-nums">{formatted}</span>;
|
||||
}
|
||||
|
||||
interface DateValueProps {
|
||||
value: string;
|
||||
options?: Extract<FormatConfig, { kind: "date" }>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function DateValue({ value, options, locale }: DateValueProps) {
|
||||
const dateFormat = options?.dateFormat ?? "short";
|
||||
const date = new Date(value);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return <span className="text-muted-foreground">{value}</span>;
|
||||
}
|
||||
|
||||
let formatted: string;
|
||||
|
||||
if (dateFormat === "relative") {
|
||||
formatted = getRelativeTime(date, locale);
|
||||
} else if (dateFormat === "long") {
|
||||
formatted = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
} else {
|
||||
formatted = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
const title = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
|
||||
return (
|
||||
<span className="tabular-nums" title={title}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getRelativeTime(date: Date, locale?: string): string {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.trunc((date.getTime() - now.getTime()) / 1000);
|
||||
const absDiffInSeconds = Math.abs(diffInSeconds);
|
||||
|
||||
if (absDiffInSeconds < 60) return "just now";
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
|
||||
if (absDiffInSeconds < 3600) {
|
||||
const mins = Math.trunc(diffInSeconds / 60);
|
||||
return rtf.format(mins, "minute");
|
||||
}
|
||||
if (absDiffInSeconds < 86400) {
|
||||
const hours = Math.trunc(diffInSeconds / 3600);
|
||||
return rtf.format(hours, "hour");
|
||||
}
|
||||
if (absDiffInSeconds < 604800) {
|
||||
const days = Math.trunc(diffInSeconds / 86400);
|
||||
return rtf.format(days, "day");
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
interface BooleanValueProps {
|
||||
value: boolean;
|
||||
options?: Extract<FormatConfig, { kind: "boolean" }>;
|
||||
}
|
||||
|
||||
export function BooleanValue({ value, options }: BooleanValueProps) {
|
||||
const labels = options?.labels ?? { true: "Yes", false: "No" };
|
||||
const label = value ? labels.true : labels.false;
|
||||
const variant = value ? "secondary" : "outline";
|
||||
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
}
|
||||
|
||||
interface LinkValueProps {
|
||||
value: string;
|
||||
options?: Extract<FormatConfig, { kind: "link" }>;
|
||||
row?: Record<
|
||||
string,
|
||||
string | number | boolean | null | (string | number | boolean | null)[]
|
||||
>;
|
||||
}
|
||||
|
||||
export function LinkValue({ value, options, row }: LinkValueProps) {
|
||||
const rawHref =
|
||||
options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value;
|
||||
const href = resolveSafeNavigationHref(rawHref);
|
||||
const external = options?.external ?? false;
|
||||
|
||||
if (!href) {
|
||||
return <span>{value}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className="text-accent-foreground inline-block max-w-full break-words underline underline-offset-2 hover:opacity-90"
|
||||
aria-label={external ? `${value} (opens in a new tab)` : undefined}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{value}
|
||||
{external && (
|
||||
<span className="ml-1 inline-block" aria-label="Opens in new tab">
|
||||
↗
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberValueProps {
|
||||
value: number;
|
||||
options?: Extract<FormatConfig, { kind: "number" }>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function NumberValue({ value, options, locale }: NumberValueProps) {
|
||||
const decimals = options?.decimals ?? 0;
|
||||
const unit = options?.unit ?? "";
|
||||
const compact = options?.compact ?? false;
|
||||
const showSign = options?.showSign ?? false;
|
||||
|
||||
const formatted = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
notation: compact ? "compact" : "standard",
|
||||
}).format(value);
|
||||
|
||||
const display = showSign && value > 0 ? `+${formatted}` : formatted;
|
||||
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{display}
|
||||
{unit}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface BadgeValueProps {
|
||||
value: string;
|
||||
options?: Extract<FormatConfig, { kind: "badge" }>;
|
||||
}
|
||||
|
||||
export function BadgeValue({ value, options }: BadgeValueProps) {
|
||||
const tone = options?.colorMap?.[value] ?? "neutral";
|
||||
|
||||
const variant =
|
||||
tone === "danger"
|
||||
? "destructive"
|
||||
: tone === "neutral"
|
||||
? "outline"
|
||||
: "secondary";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"border",
|
||||
tone === "warning" &&
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
|
||||
tone === "success" &&
|
||||
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
|
||||
tone === "info" &&
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
|
||||
tone === "danger" &&
|
||||
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
interface ArrayValueProps {
|
||||
value: (string | number | boolean | null)[] | string;
|
||||
options?: Extract<FormatConfig, { kind: "array" }>;
|
||||
}
|
||||
|
||||
export function ArrayValue({ value, options }: ArrayValueProps) {
|
||||
const maxVisible = options?.maxVisible ?? 3;
|
||||
const items: (string | number | boolean | null)[] = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? value.split(",").map((s) => s.trim())
|
||||
: [];
|
||||
|
||||
if (items.length === 0) {
|
||||
return <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
const visible = items.slice(0, maxVisible);
|
||||
const remaining = items.length - maxVisible;
|
||||
|
||||
const hidden = items.slice(maxVisible);
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
{visible.map((item, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5"
|
||||
>
|
||||
{item === null ? "null" : String(item)}
|
||||
</span>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground cursor-default">
|
||||
+{remaining} more
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hidden
|
||||
.map((item) => (item === null ? "null" : String(item)))
|
||||
.join(", ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderFormattedValueParams {
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| (string | number | boolean | null)[];
|
||||
column: { format?: FormatConfig };
|
||||
row?: Record<
|
||||
string,
|
||||
string | number | boolean | null | (string | number | boolean | null)[]
|
||||
>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function renderFormattedValue({
|
||||
value,
|
||||
column,
|
||||
row,
|
||||
locale,
|
||||
}: RenderFormattedValueParams): React.ReactNode {
|
||||
if (value == null || value === "") {
|
||||
return <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
const fmt = column.format;
|
||||
|
||||
switch (fmt?.kind) {
|
||||
case "delta":
|
||||
return <DeltaValue value={Number(value)} options={fmt} locale={locale} />;
|
||||
case "status":
|
||||
return <StatusBadge value={String(value)} options={fmt} />;
|
||||
case "currency":
|
||||
return (
|
||||
<CurrencyValue value={Number(value)} options={fmt} locale={locale} />
|
||||
);
|
||||
case "percent":
|
||||
return (
|
||||
<PercentValue value={Number(value)} options={fmt} locale={locale} />
|
||||
);
|
||||
case "date":
|
||||
return <DateValue value={String(value)} options={fmt} locale={locale} />;
|
||||
case "boolean":
|
||||
return <BooleanValue value={Boolean(value)} options={fmt} />;
|
||||
case "link":
|
||||
return <LinkValue value={String(value)} options={fmt} row={row} />;
|
||||
case "number":
|
||||
return (
|
||||
<NumberValue value={Number(value)} options={fmt} locale={locale} />
|
||||
);
|
||||
case "badge":
|
||||
return <BadgeValue value={String(value)} options={fmt} />;
|
||||
case "array":
|
||||
return (
|
||||
<ArrayValue
|
||||
value={Array.isArray(value) ? value : String(value)}
|
||||
options={fmt}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { DataTable, useDataTable } from "./data-table";
|
||||
|
||||
export { renderFormattedValue } from "./formatters";
|
||||
export {
|
||||
NumberValue,
|
||||
CurrencyValue,
|
||||
PercentValue,
|
||||
DeltaValue,
|
||||
DateValue,
|
||||
BooleanValue,
|
||||
LinkValue,
|
||||
BadgeValue,
|
||||
StatusBadge,
|
||||
ArrayValue,
|
||||
} from "./formatters";
|
||||
|
||||
export type {
|
||||
Column,
|
||||
DataTableProps,
|
||||
DataTableSerializableProps,
|
||||
DataTableClientProps,
|
||||
DataTableRowData,
|
||||
RowPrimitive,
|
||||
RowData,
|
||||
ColumnKey,
|
||||
} from "./types";
|
||||
export type { FormatConfig } from "./formatters";
|
||||
|
||||
export { sortData, parseNumericLike } from "./utilities";
|
||||
@@ -0,0 +1,345 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import type { Column, DataTableProps, RowData } from "./types";
|
||||
|
||||
const AlignEnum = z.enum(["left", "right", "center"]);
|
||||
const PriorityEnum = z.enum(["primary", "secondary", "tertiary"]);
|
||||
|
||||
const formatSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("text") }),
|
||||
z.object({
|
||||
kind: z.literal("number"),
|
||||
decimals: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
compact: z.boolean().optional(),
|
||||
showSign: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("currency"),
|
||||
currency: z.string(),
|
||||
decimals: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("percent"),
|
||||
decimals: z.number().optional(),
|
||||
showSign: z.boolean().optional(),
|
||||
basis: z.enum(["fraction", "unit"]).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("date"),
|
||||
dateFormat: z.enum(["short", "long", "relative"]).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("delta"),
|
||||
decimals: z.number().optional(),
|
||||
upIsPositive: z.boolean().optional(),
|
||||
showSign: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("status"),
|
||||
statusMap: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
tone: z.enum(["success", "warning", "danger", "info", "neutral"]),
|
||||
label: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("boolean"),
|
||||
labels: z
|
||||
.object({
|
||||
true: z.string(),
|
||||
false: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("link"),
|
||||
hrefKey: z.string().optional(),
|
||||
external: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("badge"),
|
||||
colorMap: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.enum(["success", "warning", "danger", "info", "neutral"]),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("array"),
|
||||
maxVisible: z.number().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const serializableColumnSchema = z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
abbr: z.string().optional(),
|
||||
sortable: z.boolean().optional(),
|
||||
align: AlignEnum.optional(),
|
||||
width: z.string().optional(),
|
||||
truncate: z.boolean().optional(),
|
||||
priority: PriorityEnum.optional(),
|
||||
hideOnMobile: z.boolean().optional(),
|
||||
format: formatSchema.optional(),
|
||||
});
|
||||
|
||||
const JsonPrimitiveSchema = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Schema for serializable row data.
|
||||
*
|
||||
* Supports:
|
||||
* - Primitives: string, number, boolean, null
|
||||
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
|
||||
*
|
||||
* Does NOT support:
|
||||
* - Functions
|
||||
* - Class instances (Date, Map, Set, etc.)
|
||||
* - Plain objects (use format configs instead)
|
||||
*
|
||||
* @example
|
||||
* Valid row data:
|
||||
* ```json
|
||||
* {
|
||||
* "name": "Widget",
|
||||
* "price": 29.99,
|
||||
* "active": true,
|
||||
* "tags": ["electronics", "featured"],
|
||||
* "metrics": [1.2, 3.4, 5.6],
|
||||
* "flags": [true, false, true],
|
||||
* "mixed": ["label", 42, true]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const serializableDataSchema = z.record(
|
||||
z.string(),
|
||||
z.union([JsonPrimitiveSchema, z.array(JsonPrimitiveSchema)]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Zod schema for validating DataTable payloads from LLM tool calls.
|
||||
*
|
||||
* This schema validates the serializable parts of a DataTable:
|
||||
* - id: Unique identifier for this tool UI in the conversation
|
||||
* - columns: Column definitions (keys, labels, formatting, etc.)
|
||||
* - data: Data rows (primitives only - no functions or class instances)
|
||||
* - optional presentation props: rowIdKey, sort/defaultSort, locale, etc.
|
||||
*
|
||||
* Non-serializable props like `onSortChange`, `className`, and sibling action surfaces
|
||||
* must be provided separately in your React component.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = SerializableDataTableSchema.safeParse(llmResponse)
|
||||
* if (result.success) {
|
||||
* // result.data contains validated id, columns, and data
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const SerializableDataTableSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
columns: z.array(serializableColumnSchema),
|
||||
data: z.array(serializableDataSchema),
|
||||
rowIdKey: z.string().optional(),
|
||||
defaultSort: z
|
||||
.object({
|
||||
by: z.string().optional(),
|
||||
direction: z.enum(["asc", "desc"]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
sort: z
|
||||
.object({
|
||||
by: z.string().optional(),
|
||||
direction: z.enum(["asc", "desc"]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
emptyMessage: z.string().optional(),
|
||||
maxHeight: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
});
|
||||
|
||||
const SerializableDataTableSchemaContract = defineToolUiContract(
|
||||
"DataTable",
|
||||
SerializableDataTableSchema,
|
||||
);
|
||||
|
||||
/**
|
||||
* Type representing the serializable parts of a DataTable payload.
|
||||
*
|
||||
* This type includes only JSON-serializable data that can come from LLM tool calls:
|
||||
* - Column definitions (format configs, alignment, labels, etc.)
|
||||
* - Row data (primitives: strings, numbers, booleans, null, string arrays)
|
||||
*
|
||||
* Excluded from this type:
|
||||
* - Event handlers (`onSortChange`)
|
||||
* - React-specific props (`className`)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const payload: SerializableDataTable = {
|
||||
* id: "data-table-expenses",
|
||||
* columns: [
|
||||
* { key: "name", label: "Name" },
|
||||
* { key: "price", label: "Price", format: { kind: "currency", currency: "USD" } }
|
||||
* ],
|
||||
* data: [
|
||||
* { name: "Widget", price: 29.99 }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type SerializableDataTable = z.infer<typeof SerializableDataTableSchema>;
|
||||
|
||||
/**
|
||||
* Validates and parses a DataTable payload from unknown data (e.g., LLM tool call result).
|
||||
*
|
||||
* This function:
|
||||
* 1. Validates the input against the `SerializableDataTableSchema`
|
||||
* 2. Throws a descriptive error if validation fails
|
||||
* 3. Returns typed serializable props ready to pass to the `<DataTable>` component
|
||||
*
|
||||
* The returned props are **serializable only** - you must provide client-side props
|
||||
* separately (onSortChange, className).
|
||||
*
|
||||
* @param input - Unknown data to validate (typically from an LLM tool call)
|
||||
* @returns Validated and typed DataTable serializable props (id, columns, data)
|
||||
* @throws Error with validation details if input is invalid
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyToolUI({ result }: { result: unknown }) {
|
||||
* const serializableProps = parseSerializableDataTable(result)
|
||||
*
|
||||
* return (
|
||||
* <DataTable
|
||||
* {...serializableProps}
|
||||
* />
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function parseSerializableDataTable(
|
||||
input: unknown,
|
||||
): Pick<
|
||||
DataTableProps<RowData>,
|
||||
| "id"
|
||||
| "role"
|
||||
| "receipt"
|
||||
| "columns"
|
||||
| "data"
|
||||
| "rowIdKey"
|
||||
| "defaultSort"
|
||||
| "sort"
|
||||
| "emptyMessage"
|
||||
| "maxHeight"
|
||||
| "locale"
|
||||
> {
|
||||
const {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns,
|
||||
data,
|
||||
rowIdKey,
|
||||
defaultSort,
|
||||
sort,
|
||||
emptyMessage,
|
||||
maxHeight,
|
||||
locale,
|
||||
} = SerializableDataTableSchemaContract.parse(input);
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns: columns as unknown as Column<RowData>[],
|
||||
data: data as RowData[],
|
||||
rowIdKey: rowIdKey as keyof RowData | undefined,
|
||||
defaultSort: defaultSort
|
||||
? {
|
||||
by: defaultSort.by as keyof RowData | undefined,
|
||||
direction: defaultSort.direction,
|
||||
}
|
||||
: undefined,
|
||||
sort: sort
|
||||
? {
|
||||
by: sort.by as keyof RowData | undefined,
|
||||
direction: sort.direction,
|
||||
}
|
||||
: undefined,
|
||||
emptyMessage,
|
||||
maxHeight,
|
||||
locale,
|
||||
};
|
||||
}
|
||||
|
||||
export function safeParseSerializableDataTable(
|
||||
input: unknown,
|
||||
): Pick<
|
||||
DataTableProps<RowData>,
|
||||
| "id"
|
||||
| "role"
|
||||
| "receipt"
|
||||
| "columns"
|
||||
| "data"
|
||||
| "rowIdKey"
|
||||
| "defaultSort"
|
||||
| "sort"
|
||||
| "emptyMessage"
|
||||
| "maxHeight"
|
||||
| "locale"
|
||||
> | null {
|
||||
const res = SerializableDataTableSchemaContract.safeParse(input);
|
||||
if (!res) return null;
|
||||
const {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns,
|
||||
data,
|
||||
rowIdKey,
|
||||
defaultSort,
|
||||
sort,
|
||||
emptyMessage,
|
||||
maxHeight,
|
||||
locale,
|
||||
} = res;
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns: columns as unknown as Column<RowData>[],
|
||||
data: data as RowData[],
|
||||
rowIdKey: rowIdKey as keyof RowData | undefined,
|
||||
defaultSort: defaultSort
|
||||
? {
|
||||
by: defaultSort.by as keyof RowData | undefined,
|
||||
direction: defaultSort.direction,
|
||||
}
|
||||
: undefined,
|
||||
sort: sort
|
||||
? {
|
||||
by: sort.by as keyof RowData | undefined,
|
||||
direction: sort.direction,
|
||||
}
|
||||
: undefined,
|
||||
emptyMessage,
|
||||
maxHeight,
|
||||
locale,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { ToolUIId, ToolUIReceipt, ToolUIRole } from "../shared/schema";
|
||||
import type { FormatConfig } from "./formatters";
|
||||
|
||||
/**
|
||||
* JSON primitive type that can be serialized.
|
||||
*/
|
||||
type JsonPrimitive = string | number | boolean | null;
|
||||
|
||||
/**
|
||||
* Valid row value types for serializable DataTable data.
|
||||
*
|
||||
* Supports:
|
||||
* - Primitives: string, number, boolean, null
|
||||
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
|
||||
*
|
||||
* For complex data (objects with href/label, etc.), use column format configs
|
||||
* instead of putting objects in row data.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 👍 Good: Use primitives and primitive arrays
|
||||
* const row = {
|
||||
* name: "Widget",
|
||||
* price: 29.99,
|
||||
* tags: ["electronics", "featured"],
|
||||
* metrics: [1.2, 3.4, 5.6]
|
||||
* }
|
||||
*
|
||||
* // 🚫 Bad: Don't put objects in row data
|
||||
* const row = {
|
||||
* link: { href: "/path", label: "Click" } // Use format: { kind: 'link' } instead
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type RowPrimitive = JsonPrimitive | JsonPrimitive[];
|
||||
export type DataTableRowData = Record<string, RowPrimitive>;
|
||||
export type RowData = Record<string, unknown>;
|
||||
export type ColumnKey<T extends object> = Extract<keyof T, string>;
|
||||
|
||||
export type FormatFor<V> = V extends number
|
||||
? Extract<FormatConfig, { kind: "number" | "currency" | "percent" | "delta" }>
|
||||
: V extends boolean
|
||||
? Extract<FormatConfig, { kind: "boolean" | "status" | "badge" }>
|
||||
: V extends (string | number | boolean | null)[]
|
||||
? Extract<FormatConfig, { kind: "array" }>
|
||||
: V extends string
|
||||
? Extract<
|
||||
FormatConfig,
|
||||
{ kind: "text" | "link" | "date" | "badge" | "status" }
|
||||
>
|
||||
: Extract<FormatConfig, { kind: "text" }>;
|
||||
|
||||
/**
|
||||
* Column definition for DataTable
|
||||
*
|
||||
* @remarks
|
||||
* **Important:** Columns are sortable by default (opt-out pattern).
|
||||
* Set `sortable: false` explicitly to disable sorting for specific columns.
|
||||
*/
|
||||
export interface Column<
|
||||
T extends object = DataTableRowData,
|
||||
K extends ColumnKey<T> = ColumnKey<T>,
|
||||
> {
|
||||
/** Unique identifier that maps to a key in the row data */
|
||||
key: K;
|
||||
/** Display text for the column header */
|
||||
label: string;
|
||||
/** Abbreviated label for narrow viewports */
|
||||
abbr?: string;
|
||||
/** Whether column is sortable. Default: true (opt-out pattern) */
|
||||
sortable?: boolean;
|
||||
/** Text alignment for column cells */
|
||||
align?: "left" | "right" | "center";
|
||||
/** Optional fixed width (CSS value) */
|
||||
width?: string;
|
||||
/** Enable text truncation with ellipsis */
|
||||
truncate?: boolean;
|
||||
/** Mobile display priority (primary = always visible, secondary = expandable, tertiary = hidden) */
|
||||
priority?: "primary" | "secondary" | "tertiary";
|
||||
/** Completely hide column on mobile viewports */
|
||||
hideOnMobile?: boolean;
|
||||
/** Formatting configuration for cell values */
|
||||
format?: FormatFor<T[K]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable props that can come from LLM tool calls or be JSON-serialized.
|
||||
*
|
||||
* These props contain only primitive values, arrays, and plain objects -
|
||||
* no functions, class instances, or other non-serializable values.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const serializableProps: DataTableSerializableProps = {
|
||||
* columns: [...],
|
||||
* data: [...],
|
||||
* rowIdKey: "id",
|
||||
* defaultSort: { by: "price", direction: "desc" }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface DataTableSerializableProps<T extends object = RowData> {
|
||||
/**
|
||||
* Unique identifier for this tool UI instance in the conversation.
|
||||
*
|
||||
* Used for:
|
||||
* - Assistant referencing ("the table above")
|
||||
* - Receipt generation (linking actions to their source)
|
||||
* - Narration context
|
||||
*
|
||||
* Should be stable across re-renders, meaningful, and unique within the conversation.
|
||||
*
|
||||
* @example "data-table-expenses-q3", "search-results-repos"
|
||||
*/
|
||||
id: ToolUIId;
|
||||
/** Optional surface role metadata (serializable) */
|
||||
role?: ToolUIRole;
|
||||
/** Optional receipt metadata for consequential outcomes (serializable) */
|
||||
receipt?: ToolUIReceipt;
|
||||
/** Column definitions */
|
||||
columns: Column<T>[];
|
||||
/** Row data (primitives only - no functions or class instances) */
|
||||
data: T[];
|
||||
/**
|
||||
* Key in row data to use as unique identifier for React keys
|
||||
*
|
||||
* **Strongly recommended:** Always provide this for dynamic data to prevent
|
||||
* reconciliation issues (focus traps, animation glitches, incorrect state preservation)
|
||||
* when data reorders. Falls back to array index if omitted (only acceptable for static mock data).
|
||||
*
|
||||
* @example rowIdKey="id" or rowIdKey="uuid"
|
||||
*/
|
||||
rowIdKey?: ColumnKey<T>;
|
||||
/**
|
||||
* Uncontrolled initial sort state (table manages its own sort state internally)
|
||||
*
|
||||
* **Sorting cycle:** Clicking column headers cycles through tri-state:
|
||||
* 1. none (unsorted) → 2. asc → 3. desc → 4. none (back to unsorted)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Start with descending price sort
|
||||
* <DataTable defaultSort={{ by: "price", direction: "desc" }} />
|
||||
* ```
|
||||
*/
|
||||
defaultSort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
|
||||
/**
|
||||
* Controlled sort state (use with onSortChange from client props)
|
||||
*
|
||||
* When provided, you must also provide `onSortChange` to handle sort updates.
|
||||
* The table will cycle through: none → asc → desc → none.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [sort, setSort] = useState({ by: "price", direction: "desc" })
|
||||
* <DataTable sort={sort} onSortChange={setSort} />
|
||||
* ```
|
||||
*/
|
||||
sort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
|
||||
/** Empty state message */
|
||||
emptyMessage?: string;
|
||||
/** Max table height with vertical scroll (CSS value) */
|
||||
maxHeight?: string;
|
||||
/**
|
||||
* BCP47 locale for formatting and sorting (e.g., 'en-US', 'de-DE', 'ja-JP')
|
||||
*
|
||||
* Defaults to 'en-US' to ensure consistent server/client rendering.
|
||||
* Pass explicit locale for internationalization.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DataTable locale="de-DE" /> // German formatting
|
||||
* <DataTable locale="ja-JP" /> // Japanese formatting
|
||||
* <DataTable /> // Uses 'en-US' default
|
||||
* ```
|
||||
*/
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side React-only props that cannot be serialized.
|
||||
*
|
||||
* These props contain functions, component state, or other React-specific values
|
||||
* that must be provided by your React code (not from LLM tool calls).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const clientProps: DataTableClientProps = {
|
||||
* className: "my-table",
|
||||
* onSortChange: (next) => setSort(next),
|
||||
* // Compose local/decision actions externally via LocalActions/DecisionActions
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface DataTableClientProps<T extends object = RowData> {
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/**
|
||||
* Sort change handler for controlled mode (required if sort is provided)
|
||||
*
|
||||
* **Tri-state cycle behavior:**
|
||||
* - Click unsorted column: `{ by: "column", direction: "asc" }`
|
||||
* - Click asc column: `{ by: "column", direction: "desc" }`
|
||||
* - Click desc column: `{ by: "column", direction: undefined }` (returns to unsorted)
|
||||
* - Click different column: `{ by: "newColumn", direction: "asc" }`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [sort, setSort] = useState<{ by?: string; direction?: "asc" | "desc" }>({})
|
||||
*
|
||||
* <DataTable
|
||||
* sort={sort}
|
||||
* onSortChange={(next) => {
|
||||
* console.log("Sort changed:", next)
|
||||
* setSort(next)
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
onSortChange?: (next: {
|
||||
by?: ColumnKey<T>;
|
||||
direction?: "asc" | "desc";
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete props for the DataTable component.
|
||||
*
|
||||
* Combines serializable props (can come from LLM tool calls) with client-side
|
||||
* React-only props. This separation makes the boundary explicit and prevents
|
||||
* accidental serialization of non-serializable values.
|
||||
*
|
||||
* @see {@link DataTableSerializableProps} for props that can be JSON-serialized
|
||||
* @see {@link DataTableClientProps} for React-only props
|
||||
* @see {@link parseSerializableDataTable} for parsing LLM tool call results
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // From LLM tool call
|
||||
* const serializableProps = parseSerializableDataTable(llmResult)
|
||||
*
|
||||
* // Combine with React-specific props
|
||||
* <DataTable
|
||||
* {...serializableProps}
|
||||
* onSortChange={setSort}
|
||||
* // Render sibling LocalActions / DecisionActions where needed
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export interface DataTableProps<T extends object = RowData>
|
||||
extends DataTableSerializableProps<T>, DataTableClientProps<T> {}
|
||||
|
||||
export interface DataTableContextValue<T extends object = RowData> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
rowIdKey?: ColumnKey<T>;
|
||||
sortBy?: ColumnKey<T>;
|
||||
sortDirection?: "asc" | "desc";
|
||||
toggleSort?: (key: ColumnKey<T>) => void;
|
||||
id?: string;
|
||||
locale?: string;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Sort an array of objects by a key
|
||||
*/
|
||||
export function sortData<T, K extends Extract<keyof T, string>>(
|
||||
data: T[],
|
||||
key: K,
|
||||
direction: "asc" | "desc",
|
||||
locale?: string,
|
||||
): T[] {
|
||||
const get = (obj: T, k: K): unknown => (obj as Record<string, unknown>)[k];
|
||||
const collator = new Intl.Collator(locale, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = get(a, key);
|
||||
const bVal = get(b, key);
|
||||
|
||||
// Handle nulls
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
// Type-specific comparison
|
||||
// Numbers
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
return direction === "asc" ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
// Dates (Date instances)
|
||||
if (aVal instanceof Date && bVal instanceof Date) {
|
||||
const diff = aVal.getTime() - bVal.getTime();
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
// Booleans: false < true
|
||||
if (typeof aVal === "boolean" && typeof bVal === "boolean") {
|
||||
const diff = aVal === bVal ? 0 : aVal ? 1 : -1;
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
// Arrays: compare length
|
||||
if (Array.isArray(aVal) && Array.isArray(bVal)) {
|
||||
const diff = aVal.length - bVal.length;
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
// Strings that look like numbers -> numeric compare
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
const numA = parseNumericLike(aVal);
|
||||
const numB = parseNumericLike(bVal);
|
||||
if (numA != null && numB != null) {
|
||||
const diff = numA - numB;
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
// ISO-like date strings
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(aVal) && /^\d{4}-\d{2}-\d{2}/.test(bVal)) {
|
||||
const da = new Date(aVal).getTime();
|
||||
const db = new Date(bVal).getTime();
|
||||
const diff = da - db;
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: locale-aware string compare with numeric collation
|
||||
const aStr = String(aVal);
|
||||
const bStr = String(bVal);
|
||||
const comparison = collator.compare(aStr, bStr);
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a human-friendly identifier for a row using common keys
|
||||
*
|
||||
* Accepts any JSON-serializable primitive or array of primitives.
|
||||
* Arrays are converted to comma-separated strings.
|
||||
*/
|
||||
export function getRowIdentifier(
|
||||
row: Record<
|
||||
string,
|
||||
string | number | boolean | null | (string | number | boolean | null)[]
|
||||
>,
|
||||
identifierKey?: string,
|
||||
): string {
|
||||
const candidate =
|
||||
(identifierKey ? row[identifierKey] : undefined) ??
|
||||
(row as Record<string, unknown>).name ??
|
||||
(row as Record<string, unknown>).title ??
|
||||
(row as Record<string, unknown>).id;
|
||||
|
||||
if (candidate == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle arrays by joining them
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate.map((v) => (v === null ? "null" : String(v))).join(", ");
|
||||
}
|
||||
|
||||
return String(candidate).trim();
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value == null) return "null";
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "bigint"
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>).sort(
|
||||
([a], [b]) => a.localeCompare(b),
|
||||
);
|
||||
return `{${entries
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(String(value));
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash * 33) ^ value.charCodeAt(i);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create deterministic, reorder-stable React keys for DataTable rows.
|
||||
*
|
||||
* - Uses `identifierKey` or common identifier fields as the primary base.
|
||||
* - Falls back to stable content fingerprints when no identifier exists.
|
||||
* - Disambiguates duplicates without relying on array index.
|
||||
*/
|
||||
export function createDataTableRowKeys(
|
||||
rows: Array<Record<string, unknown>>,
|
||||
identifierKey?: string,
|
||||
): string[] {
|
||||
const canonicalRows = rows.map((row) => stableStringify(row));
|
||||
|
||||
const baseKeys = rows.map((row, index) => {
|
||||
const identifier = getRowIdentifier(
|
||||
row as Record<
|
||||
string,
|
||||
string | number | boolean | null | (string | number | boolean | null)[]
|
||||
>,
|
||||
identifierKey,
|
||||
);
|
||||
|
||||
if (identifier) {
|
||||
return `id:${identifier}`;
|
||||
}
|
||||
|
||||
return `row:${hashString(canonicalRows[index])}`;
|
||||
});
|
||||
|
||||
const baseCounts = new Map<string, number>();
|
||||
baseKeys.forEach((key) => {
|
||||
baseCounts.set(key, (baseCounts.get(key) ?? 0) + 1);
|
||||
});
|
||||
|
||||
const usedKeys = new Map<string, number>();
|
||||
|
||||
return rows.map((row, index) => {
|
||||
const baseKey = baseKeys[index];
|
||||
if ((baseCounts.get(baseKey) ?? 0) === 1) {
|
||||
return baseKey;
|
||||
}
|
||||
|
||||
const rowFingerprint = hashString(canonicalRows[index]);
|
||||
let disambiguatedKey = `${baseKey}::${rowFingerprint}`;
|
||||
|
||||
const seenCount = usedKeys.get(disambiguatedKey) ?? 0;
|
||||
usedKeys.set(disambiguatedKey, seenCount + 1);
|
||||
if (seenCount > 0) {
|
||||
disambiguatedKey = `${disambiguatedKey}::d${seenCount + 1}`;
|
||||
}
|
||||
|
||||
return disambiguatedKey;
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeDomIdToken(value: string): string {
|
||||
return encodeURIComponent(value).replace(/%/g, "_");
|
||||
}
|
||||
|
||||
export function getDataTableMobileDescriptionId(surfaceId: string): string {
|
||||
return `${sanitizeDomIdToken(surfaceId)}-mobile-table-description`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string that represents a numeric value, handling various formats:
|
||||
* - Currency symbols: $, €, £, ¥, etc.
|
||||
* - Percent symbols: %
|
||||
* - Accounting negatives: (1234) → -1234
|
||||
* - Thousands/decimal separators: 1,234.56 or 1.234,56
|
||||
* - Compact notation: 2.8T (trillion), 1.5M (million), 500K (thousand)
|
||||
* - Byte suffixes: 768B (bytes), 1.5KB, 2GB, 1TB
|
||||
*
|
||||
* Note: Single "B" is disambiguated - integers < 1024 are bytes, otherwise billions.
|
||||
*
|
||||
* @param input - String to parse
|
||||
* @returns Parsed number or null if unparseable
|
||||
*
|
||||
* @example
|
||||
* parseNumericLike("$1,234.56") // 1234.56
|
||||
* parseNumericLike("2.8T") // 2800000000000
|
||||
* parseNumericLike("768B") // 768
|
||||
* parseNumericLike("50%") // 50
|
||||
* parseNumericLike("(1234)") // -1234
|
||||
*/
|
||||
export function parseNumericLike(input: string): number | null {
|
||||
// Normalize whitespace (spaces, NBSPs, thin spaces)
|
||||
let s = input.replace(/[\u00A0\u202F\s]/g, "").trim();
|
||||
if (!s) return null;
|
||||
|
||||
// Accounting negatives: (1234) -> -1234
|
||||
s = s.replace(/^\((.*)\)$/g, "-$1");
|
||||
|
||||
// Strip common currency and percent symbols
|
||||
s = s.replace(/[%$€£¥₩₹₽₺₪₫฿₦₴₡₲₵₸]/g, "");
|
||||
|
||||
function hasGroupedThousands(value: string, sep: "," | "."): boolean {
|
||||
const unsigned = value.replace(/^[+-]/, "");
|
||||
const parts = unsigned.split(sep);
|
||||
if (parts.length < 2) return false;
|
||||
if (parts.some((part) => part.length === 0)) return false;
|
||||
if (!/^\d{1,3}$/.test(parts[0])) return false;
|
||||
if (parts[0] === "0") return false;
|
||||
return parts.slice(1).every((part) => /^\d{3}$/.test(part));
|
||||
}
|
||||
|
||||
const lastComma = s.lastIndexOf(",");
|
||||
const lastDot = s.lastIndexOf(".");
|
||||
if (lastComma !== -1 && lastDot !== -1) {
|
||||
// Decide decimal by whichever occurs last
|
||||
const decimalSep = lastComma > lastDot ? "," : ".";
|
||||
const thousandSep = decimalSep === "," ? "." : ",";
|
||||
s = s.split(thousandSep).join("");
|
||||
s = s.replace(decimalSep, ".");
|
||||
} else if (lastComma !== -1) {
|
||||
// Only comma present
|
||||
if (hasGroupedThousands(s, ",")) {
|
||||
s = s.replace(/,/g, "");
|
||||
} else {
|
||||
const frac = s.length - lastComma - 1;
|
||||
if (frac >= 1 && frac <= 3) s = s.replace(/,/g, ".");
|
||||
else s = s.replace(/,/g, "");
|
||||
}
|
||||
} else if (lastDot !== -1) {
|
||||
// Only dot present; normalize grouped thousands separators.
|
||||
if (hasGroupedThousands(s, ".")) {
|
||||
s = s.replace(/\./g, "");
|
||||
} else if ((s.match(/\./g) || []).length > 1) {
|
||||
s = s.replace(/\./g, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle compact notation (K, M, B, T, P, G) and byte suffixes (KB, MB, GB, TB, PB)
|
||||
const compactMatch = s.match(/^([+-]?\d+\.?\d*|\d*\.\d+)([KMBTPG]B?|B)$/i);
|
||||
if (compactMatch) {
|
||||
const baseNum = Number(compactMatch[1]);
|
||||
if (Number.isNaN(baseNum)) return null;
|
||||
|
||||
const suffix = compactMatch[2].toUpperCase();
|
||||
|
||||
// Disambiguate single "B" (bytes vs billions)
|
||||
// If whole number < 1024, treat as bytes. Otherwise, billions.
|
||||
if (suffix === "B") {
|
||||
const isLikelyBytes = Number.isInteger(baseNum) && baseNum < 1024;
|
||||
return isLikelyBytes ? baseNum : baseNum * 1e9;
|
||||
}
|
||||
|
||||
const multipliers: Record<string, number> = {
|
||||
K: 1e3,
|
||||
KB: 1024, // Kilo: metric vs binary
|
||||
M: 1e6,
|
||||
MB: 1024 ** 2, // Mega
|
||||
G: 1e9,
|
||||
GB: 1024 ** 3, // Giga
|
||||
T: 1e12,
|
||||
TB: 1024 ** 4, // Tera
|
||||
P: 1e15,
|
||||
PB: 1024 ** 5, // Peta
|
||||
};
|
||||
|
||||
return baseNum * (multipliers[suffix] ?? 1);
|
||||
}
|
||||
|
||||
if (/^[+-]?(?:\d+\.?\d*|\d*\.\d+)$/.test(s)) {
|
||||
const n = Number(s);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Geo Map
|
||||
|
||||
Implementation for the "geo-map" Tool UI surface.
|
||||
|
||||
## Files
|
||||
|
||||
- public exports: components/tool-ui/geo-map/index.tsx
|
||||
- serializable schema + parse helpers: components/tool-ui/geo-map/schema.ts
|
||||
- public facade component: components/tool-ui/geo-map/geo-map.tsx
|
||||
- internal Leaflet engine: components/tool-ui/geo-map/geo-map-engine.tsx
|
||||
- colocated Leaflet shell theme styles: components/tool-ui/geo-map/geo-map-theme.module.css
|
||||
- icon construction helpers: components/tool-ui/geo-map/geo-map-icons.ts
|
||||
- popup/tooltip overlay renderer: components/tool-ui/geo-map/geo-map-overlays.tsx
|
||||
|
||||
## Companion assets
|
||||
|
||||
- Docs page: app/docs/geo-map/content.mdx
|
||||
- Preset payload: lib/presets/geo-map.ts
|
||||
|
||||
## Quick check
|
||||
|
||||
Run this after edits:
|
||||
|
||||
pnpm test
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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")
|
||||
* Leaflet → map primitives from react-leaflet
|
||||
*/
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
export {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Marker,
|
||||
Polyline,
|
||||
Popup,
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
ZoomControl,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
} from "react-leaflet";
|
||||
@@ -0,0 +1,756 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as LeafletMap } from "leaflet";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Supercluster from "supercluster";
|
||||
import {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Marker,
|
||||
Polyline,
|
||||
TileLayer,
|
||||
ZoomControl,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
} from "./_adapter";
|
||||
import { createClusterIcon, resolveMarkerIcon } from "./geo-map-icons";
|
||||
import { GeoMapOverlays } from "./geo-map-overlays";
|
||||
import type {
|
||||
GeoMapClustering,
|
||||
GeoMapFitTarget,
|
||||
GeoMapMarker,
|
||||
GeoMapRoute,
|
||||
GeoMapViewport,
|
||||
} from "./schema";
|
||||
|
||||
const TILE_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
const ROUTE_DEFAULT_COLOR = "var(--primary)";
|
||||
const ROUTE_DEFAULT_WEIGHT = 3;
|
||||
const ROUTE_DEFAULT_OPACITY = 0.85;
|
||||
const EMPTY_ROUTES: GeoMapRoute[] = [];
|
||||
|
||||
const CLUSTER_RADIUS_DEFAULT = 60;
|
||||
const CLUSTER_MAX_ZOOM_DEFAULT = 16;
|
||||
const CLUSTER_MIN_POINTS_DEFAULT = 2;
|
||||
|
||||
const DEFAULT_CENTER: [number, number] = [20, 0];
|
||||
export const DEFAULT_VIEW_ZOOM = 2;
|
||||
const SINGLE_LOCATION_ZOOM = 13;
|
||||
const DEFAULT_VIEWPORT_PADDING = 32;
|
||||
|
||||
type LeafletRuntime = Pick<
|
||||
typeof import("leaflet"),
|
||||
"divIcon" | "latLngBounds"
|
||||
>;
|
||||
|
||||
export type GeoMapBbox = [
|
||||
west: number,
|
||||
south: number,
|
||||
east: number,
|
||||
north: number,
|
||||
];
|
||||
export type GeoMapLatLng = [lat: number, lng: number];
|
||||
|
||||
export type GeoMapClusterProperties = {
|
||||
cluster?: boolean;
|
||||
cluster_id?: number;
|
||||
point_count?: number;
|
||||
markerId?: string;
|
||||
};
|
||||
|
||||
export type GeoMapClusterFeature = GeoJSON.Feature<
|
||||
GeoJSON.Point,
|
||||
GeoMapClusterProperties
|
||||
>;
|
||||
|
||||
type MarkerClusterPointProperties = GeoMapClusterProperties & {
|
||||
markerId?: string;
|
||||
marker?: GeoMapMarker;
|
||||
};
|
||||
|
||||
type MapViewportState = {
|
||||
bbox: GeoMapBbox;
|
||||
zoom: number;
|
||||
};
|
||||
|
||||
function roundCoordinate(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function normalizeViewportState(state: MapViewportState): MapViewportState {
|
||||
return {
|
||||
bbox: [
|
||||
roundCoordinate(state.bbox[0]),
|
||||
roundCoordinate(state.bbox[1]),
|
||||
roundCoordinate(state.bbox[2]),
|
||||
roundCoordinate(state.bbox[3]),
|
||||
],
|
||||
zoom: state.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
function areViewportStatesEqual(
|
||||
a: MapViewportState | null,
|
||||
b: MapViewportState,
|
||||
): boolean {
|
||||
if (!a) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
a.zoom === b.zoom &&
|
||||
a.bbox[0] === b.bbox[0] &&
|
||||
a.bbox[1] === b.bbox[1] &&
|
||||
a.bbox[2] === b.bbox[2] &&
|
||||
a.bbox[3] === b.bbox[3]
|
||||
);
|
||||
}
|
||||
|
||||
function serializeFitPoints(points: [number, number][]): string {
|
||||
return points
|
||||
.map(([lat, lng]) => `${roundCoordinate(lat)},${roundCoordinate(lng)}`)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function readViewportState(map: LeafletMap): MapViewportState {
|
||||
const bounds = map.getBounds();
|
||||
return normalizeViewportState({
|
||||
bbox: [
|
||||
bounds.getWest(),
|
||||
bounds.getSouth(),
|
||||
bounds.getEast(),
|
||||
bounds.getNorth(),
|
||||
],
|
||||
zoom: Math.round(map.getZoom()),
|
||||
});
|
||||
}
|
||||
|
||||
export function collectFitPoints(
|
||||
markers: GeoMapMarker[],
|
||||
routes: GeoMapRoute[],
|
||||
target: GeoMapFitTarget,
|
||||
): GeoMapLatLng[] {
|
||||
const markerPoints =
|
||||
target === "markers" || target === "all"
|
||||
? markers.map((marker) => [marker.lat, marker.lng] as GeoMapLatLng)
|
||||
: [];
|
||||
|
||||
const routePoints =
|
||||
target === "routes" || target === "all"
|
||||
? routes.flatMap((route) =>
|
||||
route.points.map((point) => [point.lat, point.lng] as GeoMapLatLng),
|
||||
)
|
||||
: [];
|
||||
|
||||
return [...markerPoints, ...routePoints];
|
||||
}
|
||||
|
||||
export function resolveFitPointsWithFallback(
|
||||
markers: GeoMapMarker[],
|
||||
routes: GeoMapRoute[],
|
||||
target: GeoMapFitTarget,
|
||||
): GeoMapLatLng[] {
|
||||
const selected = collectFitPoints(markers, routes, target);
|
||||
if (selected.length > 0) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
if (target !== "markers") {
|
||||
return collectFitPoints(markers, routes, "markers");
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function splitDatelineBbox(bbox: GeoMapBbox): GeoMapBbox[] {
|
||||
const [west, south, east, north] = bbox;
|
||||
|
||||
if (west <= east) {
|
||||
return [bbox];
|
||||
}
|
||||
|
||||
return [
|
||||
[west, south, 180, north],
|
||||
[-180, south, east, north],
|
||||
];
|
||||
}
|
||||
|
||||
function getClusterFeatureKey(feature: GeoMapClusterFeature): string {
|
||||
const properties = feature.properties ?? {};
|
||||
|
||||
if (properties.cluster && typeof properties.cluster_id === "number") {
|
||||
return `cluster:${properties.cluster_id}`;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof properties.markerId === "string" &&
|
||||
properties.markerId.length > 0
|
||||
) {
|
||||
return `marker:${properties.markerId}`;
|
||||
}
|
||||
|
||||
if (feature.id !== undefined && feature.id !== null) {
|
||||
return `id:${String(feature.id)}`;
|
||||
}
|
||||
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
return `point:${lat}:${lng}`;
|
||||
}
|
||||
|
||||
function dedupeClusterFeatures(
|
||||
features: GeoMapClusterFeature[],
|
||||
): GeoMapClusterFeature[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: GeoMapClusterFeature[] = [];
|
||||
|
||||
features.forEach((feature) => {
|
||||
const key = getClusterFeatureKey(feature);
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
deduped.push(feature);
|
||||
});
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function getClustersForDatelineAwareBbox(
|
||||
bbox: GeoMapBbox,
|
||||
zoom: number,
|
||||
getClustersForBbox: (
|
||||
candidateBbox: GeoMapBbox,
|
||||
zoom: number,
|
||||
) => GeoMapClusterFeature[],
|
||||
): GeoMapClusterFeature[] {
|
||||
const queried = splitDatelineBbox(bbox).flatMap((candidateBbox) =>
|
||||
getClustersForBbox(candidateBbox, zoom),
|
||||
);
|
||||
|
||||
return dedupeClusterFeatures(queried);
|
||||
}
|
||||
|
||||
export function toSafeExpansionZoom(
|
||||
zoom: number,
|
||||
options?: { minZoom?: number; maxZoom?: number; fallback?: number },
|
||||
): number {
|
||||
const minZoom = options?.minZoom ?? 1;
|
||||
const maxZoom = options?.maxZoom ?? 22;
|
||||
const fallback = options?.fallback ?? 2;
|
||||
|
||||
if (!Number.isFinite(zoom)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(maxZoom, Math.max(minZoom, Math.round(zoom)));
|
||||
}
|
||||
|
||||
function resolveInitialView(
|
||||
markers: GeoMapMarker[],
|
||||
routes: GeoMapRoute[],
|
||||
viewport: GeoMapViewport | undefined,
|
||||
): { center: [number, number]; zoom: number } {
|
||||
if (viewport?.mode === "center") {
|
||||
return {
|
||||
center: [viewport.center.lat, viewport.center.lng],
|
||||
zoom: viewport.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
const fitTarget = viewport?.target ?? "all";
|
||||
const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);
|
||||
|
||||
if (fitPoints.length === 1) {
|
||||
return {
|
||||
center: [fitPoints[0][0], fitPoints[0][1]],
|
||||
zoom: viewport?.maxZoom
|
||||
? Math.min(SINGLE_LOCATION_ZOOM, viewport.maxZoom)
|
||||
: SINGLE_LOCATION_ZOOM,
|
||||
};
|
||||
}
|
||||
|
||||
return { center: DEFAULT_CENTER, zoom: DEFAULT_VIEW_ZOOM };
|
||||
}
|
||||
|
||||
function ViewportController({
|
||||
markers,
|
||||
routes,
|
||||
viewport,
|
||||
leafletRuntime,
|
||||
}: {
|
||||
markers: GeoMapMarker[];
|
||||
routes: GeoMapRoute[];
|
||||
viewport: GeoMapViewport | undefined;
|
||||
leafletRuntime: LeafletRuntime;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const lastAppliedViewportRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
lastAppliedViewportRef.current = null;
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewport?.mode === "center") {
|
||||
const viewportKey = `center:${roundCoordinate(viewport.center.lat)}:${roundCoordinate(viewport.center.lng)}:${viewport.zoom}`;
|
||||
if (lastAppliedViewportRef.current === viewportKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedViewportRef.current = viewportKey;
|
||||
map.setView([viewport.center.lat, viewport.center.lng], viewport.zoom);
|
||||
return;
|
||||
}
|
||||
|
||||
const fitTarget = viewport?.target ?? "all";
|
||||
const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);
|
||||
if (fitPoints.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxZoom = viewport?.maxZoom;
|
||||
if (fitPoints.length === 1) {
|
||||
const [lat, lng] = fitPoints[0];
|
||||
const zoom = maxZoom
|
||||
? Math.min(SINGLE_LOCATION_ZOOM, maxZoom)
|
||||
: SINGLE_LOCATION_ZOOM;
|
||||
const viewportKey = `fit-single:${roundCoordinate(lat)}:${roundCoordinate(lng)}:${zoom}`;
|
||||
if (lastAppliedViewportRef.current === viewportKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedViewportRef.current = viewportKey;
|
||||
map.setView([lat, lng], zoom);
|
||||
return;
|
||||
}
|
||||
|
||||
const padding = viewport?.padding ?? DEFAULT_VIEWPORT_PADDING;
|
||||
const viewportKey = `fit:${fitTarget}:${padding}:${maxZoom ?? "none"}:${serializeFitPoints(fitPoints)}`;
|
||||
if (lastAppliedViewportRef.current === viewportKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedViewportRef.current = viewportKey;
|
||||
const bounds = leafletRuntime.latLngBounds(fitPoints);
|
||||
map.fitBounds(bounds, {
|
||||
maxZoom,
|
||||
padding: [padding, padding],
|
||||
});
|
||||
}, [leafletRuntime, map, markers, routes, viewport]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapObserver({
|
||||
onViewportChange,
|
||||
onMapReady,
|
||||
}: {
|
||||
onViewportChange: (state: MapViewportState) => void;
|
||||
onMapReady: (map: LeafletMap) => void;
|
||||
}) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => {
|
||||
onViewportChange(readViewportState(map));
|
||||
},
|
||||
zoomend: () => {
|
||||
onViewportChange(readViewportState(map));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onMapReady(map);
|
||||
onViewportChange(readViewportState(map));
|
||||
}, [map, onMapReady, onViewportChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveMarkerAriaLabel(marker: GeoMapMarker): string {
|
||||
if (marker.label && marker.description) {
|
||||
return `${marker.label}. ${marker.description}`;
|
||||
}
|
||||
|
||||
return (
|
||||
marker.label ??
|
||||
marker.description ??
|
||||
`Marker at ${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}`
|
||||
);
|
||||
}
|
||||
|
||||
export const GeoMapEngine = memo(function GeoMapEngine({
|
||||
id,
|
||||
markers,
|
||||
routes,
|
||||
clustering,
|
||||
viewport,
|
||||
showZoomControl,
|
||||
tileUrl,
|
||||
mapAriaLabel,
|
||||
tooltipClassName,
|
||||
popupClassName,
|
||||
onMarkerClick,
|
||||
onRouteClick,
|
||||
onReadyChange,
|
||||
}: {
|
||||
id: string;
|
||||
markers: GeoMapMarker[];
|
||||
routes?: GeoMapRoute[];
|
||||
clustering?: GeoMapClustering;
|
||||
viewport?: GeoMapViewport;
|
||||
showZoomControl: boolean;
|
||||
tileUrl: string;
|
||||
mapAriaLabel: string;
|
||||
tooltipClassName?: string;
|
||||
popupClassName?: string;
|
||||
onMarkerClick?: (marker: GeoMapMarker) => void;
|
||||
onRouteClick?: (route: GeoMapRoute) => void;
|
||||
onReadyChange?: (isReady: boolean) => void;
|
||||
}) {
|
||||
const resolvedRoutes = routes ?? EMPTY_ROUTES;
|
||||
const [leafletRuntime, setLeafletRuntime] = useState<LeafletRuntime | null>(
|
||||
null,
|
||||
);
|
||||
const [mapInstance, setMapInstance] = useState<LeafletMap | null>(null);
|
||||
const [viewportState, setViewportState] = useState<MapViewportState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleViewportChange = useCallback((nextState: MapViewportState) => {
|
||||
const normalized = normalizeViewportState(nextState);
|
||||
setViewportState((previousState) =>
|
||||
areViewportStatesEqual(previousState, normalized)
|
||||
? previousState
|
||||
: normalized,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
void import("leaflet").then((module) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLeafletRuntime({
|
||||
divIcon: module.divIcon,
|
||||
latLngBounds: module.latLngBounds,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isReady = leafletRuntime !== null;
|
||||
|
||||
useEffect(() => {
|
||||
onReadyChange?.(isReady);
|
||||
}, [isReady, onReadyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = mapInstance.getContainer();
|
||||
container.setAttribute("role", "region");
|
||||
container.setAttribute("aria-label", mapAriaLabel);
|
||||
}, [mapAriaLabel, mapInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
mapInstance.closePopup();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [mapInstance]);
|
||||
|
||||
const initialView = useMemo(
|
||||
() => resolveInitialView(markers, resolvedRoutes, viewport),
|
||||
[markers, resolvedRoutes, viewport],
|
||||
);
|
||||
|
||||
const markerById = useMemo(() => {
|
||||
const map = new Map<string, GeoMapMarker>();
|
||||
markers.forEach((marker, index) => {
|
||||
map.set(marker.id ?? `marker-${index}`, marker);
|
||||
});
|
||||
return map;
|
||||
}, [markers]);
|
||||
|
||||
const clusterConfig = useMemo(
|
||||
() => ({
|
||||
enabled: clustering?.enabled === true,
|
||||
radius: clustering?.radius ?? CLUSTER_RADIUS_DEFAULT,
|
||||
maxZoom: clustering?.maxZoom ?? CLUSTER_MAX_ZOOM_DEFAULT,
|
||||
minPoints: clustering?.minPoints ?? CLUSTER_MIN_POINTS_DEFAULT,
|
||||
}),
|
||||
[clustering],
|
||||
);
|
||||
|
||||
const clusterIndex = useMemo(() => {
|
||||
if (!clusterConfig.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = new Supercluster<MarkerClusterPointProperties>({
|
||||
radius: clusterConfig.radius,
|
||||
maxZoom: clusterConfig.maxZoom,
|
||||
minPoints: clusterConfig.minPoints,
|
||||
});
|
||||
|
||||
const points = markers.map((marker, index) => {
|
||||
const markerId = marker.id ?? `marker-${index}`;
|
||||
return {
|
||||
type: "Feature" as const,
|
||||
id: markerId,
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: [marker.lng, marker.lat] as [number, number],
|
||||
},
|
||||
properties: {
|
||||
markerId,
|
||||
marker,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
index.load(points);
|
||||
return index;
|
||||
}, [
|
||||
clusterConfig.enabled,
|
||||
clusterConfig.maxZoom,
|
||||
clusterConfig.minPoints,
|
||||
clusterConfig.radius,
|
||||
markers,
|
||||
]);
|
||||
|
||||
const clusteredFeatures = useMemo(() => {
|
||||
if (!clusterConfig.enabled || !clusterIndex || !viewportState) {
|
||||
return [] as GeoMapClusterFeature[];
|
||||
}
|
||||
|
||||
return getClustersForDatelineAwareBbox(
|
||||
viewportState.bbox,
|
||||
viewportState.zoom,
|
||||
(bbox, zoom) =>
|
||||
clusterIndex.getClusters(bbox, zoom) as GeoMapClusterFeature[],
|
||||
);
|
||||
}, [clusterConfig.enabled, clusterIndex, viewportState]);
|
||||
|
||||
const renderMarker = useCallback(
|
||||
(
|
||||
marker: GeoMapMarker,
|
||||
markerKey: string,
|
||||
markerPositionOverride?: [number, number],
|
||||
) => {
|
||||
const markerPosition: [number, number] = markerPositionOverride ?? [
|
||||
marker.lat,
|
||||
marker.lng,
|
||||
];
|
||||
const tooltipMode = marker.tooltip ?? "hover";
|
||||
const tooltipContent = marker.label ?? marker.description;
|
||||
const icon = marker.icon;
|
||||
const markerAriaLabel = resolveMarkerAriaLabel(marker);
|
||||
|
||||
if (!leafletRuntime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leafletIcon = resolveMarkerIcon(icon, leafletRuntime);
|
||||
if (leafletIcon) {
|
||||
return (
|
||||
<Marker
|
||||
key={markerKey}
|
||||
position={markerPosition}
|
||||
icon={leafletIcon}
|
||||
title={markerAriaLabel}
|
||||
alt={markerAriaLabel}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick?.(marker),
|
||||
}}
|
||||
>
|
||||
<GeoMapOverlays
|
||||
tooltipMode={tooltipMode}
|
||||
tooltipContent={tooltipContent}
|
||||
label={marker.label}
|
||||
description={marker.description}
|
||||
tooltipClassName={tooltipClassName}
|
||||
popupClassName={popupClassName}
|
||||
/>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
const markerStroke =
|
||||
icon?.type === "dot"
|
||||
? (icon.borderColor ?? "var(--border)")
|
||||
: "var(--border)";
|
||||
const markerFill =
|
||||
icon?.type === "dot"
|
||||
? (icon.color ?? "var(--primary)")
|
||||
: "var(--primary)";
|
||||
const markerRadius = icon?.type === "dot" ? (icon.radius ?? 7) : 7;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={markerKey}
|
||||
center={markerPosition}
|
||||
radius={markerRadius}
|
||||
pathOptions={{
|
||||
color: markerStroke,
|
||||
fillColor: markerFill,
|
||||
fillOpacity: 0.95,
|
||||
weight: 2,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick?.(marker),
|
||||
}}
|
||||
>
|
||||
<GeoMapOverlays
|
||||
tooltipMode={tooltipMode}
|
||||
tooltipContent={tooltipContent}
|
||||
label={marker.label}
|
||||
description={marker.description}
|
||||
tooltipClassName={tooltipClassName}
|
||||
popupClassName={popupClassName}
|
||||
/>
|
||||
</CircleMarker>
|
||||
);
|
||||
},
|
||||
[leafletRuntime, onMarkerClick, popupClassName, tooltipClassName],
|
||||
);
|
||||
|
||||
if (!leafletRuntime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={initialView.center}
|
||||
zoom={initialView.zoom}
|
||||
zoomControl={false}
|
||||
className="h-full w-full"
|
||||
scrollWheelZoom
|
||||
>
|
||||
<TileLayer attribution={TILE_ATTRIBUTION} url={tileUrl} />
|
||||
{showZoomControl && <ZoomControl position="topright" />}
|
||||
<MapObserver
|
||||
onMapReady={setMapInstance}
|
||||
onViewportChange={handleViewportChange}
|
||||
/>
|
||||
<ViewportController
|
||||
leafletRuntime={leafletRuntime}
|
||||
markers={markers}
|
||||
routes={resolvedRoutes}
|
||||
viewport={viewport}
|
||||
/>
|
||||
|
||||
{resolvedRoutes.map((route, routeIndex) => {
|
||||
const routeKey = route.id ?? `${id}-route-${routeIndex}`;
|
||||
const positions = route.points.map((point) => [
|
||||
point.lat,
|
||||
point.lng,
|
||||
]) as [number, number][];
|
||||
const tooltipMode = route.tooltip ?? "hover";
|
||||
const tooltipContent = route.label ?? route.description;
|
||||
|
||||
return (
|
||||
<Polyline
|
||||
key={routeKey}
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: route.color ?? ROUTE_DEFAULT_COLOR,
|
||||
weight: route.weight ?? ROUTE_DEFAULT_WEIGHT,
|
||||
opacity: route.opacity ?? ROUTE_DEFAULT_OPACITY,
|
||||
dashArray: route.dashArray,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onRouteClick?.(route),
|
||||
}}
|
||||
>
|
||||
<GeoMapOverlays
|
||||
tooltipMode={tooltipMode}
|
||||
tooltipContent={tooltipContent}
|
||||
label={route.label}
|
||||
description={route.description}
|
||||
tooltipClassName={tooltipClassName}
|
||||
popupClassName={popupClassName}
|
||||
/>
|
||||
</Polyline>
|
||||
);
|
||||
})}
|
||||
|
||||
{clusterConfig.enabled && clusterIndex && viewportState
|
||||
? clusteredFeatures.map((feature, index) => {
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
const properties = (feature.properties ??
|
||||
{}) as MarkerClusterPointProperties;
|
||||
|
||||
if (
|
||||
properties.cluster &&
|
||||
typeof properties.cluster_id === "number"
|
||||
) {
|
||||
const pointCount = properties.point_count ?? 0;
|
||||
const clusterId = properties.cluster_id;
|
||||
const clusterIcon = createClusterIcon(pointCount, leafletRuntime);
|
||||
const clusterAriaLabel = `Cluster containing ${pointCount} locations`;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`cluster-${clusterId}`}
|
||||
position={[lat, lng]}
|
||||
icon={clusterIcon}
|
||||
title={clusterAriaLabel}
|
||||
alt={clusterAriaLabel}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
if (!mapInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expansionZoom = toSafeExpansionZoom(
|
||||
clusterIndex.getClusterExpansionZoom(clusterId),
|
||||
{
|
||||
maxZoom: 22,
|
||||
fallback:
|
||||
(viewportState.zoom ?? DEFAULT_VIEW_ZOOM) + 2,
|
||||
},
|
||||
);
|
||||
mapInstance.flyTo([lat, lng], expansionZoom);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const marker =
|
||||
properties.marker ??
|
||||
markerById.get(properties.markerId ?? `marker-${index}`);
|
||||
if (!marker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const markerKey =
|
||||
marker.id ?? properties.markerId ?? `${id}-cluster-leaf-${index}`;
|
||||
return renderMarker(marker, markerKey, [lat, lng]);
|
||||
})
|
||||
: markers.map((marker, index) =>
|
||||
renderMarker(marker, marker.id ?? `${id}-marker-${index}`),
|
||||
)}
|
||||
</MapContainer>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { DivIcon } from "leaflet";
|
||||
import type { GeoMapMarker } from "./schema";
|
||||
|
||||
type LeafletIconRuntime = Pick<typeof import("leaflet"), "divIcon">;
|
||||
|
||||
function isSafeHttpUrl(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function createEmojiIcon(
|
||||
icon: Extract<NonNullable<GeoMapMarker["icon"]>, { type: "emoji" }>,
|
||||
leafletRuntime: LeafletIconRuntime,
|
||||
): DivIcon {
|
||||
const size = icon.size ?? 24;
|
||||
const background = icon.bgColor ?? "var(--card)";
|
||||
const border = icon.borderColor ?? "var(--border)";
|
||||
|
||||
return leafletRuntime.divIcon({
|
||||
className: "",
|
||||
html: `<span style="
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
width:${size}px;
|
||||
height:${size}px;
|
||||
border-radius:999px;
|
||||
background:${background};
|
||||
border:1px solid ${border};
|
||||
font-size:${Math.round(size * 0.62)}px;
|
||||
line-height:1;
|
||||
box-shadow:0 1px 3px oklch(from var(--foreground) l c h / 0.22);
|
||||
">${escapeHtml(icon.value)}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -Math.round(size / 2)],
|
||||
tooltipAnchor: [0, -Math.round(size / 2)],
|
||||
});
|
||||
}
|
||||
|
||||
function createImageIcon(
|
||||
icon: Extract<NonNullable<GeoMapMarker["icon"]>, { type: "image" }>,
|
||||
leafletRuntime: LeafletIconRuntime,
|
||||
): DivIcon {
|
||||
const width = icon.width ?? 28;
|
||||
const height = icon.height ?? 28;
|
||||
const borderRadius = icon.borderRadius ?? Math.min(width, height) / 2;
|
||||
const border = icon.borderColor ?? "var(--border)";
|
||||
|
||||
return leafletRuntime.divIcon({
|
||||
className: "",
|
||||
html: `<span style="
|
||||
display:block;
|
||||
width:${width}px;
|
||||
height:${height}px;
|
||||
border-radius:${borderRadius}px;
|
||||
overflow:hidden;
|
||||
border:1px solid ${border};
|
||||
background:var(--card);
|
||||
box-shadow:0 1px 3px oklch(from var(--foreground) l c h / 0.22);
|
||||
"><img src="${escapeHtml(icon.url)}" alt="" style="width:100%;height:100%;object-fit:cover;display:block;" /></span>`,
|
||||
iconSize: [width, height],
|
||||
iconAnchor: [width / 2, height / 2],
|
||||
popupAnchor: [0, -Math.round(height / 2)],
|
||||
tooltipAnchor: [0, -Math.round(height / 2)],
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterIcon(
|
||||
count: number,
|
||||
leafletRuntime: LeafletIconRuntime,
|
||||
): DivIcon {
|
||||
const size = count >= 100 ? 42 : count >= 10 ? 38 : 34;
|
||||
const background = "var(--primary)";
|
||||
const border = "var(--background)";
|
||||
|
||||
return leafletRuntime.divIcon({
|
||||
className: "",
|
||||
html: `<span style="
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
width:${size}px;
|
||||
height:${size}px;
|
||||
border-radius:999px;
|
||||
background:${background};
|
||||
border:2px solid ${border};
|
||||
color:var(--primary-foreground);
|
||||
font-size:12px;
|
||||
font-weight:700;
|
||||
line-height:1;
|
||||
box-shadow:0 2px 6px oklch(from var(--foreground) l c h / 0.25);
|
||||
">${count}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -Math.round(size / 2)],
|
||||
tooltipAnchor: [0, -Math.round(size / 2)],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMarkerIcon(
|
||||
icon: GeoMapMarker["icon"] | undefined,
|
||||
leafletRuntime: LeafletIconRuntime,
|
||||
): DivIcon | null {
|
||||
if (icon?.type === "emoji") {
|
||||
return createEmojiIcon(icon, leafletRuntime);
|
||||
}
|
||||
|
||||
if (icon?.type === "image" && isSafeHttpUrl(icon.url)) {
|
||||
return createImageIcon(icon, leafletRuntime);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user