diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a1e921..9b4a5465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. # Changelog +## Unreleased + +### appkit + +* **appkit:** **Breaking change:** markdown agents must live under `config/agents//agent.md`. Top-level `config/agents/*.md` is no longer discovered; migrate each file to `/agent.md`. The reserved folder `config/agents/skills` is ignored until per-agent skills ship. + ## [0.24.0](https://github.com/databricks/appkit/compare/v0.23.0...v0.24.0) (2026-04-20) * add AST extraction to serving type generator and move types to shared/ ([#279](https://github.com/databricks/appkit/issues/279)) ([422afb3](https://github.com/databricks/appkit/commit/422afb38aa73f8adb94e091225dc3381bd92cfcd)) diff --git a/apps/dev-playground/client/package-lock.json b/apps/dev-playground/client/package-lock.json index 80bd5ad4..7a34b5b2 100644 --- a/apps/dev-playground/client/package-lock.json +++ b/apps/dev-playground/client/package-lock.json @@ -18,6 +18,8 @@ "@tanstack/router-plugin": "1.133.22", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "html2canvas": "1.4.1", + "html2canvas-pro": "2.0.2", "lucide-react": "0.546.0", "react": "19.2.0", "react-dom": "19.2.0", @@ -3559,6 +3561,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://npm-proxy.dev.databricks.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", @@ -3916,6 +3927,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://npm-proxy.dev.databricks.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -4711,6 +4731,32 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://npm-proxy.dev.databricks.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/html2canvas-pro": { + "version": "2.0.2", + "resolved": "https://npm-proxy.dev.databricks.com/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz", + "integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6215,6 +6261,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://npm-proxy.dev.databricks.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -6592,6 +6647,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://npm-proxy.dev.databricks.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/apps/dev-playground/client/package.json b/apps/dev-playground/client/package.json index 9bf90c3f..e69a49a3 100644 --- a/apps/dev-playground/client/package.json +++ b/apps/dev-playground/client/package.json @@ -20,6 +20,8 @@ "@tanstack/router-plugin": "1.133.22", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "html2canvas": "1.4.1", + "html2canvas-pro": "2.0.2", "lucide-react": "0.546.0", "react": "19.2.0", "react-dom": "19.2.0", @@ -30,6 +32,7 @@ }, "devDependencies": { "@eslint/js": "9.36.0", + "@tailwindcss/postcss": "4.1.17", "@tanstack/router-cli": "1.133.20", "@types/node": "24.6.0", "@types/react": "19.2.2", @@ -43,7 +46,6 @@ "postcss": "8.5.6", "shiki": "3.15.0", "tailwindcss": "4.1.17", - "@tailwindcss/postcss": "4.1.17", "typescript": "5.9.3", "typescript-eslint": "8.45.0", "vite": "npm:rolldown-vite@7.1.14" diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/action-toast.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/action-toast.tsx new file mode 100644 index 00000000..311ecdc2 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/action-toast.tsx @@ -0,0 +1,48 @@ +import { CheckCircle2Icon } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface ActionToastProps { + /** + * Latest dispatcher-surfaced action summary. Each new value bumps a + * render key so the toast re-animates even if the same message arrives + * twice (e.g. two identical filter calls in a row). + */ + message: string | null; + durationMs?: number; +} + +/** + * Non-intrusive bottom-left toast that confirms every agent-driven UI + * action. Silent success was the worst failure mode before: an action + * silently not-applied looked identical to one that worked but didn't + * show its effect. + */ +export function ActionToast({ message, durationMs = 2800 }: ActionToastProps) { + const [visible, setVisible] = useState<{ key: number; text: string } | null>( + null, + ); + + useEffect(() => { + if (!message) return; + const key = Date.now(); + setVisible({ key, text: message }); + const t = setTimeout(() => { + setVisible((v) => (v?.key === key ? null : v)); + }, durationMs); + return () => { + clearTimeout(t); + }; + }, [message, durationMs]); + + if (!visible) return null; + + return ( +
+ + {visible.text} +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/actionable-card.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/actionable-card.tsx new file mode 100644 index 00000000..db6a42f5 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/actionable-card.tsx @@ -0,0 +1,191 @@ +import { + AlertTriangleIcon, + ArrowRightIcon, + CalendarIcon, + CrosshairIcon, + DollarSignIcon, + HighlighterIcon, + LightbulbIcon, + MapPinIcon, + MessageSquareIcon, +} from "lucide-react"; +import type { FeedAction } from "../lib/feed-actions"; + +type Variant = "insight" | "anomaly"; +type Severity = "low" | "medium" | "high"; + +interface ActionableCardProps { + variant: Variant; + severity?: Severity; + title: string; + description: string; + actions: FeedAction[]; + /** Fired for non-ask actions. Route applies them to dashboard state. */ + onAction: (action: FeedAction) => void; + /** Fired for `ask` actions. Route forwards the prompt to the chat drawer. */ + onAsk: (prompt: string) => void; +} + +// Backgrounds are written as arbitrary 8-digit hex (e.g. `bg-[#eff6ff80]`) +// instead of Tailwind's `/N` alpha shorthand. Rationale: `bg-blue-50/50` +// compiles in Tailwind v4 to a pair — an sRGB hex fallback and a +// `@supports (color-mix)` override that re-mixes in oklab over the oklch +// palette token. Browsers that support `color-mix` (recent Chrome/Arc) take +// the oklab path; older embedded Chromiums (e.g. Cursor's built-in browser +// at the time of writing) fall through to the sRGB hex. Because oklab and +// sRGB interpolation produce visibly different tints — especially against +// the dark `--card` token — the same card ends up looking different in each +// browser. Pinning the colour to a literal hex (no `/N`, no @supports +// override) keeps all browsers on the same sRGB path and therefore the same +// visual result. +const INSIGHT_STYLES = { + border: "border-blue-200 dark:border-blue-900", + bg: "bg-[#eff6ff80] dark:bg-[#1624564d]", + icon: "text-blue-500", +}; + +const ANOMALY_STYLES: Record< + Severity, + { border: string; bg: string; icon: string; badge: string } +> = { + low: { + border: "border-yellow-200 dark:border-yellow-900", + bg: "bg-[#fefce880] dark:bg-[#4320044d]", + icon: "text-yellow-500", + badge: + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-400", + }, + medium: { + border: "border-orange-200 dark:border-orange-900", + bg: "bg-[#fff7ed80] dark:bg-[#4413064d]", + icon: "text-orange-500", + badge: + "bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-400", + }, + high: { + border: "border-red-200 dark:border-red-900", + bg: "bg-[#fef2f280] dark:bg-[#4608094d]", + icon: "text-red-500", + badge: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400", + }, +}; + +function iconForAction(kind: FeedAction["kind"]): React.ReactNode { + const cls = "h-3 w-3"; + switch (kind) { + case "filter_date": + return ; + case "filter_zip": + return ; + case "filter_fare": + return ; + case "highlight_period": + return ; + case "highlight_zone": + return ; + case "focus_chart": + return ; + case "ask": + return ; + } +} + +/** + * Action chip for a single feed suggestion. The chip's visual weight depends + * on its kind: structural mutations (filter/highlight/focus) use the primary + * tint, `ask` uses a neutral outline so the user can tell "this opens the + * chat" from "this changes the dashboard" without reading the label. + */ +function ActionChip({ + action, + onAction, + onAsk, +}: { + action: FeedAction; + onAction: (a: FeedAction) => void; + onAsk: (prompt: string) => void; +}) { + const isAsk = action.kind === "ask"; + const isHighlight = + action.kind === "highlight_period" || action.kind === "highlight_zone"; + + return ( + + ); +} + +export function ActionableCard({ + variant, + severity, + title, + description, + actions, + onAction, + onAsk, +}: ActionableCardProps) { + const isAnomaly = variant === "anomaly"; + const styles = isAnomaly + ? ANOMALY_STYLES[severity ?? "low"] + : { ...INSIGHT_STYLES, badge: "" }; + + return ( +
+
+ {isAnomaly ? ( + + ) : ( + + )} +
+
+

+ {title} +

+ {isAnomaly && severity && ( + + {severity} + + )} +
+

+ {description} +

