feat(mcp): add MCP UI variants and TUI output (#545)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-02-13 15:21:48 -06:00
committed by GitHub
parent f6e0a5b5bb
commit 8bc03d1357
59 changed files with 8264 additions and 3 deletions
+130
View File
@@ -0,0 +1,130 @@
# MCP UI Bakeoff - Instructions & Test Plan
Last updated: 2026-02-02
## Scope
Compare three presentation paths for Basic Memory MCP tools:
1. **ToolUI (React)** via MCP App resources.
2. **MCPUI Python SDK** embedded UI resources (legacy host path).
3. **ASCII/ANSI** output for TUI clients.
This doc is the running instruction set and test plan. Update as implementation progresses.
---
## Prerequisites
- Repo: `basic-memory` (worktree: `basic-memory-mcp-ui-poc`)
- Node for toolui build (already used for POC)
- Python 3.12+ with `uv`
Optional (for MCPUI Python SDK path):
- Local repo: `/Users/phernandez/dev/mcp-ui`
- Install the server SDK into the Basic Memory venv:
- `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server`
---
## Build / Refresh Steps
### ToolUI React bundle
```bash
cd ui/tool-ui-react
npm install
npm run build
```
This regenerates:
- `src/basic_memory/mcp/ui/html/search-results-tool-ui.html`
- `src/basic_memory/mcp/ui/html/note-preview-tool-ui.html`
---
## How to Run the MCP Server
```bash
basic-memory mcp --transport stdio
```
Optional to pick UI variant for MCP App resources:
```bash
export BASIC_MEMORY_MCP_UI_VARIANT=tool-ui # or vanilla | mcp-ui
```
---
## Test Cases
### 1) MCP App Resource UI (toolui / vanilla / mcpui)
Tools:
- `search_notes`
- `read_note`
Expect:
- Tool meta points to `ui://basic-memory/search-results` and `ui://basic-memory/note-preview`
- Resource content differs by `BASIC_MEMORY_MCP_UI_VARIANT`
- Variantspecific URIs also available:
- `ui://basic-memory/search-results/vanilla`
- `ui://basic-memory/search-results/tool-ui`
- `ui://basic-memory/search-results/mcp-ui`
- `ui://basic-memory/note-preview/vanilla`
- `ui://basic-memory/note-preview/tool-ui`
- `ui://basic-memory/note-preview/mcp-ui`
Manual check:
- Trigger tool in MCPAppcapable host and confirm UI renders.
---
### 2) ASCII / ANSI TUI Output
Tools:
- `search_notes(output_format="ascii" | "ansi")`
- `read_note(output_format="ascii" | "ansi")`
Expect:
- ASCII table for search, header + content preview for note.
- ANSI variants include color escape codes.
Automated:
- `uv run pytest test-int/mcp/test_output_format_ascii_integration.py`
---
### 3) MCPUI Python SDK (embedded UI resource)
Tools (embedded resource responses):
- `search_notes_ui` (MCPUI SDK)
- `read_note_ui` (MCPUI SDK)
Expected output:
- Tool response content contains an EmbeddedResource (`type: "resource"`)
- `mimeType` is `text/html`
- `_meta` includes:
- `mcpui.dev/ui-preferred-frame-size`
- `mcpui.dev/ui-initial-render-data`
Manual check:
- Render tool responses using `UIResourceRenderer` (legacy host flow).
Automated (if SDK installed):
- `uv run pytest test-int/mcp/test_ui_sdk_integration.py`
---
## Bakeoff Notes Template
Fill in after running:
- ToolUI (React): __
- MCPUI SDK (embedded): __
- ASCII/ANSI: __
Decision + rationale: __
+1
View File
@@ -53,6 +53,7 @@ def mcp(
# Import mcp tools/prompts to register them with the server
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
import basic_memory.mcp.resources # noqa: F401 # pragma: no cover
# Initialize logging for MCP (file only, stdout breaks protocol)
init_mcp_logging()
+160
View File
@@ -0,0 +1,160 @@
"""Formatting helpers for MCP tool outputs."""
from __future__ import annotations
from typing import Iterable, Sequence
from basic_memory.schemas.search import SearchResponse, SearchResult
ANSI_RESET = "\x1b[0m"
ANSI_BOLD = "\x1b[1m"
ANSI_DIM = "\x1b[2m"
ANSI_CYAN = "\x1b[36m"
def _apply_style(text: str, style: str, enabled: bool) -> str:
if not enabled:
return text
return f"{style}{text}{ANSI_RESET}"
def _strip_frontmatter(text: str) -> str:
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return text
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
return "\n".join(lines[idx + 1 :]).lstrip()
return text
def _parse_title(text: str) -> str | None:
for line in text.splitlines():
if line.startswith("# "):
return line[2:].strip()
return None
def _truncate(text: str, width: int) -> str:
if width <= 0:
return ""
if len(text) <= width:
return text
if width <= 3:
return text[:width]
return text[: width - 3] + "..."
def _make_separator(widths: Sequence[int]) -> str:
return "+" + "+".join("-" * (width + 2) for width in widths) + "+"
def _format_row(values: Sequence[str], widths: Sequence[int]) -> str:
cells = []
for value, width in zip(values, widths, strict=True):
cells.append(f" {_truncate(value, width).ljust(width)} ")
return "|" + "|".join(cells) + "|"
def _get_result_tags(result: SearchResult) -> str:
metadata = result.metadata or {}
if isinstance(metadata, dict):
tags = metadata.get("tags")
if isinstance(tags, list):
return ", ".join(str(tag) for tag in tags if tag)
return ""
def _get_result_path(result: SearchResult) -> str:
return result.permalink or result.file_path or ""
def format_search_results_ascii(
result: SearchResponse,
query: str | None = None,
color: bool = False,
) -> str:
"""Format search results as an ASCII table for TUI clients."""
results = result.results or []
header_line = _apply_style("Search results", f"{ANSI_BOLD}{ANSI_CYAN}", color)
lines = [header_line]
if query:
lines.append(f"Query: {query}")
summary = f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
lines.append(_apply_style(summary, ANSI_DIM, color))
if not results:
lines.append("No results.")
return "\n".join(lines).strip()
headers = ["#", "Title", "Type", "Score", "Path", "Tags"]
rows = []
for idx, item in enumerate(results, start=1):
rows.append(
[
str(idx),
item.title or "Untitled",
item.type.value if hasattr(item.type, "value") else str(item.type),
f"{item.score:.2f}" if isinstance(item.score, (int, float)) else "",
_get_result_path(item),
_get_result_tags(item),
]
)
max_widths = [3, 32, 10, 7, 36, 24]
widths = []
for index, header in enumerate(headers):
column_values = [header] + [row[index] for row in rows]
max_len = max(len(value) for value in column_values)
widths.append(min(max_widths[index], max_len))
table = [_make_separator(widths)]
header_row = _format_row(headers, widths)
if color:
header_cells = []
for value, width in zip(headers, widths, strict=True):
padded = f" {_truncate(value, width).ljust(width)} "
header_cells.append(_apply_style(padded, f"{ANSI_BOLD}{ANSI_CYAN}", color))
header_row = "|" + "|".join(header_cells) + "|"
table.append(header_row)
table.append(_make_separator(widths))
for row in rows:
table.append(_format_row(row, widths))
table.append(_make_separator(widths))
lines.append("")
lines.extend(table)
return "\n".join(lines).rstrip()
def format_note_preview_ascii(
content: str,
identifier: str | None = None,
color: bool = False,
) -> str:
"""Format note content for ASCII/TUI display."""
identifier = identifier or ""
cleaned = _strip_frontmatter(content)
title = _parse_title(cleaned) or identifier or "Note Preview"
header = _apply_style("Note preview", f"{ANSI_BOLD}{ANSI_CYAN}", color)
lines = [header, f"Title: {title}"]
if identifier:
lines.append(f"Identifier: {identifier}")
lines.append(_apply_style("-" * 72, ANSI_DIM, color))
if content.strip():
lines.append(content.rstrip())
else:
lines.append("(empty note)")
return "\n".join(lines).rstrip()
@@ -0,0 +1,25 @@
"""MCP resources for Basic Memory."""
from basic_memory.mcp.resources.project_info import project_info
from basic_memory.mcp.resources.ui import (
note_preview_ui,
note_preview_ui_mcp_ui,
note_preview_ui_tool_ui,
note_preview_ui_vanilla,
search_results_ui,
search_results_ui_mcp_ui,
search_results_ui_tool_ui,
search_results_ui_vanilla,
)
__all__ = [
"project_info",
"note_preview_ui",
"note_preview_ui_mcp_ui",
"note_preview_ui_tool_ui",
"note_preview_ui_vanilla",
"search_results_ui",
"search_results_ui_mcp_ui",
"search_results_ui_tool_ui",
"search_results_ui_vanilla",
]
+89
View File
@@ -0,0 +1,89 @@
"""UI resources for MCP Apps integration."""
from basic_memory.mcp.server import mcp
from basic_memory.mcp.ui import load_html, load_variant_html
# FastMCP's MIME type validator currently accepts only type/subtype, so we
# use text/html here. MCP Apps hosts typically expect text/html;profile=mcp-app.
MIME_TYPE = "text/html"
@mcp.resource(
uri="ui://basic-memory/search-results",
name="Basic Memory Search Results",
description="Search results UI for Basic Memory tools.",
mime_type=MIME_TYPE,
)
def search_results_ui() -> str:
return load_variant_html("search-results")
@mcp.resource(
uri="ui://basic-memory/note-preview",
name="Basic Memory Note Preview",
description="Note preview UI for Basic Memory read_note tool.",
mime_type=MIME_TYPE,
)
def note_preview_ui() -> str:
return load_variant_html("note-preview")
# Variant-specific resource URIs for bakeoff comparisons.
@mcp.resource(
uri="ui://basic-memory/search-results/vanilla",
name="Basic Memory Search Results (Vanilla)",
description="Vanilla HTML search results UI.",
mime_type=MIME_TYPE,
)
def search_results_ui_vanilla() -> str:
return load_html("search-results-vanilla.html")
@mcp.resource(
uri="ui://basic-memory/search-results/tool-ui",
name="Basic Memory Search Results (Tool UI)",
description="Tool UI styled search results UI.",
mime_type=MIME_TYPE,
)
def search_results_ui_tool_ui() -> str:
return load_html("search-results-tool-ui.html")
@mcp.resource(
uri="ui://basic-memory/search-results/mcp-ui",
name="Basic Memory Search Results (MCP UI)",
description="MCP UI styled search results UI.",
mime_type=MIME_TYPE,
)
def search_results_ui_mcp_ui() -> str:
return load_html("search-results-mcp-ui.html")
@mcp.resource(
uri="ui://basic-memory/note-preview/vanilla",
name="Basic Memory Note Preview (Vanilla)",
description="Vanilla HTML note preview UI.",
mime_type=MIME_TYPE,
)
def note_preview_ui_vanilla() -> str:
return load_html("note-preview-vanilla.html")
@mcp.resource(
uri="ui://basic-memory/note-preview/tool-ui",
name="Basic Memory Note Preview (Tool UI)",
description="Tool UI styled note preview UI.",
mime_type=MIME_TYPE,
)
def note_preview_ui_tool_ui() -> str:
return load_html("note-preview-tool-ui.html")
@mcp.resource(
uri="ui://basic-memory/note-preview/mcp-ui",
name="Basic Memory Note Preview (MCP UI)",
description="MCP UI styled note preview UI.",
mime_type=MIME_TYPE,
)
def note_preview_ui_mcp_ui() -> str:
return load_html("note-preview-mcp-ui.html")
+3
View File
@@ -11,6 +11,7 @@ from basic_memory.mcp.tools.read_content import read_content
from basic_memory.mcp.tools.build_context import build_context
from basic_memory.mcp.tools.recent_activity import recent_activity
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.ui_sdk import read_note_ui, search_notes_ui
from basic_memory.mcp.tools.view_note import view_note
from basic_memory.mcp.tools.write_note import write_note
from basic_memory.mcp.tools.cloud_info import cloud_info
@@ -47,6 +48,7 @@ __all__ = [
"read_content",
"read_note",
"release_notes",
"read_note_ui",
"recent_activity",
"schema_diff",
"schema_infer",
@@ -54,6 +56,7 @@ __all__ = [
"search",
"search_by_metadata",
"search_notes",
"search_notes_ui",
"view_note",
"write_note",
]
+18 -1
View File
@@ -1,13 +1,14 @@
"""Read note tool for Basic Memory MCP server."""
from textwrap import dedent
from typing import Optional
from typing import Optional, Literal
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.formatting import format_note_preview_ascii
from basic_memory.mcp.tools.search import search_notes
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@@ -15,12 +16,14 @@ from basic_memory.utils import validate_project_path
@mcp.tool(
description="Read a markdown note by title or permalink.",
meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
)
async def read_note(
identifier: str,
project: Optional[str] = None,
page: int = 1,
page_size: int = 10,
output_format: Literal["default", "ascii", "ansi"] = "default",
context: Context | None = None,
) -> str:
"""Return the raw markdown for a note, or guidance text if no match is found.
@@ -46,6 +49,8 @@ async def read_note(
Can be a full memory:// URL, a permalink, a title, or search text
page: Page number for paginated results (default: 1)
page_size: Number of items per page (default: 10)
output_format: "default" returns markdown, "ascii" returns a plain text preview,
"ansi" returns a colorized preview for TUI clients.
context: Optional FastMCP context for performance caching.
Returns:
@@ -116,6 +121,12 @@ async def read_note(
# If successful, return the content
if response.status_code == 200:
logger.info("Returning read_note result from resource: {path}", path=entity_path)
if output_format in ("ascii", "ansi"):
return format_note_preview_ascii(
response.text,
identifier=identifier,
color=output_format == "ansi",
)
return response.text
except Exception as e: # pragma: no cover
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
@@ -140,6 +151,12 @@ async def read_note(
if response.status_code == 200:
logger.info(f"Found note by title search: {result.permalink}")
if output_format in ("ascii", "ansi"):
return format_note_preview_ascii(
response.text,
identifier=identifier,
color=output_format == "ansi",
)
return response.text
except Exception as e: # pragma: no cover
logger.info(
+13 -1
View File
@@ -1,12 +1,13 @@
"""Search tools for Basic Memory MCP server."""
from textwrap import dedent
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict, Any, Literal
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.formatting import format_search_results_ascii
from basic_memory.mcp.server import mcp
from basic_memory.schemas.search import (
SearchItemType,
@@ -230,6 +231,7 @@ Error searching for '{query}': {error_message}
@mcp.tool(
description="Search across all content in the knowledge base with advanced syntax support.",
meta={"ui/resourceUri": "ui://basic-memory/search-results"},
)
async def search_notes(
query: str,
@@ -237,6 +239,7 @@ async def search_notes(
page: int = 1,
page_size: int = 10,
search_type: str = "text",
output_format: Literal["default", "ascii", "ansi"] = "default",
types: List[str] | None = None,
entity_types: List[str] | None = None,
after_date: Optional[str] = None,
@@ -320,6 +323,8 @@ async def search_notes(
page_size: The number of results to return per page (default 10)
search_type: Type of search to perform, one of:
"text", "title", "permalink", "vector", "hybrid" (default: "text")
output_format: "default" returns structured data, "ascii" returns a plain text table,
"ansi" returns a colorized table for TUI clients.
types: Optional list of note types to search (e.g., ["note", "person"])
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
@@ -452,6 +457,13 @@ async def search_notes(
# Don't treat this as an error, but the user might want guidance
# We return the empty result as normal - the user can decide if they need help
if output_format in ("ascii", "ansi"):
return format_search_results_ascii(
result,
query=query,
color=output_format == "ansi",
)
return result
except Exception as e:
+123
View File
@@ -0,0 +1,123 @@
"""Embedded UI tools using the MCP-UI Python SDK."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from fastmcp import Context
from mcp.types import ContentBlock, TextContent
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.ui.sdk import MissingMCPUIServerError, build_embedded_ui_resource
def _text_block(message: str) -> List[ContentBlock]:
return [TextContent(type="text", text=message)]
@mcp.tool(
description="Search notes and return an embedded MCP-UI resource (raw HTML).",
output_schema=None,
)
async def search_notes_ui(
query: str,
project: Optional[str] = None,
page: int = 1,
page_size: int = 10,
search_type: str = "text",
types: List[str] | None = None,
entity_types: List[str] | None = None,
after_date: Optional[str] = None,
metadata_filters: Optional[Dict[str, Any]] = None,
tags: Optional[List[str]] = None,
status: Optional[str] = None,
context: Context | None = None,
) -> List[ContentBlock]:
"""Return a search results UI as an embedded MCP-UI resource."""
result = await search_notes.fn(
query=query,
project=project,
page=page,
page_size=page_size,
search_type=search_type,
output_format="default",
types=types,
entity_types=entity_types,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
status=status,
context=context,
)
if isinstance(result, str):
return _text_block(result)
render_data = {
"toolInput": {
"query": query,
"search_type": search_type,
"page": page,
"page_size": page_size,
},
"toolOutput": result.model_dump(),
}
try:
resource = build_embedded_ui_resource(
uri="ui://basic-memory/search-results/mcp-ui-sdk",
html_filename="search-results-mcp-ui.html",
render_data=render_data,
preferred_frame_size=["100%", "540px"],
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
)
except MissingMCPUIServerError as exc:
return _text_block(str(exc))
return [resource]
@mcp.tool(
description="Read a note and return an embedded MCP-UI resource (raw HTML).",
output_schema=None,
)
async def read_note_ui(
identifier: str,
project: Optional[str] = None,
page: int = 1,
page_size: int = 10,
context: Context | None = None,
) -> List[ContentBlock]:
"""Return a note preview UI as an embedded MCP-UI resource."""
content = await read_note.fn(
identifier=identifier,
project=project,
page=page,
page_size=page_size,
output_format="default",
context=context,
)
render_data = {
"toolInput": {
"identifier": identifier,
"page": page,
"page_size": page_size,
},
"toolOutput": content,
}
try:
resource = build_embedded_ui_resource(
uri="ui://basic-memory/note-preview/mcp-ui-sdk",
html_filename="note-preview-mcp-ui.html",
render_data=render_data,
preferred_frame_size=["100%", "640px"],
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
)
except MissingMCPUIServerError as exc:
return _text_block(str(exc))
return [resource]
+5
View File
@@ -0,0 +1,5 @@
"""UI helpers for MCP Apps resources."""
from basic_memory.mcp.ui.templates import get_ui_variant, load_html, load_variant_html
__all__ = ["get_ui_variant", "load_html", "load_variant_html"]
@@ -0,0 +1,176 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Basic Memory Note Preview - MCP UI</title>
<style>
:root {
color-scheme: light;
--bg: #fdf2f2;
--panel: #ffffff;
--ink: #1a1a1d;
--muted: #6b6770;
--border: #ecd7dd;
--accent: #b91c1c;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: "IBM Plex Sans", "Space Grotesk", system-ui, sans-serif;
background: radial-gradient(circle at top, #fff7f7 0%, #f7ecec 60%, #f0e3e4 100%);
color: var(--ink);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header h1 {
margin: 0;
font-size: 19px;
}
.badge {
font-size: 11px;
border-radius: 999px;
padding: 4px 10px;
border: 1px solid var(--border);
background: #fee2e2;
color: var(--accent);
}
.subtitle {
font-size: 12px;
color: var(--muted);
margin-bottom: 12px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.1);
}
pre {
margin: 0;
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
color: #2a2a2a;
}
.empty {
text-align: center;
color: var(--muted);
font-size: 13px;
padding: 18px;
}
</style>
</head>
<body>
<div class="header">
<h1 id="title">Note Preview</h1>
<div class="badge">mcp-ui style</div>
</div>
<div class="subtitle" id="subtitle">Waiting for note content...</div>
<div class="panel">
<div id="empty" class="empty" style="display: none;">No content available.</div>
<pre id="content"></pre>
</div>
<script>
(function () {
const title = document.getElementById("title");
const subtitle = document.getElementById("subtitle");
const contentEl = document.getElementById("content");
const emptyEl = document.getElementById("empty");
let hasData = false;
function stripFrontmatter(text) {
if (!text.startsWith("---")) return text;
const end = text.indexOf("---", 3);
if (end === -1) return text;
return text.slice(end + 3).trim();
}
function parseTitle(text) {
const lines = text.split("\n");
for (const line of lines) {
if (line.startsWith("# ")) return line.replace("# ", "").trim();
}
return null;
}
function extractText(toolOutput) {
if (!toolOutput) return "";
if (typeof toolOutput === "string") return toolOutput;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block && block.type === "text" && typeof block.text === "string"
);
if (textBlock) return textBlock.text;
}
if (toolOutput.structuredContent) {
if (typeof toolOutput.structuredContent === "string") {
return toolOutput.structuredContent;
}
}
return "";
}
function updateFromRenderData(renderData) {
if (!renderData) return;
const identifier = renderData.toolInput && renderData.toolInput.identifier;
const rawText = extractText(renderData.toolOutput);
if (!rawText) {
emptyEl.style.display = "block";
contentEl.textContent = "";
return;
}
emptyEl.style.display = "none";
const text = stripFrontmatter(rawText);
const previewTitle = parseTitle(text) || identifier || "Note Preview";
title.textContent = previewTitle;
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
contentEl.textContent = text.slice(0, 1400).trim();
}
function handleMessage(event) {
const message = event.data || {};
if (message.type === "ui-lifecycle-iframe-render-data") {
hasData = true;
updateFromRenderData(message.payload && message.payload.renderData);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,160 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Basic Memory Note Preview</title>
<style>
:root {
color-scheme: light;
--bg: #f5f1ea;
--panel: #ffffff;
--ink: #1d1d1f;
--muted: #6d6d6d;
--accent: #0f766e;
--border: #e1d7cc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
background: linear-gradient(140deg, #f7f3ed, #efe8dd);
color: var(--ink);
}
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.subtitle {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
margin-bottom: 16px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
}
pre {
margin: 0;
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
color: #2a2a2a;
}
.empty {
text-align: center;
color: var(--muted);
font-size: 13px;
padding: 18px;
}
</style>
</head>
<body>
<h1 id="title">Note Preview</h1>
<div class="subtitle" id="subtitle">Waiting for note content...</div>
<div class="panel">
<div id="empty" class="empty" style="display: none;">No content available.</div>
<pre id="content"></pre>
</div>
<script>
(function () {
const title = document.getElementById("title");
const subtitle = document.getElementById("subtitle");
const contentEl = document.getElementById("content");
const emptyEl = document.getElementById("empty");
let hasData = false;
function stripFrontmatter(text) {
if (!text.startsWith("---")) return text;
const end = text.indexOf("---", 3);
if (end === -1) return text;
return text.slice(end + 3).trim();
}
function parseTitle(text) {
const lines = text.split("\n");
for (const line of lines) {
if (line.startsWith("# ")) return line.replace("# ", "").trim();
}
return null;
}
function extractText(toolOutput) {
if (!toolOutput) return "";
if (typeof toolOutput === "string") return toolOutput;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block && block.type === "text" && typeof block.text === "string"
);
if (textBlock) return textBlock.text;
}
if (toolOutput.structuredContent) {
if (typeof toolOutput.structuredContent === "string") {
return toolOutput.structuredContent;
}
}
return "";
}
function updateFromRenderData(renderData) {
if (!renderData) return;
const identifier = renderData.toolInput && renderData.toolInput.identifier;
const rawText = extractText(renderData.toolOutput);
if (!rawText) {
emptyEl.style.display = "block";
contentEl.textContent = "";
return;
}
emptyEl.style.display = "none";
const text = stripFrontmatter(rawText);
const previewTitle = parseTitle(text) || identifier || "Note Preview";
title.textContent = previewTitle;
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
contentEl.textContent = text.slice(0, 1400).trim();
}
function handleMessage(event) {
const message = event.data || {};
if (message.type === "ui-lifecycle-iframe-render-data") {
hasData = true;
updateFromRenderData(message.payload && message.payload.renderData);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
})();
</script>
</body>
</html>
@@ -0,0 +1,254 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Basic Memory MCP UI Search</title>
<style>
:root {
color-scheme: light;
--bg: #f6f0f2;
--panel: #ffffff;
--ink: #1a1a1d;
--muted: #6b6770;
--accent: #b91c1c;
--accent-soft: #fee2e2;
--border: #ead7dc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: "IBM Plex Sans", "Space Grotesk", system-ui, sans-serif;
background: radial-gradient(circle at top, #fdf7f8 0%, #f7eef1 60%, #f2e7ea 100%);
color: var(--ink);
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.badge {
font-size: 11px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--accent-soft);
color: var(--accent);
padding: 4px 10px;
}
#subtitle {
font-size: 12px;
color: var(--muted);
margin-bottom: 14px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
padding: 10px 8px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
tr:hover {
background: #fff5f5;
}
.title {
font-weight: 600;
}
.path {
font-size: 11px;
color: var(--muted);
margin-top: 4px;
}
.empty {
text-align: center;
padding: 18px;
color: var(--muted);
}
.footer {
margin-top: 12px;
font-size: 12px;
color: var(--muted);
}
</style>
</head>
<body>
<div class="top">
<h1>Search Results</h1>
<div class="badge">mcp-ui style</div>
</div>
<div id="subtitle">Waiting for results...</div>
<div class="panel">
<div id="table"></div>
<div id="empty" class="empty" style="display: none;">No results to show.</div>
</div>
<div id="footer" class="footer"></div>
<script>
(function () {
const subtitle = document.getElementById("subtitle");
const tableContainer = document.getElementById("table");
const emptyState = document.getElementById("empty");
const footer = document.getElementById("footer");
let hasData = false;
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch (error) {
return null;
}
}
function extractStructured(toolOutput) {
if (!toolOutput) return null;
const structured =
toolOutput.structuredContent ||
toolOutput.structured_content ||
toolOutput.structured ||
null;
if (structured) return structured;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block && block.type === "text" && typeof block.text === "string"
);
if (textBlock) {
const parsed = safeJsonParse(textBlock.text);
return parsed || { text: textBlock.text };
}
}
if (typeof toolOutput === "string") {
return safeJsonParse(toolOutput) || { text: toolOutput };
}
return toolOutput;
}
function getResults(structured) {
if (!structured) return [];
if (Array.isArray(structured.results)) return structured.results;
if (structured.result && Array.isArray(structured.result.results)) {
return structured.result.results;
}
return [];
}
function renderTable(results) {
if (!results.length) {
tableContainer.innerHTML = "";
emptyState.style.display = "block";
return;
}
emptyState.style.display = "none";
const rows = results
.map((result) => {
return `
<tr>
<td>
<div class=\"title\">${result.title || "Untitled"}</div>
<div class=\"path\">${result.permalink || result.file_path || ""}</div>
</td>
<td>${result.type || ""}</td>
<td>${Number(result.score || 0).toFixed(2)}</td>
</tr>
`;
})
.join("");
tableContainer.innerHTML = `
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Score</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
function updateFromRenderData(renderData) {
if (!renderData) return;
const query = renderData.toolInput && renderData.toolInput.query;
const structured = extractStructured(renderData.toolOutput);
const results = getResults(structured);
subtitle.textContent = query ? `Query: ${query}` : "Search results";
footer.textContent = `Results: ${results.length}`;
renderTable(results);
}
function handleMessage(event) {
const message = event.data || {};
if (message.type === "ui-lifecycle-iframe-render-data") {
hasData = true;
updateFromRenderData(message.payload && message.payload.renderData);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,280 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Basic Memory Search Results</title>
<style>
:root {
color-scheme: light;
--bg: #f7f4ef;
--panel: #ffffff;
--ink: #1f1f1f;
--muted: #5b5b5b;
--accent: #0f766e;
--accent-2: #0a4f4b;
--border: #e3dbd2;
--row: #fbf9f5;
--row-hover: #f2ece3;
--tag: #f3efe7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
background: radial-gradient(circle at top, #faf6ef 0%, #f2ece2 45%, #efe7da 100%);
color: var(--ink);
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: 0.3px;
}
#subtitle {
font-size: 13px;
color: var(--muted);
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th,
td {
padding: 12px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
tr:nth-child(even) {
background: var(--row);
}
tr:hover {
background: var(--row-hover);
}
.title {
font-weight: 600;
color: var(--accent-2);
}
.meta {
font-size: 12px;
color: var(--muted);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
font-size: 11px;
padding: 2px 8px;
background: var(--tag);
border-radius: 999px;
border: 1px solid var(--border);
}
.empty {
padding: 24px;
text-align: center;
color: var(--muted);
font-size: 14px;
}
.footer {
margin-top: 12px;
font-size: 12px;
color: var(--muted);
}
</style>
</head>
<body>
<header>
<h1>Search Results</h1>
<div id="subtitle">Waiting for results...</div>
</header>
<div class="panel">
<div id="table"></div>
<div id="empty" class="empty" style="display: none;">No results to show.</div>
</div>
<div class="footer" id="footer"></div>
<script>
(function () {
const subtitle = document.getElementById("subtitle");
const tableContainer = document.getElementById("table");
const emptyState = document.getElementById("empty");
const footer = document.getElementById("footer");
let hasData = false;
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch (error) {
return null;
}
}
function extractStructured(toolOutput) {
if (!toolOutput) return null;
const structured =
toolOutput.structuredContent ||
toolOutput.structured_content ||
toolOutput.structured ||
null;
if (structured) return structured;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block && block.type === "text" && typeof block.text === "string"
);
if (textBlock) {
const parsed = safeJsonParse(textBlock.text);
return parsed || { text: textBlock.text };
}
}
if (typeof toolOutput === "string") {
return safeJsonParse(toolOutput) || { text: toolOutput };
}
return toolOutput;
}
function getResults(structured) {
if (!structured) return [];
if (Array.isArray(structured.results)) return structured.results;
if (structured.result && Array.isArray(structured.result.results)) {
return structured.result.results;
}
return [];
}
function renderTable(results) {
if (!results.length) {
tableContainer.innerHTML = "";
emptyState.style.display = "block";
return;
}
emptyState.style.display = "none";
const rows = results
.map((result) => {
const tags =
(result.metadata && result.metadata.tags) ||
result.tags ||
result.metadata?.tag ||
[];
const tagsMarkup = Array.isArray(tags)
? tags.map((tag) => `<span class=\"tag\">${tag}</span>`).join("")
: "";
return `
<tr>
<td>
<div class=\"title\">${result.title || "Untitled"}</div>
<div class=\"meta\">${result.permalink || result.file_path || ""}</div>
</td>
<td>${result.type || ""}</td>
<td>${Number(result.score || 0).toFixed(2)}</td>
<td>
<div class=\"tags\">${tagsMarkup}</div>
</td>
</tr>
`;
})
.join("");
tableContainer.innerHTML = `
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Score</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
function updateFromRenderData(renderData) {
if (!renderData) return;
const query = renderData.toolInput && renderData.toolInput.query;
const structured = extractStructured(renderData.toolOutput);
const results = getResults(structured);
subtitle.textContent = query ? `Query: ${query}` : "Search results";
footer.textContent = `Results: ${results.length}`;
renderTable(results);
}
function handleMessage(event) {
const message = event.data || {};
if (message.type === "ui-lifecycle-iframe-render-data") {
hasData = true;
updateFromRenderData(message.payload && message.payload.renderData);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
})();
</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
"""Helpers for embedded MCP-UI resources (Python SDK)."""
from __future__ import annotations
import importlib
from typing import Any
from basic_memory.mcp.ui import load_html
try: # Optional dependency for MCP-UI embedded resources
mcp_ui_server = importlib.import_module("mcp_ui_server")
UIMetadataKey = mcp_ui_server.UIMetadataKey
create_ui_resource = mcp_ui_server.create_ui_resource
except ImportError: # pragma: no cover - handled by callers
UIMetadataKey = None
create_ui_resource = None
class MissingMCPUIServerError(RuntimeError):
"""Raised when the MCP-UI server SDK is not available."""
def _ensure_sdk() -> tuple[Any, Any]:
if create_ui_resource is None or UIMetadataKey is None:
raise MissingMCPUIServerError(
"mcp-ui-server is not installed. "
"Install it with `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server` "
"or `pip install mcp-ui-server`."
)
return create_ui_resource, UIMetadataKey
def build_embedded_ui_resource(
*,
uri: str,
html_filename: str,
render_data: dict[str, Any],
preferred_frame_size: list[str],
metadata: dict[str, Any] | None = None,
):
"""Create an embedded UI resource using the MCP-UI Python SDK."""
create_resource, metadata_keys = _ensure_sdk()
html = load_html(html_filename)
return create_resource(
{
"uri": uri,
"content": {"type": "rawHtml", "htmlString": html},
"encoding": "text",
"uiMetadata": {
metadata_keys.PREFERRED_FRAME_SIZE: preferred_frame_size,
metadata_keys.INITIAL_RENDER_DATA: render_data,
},
"metadata": metadata or {},
}
)
+27
View File
@@ -0,0 +1,27 @@
"""Helpers for serving MCP UI HTML resources."""
from __future__ import annotations
import os
from pathlib import Path
DEFAULT_VARIANT = "vanilla"
SUPPORTED_VARIANTS = {"vanilla", "tool-ui", "mcp-ui"}
def get_ui_variant() -> str:
"""Return the active UI variant from environment settings."""
value = os.getenv("BASIC_MEMORY_MCP_UI_VARIANT", DEFAULT_VARIANT).strip().lower()
return value if value in SUPPORTED_VARIANTS else DEFAULT_VARIANT
def load_html(filename: str) -> str:
"""Load a UI HTML template from disk."""
path = Path(__file__).parent / "html" / filename
return path.read_text(encoding="utf-8")
def load_variant_html(base_name: str) -> str:
"""Load a UI template for the current variant."""
variant = get_ui_variant()
return load_html(f"{base_name}-{variant}.html")
+3
View File
@@ -338,6 +338,9 @@ def mcp_server(config_manager, search_service):
# Import mcp tools to register them
import basic_memory.mcp.tools # noqa: F401
# Import resources to register them
import basic_memory.mcp.resources # noqa: F401
# Import prompts to register them
import basic_memory.mcp.prompts # noqa: F401
@@ -0,0 +1,78 @@
"""
Integration tests for ASCII/ANSI output formats in MCP tools.
"""
import pytest
from fastmcp import Client
@pytest.mark.asyncio
async def test_search_notes_ascii_output(mcp_server, app, test_project):
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "ASCII Note",
"directory": "notes",
"content": "# ASCII Note\n\nThis is a note for ASCII output.",
"tags": "ascii,output",
},
)
search_result = await client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "ASCII",
"output_format": "ascii",
},
)
assert len(search_result.content) == 1
assert search_result.content[0].type == "text"
text = search_result.content[0].text
assert "Search results" in text
assert "ASCII Note" in text
assert "+" in text
@pytest.mark.asyncio
async def test_read_note_ascii_and_ansi_output(mcp_server, app, test_project):
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Color Note",
"directory": "notes",
"content": "# Color Note\n\nThis note is for ANSI output.",
"tags": "ansi,output",
},
)
ascii_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Color Note",
"output_format": "ascii",
},
)
assert len(ascii_result.content) == 1
ascii_text = ascii_result.content[0].text
assert "Note preview" in ascii_text
assert "# Color Note" in ascii_text
ansi_result = await client.call_tool(
"read_note",
{
"project": test_project.name,
"identifier": "Color Note",
"output_format": "ansi",
},
)
ansi_text = ansi_result.content[0].text
assert "\x1b[" in ansi_text
+70
View File
@@ -0,0 +1,70 @@
"""
Integration tests for MCP-UI Python SDK embedded resources.
"""
import pytest
from fastmcp import Client
pytest.importorskip("mcp_ui_server")
@pytest.mark.asyncio
async def test_search_notes_ui_embedded_resource(mcp_server, app, test_project):
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "SDK Note",
"directory": "notes",
"content": "# SDK Note\n\nThis is for embedded UI.",
"tags": "sdk,ui",
},
)
result = await client.call_tool(
"search_notes_ui",
{
"project": test_project.name,
"query": "SDK",
},
)
assert len(result.content) == 1
block = result.content[0]
assert block.type == "resource"
assert block.resource.mimeType == "text/html"
assert "<!doctype html>" in block.resource.text.lower()
assert block.resource.meta is not None
assert "mcpui.dev/ui-initial-render-data" in block.resource.meta
@pytest.mark.asyncio
async def test_read_note_ui_embedded_resource(mcp_server, app, test_project):
async with Client(mcp_server) as client:
await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "SDK Note Preview",
"directory": "notes",
"content": "# SDK Note Preview\n\nPreview for embedded UI.",
"tags": "sdk,ui",
},
)
result = await client.call_tool(
"read_note_ui",
{
"project": test_project.name,
"identifier": "SDK Note Preview",
},
)
assert len(result.content) == 1
block = result.content[0]
assert block.type == "resource"
assert block.resource.mimeType == "text/html"
assert "<!doctype html>" in block.resource.text.lower()
assert block.resource.meta is not None
assert "mcpui.dev/ui-initial-render-data" in block.resource.meta
+2 -1
View File
@@ -28,7 +28,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"list_memory_projects": [],
"move_note": ["identifier", "destination_path", "is_directory", "project"],
"read_content": ["path", "project"],
"read_note": ["identifier", "project", "page", "page_size"],
"read_note": ["identifier", "project", "page", "page_size", "output_format"],
"release_notes": [],
"recent_activity": ["type", "depth", "timeframe", "project"],
"search": ["query"],
@@ -39,6 +39,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"page",
"page_size",
"search_type",
"output_format",
"types",
"entity_types",
"after_date",
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+85
View File
@@ -0,0 +1,85 @@
import { execFileSync } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import esbuild from "esbuild";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = __dirname;
const distDir = path.join(rootDir, "dist");
const htmlDir = path.resolve(rootDir, "..", "..", "src", "basic_memory", "mcp", "ui", "html");
const tailwindBin = path.join(rootDir, "node_modules", ".bin", "tailwindcss");
const cssInput = path.join(rootDir, "src", "styles.css");
const cssOutput = path.join(distDir, "styles.css");
function buildHtml(title, css, js) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>${css}</style>
</head>
<body>
<div id="root"></div>
<script>${js}</script>
</body>
</html>
`;
}
async function buildCss() {
execFileSync(
tailwindBin,
["-i", cssInput, "-o", cssOutput, "--minify"],
{ stdio: "inherit" },
);
return readFile(cssOutput, "utf8");
}
async function buildJs(entry, outfile) {
await esbuild.build({
entryPoints: [entry],
outfile,
bundle: true,
minify: true,
platform: "browser",
target: ["es2020"],
jsx: "automatic",
define: {
"process.env.NODE_ENV": "\"production\"",
},
});
return readFile(outfile, "utf8");
}
async function run() {
await mkdir(distDir, { recursive: true });
const css = await buildCss();
const searchJsPath = path.join(distDir, "search-results.js");
const noteJsPath = path.join(distDir, "note-preview.js");
const [searchJs, noteJs] = await Promise.all([
buildJs(path.join(rootDir, "src", "search-results.tsx"), searchJsPath),
buildJs(path.join(rootDir, "src", "note-preview.tsx"), noteJsPath),
]);
await mkdir(htmlDir, { recursive: true });
const searchHtml = buildHtml("Basic Memory Tool UI Search", css, searchJs);
const noteHtml = buildHtml("Basic Memory Tool UI Note Preview", css, noteJs);
await Promise.all([
writeFile(path.join(htmlDir, "search-results-tool-ui.html"), searchHtml, "utf8"),
writeFile(path.join(htmlDir, "note-preview-tool-ui.html"), noteHtml, "utf8"),
]);
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+2453
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "basic-memory-tool-ui",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.18",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"esbuild": "^0.25.10",
"lucide-react": "^0.561.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"zod": "^4.2.1"
}
}
@@ -0,0 +1,44 @@
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*
* When copying this component to another project, update these imports
* to match your project's paths:
*
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
* DropdownMenu → shadcn/ui DropdownMenu
* Accordion → shadcn/ui Accordion
* Tooltip → shadcn/ui Tooltip
* Badge → shadcn/ui Badge
* Table → shadcn/ui Table
*/
export { cn } from "../../../lib/ui/cn";
export { Button } from "../../ui/button";
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../ui/dropdown-menu";
export {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../../ui/accordion";
export {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../../ui/tooltip";
export { Badge } from "../../ui/badge";
export {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "../../ui/table";
@@ -0,0 +1,869 @@
"use client";
import * as React from "react";
import {
cn,
Table,
TableBody,
TableRow,
TableCell,
TableHeader,
TableHead,
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./_adapter";
import { sortData, getRowIdentifier } from "./utilities";
import { renderFormattedValue } from "./formatters";
import type {
DataTableProps,
DataTableContextValue,
RowData,
DataTableRowData,
ColumnKey,
Column,
} from "./types";
import { ActionButtons, normalizeActionsConfig } from "../shared";
import type { FormatConfig } from "./formatters";
import { DataTableErrorBoundary } from "./error-boundary";
export const DEFAULT_LOCALE = "en-US" as const;
function isNumericFormat(format?: FormatConfig): boolean {
const kind = format?.kind;
return (
kind === "number" ||
kind === "currency" ||
kind === "percent" ||
kind === "delta"
);
}
function getAlignmentClass(
align?: "left" | "right" | "center",
): string | undefined {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return undefined;
}
const DataTableContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataTableContextValue<any> | undefined
>(undefined);
export function useDataTable<T extends object = RowData>() {
const context = React.useContext(DataTableContext) as
| DataTableContextValue<T>
| undefined;
if (!context) {
throw new Error("useDataTable must be used within a DataTable");
}
return context;
}
export function DataTable<T extends object = RowData>({
columns,
data: rawData,
rowIdKey,
layout = "auto",
defaultSort,
sort: controlledSort,
emptyMessage = "No data available",
isLoading = false,
maxHeight,
id,
onSortChange,
className,
locale,
responseActions,
onResponseAction,
onBeforeResponseAction,
}: DataTableProps<T>) {
// Default locale avoids SSR/client formatting mismatches.
const resolvedLocale = locale ?? DEFAULT_LOCALE;
const [internalSortBy, setInternalSortBy] = React.useState<
ColumnKey<T> | undefined
>(defaultSort?.by);
const [internalSortDirection, setInternalSortDirection] = React.useState<
"asc" | "desc" | undefined
>(defaultSort?.direction);
const sortBy = controlledSort?.by ?? internalSortBy;
const sortDirection = controlledSort?.direction ?? internalSortDirection;
const data = React.useMemo(() => {
if (!sortBy || !sortDirection) return rawData;
return sortData(rawData, sortBy, sortDirection, resolvedLocale);
}, [rawData, sortBy, sortDirection, resolvedLocale]);
const handleSort = React.useCallback(
(key: ColumnKey<T>) => {
let newDirection: "asc" | "desc" | undefined;
if (sortBy === key) {
if (sortDirection === "asc") {
newDirection = "desc";
} else if (sortDirection === "desc") {
newDirection = undefined;
} else {
newDirection = "asc";
}
} else {
newDirection = "asc";
}
const next = {
by: newDirection ? key : undefined,
direction: newDirection,
} as const;
if (controlledSort) {
onSortChange?.(next);
} else {
setInternalSortBy(next.by);
setInternalSortDirection(next.direction);
}
},
[sortBy, sortDirection, controlledSort, onSortChange],
);
const contextValue: DataTableContextValue<T> = {
columns,
data,
rowIdKey,
sortBy,
sortDirection,
toggleSort: handleSort,
id,
isLoading,
locale: resolvedLocale,
};
const sortAnnouncement = React.useMemo(() => {
const col = columns.find((c) => c.key === sortBy);
const label = col?.label ?? sortBy;
return sortBy && sortDirection
? `Sorted by ${label}, ${sortDirection === "asc" ? "ascending" : "descending"}`
: "";
}, [columns, sortBy, sortDirection]);
const normalizedFooterActions = React.useMemo(
() => normalizeActionsConfig(responseActions),
[responseActions],
);
return (
<DataTableContext.Provider value={contextValue}>
<div
className={cn("@container w-full min-w-80", className)}
data-tool-ui-id={id}
data-slot="data-table"
data-layout={layout}
>
<div
className={cn(
layout === "table"
? "block"
: layout === "cards"
? "hidden"
: "hidden @md:block",
)}
>
<div className="relative">
<div
className={cn(
"bg-card relative w-full overflow-clip overflow-y-auto rounded-lg border",
"touch-pan-x",
maxHeight && "max-h-[--max-height]",
)}
style={
maxHeight
? ({ "--max-height": maxHeight } as React.CSSProperties)
: undefined
}
>
<DataTableErrorBoundary>
<Table aria-busy={isLoading || undefined}>
{columns.length > 0 && (
<colgroup>
{columns.map((col) => (
<col
key={String(col.key)}
style={col.width ? { width: col.width } : undefined}
/>
))}
</colgroup>
)}
{isLoading ? (
<DataTableSkeleton />
) : data.length === 0 ? (
<DataTableEmpty message={emptyMessage} />
) : (
<DataTableContent />
)}
</Table>
</DataTableErrorBoundary>
</div>
</div>
</div>
<div
className={cn(
layout === "cards"
? ""
: layout === "table"
? "hidden"
: "@md:hidden",
)}
role="list"
aria-label="Data table (mobile card view)"
aria-describedby="mobile-table-description"
>
<div id="mobile-table-description" className="sr-only">
Table data shown as expandable cards. Each card represents one row.
{columns.length > 0 &&
` Columns: ${columns.map((c) => c.label).join(", ")}.`}
</div>
<DataTableErrorBoundary>
{isLoading ? (
<DataTableSkeletonCards />
) : data.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
{emptyMessage}
</div>
) : (
<div className="bg-card flex flex-col overflow-hidden rounded-2xl border shadow-xs">
{data.map((row, i) => {
const keyVal = rowIdKey ? row[rowIdKey] : undefined;
const rowKey = keyVal != null ? String(keyVal) : String(i);
return (
<DataTableAccordionCard
key={rowKey}
row={row as unknown as DataTableRowData}
index={i}
isFirst={i === 0}
/>
);
})}
</div>
)}
</DataTableErrorBoundary>
</div>
{sortAnnouncement && (
<div className="sr-only" aria-live="polite">
{sortAnnouncement}
</div>
)}
{normalizedFooterActions ? (
<div className="@container/actions mt-4">
<ActionButtons
actions={normalizedFooterActions.items}
align={normalizedFooterActions.align}
confirmTimeout={normalizedFooterActions.confirmTimeout}
onAction={(id) => onResponseAction?.(id)}
onBeforeAction={onBeforeResponseAction}
/>
</div>
) : null}
</div>
</DataTableContext.Provider>
);
}
function DataTableContent() {
return (
<>
<DataTableHeader />
<DataTableBody />
</>
);
}
function DataTableEmpty({ message }: { message: string }) {
const { columns } = useDataTable();
return (
<TableBody>
<TableRow className="bg-card h-24 text-center">
<TableCell colSpan={columns.length} role="status" aria-live="polite">
{message}
</TableCell>
</TableRow>
</TableBody>
);
}
function DataTableSkeleton() {
const { columns } = useDataTable();
return (
<>
<DataTableHeader />
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{columns.map((_, j) => (
<TableCell key={j}>
<div className="bg-muted/50 h-4 rounded motion-safe:animate-pulse" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</>
);
}
function DataTableSkeletonCards() {
return (
<>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex flex-col gap-2 rounded-lg border p-4">
<div className="bg-muted/50 h-5 w-1/2 rounded motion-safe:animate-pulse" />
<div className="bg-muted/50 h-4 w-3/4 rounded motion-safe:animate-pulse" />
<div className="bg-muted/50 h-4 w-2/3 rounded motion-safe:animate-pulse" />
</div>
))}
</>
);
}
function SortIcon({ state }: { state?: "asc" | "desc" }) {
let char = "⇅";
let className = "opacity-20";
if (state === "asc") {
char = "↑";
className = "";
}
if (state === "desc") {
char = "↓";
className = "";
}
return (
<span aria-hidden className={cn("min-w-4 shrink-0 text-center", className)}>
{char}
</span>
);
}
function DataTableHeader() {
const { columns } = useDataTable();
return (
<TooltipProvider delayDuration={300}>
<TableHeader>
<TableRow className="hover:bg-transparent">
{columns.map((column, columnIndex) => (
<DataTableHead
key={column.key}
column={column}
columnIndex={columnIndex}
totalColumns={columns.length}
/>
))}
</TableRow>
</TableHeader>
</TooltipProvider>
);
}
interface DataTableHeadProps {
column: Column;
columnIndex?: number;
totalColumns?: number;
}
function DataTableHead({
column,
columnIndex = 0,
totalColumns = 1,
}: DataTableHeadProps) {
const { sortBy, sortDirection, toggleSort, isLoading } = useDataTable();
const isFirstColumn = columnIndex === 0;
const isLastColumn = columnIndex === totalColumns - 1;
const isSortable = column.sortable !== false;
const isSorted = sortBy === column.key;
const direction = isSorted ? sortDirection : undefined;
const isDisabled = isLoading || !isSortable;
const handleClick = () => {
if (!isDisabled && toggleSort) {
toggleSort(column.key);
}
};
const displayText = column.abbr || column.label;
const shouldShowTooltip = column.abbr || displayText.length > 15;
const isNumericKind = isNumericFormat(column.format);
const align =
column.align ??
(columnIndex === 0 ? "left" : isNumericKind ? "right" : "left");
const alignClass = getAlignmentClass(align);
const buttonAlignClass = cn(
"min-w-0 gap-1 font-normal",
align === "right" && "text-right",
align === "center" && "text-center",
align === "left" && "text-left",
);
const labelAlignClass =
align === "right"
? "text-right"
: align === "center"
? "text-center"
: "text-left";
return (
<TableHead
scope="col"
className={cn(
alignClass,
isFirstColumn && "pl-1",
isLastColumn && "pr-1",
)}
style={column.width ? { width: column.width } : undefined}
aria-sort={
isSorted
? direction === "asc"
? "ascending"
: "descending"
: undefined
}
>
<Button
type="button"
size="sm"
onClick={handleClick}
onKeyDown={(e) => {
if (isDisabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}}
disabled={isDisabled}
variant="ghost"
className={cn(
buttonAlignClass,
"w-fit min-w-10",
isFirstColumn && "pl-4",
isLastColumn && "pr-4",
)}
aria-label={
`Sort by ${column.label}` +
(isSorted && direction
? ` (${direction === "asc" ? "ascending" : "descending"})`
: "")
}
aria-disabled={isDisabled || undefined}
>
{shouldShowTooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", labelAlignClass)}>
{column.abbr ? (
<abbr
title={column.label}
className={cn(
"cursor-help border-b border-dotted border-current no-underline",
labelAlignClass,
)}
>
{column.abbr}
</abbr>
) : (
<span className={labelAlignClass}>{column.label}</span>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{column.label}</p>
</TooltipContent>
</Tooltip>
) : (
<span className={cn("truncate", labelAlignClass)}>
{column.label}
</span>
)}
{isSortable && <SortIcon state={direction} />}
</Button>
</TableHead>
);
}
function DataTableBody() {
const { data, rowIdKey } = useDataTable<DataTableRowData>();
const hasWarnedRowKeyRef = React.useRef(false);
React.useEffect(() => {
if (hasWarnedRowKeyRef.current) return;
if (process.env.NODE_ENV !== "production" && !rowIdKey && data.length > 0) {
hasWarnedRowKeyRef.current = true;
console.warn(
"[DataTable] Missing `rowIdKey` prop. Using array index as React key can cause reconciliation issues when data reorders (focus traps, animation glitches, incorrect state preservation). " +
"Strongly recommended: Pass a `rowIdKey` prop that points to a unique identifier in your row data (e.g., 'id', 'uuid', 'symbol').\n" +
'Example: <DataTable rowIdKey="id" columns={...} data={...} />',
);
}
}, [rowIdKey, data.length]);
return (
<TableBody>
{data.map((row, index) => {
const keyVal = rowIdKey ? row[rowIdKey] : undefined;
const rowKey = keyVal != null ? String(keyVal) : String(index);
return <DataTableRow key={rowKey} row={row} />;
})}
</TableBody>
);
}
interface DataTableRowProps {
row: DataTableRowData;
className?: string;
}
function DataTableRow({ row, className }: DataTableRowProps) {
const { columns } = useDataTable();
return (
<TableRow className={className}>
{columns.map((column, columnIndex) => (
<DataTableCell
key={column.key}
value={row[column.key]}
column={column}
row={row}
columnIndex={columnIndex}
/>
))}
</TableRow>
);
}
interface DataTableCellProps {
value:
| string
| number
| boolean
| null
| (string | number | boolean | null)[];
column: Column;
row: DataTableRowData;
className?: string;
columnIndex?: number;
}
function DataTableCell({
value,
column,
row,
className,
columnIndex = 0,
}: DataTableCellProps) {
const { locale } = useDataTable();
const isNumericKind = isNumericFormat(column.format);
const isNumericValue = typeof value === "number";
const displayValue = renderFormattedValue({ value, column, row, locale });
const align =
column.align ??
(columnIndex === 0
? "left"
: isNumericKind || isNumericValue
? "right"
: "left");
const alignClass = getAlignmentClass(align);
return (
<TableCell className={cn("px-5 py-3", alignClass, className)}>
{displayValue}
</TableCell>
);
}
function categorizeColumns(columns: Column[]) {
const primary: Column[] = [];
const secondary: Column[] = [];
let visibleColumnCount = 0;
columns.forEach((col) => {
if (col.hideOnMobile) return;
if (col.priority === "primary") {
primary.push(col);
} else if (col.priority === "secondary") {
secondary.push(col);
} else if (col.priority === "tertiary") {
return;
} else {
if (visibleColumnCount < 2) {
primary.push(col);
} else {
secondary.push(col);
}
visibleColumnCount++;
}
});
return { primary, secondary };
}
interface DataTableAccordionCardProps {
row: DataTableRowData;
index: number;
isFirst?: boolean;
}
function DataTableAccordionCard({
row,
index,
isFirst = false,
}: DataTableAccordionCardProps) {
const { columns, locale, rowIdKey } = useDataTable();
const { primary, secondary } = React.useMemo(
() => categorizeColumns(columns),
[columns],
);
if (secondary.length === 0) {
return (
<SimpleCard row={row} columns={primary} index={index} isFirst={isFirst} />
);
}
const primaryColumn = primary[0];
const remainingPrimaryColumns = primary.slice(1);
const stableRowId =
getRowIdentifier(row, rowIdKey ? String(rowIdKey) : undefined) ||
`${index}-${primaryColumn?.key ?? "row"}`;
const headingId = `row-${stableRowId}-heading`;
const detailsId = `row-${stableRowId}-details`;
const remainingPrimaryDataIds = remainingPrimaryColumns.map(
(col) => `row-${stableRowId}-${String(col.key)}`,
);
const primaryValue = primaryColumn
? String(row[primaryColumn.key] ?? "")
: "";
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
const accordionItemId = `row-${stableRowId}`;
return (
<Accordion
type="single"
collapsible
className={cn(!isFirst && "border-t")}
role="listitem"
aria-label={rowLabel}
>
<AccordionItem value={accordionItemId} className="group border-0">
<AccordionTrigger
className="group-data-[state=closed]:hover:bg-accent/50 active:bg-accent/50 group-data-[state=open]:bg-muted w-full rounded-none px-4 py-3 hover:no-underline"
aria-controls={detailsId}
aria-label={`${rowLabel}. ${secondary.length > 0 ? "Expand for details" : ""}`}
>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{primaryColumn && (
<div
id={headingId}
role="heading"
aria-level={3}
className="truncate"
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
>
{renderFormattedValue({
value: row[primaryColumn.key],
column: primaryColumn,
row,
locale,
})}
</div>
)}
{remainingPrimaryColumns.length > 0 && (
<div
className="text-muted-foreground flex w-full flex-wrap gap-x-4 gap-y-0.5"
role="group"
aria-label="Summary information"
>
{remainingPrimaryColumns.map((col, idx) => (
<span
key={col.key}
id={remainingPrimaryDataIds[idx]}
className="flex min-w-0 gap-1 font-normal"
role="cell"
aria-label={`${col.label}: ${row[col.key]}`}
>
<span className="sr-only">{col.label}:</span>
<span aria-hidden="true">{col.label}:</span>
<span className="truncate">
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</span>
</span>
))}
</div>
)}
</div>
</AccordionTrigger>
<AccordionContent
className={"flex flex-col gap-4 px-4 pb-4"}
id={detailsId}
role="region"
aria-labelledby={headingId}
>
{secondary.length > 0 && (
<dl
className={cn(
"flex flex-col gap-2 pt-4",
"motion-safe:group-data-[state=open]:animate-in motion-safe:group-data-[state=open]:fade-in-0",
"motion-safe:group-data-[state=open]:slide-in-from-top-1",
"motion-safe:group-data-[state=closed]:animate-out motion-safe:group-data-[state=closed]:fade-out-0",
"motion-safe:group-data-[state=closed]:slide-out-to-top-1",
"duration-150",
)}
role="list"
aria-label="Additional data"
>
{secondary.map((col) => (
<div
key={col.key}
className="flex items-start justify-between gap-4"
role="listitem"
>
<dt
className="text-muted-foreground shrink-0"
id={`row-${stableRowId}-${String(col.key)}-label`}
>
{col.label}
</dt>
<dd
className={cn(
"text-foreground min-w-0 text-pretty wrap-break-word",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
role="cell"
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
>
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</dd>
</div>
))}
</dl>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
/**
* Simple card with no accordion, for when there are only primary columns
*/
function SimpleCard({
row,
columns,
index,
isFirst = false,
}: {
row: DataTableRowData;
columns: Column[];
index: number;
isFirst?: boolean;
}) {
const { locale, rowIdKey } = useDataTable();
const primaryColumn = columns[0];
const otherColumns = columns.slice(1);
const stableRowId =
getRowIdentifier(row, rowIdKey ? String(rowIdKey) : undefined) ||
`${index}-${primaryColumn?.key ?? "row"}`;
const primaryValue = primaryColumn
? String(row[primaryColumn.key] ?? "")
: "";
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
return (
<div
className={cn("flex flex-col gap-2 p-4", !isFirst && "border-t")}
role="listitem"
aria-label={rowLabel}
>
{primaryColumn && (
<div
role="heading"
aria-level={3}
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
>
{renderFormattedValue({
value: row[primaryColumn.key],
column: primaryColumn,
row,
locale,
})}
</div>
)}
{otherColumns.map((col) => (
<div
key={col.key}
className="flex items-start justify-between gap-4"
role="group"
>
<span
className="text-muted-foreground"
id={`row-${stableRowId}-${String(col.key)}-label`}
>
{col.label}:
</span>
<span
className={cn(
"min-w-0 wrap-break-word",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
role="cell"
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
>
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,19 @@
"use client";
import * as React from "react";
import {
ToolUIErrorBoundary,
type ToolUIErrorBoundaryProps,
} from "../shared";
export function DataTableErrorBoundary(
props: Omit<ToolUIErrorBoundaryProps, "componentName">,
) {
const { children, ...rest } = props;
return (
<ToolUIErrorBoundary componentName="DataTable" {...rest}>
{children}
</ToolUIErrorBoundary>
);
}
@@ -0,0 +1,453 @@
"use client";
import * as React from "react";
import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter";
type Tone = "success" | "warning" | "danger" | "info" | "neutral";
export type FormatConfig =
| { kind: "text" }
| {
kind: "number";
decimals?: number;
unit?: string;
compact?: boolean;
showSign?: boolean;
}
| { kind: "currency"; currency: string; decimals?: number }
| {
kind: "percent";
decimals?: number;
showSign?: boolean;
basis?: "fraction" | "unit";
}
| { kind: "date"; dateFormat?: "short" | "long" | "relative" }
| {
kind: "delta";
decimals?: number;
upIsPositive?: boolean;
showSign?: boolean;
}
| {
kind: "status";
statusMap: Record<string, { tone: Tone; label?: string }>;
}
| { kind: "boolean"; labels?: { true: string; false: string } }
| { kind: "link"; hrefKey?: string; external?: boolean }
| { kind: "badge"; colorMap?: Record<string, Tone> }
| { kind: "array"; maxVisible?: number };
interface DeltaValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "delta" }>;
}
export function DeltaValue({ value, options }: DeltaValueProps) {
const decimals = options?.decimals ?? 2;
const upIsPositive = options?.upIsPositive ?? true;
const showSign = options?.showSign ?? true;
const isPositive = value > 0;
const isNegative = value < 0;
const isNeutral = value === 0;
const isGood = upIsPositive ? isPositive : isNegative;
const isBad = upIsPositive ? isNegative : isPositive;
const colorClass = isGood
? "text-green-700 dark:text-green-500"
: isBad
? "text-destructive"
: "text-muted-foreground";
const formatted = value.toFixed(decimals);
const display = showSign && !isNegative ? `+${formatted}` : formatted;
const arrow = isPositive ? "↑" : isNegative ? "↓" : "";
return (
<span className={cn("tabular-nums", colorClass)}>
{display}
{!isNeutral && <span className="ml-0.5">{arrow}</span>}
</span>
);
}
interface StatusBadgeProps {
value: string;
options?: Extract<FormatConfig, { kind: "status" }>;
}
export function StatusBadge({ value, options }: StatusBadgeProps) {
const config = options?.statusMap?.[value] ?? {
tone: "neutral" as Tone,
label: value,
};
const label = config.label ?? value;
const variant =
config.tone === "danger"
? "destructive"
: config.tone === "neutral"
? "outline"
: "secondary";
return (
<Badge
variant={variant}
className={cn(
"border",
config.tone === "warning" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
config.tone === "success" &&
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
config.tone === "info" &&
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
config.tone === "danger" &&
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{label}
</Badge>
);
}
interface CurrencyValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "currency" }>;
locale?: string;
}
export function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
const currency = options?.currency ?? "USD";
const decimals = options?.decimals ?? 2;
const formatted = new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
return <span className="tabular-nums">{formatted}</span>;
}
interface PercentValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "percent" }>;
}
export function PercentValue({ value, options }: PercentValueProps) {
const decimals = options?.decimals ?? 2;
const showSign = options?.showSign ?? false;
const basis = options?.basis ?? "fraction";
const numeric = basis === "fraction" ? value * 100 : value;
const absoluteFormatted = Math.abs(numeric).toFixed(decimals);
const signed =
numeric > 0 && showSign
? `+${absoluteFormatted}`
: numeric < 0
? `-${absoluteFormatted}`
: absoluteFormatted;
return <span className="tabular-nums">{signed}%</span>;
}
interface DateValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "date" }>;
locale?: string;
}
export function DateValue({ value, options, locale }: DateValueProps) {
const dateFormat = options?.dateFormat ?? "short";
const date = new Date(value);
if (isNaN(date.getTime())) {
return <span>Invalid date</span>;
}
let formatted: string;
if (dateFormat === "relative") {
formatted = getRelativeTime(date, locale);
} else if (dateFormat === "long") {
formatted = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
} else {
formatted = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
const title = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
return (
<span className="tabular-nums" title={title}>
{formatted}
</span>
);
}
function getRelativeTime(date: Date, locale?: string): string {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return "just now";
if (diffInSeconds < 3600) {
const mins = Math.floor(diffInSeconds / 60);
return `${mins} ${mins === 1 ? "minute" : "minutes"} ago`;
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400);
return `${days} ${days === 1 ? "day" : "days"} ago`;
}
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
interface BooleanValueProps {
value: boolean;
options?: Extract<FormatConfig, { kind: "boolean" }>;
}
export function BooleanValue({ value, options }: BooleanValueProps) {
const labels = options?.labels ?? { true: "Yes", false: "No" };
const label = value ? labels.true : labels.false;
const variant = value ? "secondary" : "outline";
return <Badge variant={variant}>{label}</Badge>;
}
interface LinkValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "link" }>;
row?: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>;
}
export function LinkValue({ value, options, row }: LinkValueProps) {
const href =
options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value;
const external = options?.external ?? false;
if (!href) {
return <span>{value}</span>;
}
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className="text-accent-foreground inline-block max-w-full break-words underline underline-offset-2 hover:opacity-90"
aria-label={external ? `${value} (opens in a new tab)` : undefined}
onClick={(e) => e.stopPropagation()}
>
{value}
{external && (
<span className="ml-1 inline-block" aria-label="Opens in new tab">
</span>
)}
</a>
);
}
interface NumberValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "number" }>;
locale?: string;
}
export function NumberValue({ value, options, locale }: NumberValueProps) {
const decimals = options?.decimals ?? 0;
const unit = options?.unit ?? "";
const compact = options?.compact ?? false;
const showSign = options?.showSign ?? false;
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
notation: compact ? "compact" : "standard",
}).format(value);
const display = showSign && value > 0 ? `+${formatted}` : formatted;
return (
<span className="tabular-nums">
{display}
{unit}
</span>
);
}
interface BadgeValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "badge" }>;
}
export function BadgeValue({ value, options }: BadgeValueProps) {
const tone = options?.colorMap?.[value] ?? "neutral";
const variant =
tone === "danger"
? "destructive"
: tone === "neutral"
? "outline"
: "secondary";
return (
<Badge
variant={variant}
className={cn(
"border",
tone === "warning" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
tone === "success" &&
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
tone === "info" &&
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
tone === "danger" &&
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{value}
</Badge>
);
}
interface ArrayValueProps {
value: (string | number | boolean | null)[] | string;
options?: Extract<FormatConfig, { kind: "array" }>;
}
export function ArrayValue({ value, options }: ArrayValueProps) {
const maxVisible = options?.maxVisible ?? 3;
const items: (string | number | boolean | null)[] = Array.isArray(value)
? value
: typeof value === "string"
? value.split(",").map((s) => s.trim())
: [];
if (items.length === 0) {
return <span className="text-muted"></span>;
}
const visible = items.slice(0, maxVisible);
const remaining = items.length - maxVisible;
const hidden = items.slice(maxVisible);
return (
<span className="inline-flex flex-wrap items-center gap-1">
{visible.map((item, i) => (
<span
key={i}
className="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5"
>
{item === null ? "null" : String(item)}
</span>
))}
{remaining > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-default">
+{remaining} more
</span>
</TooltipTrigger>
<TooltipContent>
{hidden
.map((item) => (item === null ? "null" : String(item)))
.join(", ")}
</TooltipContent>
</Tooltip>
)}
</span>
);
}
interface RenderFormattedValueParams {
value:
| string
| number
| boolean
| null
| (string | number | boolean | null)[];
column: { format?: FormatConfig };
row?: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>;
locale?: string;
}
export function renderFormattedValue({
value,
column,
row,
locale,
}: RenderFormattedValueParams): React.ReactNode {
if (value == null || value === "") {
return <span className="text-muted"></span>;
}
const fmt = column.format;
switch (fmt?.kind) {
case "delta":
return <DeltaValue value={Number(value)} options={fmt} />;
case "status":
return <StatusBadge value={String(value)} options={fmt} />;
case "currency":
return (
<CurrencyValue value={Number(value)} options={fmt} locale={locale} />
);
case "percent":
return <PercentValue value={Number(value)} options={fmt} />;
case "date":
return <DateValue value={String(value)} options={fmt} locale={locale} />;
case "boolean":
return <BooleanValue value={Boolean(value)} options={fmt} />;
case "link":
return <LinkValue value={String(value)} options={fmt} row={row} />;
case "number":
return (
<NumberValue value={Number(value)} options={fmt} locale={locale} />
);
case "badge":
return <BadgeValue value={String(value)} options={fmt} />;
case "array":
return (
<ArrayValue
value={Array.isArray(value) ? value : String(value)}
options={fmt}
/>
);
case "text":
default:
return String(value);
}
}
@@ -0,0 +1,31 @@
export { DataTable, useDataTable } from "./data-table";
export { DataTableErrorBoundary } from "./error-boundary";
export { renderFormattedValue } from "./formatters";
export {
NumberValue,
CurrencyValue,
PercentValue,
DeltaValue,
DateValue,
BooleanValue,
LinkValue,
BadgeValue,
StatusBadge,
ArrayValue,
} from "./formatters";
export { parseSerializableDataTable } from "./schema";
export type {
Column,
DataTableProps,
DataTableSerializableProps,
DataTableClientProps,
DataTableRowData,
RowPrimitive,
RowData,
ColumnKey,
} from "./types";
export type { FormatConfig } from "./formatters";
export { sortData, parseNumericLike } from "./utilities";
@@ -0,0 +1,234 @@
import { z } from "zod";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
parseWithSchema,
} from "../shared";
import type { Column, DataTableProps, RowData } from "./types";
const AlignEnum = z.enum(["left", "right", "center"]);
const PriorityEnum = z.enum(["primary", "secondary", "tertiary"]);
const LayoutEnum = z.enum(["auto", "table", "cards"]);
const formatSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text") }),
z.object({
kind: z.literal("number"),
decimals: z.number().optional(),
unit: z.string().optional(),
compact: z.boolean().optional(),
showSign: z.boolean().optional(),
}),
z.object({
kind: z.literal("currency"),
currency: z.string(),
decimals: z.number().optional(),
}),
z.object({
kind: z.literal("percent"),
decimals: z.number().optional(),
showSign: z.boolean().optional(),
basis: z.enum(["fraction", "unit"]).optional(),
}),
z.object({
kind: z.literal("date"),
dateFormat: z.enum(["short", "long", "relative"]).optional(),
}),
z.object({
kind: z.literal("delta"),
decimals: z.number().optional(),
upIsPositive: z.boolean().optional(),
showSign: z.boolean().optional(),
}),
z.object({
kind: z.literal("status"),
statusMap: z.record(
z.string(),
z.object({
tone: z.enum(["success", "warning", "danger", "info", "neutral"]),
label: z.string().optional(),
}),
),
}),
z.object({
kind: z.literal("boolean"),
labels: z
.object({
true: z.string(),
false: z.string(),
})
.optional(),
}),
z.object({
kind: z.literal("link"),
hrefKey: z.string().optional(),
external: z.boolean().optional(),
}),
z.object({
kind: z.literal("badge"),
colorMap: z
.record(
z.string(),
z.enum(["success", "warning", "danger", "info", "neutral"]),
)
.optional(),
}),
z.object({
kind: z.literal("array"),
maxVisible: z.number().optional(),
}),
]);
export const serializableColumnSchema = z.object({
key: z.string(),
label: z.string(),
abbr: z.string().optional(),
sortable: z.boolean().optional(),
align: AlignEnum.optional(),
width: z.string().optional(),
truncate: z.boolean().optional(),
priority: PriorityEnum.optional(),
hideOnMobile: z.boolean().optional(),
format: formatSchema.optional(),
});
const JsonPrimitiveSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
/**
* Schema for serializable row data.
*
* Supports:
* - Primitives: string, number, boolean, null
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
*
* Does NOT support:
* - Functions
* - Class instances (Date, Map, Set, etc.)
* - Plain objects (use format configs instead)
*
* @example
* Valid row data:
* ```json
* {
* "name": "Widget",
* "price": 29.99,
* "active": true,
* "tags": ["electronics", "featured"],
* "metrics": [1.2, 3.4, 5.6],
* "flags": [true, false, true],
* "mixed": ["label", 42, true]
* }
* ```
*/
export const serializableDataSchema = z.record(
z.string(),
z.union([JsonPrimitiveSchema, z.array(JsonPrimitiveSchema)]),
);
/**
* Zod schema for validating DataTable payloads from LLM tool calls.
*
* This schema validates the serializable parts of a DataTable:
* - id: Unique identifier for this tool UI in the conversation
* - columns: Column definitions (keys, labels, formatting, etc.)
* - data: Data rows (primitives only - no functions or class instances)
* - layout: Optional layout override ('auto' | 'table' | 'cards')
*
* Non-serializable props like `onSortChange`, `className`, and `isLoading`
* must be provided separately in your React component.
*
* @example
* ```ts
* const result = SerializableDataTableSchema.safeParse(llmResponse)
* if (result.success) {
* // result.data contains validated id, columns, and data
* }
* ```
*/
export const SerializableDataTableSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
columns: z.array(serializableColumnSchema),
data: z.array(serializableDataSchema),
layout: LayoutEnum.optional(),
});
/**
* Type representing the serializable parts of a DataTable payload.
*
* This type includes only JSON-serializable data that can come from LLM tool calls:
* - Column definitions (format configs, alignment, labels, etc.)
* - Row data (primitives: strings, numbers, booleans, null, string arrays)
*
* Excluded from this type:
* - Event handlers (`onSortChange`, `onResponseAction`)
* - React-specific props (`className`, `isLoading`, `responseActions`)
*
* @example
* ```ts
* const payload: SerializableDataTable = {
* id: "data-table-expenses",
* columns: [
* { key: "name", label: "Name" },
* { key: "price", label: "Price", format: { kind: "currency", currency: "USD" } }
* ],
* data: [
* { name: "Widget", price: 29.99 }
* ]
* }
* ```
*/
export type SerializableDataTable = z.infer<typeof SerializableDataTableSchema>;
/**
* Validates and parses a DataTable payload from unknown data (e.g., LLM tool call result).
*
* This function:
* 1. Validates the input against the `SerializableDataTableSchema`
* 2. Throws a descriptive error if validation fails
* 3. Returns typed serializable props ready to pass to the `<DataTable>` component
*
* The returned props are **serializable only** - you must provide client-side props
* separately (onSortChange, isLoading, className, responseActions, onResponseAction).
*
* @param input - Unknown data to validate (typically from an LLM tool call)
* @returns Validated and typed DataTable serializable props (id, columns, data)
* @throws Error with validation details if input is invalid
*
* @example
* ```tsx
* function MyToolUI({ result }: { result: unknown }) {
* const serializableProps = parseSerializableDataTable(result)
*
* return (
* <DataTable
* {...serializableProps}
* responseActions={[{ id: "export", label: "Export" }]}
* onResponseAction={(id) => console.log(id)}
* />
* )
* }
* ```
*/
export function parseSerializableDataTable(
input: unknown,
): Pick<
DataTableProps<RowData>,
"id" | "role" | "receipt" | "columns" | "data" | "layout"
> {
const { id, role, receipt, columns, data, layout } = parseWithSchema(
SerializableDataTableSchema,
input,
"DataTable",
);
return {
id,
role,
receipt,
columns: columns as unknown as Column<RowData>[],
data: data as RowData[],
layout,
};
}
@@ -0,0 +1,285 @@
import type {
ActionsProp,
ToolUIId,
ToolUIReceipt,
ToolUIRole,
} from "../shared";
import type { FormatConfig } from "./formatters";
/**
* JSON primitive type that can be serialized.
*/
type JsonPrimitive = string | number | boolean | null;
/**
* Valid row value types for serializable DataTable data.
*
* Supports:
* - Primitives: string, number, boolean, null
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
*
* For complex data (objects with href/label, etc.), use column format configs
* instead of putting objects in row data.
*
* @example
* ```ts
* // 👍 Good: Use primitives and primitive arrays
* const row = {
* name: "Widget",
* price: 29.99,
* tags: ["electronics", "featured"],
* metrics: [1.2, 3.4, 5.6]
* }
*
* // 🚫 Bad: Don't put objects in row data
* const row = {
* link: { href: "/path", label: "Click" } // Use format: { kind: 'link' } instead
* }
* ```
*/
export type RowPrimitive = JsonPrimitive | JsonPrimitive[];
export type DataTableRowData = Record<string, RowPrimitive>;
export type RowData = Record<string, unknown>;
export type ColumnKey<T extends object> = Extract<keyof T, string>;
export type FormatFor<V> = V extends number
? Extract<FormatConfig, { kind: "number" | "currency" | "percent" | "delta" }>
: V extends boolean
? Extract<FormatConfig, { kind: "boolean" | "status" | "badge" }>
: V extends (string | number | boolean | null)[]
? Extract<FormatConfig, { kind: "array" }>
: V extends string
? Extract<
FormatConfig,
{ kind: "text" | "link" | "date" | "badge" | "status" }
>
: Extract<FormatConfig, { kind: "text" }>;
/**
* Column definition for DataTable
*
* @remarks
* **Important:** Columns are sortable by default (opt-out pattern).
* Set `sortable: false` explicitly to disable sorting for specific columns.
*/
export interface Column<
T extends object = DataTableRowData,
K extends ColumnKey<T> = ColumnKey<T>,
> {
/** Unique identifier that maps to a key in the row data */
key: K;
/** Display text for the column header */
label: string;
/** Abbreviated label for narrow viewports */
abbr?: string;
/** Whether column is sortable. Default: true (opt-out pattern) */
sortable?: boolean;
/** Text alignment for column cells */
align?: "left" | "right" | "center";
/** Optional fixed width (CSS value) */
width?: string;
/** Enable text truncation with ellipsis */
truncate?: boolean;
/** Mobile display priority (primary = always visible, secondary = expandable, tertiary = hidden) */
priority?: "primary" | "secondary" | "tertiary";
/** Completely hide column on mobile viewports */
hideOnMobile?: boolean;
/** Formatting configuration for cell values */
format?: FormatFor<T[K]>;
}
/**
* Serializable props that can come from LLM tool calls or be JSON-serialized.
*
* These props contain only primitive values, arrays, and plain objects -
* no functions, class instances, or other non-serializable values.
*
* @example
* ```tsx
* const serializableProps: DataTableSerializableProps = {
* columns: [...],
* data: [...],
* rowIdKey: "id",
* defaultSort: { by: "price", direction: "desc" }
* }
* ```
*/
export interface DataTableSerializableProps<T extends object = RowData> {
/**
* Unique identifier for this tool UI instance in the conversation.
*
* Used for:
* - Assistant referencing ("the table above")
* - Receipt generation (linking actions to their source)
* - Narration context
*
* Should be stable across re-renders, meaningful, and unique within the conversation.
*
* @example "data-table-expenses-q3", "search-results-repos"
*/
id: ToolUIId;
/** Optional surface role metadata (serializable) */
role?: ToolUIRole;
/** Optional receipt metadata for consequential outcomes (serializable) */
receipt?: ToolUIReceipt;
/** Column definitions */
columns: Column<T>[];
/** Row data (primitives only - no functions or class instances) */
data: T[];
/**
* Layout mode for the component.
* - 'auto' (default): Container queries choose table/cards
* - 'table': Force table layout
* - 'cards': Force stacked card layout
*/
layout?: "auto" | "table" | "cards";
/**
* Key in row data to use as unique identifier for React keys
*
* **Strongly recommended:** Always provide this for dynamic data to prevent
* reconciliation issues (focus traps, animation glitches, incorrect state preservation)
* when data reorders. Falls back to array index if omitted (only acceptable for static mock data).
*
* @example rowIdKey="id" or rowIdKey="uuid"
*/
rowIdKey?: ColumnKey<T>;
/**
* Uncontrolled initial sort state (table manages its own sort state internally)
*
* **Sorting cycle:** Clicking column headers cycles through tri-state:
* 1. none (unsorted) → 2. asc → 3. desc → 4. none (back to unsorted)
*
* @example
* ```tsx
* // Start with descending price sort
* <DataTable defaultSort={{ by: "price", direction: "desc" }} />
* ```
*/
defaultSort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
/**
* Controlled sort state (use with onSortChange from client props)
*
* When provided, you must also provide `onSortChange` to handle sort updates.
* The table will cycle through: none → asc → desc → none.
*
* @example
* ```tsx
* const [sort, setSort] = useState({ by: "price", direction: "desc" })
* <DataTable sort={sort} onSortChange={setSort} />
* ```
*/
sort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
/** Empty state message */
emptyMessage?: string;
/** Max table height with vertical scroll (CSS value) */
maxHeight?: string;
/**
* BCP47 locale for formatting and sorting (e.g., 'en-US', 'de-DE', 'ja-JP')
*
* Defaults to 'en-US' to ensure consistent server/client rendering.
* Pass explicit locale for internationalization.
*
* @example
* ```tsx
* <DataTable locale="de-DE" /> // German formatting
* <DataTable locale="ja-JP" /> // Japanese formatting
* <DataTable /> // Uses 'en-US' default
* ```
*/
locale?: string;
}
/**
* Client-side React-only props that cannot be serialized.
*
* These props contain functions, component state, or other React-specific values
* that must be provided by your React code (not from LLM tool calls).
*
* @example
* ```tsx
* const clientProps: DataTableClientProps = {
* isLoading: false,
* className: "my-table",
* onSortChange: (next) => setSort(next),
* responseActions: [{ id: "export", label: "Export" }],
* onResponseAction: (id) => console.log(id)
* }
* ```
*/
export interface DataTableClientProps<T extends object = RowData> {
/** Show loading skeleton */
isLoading?: boolean;
/** Additional CSS classes */
className?: string;
/**
* Sort change handler for controlled mode (required if sort is provided)
*
* **Tri-state cycle behavior:**
* - Click unsorted column: `{ by: "column", direction: "asc" }`
* - Click asc column: `{ by: "column", direction: "desc" }`
* - Click desc column: `{ by: "column", direction: undefined }` (returns to unsorted)
* - Click different column: `{ by: "newColumn", direction: "asc" }`
*
* @example
* ```tsx
* const [sort, setSort] = useState<{ by?: string; direction?: "asc" | "desc" }>({})
*
* <DataTable
* sort={sort}
* onSortChange={(next) => {
* console.log("Sort changed:", next)
* setSort(next)
* }}
* />
* ```
*/
onSortChange?: (next: {
by?: ColumnKey<T>;
direction?: "asc" | "desc";
}) => void;
/** Optional response actions rendered below the table */
responseActions?: ActionsProp;
onResponseAction?: (actionId: string) => void | Promise<void>;
onBeforeResponseAction?: (actionId: string) => boolean | Promise<boolean>;
}
/**
* Complete props for the DataTable component.
*
* Combines serializable props (can come from LLM tool calls) with client-side
* React-only props. This separation makes the boundary explicit and prevents
* accidental serialization of non-serializable values.
*
* @see {@link DataTableSerializableProps} for props that can be JSON-serialized
* @see {@link DataTableClientProps} for React-only props
* @see {@link parseSerializableDataTable} for parsing LLM tool call results
*
* @example
* ```tsx
* // From LLM tool call
* const serializableProps = parseSerializableDataTable(llmResult)
*
* // Combine with React-specific props
* <DataTable
* {...serializableProps}
* onSortChange={setSort}
* responseActions={[{ id: "export", label: "Export" }]}
* onResponseAction={(id) => handleAction(id)}
* isLoading={loading}
* />
* ```
*/
export interface DataTableProps<T extends object = RowData>
extends DataTableSerializableProps<T>, DataTableClientProps<T> {}
export interface DataTableContextValue<T extends object = RowData> {
columns: Column<T>[];
data: T[];
rowIdKey?: ColumnKey<T>;
sortBy?: ColumnKey<T>;
sortDirection?: "asc" | "desc";
toggleSort?: (key: ColumnKey<T>) => void;
id?: string;
isLoading?: boolean;
locale?: string;
}
@@ -0,0 +1,186 @@
/**
* Sort an array of objects by a key
*/
export function sortData<T, K extends Extract<keyof T, string>>(
data: T[],
key: K,
direction: "asc" | "desc",
locale?: string,
): T[] {
const get = (obj: T, k: K): unknown => (obj as Record<string, unknown>)[k];
const collator = new Intl.Collator(locale, {
numeric: true,
sensitivity: "base",
});
return [...data].sort((a, b) => {
const aVal = get(a, key);
const bVal = get(b, key);
// Handle nulls
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Type-specific comparison
// Numbers
if (typeof aVal === "number" && typeof bVal === "number") {
return direction === "asc" ? aVal - bVal : bVal - aVal;
}
// Dates (Date instances)
if (aVal instanceof Date && bVal instanceof Date) {
const diff = aVal.getTime() - bVal.getTime();
return direction === "asc" ? diff : -diff;
}
// Booleans: false < true
if (typeof aVal === "boolean" && typeof bVal === "boolean") {
const diff = aVal === bVal ? 0 : aVal ? 1 : -1;
return direction === "asc" ? diff : -diff;
}
// Arrays: compare length
if (Array.isArray(aVal) && Array.isArray(bVal)) {
const diff = aVal.length - bVal.length;
return direction === "asc" ? diff : -diff;
}
// Strings that look like numbers -> numeric compare
if (typeof aVal === "string" && typeof bVal === "string") {
const numA = parseNumericLike(aVal);
const numB = parseNumericLike(bVal);
if (numA != null && numB != null) {
const diff = numA - numB;
return direction === "asc" ? diff : -diff;
}
// ISO-like date strings
if (/^\d{4}-\d{2}-\d{2}/.test(aVal) && /^\d{4}-\d{2}-\d{2}/.test(bVal)) {
const da = new Date(aVal).getTime();
const db = new Date(bVal).getTime();
const diff = da - db;
return direction === "asc" ? diff : -diff;
}
}
// Fallback: locale-aware string compare with numeric collation
const aStr = String(aVal);
const bStr = String(bVal);
const comparison = collator.compare(aStr, bStr);
return direction === "asc" ? comparison : -comparison;
});
}
/**
* Return a human-friendly identifier for a row using common keys
*
* Accepts any JSON-serializable primitive or array of primitives.
* Arrays are converted to comma-separated strings.
*/
export function getRowIdentifier(
row: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>,
identifierKey?: string,
): string {
const candidate =
(identifierKey ? row[identifierKey] : undefined) ??
(row as Record<string, unknown>).name ??
(row as Record<string, unknown>).title ??
(row as Record<string, unknown>).id;
if (candidate == null) {
return "";
}
// Handle arrays by joining them
if (Array.isArray(candidate)) {
return candidate.map((v) => (v === null ? "null" : String(v))).join(", ");
}
return String(candidate).trim();
}
/**
* Parse a string that represents a numeric value, handling various formats:
* - Currency symbols: $, €, £, ¥, etc.
* - Percent symbols: %
* - Accounting negatives: (1234) → -1234
* - Thousands/decimal separators: 1,234.56 or 1.234,56
* - Compact notation: 2.8T (trillion), 1.5M (million), 500K (thousand)
* - Byte suffixes: 768B (bytes), 1.5KB, 2GB, 1TB
*
* Note: Single "B" is disambiguated - integers < 1024 are bytes, otherwise billions.
*
* @param input - String to parse
* @returns Parsed number or null if unparseable
*
* @example
* parseNumericLike("$1,234.56") // 1234.56
* parseNumericLike("2.8T") // 2800000000000
* parseNumericLike("768B") // 768
* parseNumericLike("50%") // 50
* parseNumericLike("(1234)") // -1234
*/
export function parseNumericLike(input: string): number | null {
// Normalize whitespace (spaces, NBSPs, thin spaces)
let s = input.replace(/[\u00A0\u202F\s]/g, "").trim();
if (!s) return null;
// Accounting negatives: (1234) -> -1234
s = s.replace(/^\((.*)\)$/g, "-$1");
// Strip common currency and percent symbols
s = s.replace(/[\%$€£¥₩₹₽₺₪₫฿₦₴₡₲₵₸]/g, "");
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
if (lastComma !== -1 && lastDot !== -1) {
// Decide decimal by whichever occurs last
const decimalSep = lastComma > lastDot ? "," : ".";
const thousandSep = decimalSep === "," ? "." : ",";
s = s.split(thousandSep).join("");
s = s.replace(decimalSep, ".");
} else if (lastComma !== -1) {
// Only comma present
const frac = s.length - lastComma - 1;
if (frac === 2 || frac === 3) s = s.replace(/,/g, ".");
else s = s.replace(/,/g, "");
} else if (lastDot !== -1) {
// Only dot present; if multiple dots, treat as thousands and strip
if ((s.match(/\./g) || []).length > 1) s = s.replace(/\./g, "");
}
// Handle compact notation (K, M, B, T, P, G) and byte suffixes (KB, MB, GB, TB, PB)
const compactMatch = s.match(/^([+-]?\d+\.?\d*|\d*\.\d+)([KMBTPG]B?|B)$/i);
if (compactMatch) {
const baseNum = Number(compactMatch[1]);
if (Number.isNaN(baseNum)) return null;
const suffix = compactMatch[2].toUpperCase();
// Disambiguate single "B" (bytes vs billions)
// If whole number < 1024, treat as bytes. Otherwise, billions.
if (suffix === "B") {
const isLikelyBytes = Number.isInteger(baseNum) && baseNum < 1024;
return isLikelyBytes ? baseNum : baseNum * 1e9;
}
const multipliers: Record<string, number> = {
K: 1e3,
KB: 1024, // Kilo: metric vs binary
M: 1e6,
MB: 1024 ** 2, // Mega
G: 1e9,
GB: 1024 ** 3, // Giga
T: 1e12,
TB: 1024 ** 4, // Tera
P: 1e15,
PB: 1024 ** 5, // Peta
};
return baseNum * (multipliers[suffix] ?? 1);
}
if (/^[+-]?(?:\d+\.?\d*|\d*\.\d+)$/.test(s)) {
const n = Number(s);
return Number.isNaN(n) ? null : n;
}
return null;
}
@@ -0,0 +1,12 @@
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*
* When copying this component to another project, update these imports
* to match your project's paths:
*
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
*/
export { cn } from "../../../lib/ui/cn";
export { Button } from "../../ui/button";
@@ -0,0 +1,100 @@
"use client";
import type { Action } from "./schema";
import { cn, Button } from "./_adapter";
import { useActionButtons } from "./use-action-buttons";
export interface ActionButtonsProps {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
confirmTimeout?: number;
align?: "left" | "center" | "right";
className?: string;
}
export function ActionButtons({
actions,
onAction,
onBeforeAction,
confirmTimeout = 3000,
align = "right",
className,
}: ActionButtonsProps) {
const { actions: resolvedActions, runAction } = useActionButtons({
actions,
onAction,
onBeforeAction,
confirmTimeout,
});
return (
<div
className={cn(
"flex flex-col gap-3",
"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
align === "left" && "@sm/actions:justify-start",
align === "center" && "@sm/actions:justify-center",
align === "right" && "@sm/actions:justify-end",
className,
)}
>
{resolvedActions.map((action) => {
const label = action.currentLabel;
const variant = action.variant || "default";
return (
<Button
key={action.id}
variant={variant}
onClick={() => runAction(action.id)}
disabled={action.isDisabled}
className={cn(
"rounded-full px-4!",
"justify-center",
"min-h-11 w-full text-base",
"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
action.isConfirming &&
"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
)}
aria-label={
action.shortcut ? `${label} (${action.shortcut})` : label
}
>
{action.isLoading && (
<svg
className="mr-2 h-4 w-4 motion-safe:animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{action.icon && !action.isLoading && (
<span className="mr-2">{action.icon}</span>
)}
{label}
{action.shortcut && !action.isLoading && (
<kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
{action.shortcut}
</kbd>
)}
</Button>
);
})}
</div>
);
}
@@ -0,0 +1,48 @@
import type { Action, ActionsConfig } from "./schema";
export type ActionsProp = ActionsConfig | Action[];
const NEGATORY_ACTION_IDS = new Set([
"cancel",
"dismiss",
"skip",
"no",
"reset",
"close",
"decline",
"reject",
"back",
"later",
"not-now",
"maybe-later",
]);
function inferVariant(action: Action): Action {
if (action.variant) return action;
if (NEGATORY_ACTION_IDS.has(action.id)) {
return { ...action, variant: "ghost" };
}
return action;
}
export function normalizeActionsConfig(
actions?: ActionsProp,
): ActionsConfig | null {
if (!actions) return null;
const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []);
if (rawItems.length === 0) {
return null;
}
const items = rawItems.map(inferVariant);
return Array.isArray(actions)
? { items }
: {
items,
align: actions.align,
confirmTimeout: actions.confirmTimeout,
};
}
@@ -0,0 +1,51 @@
"use client";
import * as React from "react";
export interface ToolUIErrorBoundaryProps {
componentName: string;
children: React.ReactNode;
fallback?: React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface ToolUIErrorBoundaryState {
hasError: boolean;
error?: Error;
}
export class ToolUIErrorBoundary extends React.Component<
ToolUIErrorBoundaryProps,
ToolUIErrorBoundaryState
> {
constructor(props: ToolUIErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ToolUIErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(`[${this.props.componentName}] render error:`, error, errorInfo);
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div className="border-destructive text-destructive rounded-lg border p-4">
<p className="font-semibold">
{this.props.componentName} failed to render
</p>
<p className="text-sm">{this.state.error?.message}</p>
</div>
)
);
}
return this.props.children;
}
}
@@ -0,0 +1,8 @@
export { ActionButtons } from "./action-buttons";
export type { ActionButtonsProps } from "./action-buttons";
export { normalizeActionsConfig, type ActionsProp } from "./actions-config";
export * from "./error-boundary";
export * from "./parse";
export * from "./schema";
export * from "./use-copy-to-clipboard";
export * from "./utils";
@@ -0,0 +1,27 @@
import { z } from "zod";
export const AspectRatioSchema = z
.enum(["auto", "1:1", "4:3", "16:9", "9:16"])
.default("auto");
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");
export type MediaFit = z.infer<typeof MediaFitSchema>;
export const RATIO_CLASS_MAP: Record<AspectRatio, string> = {
auto: "",
"1:1": "aspect-square",
"4:3": "aspect-[4/3]",
"16:9": "aspect-video",
"9:16": "aspect-[9/16]",
};
export function getRatioClass(ratio: AspectRatio): string {
return RATIO_CLASS_MAP[ratio];
}
export function getFitClass(fit: MediaFit): string {
return fit === "cover" ? "object-cover" : "object-contain";
}
@@ -0,0 +1,35 @@
/**
* Format duration in milliseconds to human-readable string.
* @example formatDuration(128000) => "2:08"
* @example formatDuration(3661000) => "1:01:01"
*/
export function formatDuration(durationMs: number): string {
const totalSeconds = Math.round(durationMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Format file size in bytes to human-readable string.
* @example formatFileSize(1024) => "1 KB"
* @example formatFileSize(1536000) => "1.5 MB"
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let size = bytes / 1024;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
}
@@ -0,0 +1,15 @@
export {
AspectRatioSchema,
MediaFitSchema,
RATIO_CLASS_MAP,
getRatioClass,
getFitClass,
type AspectRatio,
type MediaFit,
} from "./aspect-ratio";
export { OVERLAY_GRADIENT } from "./overlay-gradient";
export { formatDuration, formatFileSize } from "./format-utils";
export { sanitizeHref } from "./sanitize-href";
@@ -0,0 +1,25 @@
/**
* Eased gradient for hover overlays on media elements.
* Creates a smooth fade from opaque black at top to transparent.
*
* @see https://larsenwork.com/easing-gradients/
*/
export const OVERLAY_GRADIENT = `linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 8.3%,
hsla(0, 0%, 0%, 0.951) 16.6%,
hsla(0, 0%, 0%, 0.896) 24.6%,
hsla(0, 0%, 0%, 0.825) 32.5%,
hsla(0, 0%, 0%, 0.741) 40.1%,
hsla(0, 0%, 0%, 0.648) 47.6%,
hsla(0, 0%, 0%, 0.55) 54.8%,
hsla(0, 0%, 0%, 0.45) 61.7%,
hsla(0, 0%, 0%, 0.352) 68.3%,
hsla(0, 0%, 0%, 0.259) 74.5%,
hsla(0, 0%, 0%, 0.175) 80.4%,
hsla(0, 0%, 0%, 0.104) 86%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.8%,
hsla(0, 0%, 0%, 0) 100%
)` as const;
@@ -0,0 +1,18 @@
/**
* Sanitize a URL to ensure it's safe for use in href attributes.
* Only allows http: and https: protocols.
*
* @returns The sanitized URL string, or undefined if invalid/unsafe
*/
export function sanitizeHref(href?: string): string | undefined {
if (!href) return undefined;
try {
const url = new URL(href);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
return undefined;
}
return undefined;
}
@@ -0,0 +1,37 @@
import { z } from "zod";
function formatZodPath(path: Array<string | number | symbol>): string {
if (path.length === 0) return "root";
return path
.map((segment) =>
typeof segment === "number" ? `[${segment}]` : String(segment),
)
.join(".");
}
/**
* Format Zod errors into a compact `path: message` string.
*/
export function formatZodError(error: z.ZodError): string {
const parts = error.issues.map((issue) => {
const path = formatZodPath(issue.path);
return `${path}: ${issue.message}`;
});
return Array.from(new Set(parts)).join("; ");
}
/**
* Parse unknown input and throw a readable error.
*/
export function parseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
name: string,
): T {
const res = schema.safeParse(input);
if (!res.success) {
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
}
return res.data;
}
@@ -0,0 +1,122 @@
import { z } from "zod";
import type { ReactNode } from "react";
/**
* Tool UI conventions:
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
* - Schema: `SerializableXSchema`
* - Parser: `parseSerializableX(input: unknown)`
* - Actions: `responseActions`, `onResponseAction`, `onBeforeResponseAction`
* - Root attrs: `data-tool-ui-id` + `data-slot`
*/
/**
* Schema for tool UI identity.
*
* Every tool UI should have a unique identifier that:
* - Is stable across re-renders
* - Is meaningful (not auto-generated)
* - Is unique within the conversation
*
* Format recommendation: `{component-type}-{semantic-identifier}`
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
*/
export const ToolUIIdSchema = z.string().min(1);
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
/**
* Primary role of a Tool UI surface in a chat context.
*/
export const ToolUIRoleSchema = z.enum([
"information",
"decision",
"control",
"state",
"composite",
]);
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum([
"success",
"partial",
"failed",
"cancelled",
]);
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
/**
* Optional receipt metadata: a durable summary of an outcome.
*/
export const ToolUIReceiptSchema = z.object({
outcome: ToolUIReceiptOutcomeSchema,
summary: z.string().min(1),
identifiers: z.record(z.string(), z.string()).optional(),
at: z.string().datetime(),
});
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
/**
* Base schema for Tool UI payloads (id + optional role/receipt).
*/
export const ToolUISurfaceSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
});
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
export const ActionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
/**
* Canonical narration the assistant can use after this action is taken.
*
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
*/
sentence: z.string().optional(),
confirmLabel: z.string().optional(),
variant: z
.enum(["default", "destructive", "secondary", "ghost", "outline"])
.optional(),
icon: z.custom<ReactNode>().optional(),
loading: z.boolean().optional(),
disabled: z.boolean().optional(),
shortcut: z.string().optional(),
});
export type Action = z.infer<typeof ActionSchema>;
export const ActionButtonsPropsSchema = z.object({
actions: z.array(ActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
className: z.string().optional(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema),
}).omit({ className: true });
export interface ActionsConfig {
items: Action[];
align?: "left" | "center" | "right";
confirmTimeout?: number;
}
export const SerializableActionsConfigSchema = z.object({
items: z.array(SerializableActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
});
export type SerializableActionsConfig = z.infer<
typeof SerializableActionsConfigSchema
>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
@@ -0,0 +1,128 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Action } from "./schema";
export type UseActionButtonsOptions = {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
confirmTimeout?: number;
};
export type UseActionButtonsResult = {
actions: Array<
Action & {
currentLabel: string;
isConfirming: boolean;
isExecuting: boolean;
isDisabled: boolean;
isLoading: boolean;
}
>;
runAction: (actionId: string) => Promise<void>;
confirmingActionId: string | null;
executingActionId: string | null;
};
export function useActionButtons(
options: UseActionButtonsOptions,
): UseActionButtonsResult {
const {
actions,
onAction,
onBeforeAction,
confirmTimeout = 3000,
} = options;
const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
null,
);
const [executingActionId, setExecutingActionId] = useState<string | null>(
null,
);
useEffect(() => {
if (!confirmingActionId) return;
const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
return () => clearTimeout(id);
}, [confirmingActionId, confirmTimeout]);
useEffect(() => {
if (!confirmingActionId) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setConfirmingActionId(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [confirmingActionId]);
const runAction = useCallback(
async (actionId: string) => {
const action = actions.find((a) => a.id === actionId);
if (!action) return;
const isAnyActionExecuting = executingActionId !== null;
if (action.disabled || action.loading || isAnyActionExecuting) {
return;
}
if (action.confirmLabel && confirmingActionId !== action.id) {
setConfirmingActionId(action.id);
return;
}
if (onBeforeAction) {
const shouldProceed = await onBeforeAction(action.id);
if (!shouldProceed) {
setConfirmingActionId(null);
return;
}
}
try {
setExecutingActionId(action.id);
await onAction(action.id);
} finally {
setExecutingActionId(null);
setConfirmingActionId(null);
}
},
[actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
);
const resolvedActions = useMemo(
() =>
actions.map((action) => {
const isConfirming = confirmingActionId === action.id;
const isThisActionExecuting = executingActionId === action.id;
const isLoading = action.loading || isThisActionExecuting;
const isDisabled =
action.disabled || (executingActionId !== null && !isThisActionExecuting);
const currentLabel =
isConfirming && action.confirmLabel
? action.confirmLabel
: action.label;
return {
...action,
currentLabel,
isConfirming,
isExecuting: isThisActionExecuting,
isDisabled,
isLoading,
};
}),
[actions, confirmingActionId, executingActionId],
);
return {
actions: resolvedActions,
runAction,
confirmingActionId,
executingActionId,
};
}
@@ -0,0 +1,63 @@
"use client";
import { useCallback, useEffect, useState } from "react";
function fallbackCopyToClipboard(text: string): boolean {
try {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.setAttribute("readonly", "");
textArea.style.position = "fixed";
textArea.style.top = "-9999px";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textArea);
return ok;
} catch {
return false;
}
}
export function useCopyToClipboard(options?: {
resetAfterMs?: number;
}): {
copiedId: string | null;
copy: (text: string, id?: string) => Promise<boolean>;
} {
const resetAfterMs = options?.resetAfterMs ?? 2000;
const [copiedId, setCopiedId] = useState<string | null>(null);
const copy = useCallback(
async (text: string, id: string = "default") => {
let ok = false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ok = true;
} else {
ok = fallbackCopyToClipboard(text);
}
} catch {
ok = fallbackCopyToClipboard(text);
}
if (ok) {
setCopiedId(id);
}
return ok;
},
[],
);
useEffect(() => {
if (!copiedId) return;
const timeout = setTimeout(() => setCopiedId(null), resetAfterMs);
return () => clearTimeout(timeout);
}, [copiedId, resetAfterMs]);
return { copiedId, copy };
}
@@ -0,0 +1,29 @@
export function formatRelativeTime(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
if (seconds < 604800) return `${Math.round(seconds / 86400)}d`;
return `${Math.round(seconds / 604800)}w`;
}
export function formatCount(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
}
export function getDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
export function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
}
@@ -0,0 +1,76 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "../../lib/ui/cn";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:scale-y-[-1]",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="group data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-y-clip overflow-x-visible text-sm"
{...props}
>
<div
className={cn(
"pt-0 pb-4",
"group-data-[state=open]:animate-in group-data-[state=open]:fade-in-0",
"group-data-[state=closed]:animate-out group-data-[state=closed]:fade-out-0",
"duration-200",
className,
)}
>
{children}
</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,48 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/ui/cn";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
warning:
"border-transparent bg-amber-500/10 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 [a&]:hover:bg-amber-500/20 dark:[a&]:hover:bg-amber-500/30",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
@@ -0,0 +1,63 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/ui/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
homeCTA: "rounded-full px-6 py-3 text-lg w-fit text-start",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "../../lib/ui/cn";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
@@ -0,0 +1,90 @@
"use client";
import * as React from "react";
import { cn } from "../../lib/ui/cn";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
};
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "../../lib/ui/cn";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+122
View File
@@ -0,0 +1,122 @@
import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { Badge } from "./components/ui/badge";
type ToolOutput = {
content?: Array<{ type?: string; text?: string }>;
structuredContent?: unknown;
structured_content?: unknown;
structured?: unknown;
};
type RenderData = {
toolInput?: { identifier?: string };
toolOutput?: ToolOutput | string;
};
function stripFrontmatter(text: string): string {
if (!text.startsWith("---")) return text;
const end = text.indexOf("---", 3);
if (end === -1) return text;
return text.slice(end + 3).trim();
}
function parseTitle(text: string): string | null {
const lines = text.split("\n");
for (const line of lines) {
if (line.startsWith("# ")) {
return line.replace("# ", "").trim();
}
}
return null;
}
function extractText(toolOutput: ToolOutput | string | undefined): string {
if (!toolOutput) return "";
if (typeof toolOutput === "string") return toolOutput;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block?.type === "text" && typeof block.text === "string",
);
if (textBlock?.text) return textBlock.text;
}
if (typeof toolOutput.structuredContent === "string") {
return toolOutput.structuredContent;
}
return "";
}
function NotePreviewApp() {
const [identifier, setIdentifier] = useState<string>("");
const [title, setTitle] = useState<string>("Note Preview");
const [content, setContent] = useState<string>("");
const [hasData, setHasData] = useState(false);
useEffect(() => {
function handleMessage(event: MessageEvent) {
const message = event.data as { type?: string; payload?: { renderData?: RenderData } };
if (message?.type === "ui-lifecycle-iframe-render-data") {
setHasData(true);
const renderData = message.payload?.renderData;
const nextIdentifier = renderData?.toolInput?.identifier || "";
const rawText = extractText(renderData?.toolOutput);
const cleaned = stripFrontmatter(rawText).trim();
const parsedTitle = parseTitle(cleaned);
setIdentifier(nextIdentifier);
setTitle(parsedTitle || nextIdentifier || "Note Preview");
setContent(cleaned);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
const timeout = setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
return () => {
window.removeEventListener("message", handleMessage);
clearTimeout(timeout);
};
}, [hasData]);
return (
<div className="min-h-screen bg-background text-foreground" data-theme="light">
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-6 py-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">{title}</h1>
<p className="text-muted-foreground text-xs">
{identifier ? `Identifier: ${identifier}` : "Note content"}
</p>
</div>
<Badge variant="secondary">tool-ui (React)</Badge>
</div>
<div className="border-border bg-card rounded-2xl border p-4 shadow-sm">
{content ? (
<pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-foreground/80">
{content}
</pre>
) : (
<div className="text-muted-foreground text-sm">No content available.</div>
)}
</div>
</div>
</div>
);
}
const root = document.getElementById("root");
if (root) {
createRoot(root).render(<NotePreviewApp />);
}
+175
View File
@@ -0,0 +1,175 @@
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import { DataTable, DataTableErrorBoundary } from "./components/tool-ui/data-table";
import type { Column, DataTableRowData } from "./components/tool-ui/data-table/types";
type SearchResult = {
title?: string;
type?: string;
score?: number;
permalink?: string;
file_path?: string;
metadata?: { tags?: string[] };
tags?: string[];
};
type ToolOutput = {
structuredContent?: unknown;
structured_content?: unknown;
structured?: unknown;
content?: Array<{ type?: string; text?: string }>;
};
type RenderData = {
toolInput?: { query?: string };
toolOutput?: ToolOutput | string;
};
function safeJsonParse(text: string): unknown | null {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function extractStructured(toolOutput: ToolOutput | string | undefined): unknown {
if (!toolOutput) return null;
if (typeof toolOutput === "string") {
return safeJsonParse(toolOutput) || { text: toolOutput };
}
const structured =
toolOutput.structuredContent ||
toolOutput.structured_content ||
toolOutput.structured ||
null;
if (structured) return structured;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block?.type === "text" && typeof block.text === "string",
);
if (textBlock?.text) {
return safeJsonParse(textBlock.text) || { text: textBlock.text };
}
}
return toolOutput;
}
function getResults(structured: unknown): SearchResult[] {
if (!structured || typeof structured !== "object") return [];
const record = structured as Record<string, unknown>;
if (Array.isArray(record.results)) return record.results as SearchResult[];
const nested = record.result as Record<string, unknown> | undefined;
if (nested && Array.isArray(nested.results)) return nested.results as SearchResult[];
return [];
}
function formatTags(result: SearchResult): string {
const tags = result.metadata?.tags || result.tags || [];
if (!Array.isArray(tags)) return "";
return tags.join(", ");
}
function formatPath(result: SearchResult): string {
return result.permalink || result.file_path || "";
}
const columns: Column<DataTableRowData>[] = [
{ key: "title", label: "Title", priority: "primary" },
{ key: "type", label: "Type", priority: "secondary" },
{ key: "score", label: "Score", align: "right", priority: "secondary" },
{ key: "path", label: "Path", priority: "tertiary" },
{ key: "tags", label: "Tags", priority: "tertiary" },
];
function SearchResultsApp() {
const [query, setQuery] = useState<string>("Search results");
const [rows, setRows] = useState<DataTableRowData[]>([]);
const [hasData, setHasData] = useState(false);
useEffect(() => {
function handleMessage(event: MessageEvent) {
const message = event.data as { type?: string; payload?: { renderData?: RenderData } };
if (message?.type === "ui-lifecycle-iframe-render-data") {
setHasData(true);
const renderData = message.payload?.renderData;
const nextQuery = renderData?.toolInput?.query;
setQuery(nextQuery ? `Query: ${nextQuery}` : "Search results");
const structured = extractStructured(renderData?.toolOutput);
const results = getResults(structured);
const nextRows = results.map((result, index) => ({
id: result.permalink || result.file_path || result.title || `row-${index}`,
title: result.title || "Untitled",
type: result.type || "",
score: typeof result.score === "number" ? Number(result.score.toFixed(2)) : 0,
path: formatPath(result),
tags: formatTags(result),
}));
setRows(nextRows);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
const timeout = setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
return () => {
window.removeEventListener("message", handleMessage);
clearTimeout(timeout);
};
}, [hasData]);
const footer = useMemo(() => {
if (!rows.length) return "No results";
return `${rows.length} result${rows.length === 1 ? "" : "s"}`;
}, [rows.length]);
return (
<div className="min-h-screen bg-background text-foreground" data-theme="light">
<div className="mx-auto flex max-w-5xl flex-col gap-4 px-6 py-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">Search Results</h1>
<p className="text-muted-foreground text-xs">{query}</p>
</div>
<span className="border-border bg-card text-foreground rounded-full border px-3 py-1 text-[11px]">
tool-ui (React)
</span>
</div>
<div className="border-border bg-card rounded-2xl border p-3 shadow-sm">
<DataTableErrorBoundary>
<DataTable
id="basic-memory-search-results"
rowIdKey="id"
columns={columns}
data={rows}
emptyMessage="No results to show"
/>
</DataTableErrorBoundary>
</div>
<div className="text-muted-foreground text-xs">{footer}</div>
</div>
</div>
);
}
const root = document.getElementById("root");
if (root) {
createRoot(root).render(<SearchResultsApp />);
}
+74
View File
@@ -0,0 +1,74 @@
@custom-variant dark (&:is(.dark *, [data-theme="dark"], [data-theme="dark"] *));
@import "tailwindcss";
@import "tw-animate-css";
@source "./**/*.{ts,tsx,html}";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
}
:root {
color-scheme: light;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
[data-theme="light"][data-theme] {
color-scheme: light;
}
body {
margin: 0;
font-family: "Geist", "Space Grotesk", system-ui, sans-serif;
background: var(--background);
color: var(--foreground);
}
#root {
min-height: 100vh;
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{ts,tsx,html}"],
theme: {
extend: {},
},
plugins: [],
};