[eric] chat: AskUI blocking channel (interactive tool-ui answers flow back as tool results) + restore audio/chart/code-block/code-diff/geo-map + zod-generated prop hints

This commit is contained in:
ciregenz
2026-07-19 22:55:09 -07:00
parent 3eb8969c87
commit 17b3a5f157
44 changed files with 5207 additions and 79 deletions
@@ -82,10 +82,11 @@ def build_effective_tool_lists(
if name == "openswarm-ui":
policy = builtin_perms.get("ShowUI", "always_allow")
if policy == "always_allow":
effective_allowed.append("mcp__openswarm-ui__ShowUI")
else:
effective_disallowed.append("mcp__openswarm-ui__ShowUI")
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":
@@ -159,16 +159,20 @@ def register_builtin_mcp_servers(
"type": "stdio",
}
# Display-only ShowUI server: renders rich inline components (weather, plan, stats, links)
# in the transcript. Pure display, no state mutation; the frontend renders from the
# tool_call input, the server only validates. Gated on the ShowUI builtin perm.
# 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": {},
"env": {
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
"OPENSWARM_AUTH_TOKEN": get_auth_token(),
"OPENSWARM_PARENT_SESSION_ID": session.id,
},
"type": "stdio",
}
+106 -25
View File
@@ -7,40 +7,84 @@ 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
COMPONENT_SPECS = {
"weather": "props: {location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high: number, low?: number}] (max 7)}",
"plan": "props: {title?: str, steps: [{label: str, status: 'pending'|'in_progress'|'completed'}] (max 20)}",
"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)}",
# tool-ui vendored set: props follow the upstream Serializable contracts (https://tool-ui.com);
# the client validates strictly and shows a validation note instead of rendering on mismatch.
"data-table": "tabular results. props: {id: str, columns: [{key: str, label: str}], data: [{<key>: str|number|bool}]}",
"citation": "sourced claims. props: {id: str, citations: [{id: str, title: str, url?: str, snippet?: str}]}",
"item-carousel": "browsable items. props: {id: str, items: [{id: str, title: str, description?: str, imageUrl?: str, badge?: str}]}",
"link-preview": "one rich link card. props: {id: str, url: str, title: str, description?: str, imageUrl?: str, siteName?: str}",
"progress-tracker": "multi-stage progress. props: {id: str, stages: [{id: str, label: str, status: 'pending'|'active'|'complete'|'error'}]}",
"order-summary": "purchase/receipt breakdown. props: {id: str, items: [{id: str, label: str, amount: number}], total?: number, currency?: str}",
"terminal": "command output. props: {id: str, command?: str, output: str}",
"image": "single image. props: {id: str, src: str, alt?: str, caption?: str}",
"image-gallery": "several images. props: {id: str, images: [{src: str, alt?: str}]}",
"video": "video embed. props: {id: str, src: str, poster?: str, title?: str}",
"message-draft": "email/message draft for review. props: {id: str, to?: [str], subject?: str, body: str}",
"x-post": "an X/Twitter post preview. props: {id: str, author: {name: str, handle: str}, text: str}",
"linkedin-post": "a LinkedIn post preview. props: {id: str, author: {name: str, headline?: str}, text: str}",
"instagram-post": "an Instagram post preview. props: {id: str, username: str, imageUrl: str, caption?: str}",
"option-list": "choices for the user (display for now). props: {id: str, options: [{id: str, label: str, description?: str}], selectionMode?: 'single'|'multi'}",
"question-flow": "step-by-step question sequence (display for now). props follow the upstream question-flow contract",
"parameter-slider": "adjustable parameters (display for now). props: {id: str, parameters: [{id: str, label: str, min: number, max: number, value: number, step?: number}]}",
"preferences-panel": "grouped preference toggles (display for now). props follow the upstream preferences-panel contract",
"approval-card": "an approve/reject summary card. props follow the upstream approval-card contract",
"stats-display": "upstream stats-display contract (prefer 'stats' unless you need its exact shape)",
# Vendored tool-ui set (MIT, https://tool-ui.com); hints are AUTO-GENERATED from the shipped zod
# contracts by frontend/scripts/gen-toolui-hints.ts. Regenerate after upgrading src/toolui.
"approval-card": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', title: str, description?: str, icon?: str, metadata?: [{key: str, value: str}], variant?: 'default'|'destructive', confirmLabel?: str, cancelLabel?: str, choice?: 'approved'|'denied'}",
"audio": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, assetId: str, src: str, title?: str, description?: str, artwork?: str, durationMs?: num, fileSizeBytes?: num, createdAt?: str, locale?: str, source?: {label: str, iconUrl?: str, u...",
"chart": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, type: 'bar'|'line', title?: str, description?: str, data: [{}], xKey: str, series: [{key: str, label: str, color?: str}], colors?: [str], showLegend?: bool, showGrid?: bool}",
"citation": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, href: str, title: str, snippet?: str, domain?: str, favicon?: str, author?: str, publishedAt?: str, type?: 'webpage'|'document'|'article'|'api'|'code'|'other', locale?: str}",
"code-block": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, code: str, language?: str, lineNumbers?: 'visible'|'hidden', filename?: str, highlightLines?: [num], maxCollapsedLines?: num}",
"code-diff": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, oldCode?: str, newCode?: str, patch?: str, language?: str, filename?: str, lineNumbers?: 'visible'|'hidden', diffStyle?: 'unified'|'split', maxCollapsedLines?: num}",
"data-table": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, columns: [{key: str, label: str, abbr?: str, sortable?: bool, align?: 'left'|'right'|'center', width?: str, truncate?: bool, priority?: 'primary'|'secondary'|'tertiary', hideOnMob...",
"geo-map": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, title?: str, description?: str, markers: [{id?: str, lat: num, lng: num, label?: str, description?: str, tooltip?: 'none'|'hover'|'always', icon?: obj|obj|obj}], routes?: [{id?: s...",
"image": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, assetId: str, src: str, alt: str, title?: str, description?: str, href?: str, domain?: str, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain', fileSizeBytes?: num,...",
"image-gallery": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, images: [{id: str, src: str, alt: str, width: num, height: num, title?: str, caption?: str, source?: obj}], title?: str, description?: str}",
"instagram-post": "props: {id: str, author: {name: str, handle: str, avatarUrl: str, verified?: bool}, text?: str, media?: [{type: 'image'|'video', url: str, alt: str}], stats?: {likes?: num, isLiked?: bool}, createdAt?: str}",
"item-carousel": "props: {id: str, name: str, subtitle?: str, image?: str, color?: str, actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|'outline', loading?: bool, disabled?: bool, shortcut?: str}]}",
"link-preview": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, href: str, title?: str, description?: str, image?: str, domain?: str, favicon?: str, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain', createdAt?: str, locale?: str}",
"linkedin-post": "props: {id: str, author: {name: str, avatarUrl: str, headline?: str}, text?: str, media?: {type: 'image'|'video', url: str, alt: str}, linkPreview?: {url: str, title?: str, description?: str, imageUrl?: str, domain?: str}, stats?: {likes?: num, isLiked?: bool}, createdAt?: str}",
"message-draft": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', body: str, outcome?: 'sent'|'cancelled', channel: str, subject: str, from?: str, to: [str], cc?: [str], bcc?: [str]}",
"option-list": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, options: [{id: str, label: str, description?: str, disabled?: bool}], selectionMode?: 'multi'|'single', defaultValue?: [str]|str|any, choice?: [str]|str|any, actions?: [{id: str, ...",
"order-summary": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', title?: str, variant?: 'summary'|'receipt', items: [{id: str, name: str, description?: str, imageUrl?: str, quantity?: num, unitPrice: num}], pricing: {subtotal: num, tax?: num, taxLabel?: str, shipping?: num, discount?: num, discountLabel?: str, total: num, currency?: str}, choice?:...",
"parameter-slider": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', sliders: [{id: str, label: str, min: num, max: num, step?: num, value: num, unit?: str, precision?: num, disabled?: bool, trackClassName?: str, fillClassName?: str, handleClassName?: str}], actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'dest...",
"plan": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, title: str, description?: str, todos: [{id: str, label: str, status: 'pending'|'in_progress'|'completed'|'cancelled', description?: str}], maxVisibleTodos?: num}",
"preferences-panel": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, title?: str, sections: [{heading?: str, items: [any]}], actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|...",
"progress-tracker": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', steps: [{id: str, label: str, description?: str, status: 'pending'|'in-progress'|'completed'|'failed'}], elapsedTime?: num, choice?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}}",
"question-flow": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', step: num, title: str, description?: str, options: [{id: str, label: str, description?: str, disabled?: bool}], selectionMode?: 'single'|'multi'}",
"stats-display": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', title?: str, description?: str, stats: [{key: str, label: str, value: str|num, format?: any, diff?: obj, sparkline?: obj}]}",
"terminal": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, command: str, stdout?: str, stderr?: str, exitCode: num, durationMs?: num, cwd?: str, truncated?: bool, maxCollapsedLines?: num}",
"video": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, assetId: str, src: str, poster?: str, title?: str, description?: str, href?: str, domain?: str, durationMs?: num, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain'...",
"x-post": "props: {id: str, author: {name: str, handle: str, avatarUrl: str, verified?: bool}, text?: str, media?: {type: 'image'|'video', url: str, alt: str, aspectRatio?: '1:1'|'4:3'|'16:9'|'9:16'}, linkPreview?: {url: str, title?: str, description?: str, imageUrl?: str, domain?: str}, quotedPost?: any, stats?: {likes?: num, isLiked?: bool, isReposted?: bool, isBookmarke...",
}
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."
),
"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": (
@@ -90,8 +134,6 @@ def validate(component: str, props: dict) -> str:
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 == "plan" and not (isinstance(props.get("steps"), list) and props["steps"]):
return f"plan needs a non-empty steps list. {COMPONENT_SPECS['plan']}"
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"]):
@@ -101,7 +143,46 @@ def validate(component: str, props: dict) -> str:
return ""
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()
+56
View File
@@ -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
+37
View File
@@ -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.
+839 -39
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -19,6 +19,7 @@
"@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",
@@ -27,16 +28,20 @@
"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",
"recharts": "^3.9.2",
"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"
},
@@ -46,9 +51,11 @@
"@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",
@@ -60,6 +67,7 @@
"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",
+73
View File
@@ -0,0 +1,73 @@
/* 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', 'SerializableItemSchema'],
'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 || {};
const parts = Object.keys(props).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 out: Record<string, string> = {};
for (const [name, [path, exportName]] of Object.entries(TARGETS)) {
try {
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 > 360) hint = hint.slice(0, 357) + '...';
out[name] = hint;
} catch (e) {
out[name] = `ERROR: ${(e as Error).message.slice(0, 80)}`;
}
}
for (const [name, hint] of Object.entries(out)) {
console.log(` "${name}": "props: ${hint.replace(/"/g, "'")}",`);
}
}
void main();
+13 -4
View File
@@ -59,7 +59,8 @@ import MessageActionBar from './shell/MessageActionBar';
import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble';
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble';
import ToolUiBubble from './tool-ui/ToolUiBubble';
import { isShowUiPair } from './tool-ui/showUiPayload';
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';
@@ -1079,10 +1080,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
result: results[idx] || null,
}));
// ShowUI calls render as inline components, never buried inside a collapsed group.
// 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(isShowUiPair);
const pairs = allPairs.filter((p) => !isShowUiPair(p));
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);
const mcpServers = new Set(
@@ -1607,6 +1608,14 @@ 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}>
@@ -0,0 +1,88 @@
import React, { useCallback, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
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';
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 answered = parseResultResponse(pair);
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 }),
}).catch(() => setSubmitted(false));
},
[submitted, sessionId, componentId],
);
// Their embedded-actions contract: onAction(actionId, state) delivers the component's full state;
// approval-card uses onConfirm/onCancel instead. Inject a default Send action when none given.
const extraProps = useMemo(() => {
if (!payload || payload.component !== 'vendored') return {};
const waiting = pair.result === null && !submitted;
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) {
const hasActions = Array.isArray((payload.props as { actions?: unknown[] }).actions) && (payload.props as { actions?: unknown[] }).actions!.length > 0;
return {
...(hasActions ? {} : { actions: [{ id: 'submit', label: 'Send' }] }),
onAction: (actionId: string, state: unknown) => respond({ action: actionId, value: state ?? null }),
};
}
return answered && 'value' in answered ? { choice: answered.value } : {};
}, [payload, pair.result, submitted, respond, answered]);
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} />
{submitted && pair.result === null && (
<Box sx={{ fontSize: '0.72rem', opacity: 0.55, pt: 0.5 }}>Sent to the agent...</Box>
)}
</Box>
);
}
export default AskUiBubble;
@@ -70,6 +70,11 @@ export function isShowUiPair(pair: ToolPair): boolean {
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--) {
+4 -2
View File
@@ -5,12 +5,14 @@ import { TOOL_UI_REGISTRY } from './registry';
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 = 'pending' | 'ok' | 'bad';
/** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */
function VendoredToolUi({ name, props }: VendoredToolUiProps): React.ReactElement | null {
function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React.ReactElement | null {
const { mode } = useThemeMode();
const entry = TOOL_UI_REGISTRY[name];
const [gate, setGate] = useState<Gate>('pending');
@@ -50,7 +52,7 @@ function VendoredToolUi({ name, props }: VendoredToolUiProps): React.ReactElemen
return (
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
<Component {...props} />
<Component {...props} {...(extraProps || {})} />
</Suspense>
</div>
);
@@ -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 @@
# 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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">&bull;</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,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 =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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;
}
@@ -0,0 +1,86 @@
"use client";
import { useMemo, useState } from "react";
import { Popup, Tooltip, cn } from "./_adapter";
function GeoMapPopupContent({
label,
description,
}: {
label?: string;
description?: string;
}) {
return (
<div className="flex flex-col gap-0.5">
{label && (
<p className="block text-sm leading-tight font-semibold tracking-tight text-foreground">
{label}
</p>
)}
{description && (
<p className="block text-xs leading-relaxed text-muted-foreground">
{description}
</p>
)}
</div>
);
}
function GeoMapTooltipContent({ text }: { text: string }) {
return <span className="block">{text}</span>;
}
export function GeoMapOverlays({
tooltipMode,
tooltipContent,
label,
description,
tooltipClassName,
popupClassName,
}: {
tooltipMode: "none" | "hover" | "always";
tooltipContent?: string;
label?: string;
description?: string;
tooltipClassName?: string;
popupClassName?: string;
}) {
const hasPopup = Boolean(label || description);
const [isPopupOpen, setIsPopupOpen] = useState(false);
const shouldRenderTooltip =
tooltipMode !== "none" && tooltipContent && (!hasPopup || !isPopupOpen);
const popupEventHandlers = useMemo(
() => ({
add: () => setIsPopupOpen(true),
remove: () => setIsPopupOpen(false),
}),
[],
);
return (
<>
{shouldRenderTooltip && (
<Tooltip
direction="top"
permanent={tooltipMode === "always"}
className={cn("geo-map-tooltip", tooltipClassName)}
>
<GeoMapTooltipContent text={tooltipContent} />
</Tooltip>
)}
{hasPopup && (
<Popup
className={cn("geo-map-popup", popupClassName)}
closeButton
closeOnEscapeKey
minWidth={0}
maxWidth={288}
eventHandlers={popupEventHandlers}
>
<GeoMapPopupContent label={label} description={description} />
</Popup>
)}
</>
);
}
@@ -0,0 +1,216 @@
.root[data-slot="geo-map"] {
--geo-map-canvas-bg: var(--muted);
--geo-map-tooltip-bg: var(--foreground);
--geo-map-tooltip-fg: var(--background);
--geo-map-tooltip-shadow: 0 8px 20px
oklch(from var(--foreground) l c h / 0.18);
--geo-map-tooltip-radius: calc(var(--radius) - 2px);
--geo-map-tooltip-padding: 0.375rem 0.625rem;
--geo-map-tooltip-font-size: 0.75rem;
--geo-map-tooltip-font-weight: 500;
--geo-map-tooltip-line-height: 1.2;
--geo-map-popup-margin-bottom: 12px;
--geo-map-popup-border: var(--border);
--geo-map-popup-radius: calc(var(--radius) + 2px);
--geo-map-popup-bg: oklch(from var(--popover) l c h / 0.96);
--geo-map-popup-fg: var(--popover-foreground);
--geo-map-popup-shadow: 0 10px 30px oklch(from var(--foreground) l c h / 0.12);
--geo-map-popup-blur: 8px;
--geo-map-popup-content-padding: 0.625rem 0.75rem;
--geo-map-popup-max-width: min(80vw, 18rem);
--geo-map-popup-font-family: var(
--font-sans,
ui-sans-serif,
system-ui,
sans-serif
);
--geo-map-zoom-bg: oklch(from var(--background) l c h / 0.78);
--geo-map-zoom-fg: var(--foreground);
--geo-map-zoom-border: var(--border);
--geo-map-zoom-hover-bg: oklch(from var(--accent) l c h / 0.82);
--geo-map-zoom-hover-fg: var(--accent-foreground);
--geo-map-zoom-disabled-bg: oklch(from var(--muted) l c h / 0.72);
--geo-map-zoom-disabled-fg: var(--muted-foreground);
--geo-map-zoom-shadow: 0 1px 2px oklch(from var(--foreground) l c h / 0.08);
--geo-map-zoom-focus-ring: var(--ring);
--geo-map-zoom-radius: 0.5rem;
--geo-map-zoom-size: 2.25rem;
--geo-map-zoom-font-size: 1.125rem;
}
.root[data-slot="geo-map"] :global(.leaflet-container) {
background: var(--geo-map-canvas-bg);
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom) {
border: 1px solid var(--geo-map-zoom-border);
box-shadow: var(--geo-map-zoom-shadow);
background: var(--geo-map-zoom-bg);
backdrop-filter: blur(var(--geo-map-popup-blur));
-webkit-backdrop-filter: blur(var(--geo-map-popup-blur));
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom.leaflet-bar) {
border-radius: var(--geo-map-zoom-radius) !important;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a) {
display: flex;
align-items: center;
justify-content: center;
width: var(--geo-map-zoom-size);
height: var(--geo-map-zoom-size);
line-height: 1;
text-indent: 0;
border: 0;
background: transparent;
color: var(--geo-map-zoom-fg);
font-size: var(--geo-map-zoom-font-size);
font-weight: 500;
box-shadow: none;
cursor: default;
transition:
background-color 150ms ease,
color 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease;
border-radius: 0 !important;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a + a) {
border-top: 1px solid var(--geo-map-zoom-border);
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:first-child),
.root[data-slot="geo-map"]
:global(.leaflet-touch .leaflet-control-zoom a:first-child),
.root[data-slot="geo-map"]
:global(.leaflet-control-zoom .leaflet-control-zoom-in) {
border-radius: var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) 0 0 !important;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:last-child),
.root[data-slot="geo-map"]
:global(.leaflet-touch .leaflet-control-zoom a:last-child),
.root[data-slot="geo-map"]
:global(.leaflet-control-zoom .leaflet-control-zoom-out) {
border-top: 0;
border-radius: 0 0 var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) !important;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:hover) {
background: var(--geo-map-zoom-hover-bg);
color: var(--geo-map-zoom-hover-fg);
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus),
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus-visible) {
position: relative;
z-index: 1;
outline: 2px solid var(--geo-map-zoom-focus-ring);
outline-offset: 1px;
}
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a.leaflet-disabled),
.root[data-slot="geo-map"]
:global(.leaflet-control-zoom a.leaflet-disabled:hover) {
background: var(--geo-map-zoom-disabled-bg);
color: var(--geo-map-zoom-disabled-fg);
opacity: 0.55;
}
.root[data-slot="geo-map"] :global(.leaflet-tooltip.geo-map-tooltip) {
border: 0;
border-radius: var(--geo-map-tooltip-radius);
background: var(--geo-map-tooltip-bg);
color: var(--geo-map-tooltip-fg);
box-shadow: var(--geo-map-tooltip-shadow);
font-size: var(--geo-map-tooltip-font-size);
font-weight: var(--geo-map-tooltip-font-weight);
line-height: var(--geo-map-tooltip-line-height);
padding: var(--geo-map-tooltip-padding);
}
.root[data-slot="geo-map"]
:global(.leaflet-tooltip-top.geo-map-tooltip::before) {
border-top-color: var(--geo-map-tooltip-bg);
}
.root[data-slot="geo-map"]
:global(.leaflet-tooltip-bottom.geo-map-tooltip::before) {
border-bottom-color: var(--geo-map-tooltip-bg);
}
.root[data-slot="geo-map"]
:global(.leaflet-tooltip-left.geo-map-tooltip::before) {
border-left-color: var(--geo-map-tooltip-bg);
}
.root[data-slot="geo-map"]
:global(.leaflet-tooltip-right.geo-map-tooltip::before) {
border-right-color: var(--geo-map-tooltip-bg);
}
.root[data-slot="geo-map"] :global(.leaflet-popup.geo-map-popup) {
margin-bottom: var(--geo-map-popup-margin-bottom);
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-content-wrapper) {
border: 1px solid var(--geo-map-popup-border);
border-radius: var(--geo-map-popup-radius);
background: var(--geo-map-popup-bg);
color: var(--geo-map-popup-fg);
box-shadow: var(--geo-map-popup-shadow);
backdrop-filter: blur(var(--geo-map-popup-blur));
-webkit-backdrop-filter: blur(var(--geo-map-popup-blur));
padding: 0;
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-content) {
margin: 0;
min-width: 0;
width: max-content;
max-width: var(--geo-map-popup-max-width);
padding: var(--geo-map-popup-content-padding);
font-family: var(--geo-map-popup-font-family);
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-content p) {
margin: 0;
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-tip-container) {
display: none;
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button) {
color: var(--geo-map-popup-fg);
opacity: 0.75;
top: 0.25rem;
right: 0.25rem;
width: 1.5rem;
height: 1.5rem;
font-size: 1rem;
line-height: 1.5rem;
border-radius: calc(var(--radius) - 2px);
}
.root[data-slot="geo-map"]
:global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button:hover) {
opacity: 1;
background: oklch(from var(--muted) l c h / 0.65);
}
.root[data-slot="geo-map"]
:global(
.leaflet-popup.geo-map-popup .leaflet-popup-close-button:focus-visible
) {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
@@ -0,0 +1,162 @@
"use client";
import { memo, useEffect, useState } from "react";
import { cn } from "./_adapter";
import { GeoMapEngine } from "./geo-map-engine";
import styles from "./geo-map-theme.module.css";
import type { GeoMapProps, GeoMapStyle } from "./schema";
const LIGHT_TILE_URL =
"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
const DARK_TILE_URL =
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
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 useInheritedTheme(): "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;
}
function resolveMapAriaLabel(title?: string, description?: string): string {
if (title && description) {
return `${title}. ${description}`;
}
return title ?? description ?? "Geographic map";
}
export const GeoMap = memo(function GeoMap({
id,
role: _role,
receipt: _receipt,
title,
description,
markers,
routes,
clustering,
viewport,
showZoomControl = true,
theme,
className,
style,
tooltipClassName,
popupClassName,
onMarkerClick,
onRouteClick,
}: GeoMapProps) {
const inheritedTheme = useInheritedTheme();
const resolvedTheme = theme ?? inheritedTheme;
const [isMapReady, setIsMapReady] = useState(false);
const tileUrl = resolvedTheme === "dark" ? DARK_TILE_URL : LIGHT_TILE_URL;
const mapAriaLabel = resolveMapAriaLabel(title, description);
const resolvedRootStyle: GeoMapStyle = {
"--geo-map-canvas-bg":
resolvedTheme === "dark" ? "var(--background)" : "var(--muted)",
...style,
};
return (
<div
className={cn("w-full min-w-80", styles.root, className)}
style={resolvedRootStyle}
data-slot="geo-map"
data-tool-ui-id={id}
>
<div
className="bg-muted/20 relative h-[320px] w-full overflow-hidden rounded-lg border"
role="region"
aria-label={mapAriaLabel}
>
<GeoMapEngine
id={id}
markers={markers}
routes={routes}
clustering={clustering}
viewport={viewport}
showZoomControl={showZoomControl}
tileUrl={tileUrl}
mapAriaLabel={mapAriaLabel}
tooltipClassName={tooltipClassName}
popupClassName={popupClassName}
onMarkerClick={onMarkerClick}
onRouteClick={onRouteClick}
onReadyChange={setIsMapReady}
/>
{(title || description) && (
<div
className={cn(
"pointer-events-none absolute top-3 left-3 z-[900]",
"max-w-[min(75%,22rem)] rounded-lg border border-border/70 bg-background/70 px-3 py-2",
"shadow-sm backdrop-blur-md",
)}
>
{title && (
<p className="text-foreground text-sm leading-tight font-semibold">
{title}
</p>
)}
{description && (
<p className="text-muted-foreground mt-1 text-xs leading-snug">
{description}
</p>
)}
</div>
)}
{!isMapReady && (
<div
data-slot="geo-map-loading"
className="bg-muted/30 text-muted-foreground pointer-events-none absolute inset-0 flex items-center justify-center"
>
<span data-slot="geo-map-loading-label">Loading map...</span>
</div>
)}
</div>
</div>
);
});
@@ -0,0 +1,16 @@
import "leaflet/dist/leaflet.css";
import "./leaflet-overrides.css";
export { GeoMap } from "./geo-map";
export {
type GeoMapClustering,
type GeoMapFitTarget,
type GeoMapMarker,
type GeoMapMarkerIcon,
type GeoMapStyle,
type GeoMapRoute,
type GeoMapViewport,
type GeoMapProps,
type GeoMapClientProps,
type SerializableGeoMap,
} from "./schema";
@@ -0,0 +1,37 @@
/* Leaflet overrides for theme integration */
.leaflet-container {
background: var(--muted);
/* Isolate Leaflet's high z-indices to prevent them from escaping the container */
isolation: isolate;
}
[data-theme="dark"] .leaflet-container {
background: hsl(240 0 10%);
}
.leaflet-control-attribution {
background: oklch(from var(--background) l c h / 0.8) !important;
color: var(--muted-foreground);
}
[data-theme="dark"] .leaflet-control-attribution {
background: hsl(240 10% 3.9% / 0.8) !important;
color: hsl(240 5% 64.9%);
}
.leaflet-control-attribution a {
color: var(--muted-foreground);
}
[data-theme="dark"] .leaflet-control-attribution a {
color: hsl(240 5% 64.9%);
}
.leaflet-control-attribution a:hover {
color: var(--foreground);
}
[data-theme="dark"] .leaflet-control-attribution a:hover {
color: hsl(0 0% 98%);
}
@@ -0,0 +1,198 @@
import { z } from "zod";
import type { CSSProperties } from "react";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
const LatitudeSchema = z.number().finite().min(-90).max(90);
const LongitudeSchema = z.number().finite().min(-180).max(180);
const HttpUrlSchema = z
.string()
.url()
.refine((value) => /^https?:\/\//i.test(value), {
message: "Expected an http or https URL.",
});
const GeoMapMarkerIconDotSchema = z.object({
type: z.literal("dot"),
color: z.string().optional(),
borderColor: z.string().optional(),
radius: z.number().min(3).max(16).optional(),
});
const GeoMapMarkerIconEmojiSchema = z.object({
type: z.literal("emoji"),
value: z.string().min(1),
size: z.number().min(16).max(40).optional(),
bgColor: z.string().optional(),
borderColor: z.string().optional(),
});
const GeoMapMarkerIconImageSchema = z.object({
type: z.literal("image"),
url: HttpUrlSchema,
width: z.number().min(16).max(64).optional(),
height: z.number().min(16).max(64).optional(),
borderRadius: z.number().min(0).max(999).optional(),
borderColor: z.string().optional(),
});
export const GeoMapMarkerIconSchema = z.union([
GeoMapMarkerIconDotSchema,
GeoMapMarkerIconEmojiSchema,
GeoMapMarkerIconImageSchema,
]);
export type GeoMapMarkerIcon = z.infer<typeof GeoMapMarkerIconSchema>;
export const GeoMapMarkerSchema = z.object({
id: z.string().min(1).optional(),
lat: LatitudeSchema,
lng: LongitudeSchema,
label: z.string().optional(),
description: z.string().optional(),
tooltip: z.enum(["none", "hover", "always"]).optional(),
icon: GeoMapMarkerIconSchema.optional(),
});
export type GeoMapMarker = z.infer<typeof GeoMapMarkerSchema>;
export const GeoMapRoutePointSchema = z.object({
lat: LatitudeSchema,
lng: LongitudeSchema,
});
export const GeoMapRouteSchema = z.object({
id: z.string().min(1).optional(),
points: z.array(GeoMapRoutePointSchema).min(2),
label: z.string().optional(),
description: z.string().optional(),
tooltip: z.enum(["none", "hover", "always"]).optional(),
color: z.string().optional(),
weight: z.number().min(1).max(12).optional(),
opacity: z.number().min(0).max(1).optional(),
dashArray: z.string().optional(),
});
export type GeoMapRoute = z.infer<typeof GeoMapRouteSchema>;
export const GeoMapClusteringSchema = z.object({
enabled: z.boolean().optional(),
radius: z.number().min(20).max(120).optional(),
maxZoom: z.number().min(1).max(22).optional(),
minPoints: z.number().min(2).max(20).optional(),
});
export type GeoMapClustering = z.infer<typeof GeoMapClusteringSchema>;
export const GeoMapFitTargetSchema = z.enum(["markers", "routes", "all"]);
export type GeoMapFitTarget = z.infer<typeof GeoMapFitTargetSchema>;
const GeoMapFitViewportSchema = z.object({
mode: z.literal("fit"),
padding: z.number().nonnegative().optional(),
maxZoom: z.number().min(1).max(22).optional(),
target: GeoMapFitTargetSchema.optional(),
});
const GeoMapCenterViewportSchema = z.object({
mode: z.literal("center"),
center: z.object({
lat: LatitudeSchema,
lng: LongitudeSchema,
}),
zoom: z.number().min(1).max(22),
});
export const GeoMapViewportSchema = z.union([
GeoMapFitViewportSchema,
GeoMapCenterViewportSchema,
]);
export type GeoMapViewport = z.infer<typeof GeoMapViewportSchema>;
export const GeoMapPropsSchema = z
.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
title: z.string().optional(),
description: z.string().optional(),
markers: z.array(GeoMapMarkerSchema).min(1),
routes: z.array(GeoMapRouteSchema).optional(),
clustering: GeoMapClusteringSchema.optional(),
viewport: GeoMapViewportSchema.optional(),
showZoomControl: z.boolean().optional(),
theme: z.enum(["light", "dark"]).optional(),
})
.superRefine((value, ctx) => {
const seenMarkerIds = new Set<string>();
value.markers.forEach((marker, index) => {
if (!marker.id) {
return;
}
if (seenMarkerIds.has(marker.id)) {
ctx.addIssue({
code: "custom",
path: ["markers", index, "id"],
message: `Duplicate marker id "${marker.id}".`,
});
return;
}
seenMarkerIds.add(marker.id);
});
const seenRouteIds = new Set<string>();
value.routes?.forEach((route, index) => {
if (!route.id) {
return;
}
if (seenRouteIds.has(route.id)) {
ctx.addIssue({
code: "custom",
path: ["routes", index, "id"],
message: `Duplicate route id "${route.id}".`,
});
return;
}
seenRouteIds.add(route.id);
});
});
export type GeoMapStyle = CSSProperties &
Partial<Record<`--${string}`, string | number>>;
export type GeoMapClientProps = {
className?: string;
style?: GeoMapStyle;
tooltipClassName?: string;
popupClassName?: string;
onMarkerClick?: (marker: GeoMapMarker) => void;
onRouteClick?: (route: GeoMapRoute) => void;
};
export type GeoMapProps = z.infer<typeof GeoMapPropsSchema> & GeoMapClientProps;
export const SerializableGeoMapSchema = GeoMapPropsSchema;
export type SerializableGeoMap = z.infer<typeof SerializableGeoMapSchema>;
const SerializableGeoMapSchemaContract = defineToolUiContract(
"GeoMap",
SerializableGeoMapSchema,
);
export const parseSerializableGeoMap: (input: unknown) => SerializableGeoMap =
SerializableGeoMapSchemaContract.parse;
export const safeParseSerializableGeoMap: (
input: unknown,
) => SerializableGeoMap | null = SerializableGeoMapSchemaContract.safeParse;
+20
View File
@@ -11,6 +11,26 @@ export interface ToolUiEntry {
/* Every entry lazy-loads both the component and its zod contract so the chat bundle only pays
for components a transcript actually uses. Names mirror upstream tool-ui component slugs. */
export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
'audio': {
Component: lazy(() => import('./components/audio').then((m) => ({ default: m.Audio }))),
loadSchema: () => import('./components/audio/schema').then((m) => m.SerializableAudioSchema),
},
'chart': {
Component: lazy(() => import('./components/chart').then((m) => ({ default: m.Chart }))),
loadSchema: () => import('./components/chart/schema').then((m) => m.SerializableChartSchema),
},
'code-block': {
Component: lazy(() => import('./components/code-block').then((m) => ({ default: m.CodeBlock }))),
loadSchema: () => import('./components/code-block/schema').then((m) => m.SerializableCodeBlockSchema),
},
'code-diff': {
Component: lazy(() => import('./components/code-diff').then((m) => ({ default: m.CodeDiff }))),
loadSchema: () => import('./components/code-diff/schema').then((m) => m.SerializableCodeDiffSchema),
},
'geo-map': {
Component: lazy(() => import('./components/geo-map').then((m) => ({ default: m.GeoMap }))),
loadSchema: () => import('./components/geo-map/schema').then((m) => m.SerializableGeoMapSchema),
},
'approval-card': {
Component: lazy(() => import('./components/approval-card').then((m) => ({ default: m.ApprovalCard }))),
loadSchema: () => import('./components/approval-card/schema').then((m) => m.SerializableApprovalCardSchema),
+357
View File
@@ -0,0 +1,357 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@toolui/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};