+
+
+ + {actions.length > 0 && ( +
+ {actions.map((action, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/active-filters.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/active-filters.tsx new file mode 100644 index 00000000..f5fe96a2 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/active-filters.tsx @@ -0,0 +1,63 @@ +import { FilterIcon, XIcon } from "lucide-react"; +import type { DashboardFilters } from "../hooks/use-dashboard-data"; + +interface ActiveFiltersProps { + filters: DashboardFilters; + onClear: (key: keyof DashboardFilters) => void; + onClearAll: () => void; +} + +function formatFilterEntry(key: string, value: string): string { + const labels: Record = { + date_from: "From", + date_to: "To", + pickup_zip: "Zone", + fare_min: "Min fare", + fare_max: "Max fare", + }; + return `${labels[key] ?? key}: ${value}`; +} + +export function ActiveFilters({ + filters, + onClear, + onClearAll, +}: ActiveFiltersProps) { + const entries = Object.entries(filters).filter( + ([, v]) => v !== undefined && v !== "", + ); + + if (entries.length === 0) return null; + + return ( +
+ + + Active Filters: + + {entries.map(([key, value]) => ( + + {formatFilterEntry(key, value ?? "")} + + + ))} + +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/agent-sidebar.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/agent-sidebar.tsx new file mode 100644 index 00000000..0c14b501 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/agent-sidebar.tsx @@ -0,0 +1,266 @@ +import { BrainIcon, Loader2Icon, RefreshCwIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Highlight } from "../hooks/use-action-dispatcher"; +import { useAgentStream } from "../hooks/use-agent-stream"; +import type { DashboardFilters, KPIData } from "../hooks/use-dashboard-data"; +import { + type FeedAction, + type FeedAnomaly, + type FeedInsight, + parseFeedAnomalies, + parseFeedInsights, +} from "../lib/feed-actions"; +import { ActionableCard } from "./actionable-card"; + +interface AgentSidebarProps { + kpis: KPIData | null; + kpisLoaded: boolean; + filters: DashboardFilters; + highlights: Highlight[]; + /** Dispatches a structured action back to the dashboard without an LLM round-trip. */ + onAction: (action: FeedAction) => void; + /** Fires when the user clicks an `ask` chip — routes to the main chat drawer. */ + onAsk: (prompt: string) => void; +} + +function buildKPISummary( + kpis: KPIData, + filters: DashboardFilters, + highlights: Highlight[], +): string { + const parts = [ + `Total trips: ${kpis.total_trips.toLocaleString()}`, + `Avg fare: $${kpis.avg_fare}`, + `Avg distance: ${kpis.avg_distance} mi`, + `Fare range: $${kpis.min_fare}–$${kpis.max_fare}`, + `Top pickup zone: ${kpis.top_pickup_zone} (${kpis.top_zone_trips.toLocaleString()} trips)`, + ]; + const activeFilters = Object.entries(filters) + .filter(([, v]) => typeof v === "string" && v) + .map(([k, v]) => `${k}=${v}`); + if (activeFilters.length > 0) { + parts.push(`Active filters: ${activeFilters.join(", ")}`); + } else { + parts.push("Active filters: none (full 2016 dataset)"); + } + if (highlights.length > 0) { + parts.push( + `Highlights: ${highlights + .map( + (h) => + `${h.start}→${h.end}${h.label ? ` (${h.label})` : ""} [${h.color}]`, + ) + .join(", ")}`, + ); + } + return parts.join(". "); +} + +/** + * Debounce helper so a rapid sequence of filter/highlight changes collapses + * into one ephemeral agent re-run. 700ms is short enough to feel responsive + * but long enough to coalesce a typical click+click interaction. + */ +function useDebouncedSignal(dep: string, delayMs: number): string { + const [stable, setStable] = useState(dep); + useEffect(() => { + const t = setTimeout(() => setStable(dep), delayMs); + return () => clearTimeout(t); + }, [dep, delayMs]); + return stable; +} + +const SUGGESTED_FOLLOWUPS = [ + "Compare this slice to the prior month.", + "What ZIPs show the highest fare-per-mile?", + "Were there any days with abnormal trip counts?", +]; + +export function AgentSidebar({ + kpis, + kpisLoaded, + filters, + highlights, + onAction, + onAsk, +}: AgentSidebarProps) { + const [insights, setInsights] = useState([]); + const [anomalies, setAnomalies] = useState([]); + + const insightsStream = useAgentStream({ agentName: "insights" }); + const anomalyStream = useAgentStream({ agentName: "anomaly" }); + + // Hold the latest stream handles + context refs so `analyze()` is stable + // but still reads current state. + const insightsRef = useRef(insightsStream); + insightsRef.current = insightsStream; + const anomalyRef = useRef(anomalyStream); + anomalyRef.current = anomalyStream; + const ctxRef = useRef({ kpis, filters, highlights }); + ctxRef.current = { kpis, filters, highlights }; + + const analyze = useCallback(() => { + const { kpis: currentKpis, filters: f, highlights: h } = ctxRef.current; + if (!currentKpis) return; + const summary = buildKPISummary(currentKpis, f, h); + setInsights([]); + setAnomalies([]); + insightsRef.current.reset(); + anomalyRef.current.reset(); + insightsRef.current.send( + `Current NYC taxi dashboard state: ${summary}. Surface the most interesting patterns and insights with actionable chips.`, + ); + anomalyRef.current.send( + `Current NYC taxi dashboard state: ${summary}. Identify anomalies, outliers, or suspicious patterns with actionable chips.`, + ); + }, []); + + // Initial fire once KPIs load. + const hasFired = useRef(false); + useEffect(() => { + if (kpisLoaded && kpis && !hasFired.current) { + hasFired.current = true; + analyze(); + } + }, [kpisLoaded, kpis, analyze]); + + // Re-run whenever filters or highlights settle into a new value. Encoded as + // a string so useEffect gets a primitive dep and the debounce works off + // structural equality, not object identity. + const stateSignal = useMemo( + () => + JSON.stringify({ + f: filters, + h: highlights.map((hh) => `${hh.start}-${hh.end}-${hh.color}`), + }), + [filters, highlights], + ); + const debouncedSignal = useDebouncedSignal(stateSignal, 700); + const lastAnalyzedSignal = useRef(stateSignal); + useEffect(() => { + if (!kpisLoaded || !kpis) return; + if (!hasFired.current) return; // initial fire is in the other effect + if (debouncedSignal === lastAnalyzedSignal.current) return; + lastAnalyzedSignal.current = debouncedSignal; + analyze(); + }, [debouncedSignal, kpisLoaded, kpis, analyze]); + + useEffect(() => { + if (!insightsStream.isLoading && insightsStream.content) { + setInsights(parseFeedInsights(insightsStream.content)); + } + }, [insightsStream.isLoading, insightsStream.content]); + + useEffect(() => { + if (!anomalyStream.isLoading && anomalyStream.content) { + setAnomalies(parseFeedAnomalies(anomalyStream.content)); + } + }, [anomalyStream.isLoading, anomalyStream.content]); + + const isAnalyzing = insightsStream.isLoading || anomalyStream.isLoading; + const totalFindings = insights.length + anomalies.length; + + return ( +
+
+
+ + + Agent Feed + + {isAnalyzing ? ( + + + analyzing + + ) : totalFindings > 0 ? ( + + {insights.length} + insights · + {anomalies.length} + anomalies + + ) : null} +
+ +
+ +
+ {isAnalyzing && totalFindings === 0 && ( +
+ +

Analyzing data…

+
+ )} + + {!isAnalyzing && totalFindings === 0 && !kpisLoaded && ( +

+ Loading dashboard data… +

+ )} + + {!isAnalyzing && totalFindings === 0 && kpisLoaded && ( +

+ No findings for this slice — try widening the filters. +

+ )} + + {insights.map((insight, i) => ( + + ))} + + {anomalies.map((anomaly, i) => ( + + ))} +
+ + {kpisLoaded && ( +
+

+ Try asking +

+
+ {SUGGESTED_FOLLOWUPS.map((prompt) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/anomaly-card.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/anomaly-card.tsx new file mode 100644 index 00000000..72c8f0ef --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/anomaly-card.tsx @@ -0,0 +1,68 @@ +import { AlertTriangleIcon } from "lucide-react"; + +type Severity = "low" | "medium" | "high"; + +interface AnomalyCardProps { + title: string; + description: string; + severity: Severity; +} + +const SEVERITY_STYLES: Record< + Severity, + { border: string; bg: string; icon: string; badge: string } +> = { + low: { + border: "border-yellow-200 dark:border-yellow-900", + bg: "bg-yellow-50/50 dark:bg-yellow-950/30", + icon: "text-yellow-500", + badge: + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-400", + }, + medium: { + border: "border-orange-200 dark:border-orange-900", + bg: "bg-orange-50/50 dark:bg-orange-950/30", + icon: "text-orange-500", + badge: + "bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-400", + }, + high: { + border: "border-red-200 dark:border-red-900", + bg: "bg-red-50/50 dark:bg-red-950/30", + icon: "text-red-500", + badge: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400", + }, +}; + +export function AnomalyCard({ + title, + description, + severity, +}: AnomalyCardProps) { + const styles = SEVERITY_STYLES[severity]; + + return ( +
+
+ +
+
+

+ {title} +

+ + {severity} + +
+

+ {description} +

+
+
+
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/approval-card.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/approval-card.tsx new file mode 100644 index 00000000..47f24b33 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/approval-card.tsx @@ -0,0 +1,337 @@ +import { + CheckCircle2Icon, + PencilIcon, + PlusCircleIcon, + ShieldAlertIcon, +} from "lucide-react"; +import { useCallback, useState } from "react"; +import type { Highlight } from "../hooks/use-action-dispatcher"; +import type { DashboardFilters } from "../hooks/use-dashboard-data"; +import { captureDashboardAsDataUrl } from "../lib/capture-dashboard"; + +type ToolEffect = "read" | "write" | "update" | "destructive"; + +export interface PendingApproval { + approvalId: string; + streamId: string; + toolName: string; + args: unknown; + annotations?: { + effect?: ToolEffect; + readOnly?: boolean; + destructive?: boolean; + idempotent?: boolean; + }; +} + +/** + * Resolve the semantic tier we should render for this approval. Prefers + * the explicit `effect` label; falls back to the legacy `destructive` flag + * so tools that haven't migrated yet keep their red treatment. Anything + * with no mutation hint at all falls through as `write` — the approval + * gate fired for a reason, and `write` is the lowest-severity default. + */ +function resolveEffect( + ann: PendingApproval["annotations"], +): Exclude { + if (ann?.effect && ann.effect !== "read") return ann.effect; + if (ann?.destructive === true) return "destructive"; + return "write"; +} + +interface EffectTheme { + icon: typeof ShieldAlertIcon; + container: string; + iconColor: string; + badge: string; + badgeLabel: string; + verb: string; +} + +const EFFECT_THEMES: Record, EffectTheme> = { + write: { + icon: PlusCircleIcon, + container: "border-blue-500/40 bg-blue-500/[0.06]", + iconColor: "text-blue-500", + badge: "bg-blue-500/20 text-blue-600 dark:text-blue-400", + badgeLabel: "writes", + verb: "Approving creates new state in Databricks.", + }, + update: { + icon: PencilIcon, + container: "border-amber-500/40 bg-amber-500/[0.06]", + iconColor: "text-amber-500", + badge: "bg-amber-500/20 text-amber-700 dark:text-amber-400", + badgeLabel: "updates", + verb: "Approving modifies existing state in Databricks.", + }, + destructive: { + icon: ShieldAlertIcon, + container: "border-red-500/40 bg-red-500/[0.06]", + iconColor: "text-red-500", + badge: "bg-red-500/20 text-red-600 dark:text-red-400", + badgeLabel: "destructive", + verb: "Approving deletes or irreversibly changes state. Double-check first.", + }, +}; + +interface ApprovalCardProps { + approval: PendingApproval; + filters: DashboardFilters; + highlights: Highlight[]; + /** Root element to capture when the approved tool is `save_view`. */ + dashboardRef: React.RefObject; + onDecide: (approvalId: string, decision: "approve" | "deny") => void; + /** Notification surfaced back to the route for the toast. */ + onSaved?: (info: { name: string; volumePath: string }) => void; +} + +function formatFilters(filters: DashboardFilters): string { + const entries = Object.entries(filters).filter( + ([, v]) => v !== undefined && v !== "", + ); + if (entries.length === 0) return "(none)"; + return entries.map(([k, v]) => `${k}=${v}`).join(", "); +} + +function formatHighlights(highlights: Highlight[]): string { + if (highlights.length === 0) return "(none)"; + return highlights + .map( + (h) => + `${h.start}..${h.end}${h.label ? ` (${h.label})` : ""} [${h.color}]`, + ) + .join("; "); +} + +export function ApprovalCard({ + approval, + filters, + highlights, + dashboardRef, + onDecide, + onSaved, +}: ApprovalCardProps) { + const args = + typeof approval.args === "object" && approval.args !== null + ? (approval.args as Record) + : {}; + const effect = resolveEffect(approval.annotations); + const theme = EFFECT_THEMES[effect]; + const EffectIcon = theme.icon; + const isSaveView = approval.toolName === "save_view"; + + const [phase, setPhase] = useState< + | { kind: "idle" } + | { kind: "capturing" } + | { kind: "uploading"; previewUrl: string } + | { kind: "done"; volumePath: string } + | { kind: "error"; message: string } + >({ kind: "idle" }); + + const handleApprove = useCallback(async () => { + if (!isSaveView) { + onDecide(approval.approvalId, "approve"); + return; + } + + const root = dashboardRef.current; + if (!root) { + setPhase({ + kind: "error", + message: + "Cannot locate the dashboard element to capture. Contact support.", + }); + return; + } + + try { + setPhase({ kind: "capturing" }); + // Conservative capture settings: AppKit's server plugin caps + // JSON bodies at 100kb by default. JPEG @ quality 0.75 + scale + // 0.6 keeps base64 payloads in the 25-60kb range for typical + // dashboard viewports with room for metadata. + const { dataUrl } = await captureDashboardAsDataUrl(root, { + quality: 0.75, + scale: 0.6, + }); + setPhase({ kind: "uploading", previewUrl: dataUrl }); + + const name = + typeof args.name === "string" && args.name.trim() !== "" + ? (args.name as string) + : "Untitled view"; + const description = + typeof args.description === "string" ? args.description : undefined; + + const uploadRes = await fetch("/api/dashboard/save-view", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + description, + filters, + highlights, + pngBase64: dataUrl, + }), + }); + + if (!uploadRes.ok) { + const err = await uploadRes.text(); + throw new Error(`Upload failed (${uploadRes.status}): ${err}`); + } + + const uploadJson = (await uploadRes.json()) as { + volumePath: string; + }; + + setPhase({ kind: "done", volumePath: uploadJson.volumePath }); + onSaved?.({ name, volumePath: uploadJson.volumePath }); + onDecide(approval.approvalId, "approve"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setPhase({ + kind: "error", + message: msg, + }); + } + }, [ + isSaveView, + args, + filters, + highlights, + dashboardRef, + onDecide, + onSaved, + approval.approvalId, + ]); + + const busy = phase.kind === "capturing" || phase.kind === "uploading"; + + return ( +
+
+ +
+
+

+ Approval required +

+ + {theme.badgeLabel} + +
+

+ The agent wants to call{" "} + + {approval.toolName} + + {isSaveView + ? ". Approving captures the current dashboard and uploads it as a saved view." + : `. ${theme.verb}`} +

+
+
+ + {Object.keys(args).length > 0 && ( +
+
+ Arguments +
+ + + {Object.entries(args).map(([key, value]) => ( + + + + + ))} + +
+ {key} + + {typeof value === "string" + ? value + : JSON.stringify(value, null, 2)} +
+
+ )} + +
+
+ Current dashboard state +
+
+ filters: {formatFilters(filters)} +
+
+ highlights:{" "} + {formatHighlights(highlights)} +
+
+ + {phase.kind === "uploading" && ( +
+
+ Captured preview (uploading…) +
+ Dashboard preview +
+ )} + + {phase.kind === "done" && ( +
+ + + Saved to {phase.volumePath} + +
+ )} + + {phase.kind === "error" && ( +
+ {phase.message} +
+ )} + +
+ + +
+
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/chat-drawer.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/chat-drawer.tsx new file mode 100644 index 00000000..4c519648 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/chat-drawer.tsx @@ -0,0 +1,248 @@ +import { + FilterIcon, + Loader2Icon, + MessageSquareIcon, + SendIcon, + SparklesIcon, + XIcon, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PendingApproval } from "./approval-card"; + +export interface ChatMessage { + id: string; + role: "user" | "assistant" | "system"; + content: string; + /** When true, this is the in-progress assistant turn being streamed. */ + streaming?: boolean; +} + +interface ChatDrawerProps { + messages: ChatMessage[]; + isLoading: boolean; + onSend: (message: string) => void; + /** Rendered inline in the message list for the turn that triggered it. */ + approvalCardForMessage: (messageId: string) => React.ReactNode | null; + pendingApprovals: PendingApproval[]; + /** Floating affordance: the toggle button also shows a pending-approval dot. */ + unreadCount?: number; + /** Controlled open state so the parent can auto-open the drawer when a + * dashboard interaction (chips, heatmap cells, quick actions, follow-ups) + * dispatches a turn the user needs to see. */ + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const EXAMPLE_QUERIES = [ + "Filter to November 2016", + "Highlight the first week of Jan 2016 in red", + "Save this view as Peak Week", + "Focus on the fare distribution", + "Clear all filters and highlights", +]; + +/** + * Floating chat drawer. Toggled by the ⌘J keyboard shortcut or the + * floating message-square button in the bottom-right. Multi-turn + * conversation history stays mounted in state so previous turns remain + * visible as the user iterates. + */ +export function ChatDrawer({ + messages, + isLoading, + onSend, + approvalCardForMessage, + pendingApprovals, + unreadCount, + open, + onOpenChange, +}: ChatDrawerProps) { + const [input, setInput] = useState(""); + const [showTips, setShowTips] = useState(true); + const bottomRef = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ( + e.key === "j" && + (e.metaKey || e.ctrlKey) && + !e.altKey && + !e.shiftKey + ) { + e.preventDefault(); + onOpenChange(!open); + } else if (e.key === "Escape" && open) { + onOpenChange(false); + } + }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + }; + }, [open, onOpenChange]); + + // Auto-open when a new approval arrives so users don't miss it. + useEffect(() => { + if (pendingApprovals.length > 0) onOpenChange(true); + }, [pendingApprovals.length, onOpenChange]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages.length, messages[messages.length - 1]?.content]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const msg = input.trim(); + if (!msg || isLoading) return; + setInput(""); + setShowTips(false); + onSend(msg); + }, + [input, isLoading, onSend], + ); + + const handleExample = useCallback( + (q: string) => { + if (isLoading) return; + setShowTips(false); + onSend(q); + }, + [isLoading, onSend], + ); + + return ( + <> + + + {open && ( + + )} + + ); +} + +function MessageBubble({ message }: { message: ChatMessage }) { + const isUser = message.role === "user"; + return ( +
+
+ {message.content || (message.streaming ? "…" : "")} +
+
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/fare-chart.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/fare-chart.tsx new file mode 100644 index 00000000..383174ae --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/fare-chart.tsx @@ -0,0 +1,78 @@ +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { useChartColors } from "../hooks/use-chart-colors"; +import type { FareBucket } from "../hooks/use-dashboard-data"; + +interface FareChartProps { + data: FareBucket[]; + isLoading: boolean; +} + +export function FareChart({ data, isLoading }: FareChartProps) { + const c = useChartColors(); + + if (isLoading) { + return ( +
+

+ Fare Distribution +

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

+ Fare Distribution +

+ + + + + + v >= 1000 ? `${(v / 1000).toFixed(0)}K` : String(v) + } + /> + { + if (name === "trip_count") + return [value.toLocaleString(), "Trips"]; + return [value, name]; + }} + /> + + + +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/focusable-chart.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/focusable-chart.tsx new file mode 100644 index 00000000..689145a4 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/focusable-chart.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { + type FocusableChartId, + useFocusable, +} from "../hooks/use-focus-registry"; + +interface FocusableChartProps { + chartId: FocusableChartId; + children: ReactNode; +} + +/** + * Wraps a chart with a focus-ring pulse effect. Pairs with `focusChart(id)` + * — when the `dashboard_pilot` agent emits a `focus_chart({ chart_id })` + * tool call, the dispatcher invokes the registered callback here, which + * scrolls into view and flips `focused` true for 1.2s. + * + * Named `chartId` (not `id`) because this is a logical focus-registry key, + * not a DOM id attribute. + */ +export function FocusableChart({ chartId, children }: FocusableChartProps) { + const { setRef, focused } = useFocusable(chartId); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/hourly-heatmap.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/hourly-heatmap.tsx new file mode 100644 index 00000000..51ce98ed --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/hourly-heatmap.tsx @@ -0,0 +1,186 @@ +import { useMemo } from "react"; +import type { HeatmapCell } from "../hooks/use-dashboard-data"; + +interface HourlyHeatmapProps { + data: HeatmapCell[]; + isLoading: boolean; + /** Fires when the user clicks a cell. Receives a human-readable slot label + * the route typically routes to `dispatchToAgent` so the agent can narrate. */ + onCellClick?: (label: string, cell: HeatmapCell) => void; +} + +// Spark's DAYOFWEEK returns 1..7 (Sunday=1, Saturday=7). We render Mon–Sun +// for commuter intuition, so the row order is shifted. +const DAY_ROW_ORDER: Array<{ label: string; dayOfWeek: number }> = [ + { label: "Mon", dayOfWeek: 2 }, + { label: "Tue", dayOfWeek: 3 }, + { label: "Wed", dayOfWeek: 4 }, + { label: "Thu", dayOfWeek: 5 }, + { label: "Fri", dayOfWeek: 6 }, + { label: "Sat", dayOfWeek: 7 }, + { label: "Sun", dayOfWeek: 1 }, +]; + +const FULL_DAY_LABEL: Record = { + 1: "Sunday", + 2: "Monday", + 3: "Tuesday", + 4: "Wednesday", + 5: "Thursday", + 6: "Friday", + 7: "Saturday", +}; + +const HOURS = Array.from({ length: 24 }, (_, i) => i); + +function formatHour(h: number): string { + if (h === 0) return "12a"; + if (h === 12) return "12p"; + if (h < 12) return `${h}a`; + return `${h - 12}p`; +} + +/** + * Maps trip_count to an HSL string along the primary → hot gradient. Uses + * lightness rather than alpha so the cells stay legible on both themes; alpha + * would wash out the dark-mode variant. Missing cells render as a neutral + * muted tile rather than "empty" so the grid reads as a matrix at a glance. + */ +function cellColor(value: number, max: number, isDark: boolean): string { + if (max === 0 || value === 0) { + return isDark ? "hsl(215, 14%, 22%)" : "hsl(220, 13%, 94%)"; + } + const t = Math.min(1, value / max); + if (isDark) { + const lightness = 18 + t * 42; + return `hsl(217, 80%, ${lightness}%)`; + } + const lightness = 90 - t * 50; + return `hsl(221, 83%, ${lightness}%)`; +} + +function isDarkTheme(): boolean { + if (typeof document === "undefined") return false; + return document.documentElement.classList.contains("dark"); +} + +export function HourlyHeatmap({ + data, + isLoading, + onCellClick, +}: HourlyHeatmapProps) { + const dark = isDarkTheme(); + + const { cellByKey, maxCount } = useMemo(() => { + const map = new Map(); + let max = 0; + for (const c of data) { + map.set(`${c.day_of_week}-${c.hour_of_day}`, c); + if (c.trip_count > max) max = c.trip_count; + } + return { cellByKey: map, maxCount: max }; + }, [data]); + + if (isLoading) { + return ( +
+

+ Pickup Heatmap +

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

+ Pickup Heatmap + + day × hour + +

+ + click a cell to investigate + +
+ +
+
+
+ {HOURS.map((h) => ( +
+ {h % 3 === 0 ? formatHour(h) : ""} +
+ ))} + + {DAY_ROW_ORDER.map((row) => ( +
+
+ {row.label} +
+ {HOURS.map((h) => { + const cell = cellByKey.get(`${row.dayOfWeek}-${h}`); + const count = cell?.trip_count ?? 0; + const bg = cellColor(count, maxCount, dark); + const label = `${FULL_DAY_LABEL[row.dayOfWeek]} at ${formatHour(h)}`; + const title = `${label}: ${count.toLocaleString()} trips${ + cell ? ` · $${cell.avg_fare} avg fare` : "" + }`; + return ( +
+ ))} +
+
+ +
+ fewer +
+ {[0, 0.25, 0.5, 0.75, 1].map((t) => ( +
+ ))} +
+ more + {maxCount > 0 && ( + + peak {maxCount.toLocaleString()} trips/slot + + )} +
+
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/insight-card.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/insight-card.tsx new file mode 100644 index 00000000..b17b44d7 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/insight-card.tsx @@ -0,0 +1,24 @@ +import { LightbulbIcon } from "lucide-react"; + +interface InsightCardProps { + title: string; + description: string; +} + +export function InsightCard({ title, description }: InsightCardProps) { + return ( +
+
+ +
+

+ {title} +

+

+ {description} +

+
+
+
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/inspector-toggle.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/inspector-toggle.tsx new file mode 100644 index 00000000..4a0388b6 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/inspector-toggle.tsx @@ -0,0 +1,31 @@ +import { ActivityIcon } from "lucide-react"; +import { + toggleInspector, + useStreamInspector, +} from "../hooks/use-stream-inspector"; + +/** + * Floating icon in the bottom-right that opens the Stream Inspector. + * Complements the ⌘K keyboard shortcut with a discoverable affordance. + */ +export function InspectorToggle() { + const { records } = useStreamInspector(); + const currentRunEvents = records[0]?.events.length ?? 0; + + return ( + + ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/kpi-cards.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/kpi-cards.tsx new file mode 100644 index 00000000..8d6ddd84 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/kpi-cards.tsx @@ -0,0 +1,258 @@ +import { CarIcon, DollarSignIcon, MapPinIcon, RulerIcon } from "lucide-react"; +import { useId, useMemo } from "react"; +import { useChartColors } from "../hooks/use-chart-colors"; +import type { KPIData, SparklineRow } from "../hooks/use-dashboard-data"; + +interface KPICardsProps { + data: KPIData | null; + sparklines: SparklineRow[]; + isLoading: boolean; +} + +interface CardProps { + title: string; + value: string; + subtitle?: string; + icon: React.ReactNode; + isLoading: boolean; + /** 30-bar trailing series (or empty → no sparkline). Values are normalized inside. */ + series: number[]; + trend?: number; +} + +/** + * Fixed-size inline sparkline. Using a hand-rolled SVG rather than recharts + * because: + * - recharts inside a grid of 4 cards would mount 4× chart engines with + * ResponsiveContainer observers — heavy for a decorative element; + * - we want sub-pixel control over the baseline tint + end-cap dot. + */ +function Sparkline({ + values, + color, + isLoading, +}: { + values: number[]; + color: string; + isLoading: boolean; +}) { + const gradientId = useId(); + const width = 120; + const height = 36; + + const { pathD, areaD, lastPoint } = useMemo(() => { + if (values.length === 0) { + return { pathD: "", areaD: "", lastPoint: null }; + } + const min = Math.min(...values); + const max = Math.max(...values); + const span = max - min || 1; + const step = values.length > 1 ? width / (values.length - 1) : width; + const points = values.map((v, i) => { + const x = i * step; + const y = height - 4 - ((v - min) / span) * (height - 8); + return { x, y }; + }); + const d = points + .map( + (p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`, + ) + .join(" "); + const area = `${d} L ${width} ${height} L 0 ${height} Z`; + return { pathD: d, areaD: area, lastPoint: points[points.length - 1] }; + }, [values]); + + if (isLoading) { + return
; + } + // Intentionally-empty series (e.g. categorical KPI like "Top Pickup Zone"): + // keep the slot reserved so the four cards stay the same height, but render + // nothing inside — otherwise the muted placeholder looks like a ghost + // "still loading" spinner. + if (values.length === 0) { + return
; + } + + return ( + + + + + + + + + + {lastPoint && ( + + )} + + ); +} + +function KPICard({ + title, + value, + subtitle, + icon, + isLoading, + series, + trend, +}: CardProps) { + const c = useChartColors(); + const trendLabel = + trend === undefined + ? null + : trend > 0 + ? `+${trend.toFixed(0)}%` + : `${trend.toFixed(0)}%`; + const trendColor = + trend === undefined + ? "" + : trend > 0 + ? "text-emerald-600 dark:text-emerald-400" + : trend < 0 + ? "text-rose-600 dark:text-rose-400" + : "text-muted-foreground"; + + return ( +
+
+ + {title} + + {icon} +
+ {isLoading ? ( +
+ ) : ( + <> +
+

{value}

+ {trendLabel && ( + + {trendLabel} + + )} +
+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ +
+ + )} +
+ ); +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +} + +/** Percent delta between the last `tail` window and the previous window. */ +function windowedTrend(values: number[], tail: number): number | undefined { + // Drop nulls/undefined/NaN (e.g. days with no trips after a fare filter) and + // coerce everything to Number defensively — some drivers hand back DECIMAL + // columns as strings, and `0 + "12.35"` would silently string-concat and + // render "NaN%" once we tried to divide. + const clean = values.map((v) => Number(v)).filter(Number.isFinite); + if (clean.length < tail * 2) return undefined; + const recent = clean.slice(-tail); + const prior = clean.slice(-tail * 2, -tail); + const recentAvg = recent.reduce((a, b) => a + b, 0) / recent.length; + const priorAvg = prior.reduce((a, b) => a + b, 0) / prior.length; + if (!Number.isFinite(recentAvg) || !Number.isFinite(priorAvg)) + return undefined; + if (priorAvg === 0) return undefined; + return ((recentAvg - priorAvg) / priorAvg) * 100; +} + +export function KPICards({ data, sparklines, isLoading }: KPICardsProps) { + // Coerce on intake so downstream sparkline paths and trend math stay purely + // numeric — avoids surprises if a driver ever hands back DECIMAL-as-string. + const toNum = (v: unknown) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }; + const tripSeries = sparklines.map((r) => toNum(r.trip_count)); + const fareSeries = sparklines.map((r) => toNum(r.avg_fare)); + const distSeries = sparklines.map((r) => toNum(r.avg_distance)); + const revenueSeries = sparklines.map((r) => toNum(r.total_revenue)); + + const TREND_WINDOW = 7; + + return ( +
+ } + isLoading={isLoading} + series={tripSeries} + trend={windowedTrend(tripSeries, TREND_WINDOW)} + /> + } + isLoading={isLoading} + series={fareSeries} + trend={windowedTrend(fareSeries, TREND_WINDOW)} + /> + 0 + ? `$${formatNumber( + // Explicit Number() wrap on each accumulator step defends + // against a single stray string in the series silently + // turning the whole sum into a concatenated blob. + revenueSeries.reduce( + (a, b) => a + (Number.isFinite(b) ? Number(b) : 0), + 0, + ), + )} revenue` + : undefined + } + icon={} + isLoading={isLoading} + series={distSeries} + trend={windowedTrend(distSeries, TREND_WINDOW)} + /> + } + isLoading={isLoading} + series={[]} + /> +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/quick-actions-bar.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/quick-actions-bar.tsx new file mode 100644 index 00000000..c67aa066 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/quick-actions-bar.tsx @@ -0,0 +1,119 @@ +import { BookmarkPlusIcon, EraserIcon, FilterXIcon, XIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; + +interface QuickActionsBarProps { + /** + * Dispatches a message through the chat pipeline (same `useAgentStream` + * the text input uses). Keeps the demo narrative honest: clicks are just + * prefilled prompts — the agent still reasons and the approval gate + * still fires for destructive actions. + */ + onSend: (message: string) => void; + disabled?: boolean; +} + +export function QuickActionsBar({ + onSend, + disabled = false, +}: QuickActionsBarProps) { + const [savingName, setSavingName] = useState(null); + const saveInputRef = useRef(null); + + const startSave = useCallback(() => { + setSavingName(""); + setTimeout(() => saveInputRef.current?.focus(), 0); + }, []); + + const cancelSave = useCallback(() => { + setSavingName(null); + }, []); + + const submitSave = useCallback(() => { + const name = savingName?.trim(); + if (!name) { + setSavingName(null); + return; + } + onSend(`Save the current view as "${name}"`); + setSavingName(null); + }, [savingName, onSend]); + + return ( +
+ + Quick actions + + + {savingName === null ? ( + + ) : ( +
+ + setSavingName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitSave(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelSave(); + } + }} + placeholder="Name this view…" + disabled={disabled} + className="w-44 bg-transparent border-0 outline-none text-xs text-foreground placeholder:text-muted-foreground" + /> + + +
+ )} + + + + +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/saved-views-panel.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/saved-views-panel.tsx new file mode 100644 index 00000000..a04a8612 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/saved-views-panel.tsx @@ -0,0 +1,176 @@ +import { + BookmarkIcon, + ChevronDownIcon, + ChevronUpIcon, + Loader2Icon, + RefreshCwIcon, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +export interface SavedView { + pngPath: string; + metaPath: string; + metadata: { + name?: string; + description?: string | null; + filters?: Record; + highlights?: unknown[]; + savedAt?: string; + savedBy?: string; + pngPath?: string; + }; +} + +interface SavedViewsPanelProps { + /** + * Send-to-chat callback. Clicking a saved view dispatches a load request + * through the agent so the approval/action trail stays consistent. + */ + onLoad: (view: SavedView) => void; + /** Incrementing counter bumped by the route after each successful save. */ + refreshToken: number; +} + +export function SavedViewsPanel({ + onLoad, + refreshToken, +}: SavedViewsPanelProps) { + const [open, setOpen] = useState(true); + const [views, setViews] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/dashboard/saved-views"); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`${res.status}: ${txt}`); + } + const data = (await res.json()) as { views: SavedView[] }; + setViews(data.views); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + // Load on mount + whenever the parent bumps refreshToken. The dep on + // refreshToken is intentional — biome flags it because it's an opaque + // number with no direct read inside the effect body, but the whole + // point is that changing it in the parent invalidates the cached list. + // biome-ignore lint/correctness/useExhaustiveDependencies: see above + useEffect(() => { + load(); + }, [load, refreshToken]); + + return ( +
+ + )} + {open ? ( + + ) : ( + + )} +
+ + + {open && ( +
+ {error && ( +
+ Failed to load: {error} +
+ )} + + {!error && views.length === 0 && !loading && ( +
+ No saved views yet. Use the Save view… quick action or + ask the agent to save the current configuration. +
+ )} + + {views.length > 0 && ( +
+ {views.map((view) => ( + onLoad(view)} + /> + ))} +
+ )} +
+ )} +
+ ); +} + +function SavedViewCard({ + view, + onLoad, +}: { + view: SavedView; + onLoad: () => void; +}) { + const savedAt = view.metadata.savedAt + ? new Date(view.metadata.savedAt).toLocaleString() + : ""; + + return ( + + ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/stream-inspector.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/stream-inspector.tsx new file mode 100644 index 00000000..adf15dcc --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/stream-inspector.tsx @@ -0,0 +1,246 @@ +import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + clearInspectorHistory, + closeInspector, + type StreamEventRecord, + type StreamRecord, + useStreamInspector, +} from "../hooks/use-stream-inspector"; + +type FilterMode = + | "all" + | "tool_calls" + | "messages" + | "approvals" + | "sub_agents"; + +const FILTER_OPTIONS: Array<{ id: FilterMode; label: string }> = [ + { id: "all", label: "All" }, + { id: "tool_calls", label: "Tool calls" }, + { id: "messages", label: "Messages" }, + { id: "approvals", label: "Approvals" }, + { id: "sub_agents", label: "Sub-agents" }, +]; + +function matchesFilter( + event: StreamEventRecord["event"], + mode: FilterMode, +): boolean { + if (mode === "all") return true; + if (mode === "messages") { + return ( + event.type === "response.output_text.delta" || + event.type === "response.output_item.added" || + event.type === "response.output_item.done" || + event.type === "response.completed" + ); + } + if (mode === "tool_calls") { + return ( + (event.type === "response.output_item.added" || + event.type === "response.output_item.done") && + event.item?.type === "function_call" + ); + } + if (mode === "approvals") { + return event.type === "appkit.approval_pending"; + } + if (mode === "sub_agents") { + // Sub-agent invocations surface as `agent-` function_calls; keep + // `appkit.metadata` in here too since it carries threadId on new runs. + if (event.item?.type === "function_call") { + return event.item.name?.startsWith("agent-") ?? false; + } + return false; + } + return true; +} + +function shortType(type: string): string { + // Collapse the verbose `response.*` prefix for legibility. + return type.replace(/^response\./, "").replace(/^appkit\./, ""); +} + +function formatTimestamp(relMs: number): string { + if (relMs < 1000) return `${Math.round(relMs)}ms`; + return `${(relMs / 1000).toFixed(2)}s`; +} + +function EventRow({ + event, + receivedAt, + startedAt, +}: StreamEventRecord & { startedAt: number }) { + const [expanded, setExpanded] = useState(false); + const rel = receivedAt - startedAt; + + const isFunctionCall = event.item?.type === "function_call"; + const isApproval = event.type === "appkit.approval_pending"; + + let summary: string; + if (isApproval) { + summary = `approval: ${event.tool_name}`; + } else if (isFunctionCall) { + summary = `${event.item?.name ?? "(unnamed)"}`; + } else if (event.type === "response.output_text.delta") { + summary = event.delta ?? ""; + } else { + summary = ""; + } + + return ( +
+ +
+ ); +} + +function RunBlock({ record }: { record: StreamRecord }) { + return ( +
+
+
+ {record.label} +
+
+ {record.events.length} events · started{" "} + {new Date( + Date.now() - (performance.now() - record.startedAt), + ).toLocaleTimeString()} +
+
+
+ {record.events.map((er, idx) => ( + + ))} +
+
+ ); +} + +export function StreamInspector() { + const { isOpen, records } = useStreamInspector(); + const [filter, setFilter] = useState("all"); + + const filteredRecords = useMemo(() => { + if (filter === "all") return records; + return records.map((r) => ({ + ...r, + events: r.events.filter((er) => matchesFilter(er.event, filter)), + })); + }, [records, filter]); + + if (!isOpen) return null; + + return ( + <> + {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: backdrop dismiss handled globally via Esc */} +
+ + + ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/top-zones-chart.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/top-zones-chart.tsx new file mode 100644 index 00000000..5a466600 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/top-zones-chart.tsx @@ -0,0 +1,164 @@ +import { useMemo, useState } from "react"; +import { useChartColors } from "../hooks/use-chart-colors"; +import type { TopZoneRow } from "../hooks/use-dashboard-data"; + +export interface HighlightedZone { + zip: string; + label?: string; +} + +interface TopZonesChartProps { + data: TopZoneRow[]; + isLoading: boolean; + /** Zones with a visible emphasis ring — driven by the `highlight_zone` tool. */ + highlightedZones: HighlightedZone[]; + /** Click on a bar → filter the dashboard to that zip. */ + onZipClick?: (zip: string) => void; +} + +type Metric = "trips" | "revenue"; + +/** + * Horizontal leaderboard chart for pickup ZIPs. Hand-rolled divs rather than + * recharts' BarChart because: + * - we want per-row click handlers and a distinct ring for highlighted zones; + * - the bars need a stable text overlay (ZIP + value) that doesn't fight with + * recharts' label positioning logic; + * - 10 rows max means flexbox is trivially faster than a full chart engine. + */ +export function TopZonesChart({ + data, + isLoading, + highlightedZones, + onZipClick, +}: TopZonesChartProps) { + const c = useChartColors(); + const [metric, setMetric] = useState("trips"); + + const { rows, max } = useMemo(() => { + const sorted = [...data].sort((a, b) => + metric === "trips" + ? b.trip_count - a.trip_count + : b.total_revenue - a.total_revenue, + ); + const m = sorted.reduce( + (acc, r) => + Math.max(acc, metric === "trips" ? r.trip_count : r.total_revenue), + 0, + ); + return { rows: sorted, max: m }; + }, [data, metric]); + + const highlightSet = useMemo( + () => new Map(highlightedZones.map((h) => [h.zip, h.label ?? ""])), + [highlightedZones], + ); + + if (isLoading) { + return ( +
+

+ Top Pickup Zones +

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

+ Top Pickup Zones +

+
+ + +
+
+ + {rows.length === 0 ? ( +
+ No zones in range +
+ ) : ( +
+ {rows.map((row) => { + const value = + metric === "trips" ? row.trip_count : row.total_revenue; + const pct = max > 0 ? (value / max) * 100 : 0; + const isHighlighted = highlightSet.has(row.pickup_zip); + const highlightLabel = highlightSet.get(row.pickup_zip); + + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/trip-chart.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/trip-chart.tsx new file mode 100644 index 00000000..89f365f1 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/trip-chart.tsx @@ -0,0 +1,144 @@ +import { useId } from "react"; +import { + Area, + AreaChart, + CartesianGrid, + ReferenceArea, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { Highlight } from "../hooks/use-action-dispatcher"; +import { useChartColors } from "../hooks/use-chart-colors"; +import type { TripOverTime } from "../hooks/use-dashboard-data"; + +interface TripChartProps { + data: TripOverTime[]; + highlights: Highlight[]; + isLoading: boolean; +} + +const HIGHLIGHT_COLORS: Record = { + blue: "rgba(96, 165, 250, 0.25)", + red: "rgba(248, 113, 113, 0.25)", + yellow: "rgba(250, 204, 21, 0.25)", +}; + +function formatDate(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +function findClosestDate( + target: string, + dates: string[], + direction: "start" | "end", +): string | undefined { + if (dates.length === 0) return undefined; + const t = new Date(target).getTime(); + let best: string | undefined; + let bestDist = Number.POSITIVE_INFINITY; + for (const d of dates) { + const dt = new Date(d).getTime(); + const dist = Math.abs(dt - t); + const valid = direction === "start" ? dt <= t : dt >= t; + if (valid && dist < bestDist) { + best = d; + bestDist = dist; + } + } + return best ?? dates[direction === "start" ? 0 : dates.length - 1]; +} + +export function TripChart({ data, highlights, isLoading }: TripChartProps) { + const gradientId = useId(); + const c = useChartColors(); + const dates = data.map((d) => d.trip_date); + + if (isLoading) { + return ( +
+

+ Trips Over Time +

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

+ Trips Over Time +

+ + + + + + + + + + + + v >= 1000 ? `${(v / 1000).toFixed(0)}K` : String(v) + } + /> + [value.toLocaleString(), "Trips"]} + /> + {highlights.map((h, i) => { + const x1 = findClosestDate(h.start, dates, "start"); + const x2 = findClosestDate(h.end, dates, "end"); + if (!x1 || !x2) return null; + return ( + + ); + })} + + + +
+ ); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-action-dispatcher.ts b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-action-dispatcher.ts new file mode 100644 index 00000000..c133847c --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-action-dispatcher.ts @@ -0,0 +1,310 @@ +import { useCallback, useMemo, useRef } from "react"; +import type { SSEEvent } from "./use-agent-stream"; +import type { DashboardFilters } from "./use-dashboard-data"; +import { focusChart, isFocusableChartId } from "./use-focus-registry"; + +export interface Highlight { + start: string; + end: string; + color: "blue" | "red" | "yellow"; + label?: string; +} + +export interface HighlightedZone { + zip: string; + label?: string; +} + +const DASHBOARD_TOOLS = new Set([ + "filter_by_date_range", + "filter_by_pickup_zip", + "filter_by_fare", + "clear_filters", + "highlight_period", + "clear_highlights", + "highlight_zone", + "clear_zone_highlights", + "focus_chart", + "load_view", +]); + +interface UseActionDispatcherOptions { + /** Receives an updater fn; avoids stale-closure bugs when the agent fires multiple tool calls back-to-back. */ + onFilterUpdate: ( + updater: (prev: DashboardFilters) => DashboardFilters, + ) => void; + onAddHighlight: (highlight: Highlight) => void; + onClearFilters: () => void; + onClearHighlights: () => void; + onAddZoneHighlight: (zone: HighlightedZone) => void; + onClearZoneHighlights: () => void; + /** Called once per applied action with a short human-readable summary. Route surfaces it as a toast. */ + onAction?: (summary: string) => void; + /** Called when the dispatcher receives a tool it doesn't know how to handle. Lets the route warn visibly. */ + onUnknownTool?: (name: string, args: unknown) => void; +} + +function parseArgs(raw: string | undefined): Record | null { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +const CALL_ID_LRU_CAP = 128; + +/** + * Translates `function_call` tool events from the agent's SSE stream into + * dashboard state mutations. Exposes the same per-tool mutations as a + * synchronous {@link dispatch} function so the agent-feed action chips can + * reuse the identical code path without going through an LLM round-trip. + * + * Correctness rules (learned the hard way): + * + * - Only acts on `response.output_item.done`, never `.added`. `.added` fires + * with incomplete `arguments`, causing spurious JSON parse failures and, + * worse, double-firing: `highlight_period` used to append the same band + * twice because both events passed. + * - Dedupes by `call_id`. Keeps a bounded LRU so memory stays finite across + * a long session. A new run clears the cache on `appkit.metadata` (the + * first event of every stream carries the new threadId). + * - Uses updater callbacks (`onFilterUpdate(prev => ...)`) instead of reading + * `currentFilters` from props. Multi-tool-call runs within a single + * render cycle would otherwise see stale filter state. + * - Emits a summary for every applied action via `onAction`. Silent success + * is the worst failure mode here — if the user can't see what changed, + * they can't tell whether the agent misfired. + */ +export function useActionDispatcher(opts: UseActionDispatcherOptions) { + const { + onFilterUpdate, + onAddHighlight, + onClearFilters, + onClearHighlights, + onAddZoneHighlight, + onClearZoneHighlights, + onAction, + onUnknownTool, + } = opts; + + const seen = useRef([]); + + const markSeen = useCallback((callId: string): boolean => { + if (seen.current.includes(callId)) return true; + seen.current.push(callId); + if (seen.current.length > CALL_ID_LRU_CAP) { + seen.current.splice(0, seen.current.length - CALL_ID_LRU_CAP); + } + return false; + }, []); + + const dispatch = useCallback( + (name: string, args: Record): void => { + if (!DASHBOARD_TOOLS.has(name)) { + onUnknownTool?.(name, args); + return; + } + + switch (name) { + case "filter_by_date_range": { + const start = args.start; + const end = args.end; + if (typeof start !== "string" || typeof end !== "string") { + onUnknownTool?.(name, args); + return; + } + onFilterUpdate((prev) => ({ + ...prev, + date_from: start, + date_to: end, + })); + onAction?.(`Filtered to ${start} → ${end}`); + return; + } + case "filter_by_pickup_zip": { + const zip = args.zip; + if (typeof zip !== "string") { + onUnknownTool?.(name, args); + return; + } + onFilterUpdate((prev) => ({ ...prev, pickup_zip: zip })); + onAction?.(`Filtered to pickup ZIP ${zip}`); + return; + } + case "filter_by_fare": { + const min = typeof args.min === "number" ? args.min : undefined; + const max = typeof args.max === "number" ? args.max : undefined; + if (min === undefined && max === undefined) { + onUnknownTool?.(name, args); + return; + } + onFilterUpdate((prev) => ({ + ...prev, + ...(min !== undefined ? { fare_min: String(min) } : {}), + ...(max !== undefined ? { fare_max: String(max) } : {}), + })); + const parts: string[] = []; + if (min !== undefined) parts.push(`≥ $${min}`); + if (max !== undefined) parts.push(`≤ $${max}`); + onAction?.(`Filtered by fare ${parts.join(" and ")}`); + return; + } + case "clear_filters": { + onClearFilters(); + onAction?.("Filters cleared"); + return; + } + case "highlight_period": { + const start = args.start; + const end = args.end; + if (typeof start !== "string" || typeof end !== "string") { + onUnknownTool?.(name, args); + return; + } + const color = + args.color === "red" || args.color === "yellow" + ? args.color + : "blue"; + const label = + typeof args.label === "string" && args.label !== "" + ? args.label + : undefined; + onAddHighlight({ start, end, color, label }); + onAction?.( + `Highlighted ${start} → ${end}${label ? ` (${label})` : ""}`, + ); + return; + } + case "clear_highlights": { + onClearHighlights(); + onAction?.("Highlights cleared"); + return; + } + case "highlight_zone": { + const zip = args.zip; + if (typeof zip !== "string" || zip === "") { + onUnknownTool?.(name, args); + return; + } + const label = + typeof args.label === "string" && args.label !== "" + ? args.label + : undefined; + onAddZoneHighlight({ zip, label }); + onAction?.(`Highlighted ZIP ${zip}${label ? ` (${label})` : ""}`); + return; + } + case "clear_zone_highlights": { + onClearZoneHighlights(); + onAction?.("Zone highlights cleared"); + return; + } + case "focus_chart": { + const id = args.chart_id; + if (!isFocusableChartId(id)) { + onUnknownTool?.(name, args); + return; + } + focusChart(id); + onAction?.(`Focused ${String(id).replace(/_/g, " ")}`); + return; + } + case "load_view": { + const rawFilters = (args.filters ?? {}) as Record; + const nextFilters: DashboardFilters = {}; + if (typeof rawFilters.date_from === "string") + nextFilters.date_from = rawFilters.date_from; + if (typeof rawFilters.date_to === "string") + nextFilters.date_to = rawFilters.date_to; + if (typeof rawFilters.pickup_zip === "string") + nextFilters.pickup_zip = rawFilters.pickup_zip; + if (typeof rawFilters.fare_min === "string") + nextFilters.fare_min = rawFilters.fare_min; + if (typeof rawFilters.fare_max === "string") + nextFilters.fare_max = rawFilters.fare_max; + + const rawHighlights = Array.isArray(args.highlights) + ? (args.highlights as Array>) + : []; + const nextHighlights: Highlight[] = rawHighlights.flatMap((h) => { + const start = h.start; + const end = h.end; + if (typeof start !== "string" || typeof end !== "string") return []; + const color: Highlight["color"] = + h.color === "red" || h.color === "yellow" ? h.color : "blue"; + const label = typeof h.label === "string" ? h.label : undefined; + return [{ start, end, color, label }]; + }); + + // Restore: clear then re-apply both filters and highlights in one + // shot so partial states don't linger. + onClearFilters(); + onClearHighlights(); + onClearZoneHighlights(); + if (Object.keys(nextFilters).length > 0) { + onFilterUpdate(() => nextFilters); + } + for (const h of nextHighlights) { + onAddHighlight(h); + } + const viewName = + typeof args.name === "string" ? args.name : "saved view"; + onAction?.(`Loaded "${viewName}"`); + return; + } + default: { + onUnknownTool?.(name, args); + return; + } + } + }, + [ + onFilterUpdate, + onAddHighlight, + onClearFilters, + onClearHighlights, + onAddZoneHighlight, + onClearZoneHighlights, + onAction, + onUnknownTool, + ], + ); + + const handleEvent = useCallback( + (event: SSEEvent) => { + if (event.type === "appkit.metadata") { + seen.current = []; + return; + } + + if (event.type !== "response.output_item.done") return; + if (event.item?.type !== "function_call") return; + + const name = event.item.name; + if (!name) return; + + // Tools not owned by the dashboard (e.g. `analytics.query`, sub-agent + // `agent-sql_analyst`) flow through without a dispatcher side-effect. + if (!DASHBOARD_TOOLS.has(name)) return; + + const callId = event.item.call_id; + if (callId && markSeen(callId)) return; + + const args = parseArgs(event.item.arguments); + if (args === null) { + onUnknownTool?.(name, event.item.arguments); + return; + } + + dispatch(name, args); + }, + [dispatch, markSeen, onUnknownTool], + ); + + return useMemo(() => ({ handleEvent, dispatch }), [handleEvent, dispatch]); +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-agent-stream.ts b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-agent-stream.ts new file mode 100644 index 00000000..9810b261 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-agent-stream.ts @@ -0,0 +1,158 @@ +import { useCallback, useRef, useState } from "react"; +import { beginStreamRun, recordStreamEvent } from "./use-stream-inspector"; + +export interface SSEEvent { + type: string; + delta?: string; + item_id?: string; + item?: { + type?: string; + id?: string; + call_id?: string; + name?: string; + arguments?: string; + output?: string; + status?: string; + }; + content?: string; + data?: Record; + error?: string; + sequence_number?: number; + output_index?: number; + // appkit.approval_pending payload + approval_id?: string; + stream_id?: string; + tool_name?: string; + args?: unknown; + annotations?: { + readOnly?: boolean; + destructive?: boolean; + idempotent?: boolean; + }; +} + +interface UseAgentStreamOptions { + agentName: string; + onEvent?: (event: SSEEvent) => void; +} + +interface SendOptions { + /** + * Text prepended to the user's message on the wire. Used by the Smart + * Dashboard route to inject active filters / highlights into the system + * prompt so the agent always knows what the user is looking at. + */ + contextPrefix?: string; +} + +interface UseAgentStreamReturn { + content: string; + events: SSEEvent[]; + isLoading: boolean; + threadId: string | null; + send: (message: string, opts?: SendOptions) => Promise; + reset: () => void; +} + +export function useAgentStream({ + agentName, + onEvent, +}: UseAgentStreamOptions): UseAgentStreamReturn { + const [content, setContent] = useState(""); + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [threadId, setThreadId] = useState(null); + const contentRef = useRef(""); + const onEventRef = useRef(onEvent); + onEventRef.current = onEvent; + + const reset = useCallback(() => { + setContent(""); + setEvents([]); + contentRef.current = ""; + }, []); + + const send = useCallback( + async (message: string, opts?: SendOptions) => { + setIsLoading(true); + setContent(""); + setEvents([]); + contentRef.current = ""; + + const wire = opts?.contextPrefix + ? `${opts.contextPrefix}${message}` + : message; + + const runId = beginStreamRun(`${agentName}: ${message.slice(0, 80)}`); + + try { + const res = await fetch("/api/agents/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: wire, + agent: agentName, + ...(threadId && { threadId }), + }), + }); + + if (!res.ok) { + const errText = await res.text(); + try { + const err = JSON.parse(errText); + setContent(`Error: ${err.error}`); + } catch { + setContent(`Error: Server returned ${res.status}`); + } + return; + } + + const reader = res.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + try { + const event: SSEEvent = JSON.parse(data); + if (!event.type) continue; + setEvents((prev) => [...prev, event]); + recordStreamEvent(runId, event); + onEventRef.current?.(event); + + if (event.type === "appkit.metadata" && event.data?.threadId) { + setThreadId(event.data.threadId as string); + } + if (event.type === "response.output_text.delta" && event.delta) { + contentRef.current += event.delta; + setContent(contentRef.current); + } + } catch { + /* skip malformed events */ + } + } + } + } catch (err) { + setContent( + `Error: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } finally { + setIsLoading(false); + } + }, + [agentName, threadId], + ); + + return { content, events, isLoading, threadId, send, reset }; +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-chart-colors.ts b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-chart-colors.ts new file mode 100644 index 00000000..3403b1c1 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-chart-colors.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; + +interface ChartColors { + primary: string; + secondary: string; + grid: string; + axis: string; + tooltipBg: string; + tooltipFg: string; +} + +const LIGHT: ChartColors = { + primary: "hsl(221, 83%, 53%)", + secondary: "hsl(142, 71%, 45%)", + grid: "hsl(220, 13%, 91%)", + axis: "hsl(215, 16%, 47%)", + tooltipBg: "hsl(0, 0%, 100%)", + tooltipFg: "hsl(222, 47%, 11%)", +}; + +const DARK: ChartColors = { + primary: "hsl(217, 91%, 70%)", + secondary: "hsl(152, 69%, 55%)", + grid: "hsl(215, 14%, 25%)", + axis: "hsl(217, 20%, 70%)", + tooltipBg: "hsl(224, 71%, 4%)", + tooltipFg: "hsl(210, 40%, 96%)", +}; + +function isDark(): boolean { + return document.documentElement.classList.contains("dark"); +} + +export function useChartColors(): ChartColors { + const [colors, setColors] = useState(() => + isDark() ? DARK : LIGHT, + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + setColors(isDark() ? DARK : LIGHT); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + + return colors; +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-dashboard-data.ts b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-dashboard-data.ts new file mode 100644 index 00000000..c4e9f5d3 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-dashboard-data.ts @@ -0,0 +1,196 @@ +import { sql } from "@databricks/appkit-ui/js"; +import { useAnalyticsQuery } from "@databricks/appkit-ui/react"; +import { useMemo } from "react"; + +interface KPIRawRow { + total_trips: number; + avg_fare: number; + avg_distance: number; + max_fare: number; + min_fare: number; +} + +interface TopZoneData { + pickup_zip: string; + trip_count: number; +} + +export type KPIData = KPIRawRow & { + top_pickup_zone: string; + top_zone_trips: number; +}; + +export interface TripOverTime { + trip_date: string; + trip_count: number; + avg_fare: number; + total_revenue: number; +} + +export interface FareBucket { + fare_bucket: string; + trip_count: number; + avg_distance: number; +} + +export interface HeatmapCell { + day_of_week: number; + hour_of_day: number; + trip_count: number; + avg_fare: number; +} + +export interface TopZoneRow { + pickup_zip: string; + trip_count: number; + total_revenue: number; + avg_fare: number; +} + +export interface SparklineRow { + trip_date: string; + trip_count: number; + total_revenue: number; + avg_fare: number; + avg_distance: number; +} + +export interface DashboardFilters { + date_from?: string; + date_to?: string; + pickup_zip?: string; + fare_min?: string; + fare_max?: string; +} + +function buildParams(filters: DashboardFilters) { + return { + dateFrom: sql.string(filters.date_from ?? "all"), + dateTo: sql.string(filters.date_to ?? "all"), + pickupZip: sql.string(filters.pickup_zip ?? "all"), + fareMin: sql.string(filters.fare_min ?? "all"), + fareMax: sql.string(filters.fare_max ?? "all"), + }; +} + +export function useDashboardData(filters: DashboardFilters) { + const params = useMemo(() => buildParams(filters), [filters]); + + const { + data: kpisRaw, + loading: kpisLoading, + error: kpisError, + } = useAnalyticsQuery("dashboard_kpis", params) as { + data: KPIRawRow[] | null; + loading: boolean; + error: string | null; + }; + + const { + data: topZoneRaw, + loading: topZoneLoading, + error: topZoneError, + } = useAnalyticsQuery("dashboard_top_zone", params) as { + data: TopZoneData[] | null; + loading: boolean; + error: string | null; + }; + + const tripsParams = useMemo( + () => ({ + dateFrom: params.dateFrom, + dateTo: params.dateTo, + pickupZip: params.pickupZip, + }), + [params.dateFrom, params.dateTo, params.pickupZip], + ); + + const { + data: tripsOverTime, + loading: tripsLoading, + error: tripsError, + } = useAnalyticsQuery("dashboard_trips_over_time", tripsParams) as { + data: TripOverTime[] | null; + loading: boolean; + error: string | null; + }; + + const { + data: fareDistribution, + loading: fareLoading, + error: fareError, + } = useAnalyticsQuery("dashboard_fare_distribution", tripsParams) as { + data: FareBucket[] | null; + loading: boolean; + error: string | null; + }; + + const { + data: heatmap, + loading: heatmapLoading, + error: heatmapError, + } = useAnalyticsQuery("dashboard_hourly_heatmap", params) as { + data: HeatmapCell[] | null; + loading: boolean; + error: string | null; + }; + + const { + data: topZones, + loading: topZonesLoading, + error: topZonesError, + } = useAnalyticsQuery("dashboard_top_zones", params) as { + data: TopZoneRow[] | null; + loading: boolean; + error: string | null; + }; + + const { + data: sparklines, + loading: sparklinesLoading, + error: sparklinesError, + } = useAnalyticsQuery("dashboard_kpi_sparklines", params) as { + data: SparklineRow[] | null; + loading: boolean; + error: string | null; + }; + + const kpis = useMemo(() => { + if (!kpisRaw || kpisRaw.length === 0) return null; + const row = kpisRaw[0]; + const topZone = topZoneRaw?.[0]; + return { + ...row, + top_pickup_zone: topZone?.pickup_zip ?? "N/A", + top_zone_trips: topZone?.trip_count ?? 0, + }; + }, [kpisRaw, topZoneRaw]); + + const isLoading = + kpisLoading || + topZoneLoading || + tripsLoading || + fareLoading || + heatmapLoading || + topZonesLoading || + sparklinesLoading; + const error = + kpisError || + topZoneError || + tripsError || + fareError || + heatmapError || + topZonesError || + sparklinesError; + + return { + kpis, + tripsOverTime: tripsOverTime ?? [], + fareDistribution: fareDistribution ?? [], + heatmap: heatmap ?? [], + topZones: topZones ?? [], + sparklines: sparklines ?? [], + isLoading, + error, + }; +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-focus-registry.ts b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-focus-registry.ts new file mode 100644 index 00000000..54784fc8 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-focus-registry.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Module-level focus registry. Chart wrappers register a callback under a + * stable id; `focusChart(id)` looks up the callback and invokes it to + * scroll the user's viewport to the chart and pulse a ring around it. + * + * Registrations live outside React state so the agent's SSE stream (which + * hands off to `focusChart` via `use-action-dispatcher`) never needs to + * thread a ref through the component tree. + */ +const registry = new Map void>(); + +export type FocusableChartId = + | "kpis" + | "trips_over_time" + | "fare_distribution" + | "hourly_heatmap" + | "top_zones"; + +export const FOCUSABLE_CHART_IDS: FocusableChartId[] = [ + "kpis", + "trips_over_time", + "fare_distribution", + "hourly_heatmap", + "top_zones", +]; + +export function isFocusableChartId(id: unknown): id is FocusableChartId { + return ( + typeof id === "string" && + (FOCUSABLE_CHART_IDS as readonly string[]).includes(id) + ); +} + +export function focusChart(id: FocusableChartId): void { + registry.get(id)?.(); +} + +/** + * Registers `id` as a focusable chart. Returns a `setRef` callback for the + * wrapping element and a `focused` boolean that flips true for 1.2s when + * `focusChart(id)` is called from elsewhere. + */ +export function useFocusable(id: FocusableChartId): { + setRef: (el: HTMLElement | null) => void; + focused: boolean; +} { + const elRef = useRef(null); + const [focused, setFocused] = useState(false); + + useEffect(() => { + const onFocus = (): void => { + const el = elRef.current; + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + setFocused(true); + setTimeout(() => setFocused(false), 1200); + }; + registry.set(id, onFocus); + return () => { + if (registry.get(id) === onFocus) registry.delete(id); + }; + }, [id]); + + const setRef = (el: HTMLElement | null): void => { + elRef.current = el; + }; + + return { setRef, focused }; +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-stream-inspector.ts b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-stream-inspector.ts new file mode 100644 index 00000000..38b67213 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/hooks/use-stream-inspector.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useSyncExternalStore } from "react"; +import type { SSEEvent } from "./use-agent-stream"; + +/** + * Observability store for the agent SSE stream. Every chat message the + * dashboard sends gets a `StreamRecord`; each event the adapter yields is + * appended to that record with a relative timestamp. The Stream Inspector + * drawer reads from here to render a human-legible timeline. + * + * State is module-level on purpose — multiple components (the chat section, + * the agent sidebar, the inspector drawer itself) feed and read from the + * same store without wiring props or context. React only re-renders when + * `version` changes. + */ + +export interface StreamEventRecord { + event: SSEEvent; + receivedAt: number; +} + +export interface StreamRecord { + id: string; + label: string; + startedAt: number; + events: StreamEventRecord[]; +} + +const MAX_RECORDS = 5; + +const state = { + isOpen: false, + records: [] as StreamRecord[], +}; +const listeners = new Set<() => void>(); +let version = 0; + +function notify(): void { + version++; + for (const fn of listeners) fn(); +} + +function subscribe(fn: () => void): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +} + +function getVersion(): number { + return version; +} + +export function useStreamInspector(): { + isOpen: boolean; + records: StreamRecord[]; +} { + useSyncExternalStore(subscribe, getVersion, getVersion); + return { isOpen: state.isOpen, records: state.records }; +} + +export function beginStreamRun(label: string): string { + const id = + (globalThis.crypto?.randomUUID?.() as string | undefined) ?? + `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + const record: StreamRecord = { + id, + label, + startedAt: performance.now(), + events: [], + }; + state.records = [record, ...state.records].slice(0, MAX_RECORDS); + notify(); + return id; +} + +export function recordStreamEvent(runId: string, event: SSEEvent): void { + const record = state.records.find((r) => r.id === runId); + if (!record) return; + record.events.push({ event, receivedAt: performance.now() }); + notify(); +} + +export function openInspector(): void { + state.isOpen = true; + notify(); +} + +export function closeInspector(): void { + state.isOpen = false; + notify(); +} + +export function toggleInspector(): void { + state.isOpen = !state.isOpen; + notify(); +} + +export function clearInspectorHistory(): void { + state.records = []; + notify(); +} + +/** + * Binds ⌘K / Ctrl+K to open-toggle and `Esc` to close. Mount once inside + * the route. + */ +export function useInspectorShortcuts(): void { + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if ( + e.key === "k" && + (e.metaKey || e.ctrlKey) && + !e.altKey && + !e.shiftKey + ) { + e.preventDefault(); + toggleInspector(); + } else if (e.key === "Escape" && state.isOpen) { + closeInspector(); + } + }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + }; + }, []); +} + +/** + * Convenience hook for the currently-open run's events. Used by the agent + * sidebar's tiny "pulse" indicator next to each agent. + */ +export function useCurrentRun(): StreamRecord | null { + const { records } = useStreamInspector(); + return records[0] ?? null; +} + +// Dummy export to keep the "callback" shape callers can use if they want +// to opt out of the module-level store (none do today). +export const useStreamInspectorToggle: () => () => void = () => + useCallback(() => toggleInspector(), []); diff --git a/apps/dev-playground/client/src/features/smart-dashboard/lib/capture-dashboard.ts b/apps/dev-playground/client/src/features/smart-dashboard/lib/capture-dashboard.ts new file mode 100644 index 00000000..a00f8799 --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/lib/capture-dashboard.ts @@ -0,0 +1,48 @@ +import html2canvas from "html2canvas-pro"; + +/** + * Captures an element to a compressed JPEG data URL. + * + * We deliberately use JPEG + downscale instead of PNG because: + * + * - AppKit's server plugin applies `express.json({ limit: default = 100kb })` + * globally. A full-fidelity dashboard PNG encoded in base64 is typically + * 200-600kb — over the limit. + * - JPEG @ quality 0.85 + pixelRatio 1 keeps payloads to ~40-80kb base64 + * for the Smart Dashboard viewport, comfortably under the limit. + * + * If the payload ever needs to grow (higher fidelity, larger viewports), + * switch to a raw body route (`express.raw`) with an explicit larger limit. + * + * `html2canvas-pro` (drop-in fork of html2canvas) is required because + * Tailwind v4 emits `oklch()` colors throughout the computed styles of + * every node, which the original html2canvas 1.x cannot parse. + */ +export async function captureDashboardAsDataUrl( + el: HTMLElement, + opts: { quality?: number; scale?: number } = {}, +): Promise<{ dataUrl: string; widthPx: number; heightPx: number }> { + const quality = opts.quality ?? 0.85; + const scale = opts.scale ?? 1; + const backgroundColor = readCssVar(el, "--background") ?? "#ffffff"; + + const canvas = await html2canvas(el, { + backgroundColor, + scale, + useCORS: true, + allowTaint: false, + logging: false, + }); + + const dataUrl = canvas.toDataURL("image/jpeg", quality); + return { dataUrl, widthPx: canvas.width, heightPx: canvas.height }; +} + +function readCssVar(el: HTMLElement, name: string): string | null { + const raw = getComputedStyle(el).getPropertyValue(name).trim(); + if (!raw) return null; + // CSS var values may be raw HSL triplets ("0 0% 100%") or full hsl(...). + // Wrap naked triplets so html2canvas' painter treats them as colors. + if (/^\d/.test(raw)) return `hsl(${raw})`; + return raw; +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/lib/dashboard-context.ts b/apps/dev-playground/client/src/features/smart-dashboard/lib/dashboard-context.ts new file mode 100644 index 00000000..2348f6fe --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/lib/dashboard-context.ts @@ -0,0 +1,41 @@ +import type { Highlight } from "../hooks/use-action-dispatcher"; +import type { DashboardFilters } from "../hooks/use-dashboard-data"; + +/** + * Serialises the user's current dashboard state into a short natural-language + * preamble prepended to every chat turn. The `query` dispatcher and its + * specialists use this to stay grounded in what the user is looking at — + * e.g. "user asked 'is this unusual?' with filters {date_from: 2016-11-01}". + * + * Empty when nothing is set; callers should skip prepending in that case. + */ +export function buildDashboardContext( + filters: DashboardFilters, + highlights: Highlight[], +): string { + const parts: string[] = []; + + const filterEntries = Object.entries(filters).filter( + ([, v]) => v !== undefined && v !== "", + ); + if (filterEntries.length > 0) { + const rendered = filterEntries + .map(([key, value]) => `${key}=${value}`) + .join(", "); + parts.push(`active filters: ${rendered}`); + } + + if (highlights.length > 0) { + const rendered = highlights + .map( + (h) => + `${h.start}..${h.end}${h.color !== "blue" ? ` [${h.color}]` : ""}${h.label ? ` (${h.label})` : ""}`, + ) + .join("; "); + parts.push(`highlighted periods: ${rendered}`); + } + + if (parts.length === 0) return ""; + + return `[Dashboard state] ${parts.join(". ")}.\n\nUser question: `; +} diff --git a/apps/dev-playground/client/src/features/smart-dashboard/lib/feed-actions.ts b/apps/dev-playground/client/src/features/smart-dashboard/lib/feed-actions.ts new file mode 100644 index 00000000..a48a641c --- /dev/null +++ b/apps/dev-playground/client/src/features/smart-dashboard/lib/feed-actions.ts @@ -0,0 +1,206 @@ +import type { FocusableChartId } from "../hooks/use-focus-registry"; + +/** + * Structured actions emitted by the `insights` and `anomaly` ephemeral + * agents. Each kind maps 1:1 to a dispatcher tool (`filter_by_*`, + * `highlight_*`, `focus_chart`) except `ask`, which flows through the main + * chat dispatcher with a preloaded prompt. + * + * Kept in a neutral shape (not the wire tool-call format) so the agent can + * hand-author JSON without memorising `call_id` / `arguments` envelopes, + * and so the UI can render distinct copy per action kind. + */ + +export interface FilterDateAction { + kind: "filter_date"; + label: string; + start: string; + end: string; +} + +export interface FilterZipAction { + kind: "filter_zip"; + label: string; + zip: string; +} + +export interface FilterFareAction { + kind: "filter_fare"; + label: string; + min?: number; + max?: number; +} + +export interface HighlightPeriodAction { + kind: "highlight_period"; + label: string; + start: string; + end: string; + color?: "blue" | "red" | "yellow"; +} + +export interface HighlightZoneAction { + kind: "highlight_zone"; + label: string; + zip: string; + note?: string; +} + +export interface FocusChartAction { + kind: "focus_chart"; + label: string; + chart_id: FocusableChartId; +} + +export interface AskAction { + kind: "ask"; + label: string; + prompt: string; +} + +export type FeedAction = + | FilterDateAction + | FilterZipAction + | FilterFareAction + | HighlightPeriodAction + | HighlightZoneAction + | FocusChartAction + | AskAction; + +export interface FeedInsight { + title: string; + description: string; + actions?: FeedAction[]; +} + +export interface FeedAnomaly extends FeedInsight { + severity: "low" | "medium" | "high"; +} + +function isValidColor(v: unknown): v is "blue" | "red" | "yellow" { + return v === "blue" || v === "red" || v === "yellow"; +} + +function isValidChartId(v: unknown): v is FocusableChartId { + return ( + v === "kpis" || + v === "trips_over_time" || + v === "fare_distribution" || + v === "hourly_heatmap" || + v === "top_zones" + ); +} + +function parseAction(raw: unknown): FeedAction | null { + if (typeof raw !== "object" || raw === null) return null; + const r = raw as Record; + const kind = r.kind; + const label = typeof r.label === "string" ? r.label : ""; + if (!label) return null; + + switch (kind) { + case "filter_date": + if (typeof r.start === "string" && typeof r.end === "string") { + return { kind, label, start: r.start, end: r.end }; + } + return null; + case "filter_zip": + if (typeof r.zip === "string" && r.zip) { + return { kind, label, zip: r.zip }; + } + return null; + case "filter_fare": { + const min = typeof r.min === "number" ? r.min : undefined; + const max = typeof r.max === "number" ? r.max : undefined; + if (min === undefined && max === undefined) return null; + return { kind, label, min, max }; + } + case "highlight_period": + if (typeof r.start === "string" && typeof r.end === "string") { + return { + kind, + label, + start: r.start, + end: r.end, + color: isValidColor(r.color) ? r.color : "blue", + }; + } + return null; + case "highlight_zone": + if (typeof r.zip === "string" && r.zip) { + return { + kind, + label, + zip: r.zip, + ...(typeof r.note === "string" && r.note ? { note: r.note } : {}), + }; + } + return null; + case "focus_chart": + if (isValidChartId(r.chart_id)) { + return { kind, label, chart_id: r.chart_id }; + } + return null; + case "ask": + if (typeof r.prompt === "string" && r.prompt) { + return { kind, label, prompt: r.prompt }; + } + return null; + default: + return null; + } +} + +/** + * Extracts the first JSON array from an agent response and validates each + * element as {@link FeedInsight}. Ignores malformed entries rather than + * throwing — the agent is a Gemini flash model and occasionally wraps the + * output in fences or adds an extra element with a different shape. + */ +export function parseFeedInsights(content: string): FeedInsight[] { + return parseFeedPayload(content, (obj) => ({ + title: typeof obj.title === "string" ? obj.title : "", + description: typeof obj.description === "string" ? obj.description : "", + actions: Array.isArray(obj.actions) + ? (obj.actions.map(parseAction).filter(Boolean) as FeedAction[]) + : undefined, + })); +} + +export function parseFeedAnomalies(content: string): FeedAnomaly[] { + return parseFeedPayload(content, (obj) => { + const severity = + obj.severity === "low" || + obj.severity === "medium" || + obj.severity === "high" + ? obj.severity + : "low"; + return { + title: typeof obj.title === "string" ? obj.title : "", + description: typeof obj.description === "string" ? obj.description : "", + severity, + actions: Array.isArray(obj.actions) + ? (obj.actions.map(parseAction).filter(Boolean) as FeedAction[]) + : undefined, + }; + }); +} + +function parseFeedPayload( + content: string, + builder: (obj: Record) => T, +): T[] { + const jsonMatch = content.match(/\[[\s\S]*\]/); + if (!jsonMatch) return []; + try { + const parsed: unknown = JSON.parse(jsonMatch[0]); + if (!Array.isArray(parsed)) return []; + return parsed.flatMap((el) => { + if (typeof el !== "object" || el === null) return []; + const item = builder(el as Record); + return item.title ? [item] : []; + }); + } catch { + return []; + } +} diff --git a/apps/dev-playground/client/src/index.css b/apps/dev-playground/client/src/index.css index 5dcc4cf8..b5389ab8 100644 --- a/apps/dev-playground/client/src/index.css +++ b/apps/dev-playground/client/src/index.css @@ -1 +1,40 @@ @import "@databricks/appkit-ui/styles.css"; + +/** + * Realign Tailwind v4's `dark:` variant with appkit-ui's theme tokens. + * + * `packages/appkit-ui/.../globals.css` defines two paths into dark theme: + * - An explicit `.dark` class on (wins unconditionally). + * - `@media (prefers-color-scheme: dark)` on `:root:not(.light)` — i.e. + * the media query is ignored when the user has explicitly opted into + * light via the `.light` class. + * + * Tailwind v4's default `dark:` variant, however, is purely media-query + * driven. That mismatch produces a split-personality theme in exactly one + * scenario, which is the one we hit: OS set to dark, user forces light + * via the theme selector (bootstrap script in index.html sets + * ``). `--card`, `--background`, etc. correctly + * resolve to light, but every `dark:*` utility keeps firing under the + * media query — cards end up with dark-mode backgrounds layered under + * light-mode text and chrome. + * + * This `@custom-variant dark` rebinds the variant to mirror the token + * logic exactly: + * - Element is (or descends from) `.dark` → dark utilities fire. + * - `prefers-color-scheme: dark` AND no `.light` ancestor → also fire. + * - Everything else → no-op. + * + * Scoped to the playground because the bootstrap script in index.html is + * what makes the `.light` / `.dark` classes meaningful here; other + * appkit-ui consumers may rely on the current media-only behaviour. + */ +@custom-variant dark { + &:where(.dark, .dark *) { + @slot; + } + @media (prefers-color-scheme: dark) { + &:where(:not(.light):not(.light *)) { + @slot; + } + } +} diff --git a/apps/dev-playground/client/src/lib/nav.ts b/apps/dev-playground/client/src/lib/nav.ts new file mode 100644 index 00000000..4b391cb7 --- /dev/null +++ b/apps/dev-playground/client/src/lib/nav.ts @@ -0,0 +1,183 @@ +import { + BarChart3Icon, + BotIcon, + DatabaseIcon, + FileCode2Icon, + FolderIcon, + GaugeIcon, + LayoutDashboardIcon, + LineChartIcon, + type LucideIcon, + MessageCircleIcon, + RadioIcon, + SearchIcon, + ServerIcon, + ShieldIcon, + ZapIcon, +} from "lucide-react"; + +/** + * Metadata for a single demo route in the dev playground. + * + * `description` is used on the home page card. `icon` is used both on the + * home page card and (optionally) in the nav dropdown. Keep `description` + * to a single sentence — the home grid treats it as a one-line tagline. + */ +export interface NavItem { + to: string; + label: string; + description: string; + icon: LucideIcon; +} + +export interface NavGroup { + id: "data" | "ai" | "platform"; + label: string; + /** Short tagline shown under the section heading on the home page. */ + tagline: string; + items: ReadonlyArray; +} + +/** + * Canonical demo catalog. Both the navigation dropdown in `__root.tsx` and + * the landing grid in `index.tsx` render from this list, so adding a new + * demo is a one-line change here and both surfaces pick it up. + */ +export const NAV_GROUPS: ReadonlyArray = [ + { + id: "data", + label: "Data", + tagline: "Query, stream, and transform data with AppKit's data plugins.", + items: [ + { + to: "/analytics", + label: "Analytics", + description: + "Query execution, charts, and interactive components against live SQL.", + icon: BarChart3Icon, + }, + { + to: "/arrow-analytics", + label: "Arrow Analytics", + description: + "Same dashboard — served over Apache Arrow streaming for zero-copy speed.", + icon: ZapIcon, + }, + { + to: "/lakebase", + label: "Lakebase", + description: + "Four takes on Postgres: raw driver, Drizzle, TypeORM, Sequelize with OAuth refresh.", + icon: DatabaseIcon, + }, + { + to: "/sql-helpers", + label: "SQL Helpers", + description: + "Type-safe parameter builders and query generators for Databricks SQL.", + icon: FileCode2Icon, + }, + ], + }, + { + id: "ai", + label: "AI", + tagline: "Agents, RAG, and LLM-powered UI built on AppKit primitives.", + items: [ + { + to: "/smart-dashboard", + label: "Smart Dashboard", + description: + "Multi-agent NYC Taxi dashboard with live filters, highlights, approvals, and saved views.", + icon: LayoutDashboardIcon, + }, + { + to: "/agent", + label: "Custom Agent", + description: + "Chat agent over Databricks Model Serving with tools auto-discovered from AppKit plugins.", + icon: BotIcon, + }, + { + to: "/genie", + label: "Genie", + description: + "Natural-language Q&A against your data with SSE streaming and conversation persistence.", + icon: MessageCircleIcon, + }, + { + to: "/chart-inference", + label: "Chart Inference", + description: + "Let the agent pick the right chart type for a query result on the fly.", + icon: LineChartIcon, + }, + { + to: "/vector-search", + label: "Vector Search", + description: + "Semantic search backed by Databricks vector indexes, wired into AppKit's retrieval API.", + icon: SearchIcon, + }, + { + to: "/serving", + label: "Serving", + description: + "Call model-serving endpoints directly with the typed serving client.", + icon: ServerIcon, + }, + ], + }, + { + id: "platform", + label: "Platform", + tagline: + "Infrastructure demos: storage, policy, observability, resilience.", + items: [ + { + to: "/files", + label: "Files", + description: + "Browse, preview, and download from Unity Catalog Volumes via the Files plugin.", + icon: FolderIcon, + }, + { + to: "/policy-matrix", + label: "Policy Matrix", + description: + "Resource policies, requested claims, and per-user authorisation flows.", + icon: ShieldIcon, + }, + { + to: "/telemetry", + label: "Telemetry", + description: + "OpenTelemetry traces and metrics with a drop-in AppKit provider.", + icon: GaugeIcon, + }, + { + to: "/reconnect", + label: "Reconnect", + description: + "Resilient SSE streams: automatic Last-Event-ID tracking and reconnection.", + icon: RadioIcon, + }, + ], + }, +]; + +/** All items flattened — useful for a search index or breadcrumb lookup. */ +export const ALL_NAV_ITEMS: ReadonlyArray = NAV_GROUPS.flatMap( + (g) => g.items, +); + +/** + * Resolve a pathname back to its nav item (for breadcrumbs, titles, etc). + * Uses `startsWith` so nested routes like `/smart-dashboard/saved` match. + */ +export function findNavItemForPath(pathname: string): NavItem | null { + for (const item of ALL_NAV_ITEMS) { + if (pathname.startsWith(item.to)) return item; + } + return null; +} diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index a4669cbd..f685c648 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as VectorSearchRouteRouteImport } from './routes/vector-search.ro import { Route as TypeSafetyRouteRouteImport } from './routes/type-safety.route' import { Route as TelemetryRouteRouteImport } from './routes/telemetry.route' import { Route as SqlHelpersRouteRouteImport } from './routes/sql-helpers.route' +import { Route as SmartDashboardRouteRouteImport } from './routes/smart-dashboard.route' import { Route as ServingRouteRouteImport } from './routes/serving.route' import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route' import { Route as PolicyMatrixRouteRouteImport } from './routes/policy-matrix.route' @@ -23,6 +24,7 @@ import { Route as DataVisualizationRouteRouteImport } from './routes/data-visual import { Route as ChartInferenceRouteRouteImport } from './routes/chart-inference.route' import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route' import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route' +import { Route as AgentRouteRouteImport } from './routes/agent.route' import { Route as IndexRouteImport } from './routes/index' const VectorSearchRouteRoute = VectorSearchRouteRouteImport.update({ @@ -45,6 +47,11 @@ const SqlHelpersRouteRoute = SqlHelpersRouteRouteImport.update({ path: '/sql-helpers', getParentRoute: () => rootRouteImport, } as any) +const SmartDashboardRouteRoute = SmartDashboardRouteRouteImport.update({ + id: '/smart-dashboard', + path: '/smart-dashboard', + getParentRoute: () => rootRouteImport, +} as any) const ServingRouteRoute = ServingRouteRouteImport.update({ id: '/serving', path: '/serving', @@ -95,6 +102,11 @@ const AnalyticsRouteRoute = AnalyticsRouteRouteImport.update({ path: '/analytics', getParentRoute: () => rootRouteImport, } as any) +const AgentRouteRoute = AgentRouteRouteImport.update({ + id: '/agent', + path: '/agent', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -103,6 +115,7 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/agent': typeof AgentRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute @@ -113,6 +126,7 @@ export interface FileRoutesByFullPath { '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute + '/smart-dashboard': typeof SmartDashboardRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute @@ -120,6 +134,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/agent': typeof AgentRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute @@ -130,6 +145,7 @@ export interface FileRoutesByTo { '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute + '/smart-dashboard': typeof SmartDashboardRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute @@ -138,6 +154,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/agent': typeof AgentRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute @@ -148,6 +165,7 @@ export interface FileRoutesById { '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute + '/smart-dashboard': typeof SmartDashboardRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute @@ -157,6 +175,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/agent' | '/analytics' | '/arrow-analytics' | '/chart-inference' @@ -167,6 +186,7 @@ export interface FileRouteTypes { | '/policy-matrix' | '/reconnect' | '/serving' + | '/smart-dashboard' | '/sql-helpers' | '/telemetry' | '/type-safety' @@ -174,6 +194,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/agent' | '/analytics' | '/arrow-analytics' | '/chart-inference' @@ -184,6 +205,7 @@ export interface FileRouteTypes { | '/policy-matrix' | '/reconnect' | '/serving' + | '/smart-dashboard' | '/sql-helpers' | '/telemetry' | '/type-safety' @@ -191,6 +213,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/agent' | '/analytics' | '/arrow-analytics' | '/chart-inference' @@ -201,6 +224,7 @@ export interface FileRouteTypes { | '/policy-matrix' | '/reconnect' | '/serving' + | '/smart-dashboard' | '/sql-helpers' | '/telemetry' | '/type-safety' @@ -209,6 +233,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AgentRouteRoute: typeof AgentRouteRoute AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute ChartInferenceRouteRoute: typeof ChartInferenceRouteRoute @@ -219,6 +244,7 @@ export interface RootRouteChildren { PolicyMatrixRouteRoute: typeof PolicyMatrixRouteRoute ReconnectRouteRoute: typeof ReconnectRouteRoute ServingRouteRoute: typeof ServingRouteRoute + SmartDashboardRouteRoute: typeof SmartDashboardRouteRoute SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute TelemetryRouteRoute: typeof TelemetryRouteRoute TypeSafetyRouteRoute: typeof TypeSafetyRouteRoute @@ -255,6 +281,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SqlHelpersRouteRouteImport parentRoute: typeof rootRouteImport } + '/smart-dashboard': { + id: '/smart-dashboard' + path: '/smart-dashboard' + fullPath: '/smart-dashboard' + preLoaderRoute: typeof SmartDashboardRouteRouteImport + parentRoute: typeof rootRouteImport + } '/serving': { id: '/serving' path: '/serving' @@ -325,6 +358,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AnalyticsRouteRouteImport parentRoute: typeof rootRouteImport } + '/agent': { + id: '/agent' + path: '/agent' + fullPath: '/agent' + preLoaderRoute: typeof AgentRouteRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -337,6 +377,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AgentRouteRoute: AgentRouteRoute, AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, ChartInferenceRouteRoute: ChartInferenceRouteRoute, @@ -347,6 +388,7 @@ const rootRouteChildren: RootRouteChildren = { PolicyMatrixRouteRoute: PolicyMatrixRouteRoute, ReconnectRouteRoute: ReconnectRouteRoute, ServingRouteRoute: ServingRouteRoute, + SmartDashboardRouteRoute: SmartDashboardRouteRoute, SqlHelpersRouteRoute: SqlHelpersRouteRoute, TelemetryRouteRoute: TelemetryRouteRoute, TypeSafetyRouteRoute: TypeSafetyRouteRoute, diff --git a/apps/dev-playground/client/src/routes/__root.tsx b/apps/dev-playground/client/src/routes/__root.tsx index 4f30f234..f6479ff3 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -1,13 +1,26 @@ -import { Button, TooltipProvider } from "@databricks/appkit-ui/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + TooltipProvider, +} from "@databricks/appkit-ui/react"; import { CatchBoundary, createRootRoute, Link, Outlet, useLocation, + useNavigate, } from "@tanstack/react-router"; +import { MenuIcon } from "lucide-react"; import { ErrorComponent } from "@/components/error-component"; import { ThemeSelector } from "@/components/theme-selector"; +import { findNavItemForPath, NAV_GROUPS } from "@/lib/nav"; export const Route = createRootRoute({ component: RootComponent, @@ -15,119 +28,88 @@ export const Route = createRootRoute({ function RootComponent() { const location = useLocation(); + const navigate = useNavigate(); const isHomePage = location.pathname === "/"; + const currentPage = findNavItemForPath(location.pathname); + return ( {!isHomePage && (
diff --git a/apps/dev-playground/client/src/routes/agent.route.tsx b/apps/dev-playground/client/src/routes/agent.route.tsx new file mode 100644 index 00000000..6762a1a3 --- /dev/null +++ b/apps/dev-playground/client/src/routes/agent.route.tsx @@ -0,0 +1,567 @@ +import { getPluginClientConfig } from "@databricks/appkit-ui/js"; +import { Button } from "@databricks/appkit-ui/react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export const Route = createFileRoute("/agent")({ + component: AgentRoute, +}); + +interface SSEEvent { + type: string; + delta?: string; + item_id?: string; + item?: { + type?: string; + id?: string; + call_id?: string; + name?: string; + arguments?: string; + output?: string; + status?: string; + }; + content?: string; + data?: Record; + error?: string; + sequence_number?: number; + output_index?: number; + approval_id?: string; + stream_id?: string; + tool_name?: string; + args?: unknown; + annotations?: { + readOnly?: boolean; + destructive?: boolean; + idempotent?: boolean; + }; +} + +interface ChatMessage { + id: number; + role: "user" | "assistant"; + content: string; +} + +interface PendingApproval { + approvalId: string; + streamId: string; + toolName: string; + args: unknown; +} + +function useAutocomplete(enabled: boolean) { + const [suggestion, setSuggestion] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const abortRef = useRef(null); + const timerRef = useRef | null>(null); + + const requestSuggestion = useCallback( + (text: string) => { + setSuggestion(""); + + if (timerRef.current) clearTimeout(timerRef.current); + if (abortRef.current) abortRef.current.abort(); + + if (!text.trim() || text.length < 3 || !enabled) { + return; + } + + timerRef.current = setTimeout(async () => { + const controller = new AbortController(); + abortRef.current = controller; + setIsLoading(true); + + try { + const response = await fetch("/api/agents/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text, agent: "autocomplete" }), + signal: controller.signal, + }); + + if (!response.ok || !response.body) return; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let result = ""; + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + try { + const event = JSON.parse(data); + if ( + event.type === "response.output_text.delta" && + event.delta + ) { + result += event.delta; + setSuggestion(result); + } + } catch { + /* skip */ + } + } + } + } catch { + /* aborted or failed */ + } finally { + setIsLoading(false); + } + }, 500); + }, + [enabled], + ); + + const clear = useCallback(() => { + setSuggestion(""); + if (timerRef.current) clearTimeout(timerRef.current); + if (abortRef.current) abortRef.current.abort(); + }, []); + + return { + suggestion, + isLoading: isLoading && !suggestion, + requestSuggestion, + clear, + }; +} + +function AgentRoute() { + const [messages, setMessages] = useState([]); + const [events, setEvents] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [threadId, setThreadId] = useState(null); + const [pendingApprovals, setPendingApprovals] = useState( + [], + ); + + const decideApproval = useCallback( + async (approvalId: string, decision: "approve" | "deny") => { + const approval = pendingApprovals.find( + (a) => a.approvalId === approvalId, + ); + if (!approval) return; + try { + await fetch("/api/agents/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + streamId: approval.streamId, + approvalId, + decision, + }), + }); + } finally { + setPendingApprovals((prev) => + prev.filter((a) => a.approvalId !== approvalId), + ); + } + }, + [pendingApprovals], + ); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const msgIdCounter = useRef(0); + + const agentConfig = getPluginClientConfig<{ + agents?: string[]; + defaultAgent?: string; + }>("agents"); + const hasAutocomplete = (agentConfig.agents ?? []).includes("autocomplete"); + + const { + suggestion, + isLoading: isAutocompleting, + requestSuggestion, + clear: clearSuggestion, + } = useAutocomplete(hasAutocomplete); + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const sendMessage = useCallback(async () => { + if (!input.trim() || isLoading) return; + + clearSuggestion(); + const userMessage = input.trim(); + setInput(""); + setMessages((prev) => [ + ...prev, + { id: ++msgIdCounter.current, role: "user", content: userMessage }, + ]); + setEvents([]); + setIsLoading(true); + + try { + const response = await fetch("/api/agents/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: userMessage, + ...(threadId && { threadId }), + }), + }); + + if (!response.ok) { + const error = await response.json(); + setMessages((prev) => [ + ...prev, + { + id: ++msgIdCounter.current, + role: "assistant", + content: `Error: ${error.error}`, + }, + ]); + return; + } + + const reader = response.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let assistantContent = ""; + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + + try { + const event: SSEEvent = JSON.parse(data); + if (!event.type) continue; + setEvents((prev) => [...prev, event]); + + if ( + event.type === "appkit.approval_pending" && + event.approval_id && + event.stream_id && + event.tool_name + ) { + setPendingApprovals((prev) => [ + ...prev, + { + approvalId: event.approval_id as string, + streamId: event.stream_id as string, + toolName: event.tool_name as string, + args: event.args, + }, + ]); + } + if (event.type === "appkit.metadata" && event.data?.threadId) { + setThreadId(event.data.threadId as string); + } + + if (event.type === "response.output_text.delta" && event.delta) { + assistantContent += event.delta; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + content: assistantContent, + }; + } else { + updated.push({ + id: ++msgIdCounter.current, + role: "assistant", + content: assistantContent, + }); + } + return updated; + }); + } + } catch { + // skip malformed events + } + } + } + } catch (err) { + setMessages((prev) => [ + ...prev, + { + id: ++msgIdCounter.current, + role: "assistant", + content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`, + }, + ]); + } finally { + setIsLoading(false); + } + }, [input, isLoading, threadId, clearSuggestion]); + + const handleInputChange = (value: string) => { + setInput(value); + requestSuggestion(value); + }; + + const acceptSuggestion = () => { + if (!suggestion) return; + const newValue = input + suggestion; + setInput(newValue); + clearSuggestion(); + inputRef.current?.focus(); + }; + + return ( +
+
+
+
+

Agent Chat

+

+ AI agent with auto-discovered tools from all AppKit plugins. + {threadId && ( + + Thread: {threadId.slice(0, 8)}... + + )} +

+
+ {hasAutocomplete && ( + + Autocomplete enabled + + )} +
+ +
+
+
+ {messages.length === 0 && ( +
+

+ Send a message to start a conversation +

+

+ The agent can use analytics, files, genie, and lakebase + tools. + {hasAutocomplete && " Start typing for inline suggestions."} +

+
+ )} + + {messages.map((msg) => ( +
+
+

{msg.content}

+
+
+ ))} + + {pendingApprovals.map((approval) => ( +
+
+
+ + Destructive tool — approval required + +
+
+ {approval.toolName} +
+                        {JSON.stringify(approval.args, null, 2)}
+                      
+
+
+ + +
+
+
+ ))} + + {isLoading && + pendingApprovals.length === 0 && + messages[messages.length - 1]?.role === "user" && ( +
+
+

+ Thinking... +

+
+
+ )} + +
+
+ +
+ {hasAutocomplete && (suggestion || isAutocompleting) && ( +
+ {isAutocompleting && ( + Thinking... + )} + {suggestion && ( + + Press{" "} + + Tab + {" "} + to accept suggestion + + )} +
+ )} +
{ + e.preventDefault(); + sendMessage(); + }} + className="flex gap-2" + > +
+
+ {input} + + {suggestion} + +
+