From 6fa95eb2c530d0157511da59e0dc5e6aaa07f906 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 20:22:11 -0700 Subject: [PATCH 01/26] [eric] dashboard: monterey-style wave wallpaper layer under the dot grid (desktop shell) --- .../Dashboard/canvas/DashboardCanvas.tsx | 3 + .../Dashboard/desktop/DesktopWallpaper.tsx | 108 ++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 frontend/src/app/pages/Dashboard/desktop/DesktopWallpaper.tsx diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 33a3193b..c9af2cd2 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -7,6 +7,7 @@ import TetherLayer from './TetherLayer'; import DashboardCardLayer from './DashboardCardLayer'; import DashboardOverlays from './DashboardOverlays'; import DashboardEmptyState from './DashboardEmptyState'; +import DesktopWallpaper from '../desktop/DesktopWallpaper'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; import { GRAIN_URL } from '@/shared/styles/grainTexture'; @@ -238,6 +239,8 @@ const DashboardCanvas: React.FC = ({ : 'default', }} > + + {/* Gradient wash: the user's theme-pad stops tint the canvas, Arc-window style; intensity + grain come from the theme device; sits under the dot grid. */} {gradient && gradient.length > 1 && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Far pink ridge sweeping the right half */} + + + {/* Red-magenta ridge descending from the top-left */} + + + {/* Mid magenta band bridging center */} + + + {/* Deep purple shoulder, left */} + + + {/* Violet crest above the foreground wave */} + + + {/* Foreground indigo wave */} + + + {mode === 'dark' && ( + + )} + + ); +} + +export default DesktopWallpaper; From 7c88098ba5718eb9a8e9f0f4325a21117f25c4a2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 20:26:08 -0700 Subject: [PATCH 02/26] [eric] dashboard: left desktop dock (per-card tiles, hover previews, click focuses; settings gear) --- .../Dashboard/canvas/DashboardCanvas.tsx | 18 ++ .../pages/Dashboard/desktop/DesktopDock.tsx | 284 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index c9af2cd2..01010b5a 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -8,6 +8,7 @@ import DashboardCardLayer from './DashboardCardLayer'; import DashboardOverlays from './DashboardOverlays'; import DashboardEmptyState from './DashboardEmptyState'; import DesktopWallpaper from '../desktop/DesktopWallpaper'; +import DesktopDock from '../desktop/DesktopDock'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; import { GRAIN_URL } from '@/shared/styles/grainTexture'; @@ -218,6 +219,23 @@ const DashboardCanvas: React.FC = ({ + {!fullscreenCardId && ( + { + canvas.actions.fitToCards([rect], 1.15, true); + onHighlightCard?.(cardId); + }} + /> + )} + {/* Canvas viewport */} ; + cards: Record; + viewCards: Record; + browserCards: Record; + notes: Record; + workflowCards: Record; + outputs: Record; + selectedIds: string[]; + onFocusCard: (id: string, rect: CardRect) => void; +} + +const TILE = 30; +const PREVIEW_W = 190; + +/** Left-edge desktop dock: one tile per open card, hover previews, click focuses the window. */ +function DesktopDock({ + sessions, + cards, + viewCards, + browserCards, + notes, + workflowCards, + outputs, + selectedIds, + onFocusCard, +}: DesktopDockProps): React.ReactElement | null { + const dispatch = useAppDispatch(); + const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null); + const [liveShot, setLiveShot] = useState<{ id: string; dataUrl: string } | null>(null); + const hoverTimer = useRef(null); + + const entries = useMemo(() => { + const list: DockEntry[] = []; + for (const card of Object.values(cards)) { + const session = sessions[card.session_id]; + if (!session) continue; + list.push({ + id: card.session_id, + label: displayChatTitle(session), + rect: card, + tileBg: 'linear-gradient(135deg, #4a7dd6, #2b4fa8)', + icon: , + snippet: session.turn_label?.label || undefined, + }); + } + for (const bc of Object.values(browserCards)) { + const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0]; + list.push({ + id: bc.browser_id, + label: activeTab?.title || 'Browser', + rect: bc, + tileBg: 'linear-gradient(135deg, #4f9fe8, #2f6ed4)', + icon: , + faviconUrl: activeTab?.favicon, + browserId: bc.browser_id, + }); + } + for (const [cardKey, vc] of Object.entries(viewCards)) { + const output = outputs[vc.output_id]; + list.push({ + id: cardKey, + label: output?.name || 'App', + rect: vc, + tileBg: 'linear-gradient(135deg, #ef9552, #d96a2b)', + icon: , + thumbnail: output?.thumbnail, + }); + } + for (const note of Object.values(notes)) { + const firstLine = (note.content || '').split('\n')[0].trim(); + list.push({ + id: note.note_id, + label: firstLine || 'Note', + rect: note, + tileBg: 'linear-gradient(135deg, #f2d270, #e0b23e)', + icon: , + snippet: (note.content || '').slice(0, 140), + }); + } + for (const [cardKey, wf] of Object.entries(workflowCards)) { + list.push({ + id: cardKey, + label: 'Workflow', + rect: wf, + tileBg: 'linear-gradient(135deg, #ef7a70, #d94f45)', + icon: , + }); + } + return list; + }, [sessions, cards, viewCards, browserCards, notes, workflowCards, outputs]); + + const beginHover = useCallback( + (entry: DockEntry, target: HTMLElement) => { + if (hoverTimer.current) window.clearTimeout(hoverTimer.current); + const top = target.offsetTop; + hoverTimer.current = window.setTimeout(() => { + setHovered({ id: entry.id, top }); + if (entry.browserId) { + const wv = getWebview(entry.browserId); + const capture = wv?.capturePage?.(); + if (capture && typeof (capture as Promise).then === 'function') { + (capture as Promise<{ toDataURL(): string }>) + .then((img) => setLiveShot({ id: entry.id, dataUrl: img.toDataURL() })) + .catch(() => undefined); + } + } + }, 220); + }, + [], + ); + + const endHover = useCallback(() => { + if (hoverTimer.current) window.clearTimeout(hoverTimer.current); + setHovered(null); + setLiveShot(null); + }, []); + + if (entries.length === 0) return null; + + const hoveredEntry = hovered ? entries.find((e) => e.id === hovered.id) : undefined; + const previewImage = hoveredEntry + ? (liveShot?.id === hoveredEntry.id ? liveShot.dataUrl : hoveredEntry.thumbnail || undefined) + : undefined; + + return ( + + {entries.map((entry) => { + const isActive = selectedIds.includes(entry.id); + return ( + beginHover(entry, e.currentTarget as HTMLElement)} + onClick={() => { + endHover(); + onFocusCard(entry.id, entry.rect); + }} + sx={{ + width: TILE, + height: TILE, + borderRadius: '9px', + background: entry.tileBg, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + overflow: 'hidden', + flexShrink: 0, + transition: 'transform 0.15s ease', + '&:hover': { transform: 'scale(1.12)' }, + ...(isActive && { outline: '2px solid #6aa2ff', outlineOffset: '2px' }), + }} + > + {entry.faviconUrl ? ( + + ) : ( + entry.icon + )} + + ); + })} + + + dispatch(openSettingsModal(undefined))} + onMouseEnter={endHover} + sx={{ + width: TILE, + height: TILE, + borderRadius: '9px', + background: 'linear-gradient(135deg, #5a5a62, #34343c)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + flexShrink: 0, + transition: 'transform 0.15s ease', + '&:hover': { transform: 'scale(1.12)' }, + }} + > + + + + {hoveredEntry && ( + + {previewImage ? ( + + ) : ( + + + {hoveredEntry.label} + + {hoveredEntry.snippet && ( + + {hoveredEntry.snippet} + + )} + + )} + + )} + + ); +} + +export default DesktopDock; From 6e91099ba7173ce88c7e67327674b899b6d9f541 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 20:34:48 -0700 Subject: [PATCH 03/26] [eric] dashboard: desktop shell chrome (spawn pill composer, glass corner controls w/ close-selected, help pill) --- .../app/pages/Dashboard/DashboardToolbar.tsx | 330 +----------------- .../Dashboard/canvas/DashboardCanvas.tsx | 6 + .../Dashboard/canvas/DashboardOverlays.tsx | 12 + .../Dashboard/controls/CanvasControls.tsx | 185 +++++----- .../Dashboard/desktop/DesktopSpawnPill.tsx | 176 ++++++++++ .../app/pages/Dashboard/desktop/HelpPill.tsx | 45 +++ .../hooks/interaction/deleteSelectedCards.ts | 35 ++ .../interaction/useDashboardShortcuts.ts | 33 +- 8 files changed, 397 insertions(+), 425 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/desktop/DesktopSpawnPill.tsx create mode 100644 frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx create mode 100644 frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 1b385459..24adb1c8 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -3,27 +3,10 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import InputBase from '@mui/material/InputBase'; import CircularProgress from '@mui/material/CircularProgress'; -import Tooltip, { tooltipClasses } from '@mui/material/Tooltip'; import Snackbar from '@mui/material/Snackbar'; import Icon from '@mui/material/Icon'; -import { styled } from '@mui/material/styles'; -import AddRounded from '@mui/icons-material/AddRounded'; -import ChatBubbleTeardrop from './ChatBubbleTeardrop'; - -// Collapsed-row buttons hop up one after another when the toolbar appears. -const popIn = (i: number) => ({ - animation: `toolbar-pop 0.4s cubic-bezier(0.2, 1.4, 0.4, 1) ${i * 55}ms both`, - '@keyframes toolbar-pop': { - from: { opacity: 0, transform: 'translateY(14px)' }, - to: { opacity: 1, transform: 'translateY(0)' }, - }, -}); -import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; -import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined'; -import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded'; -import EventRepeatIcon from '@mui/icons-material/EventRepeat'; -import LanguageIcon from '@mui/icons-material/Language'; +import DesktopSpawnPill from './desktop/DesktopSpawnPill'; import SearchIcon from '@mui/icons-material/Search'; import { motion } from 'framer-motion'; import ChatInput from '@/app/pages/AgentChat/ChatInput'; @@ -37,7 +20,6 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice'; import { updateSettingsPatch, AppSettings } from '@/shared/state/settingsSlice'; import { store } from '@/shared/state/store'; -import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import type { Output } from '@/shared/state/outputsSlice'; interface Props { @@ -69,28 +51,6 @@ interface Props { } const TOOLBAR_OWNER_ID = '__toolbar__'; -const BTN = 44; - -const WarmTooltip = styled( - ({ className, ...props }: React.ComponentProps & { className?: string }) => ( - - ) -)<{ tokens: ClaudeTokens }>(({ tokens: c }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: c.bg.inverse, - color: c.text.inverse, - fontFamily: c.font.sans, - fontSize: '0.78rem', - fontWeight: 500, - padding: '6px 12px', - borderRadius: c.radius.md, - boxShadow: c.shadow.md, - letterSpacing: '0.01em', - }, - [`& .${tooltipClasses.arrow}`]: { - color: c.bg.inverse, - }, -})); const MotionBox = motion.div; @@ -178,7 +138,6 @@ const DashboardToolbar = React.forwardRef( const [historyQuery, setHistoryQuery] = useState(''); const [popoverMode, setPopoverMode] = useState<'search' | 'runs' | 'schedule'>('search'); const [expandToast, setExpandToast] = useState(null); - const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut); const outputs = useAppSelector((s) => s.outputs.items); const historySearch = useAppSelector((s) => s.agents.historySearch); const allRuns = useAppSelector((s) => s.workflows.allRuns); @@ -195,16 +154,6 @@ const DashboardToolbar = React.forwardRef( ); }, [outputList, viewSearch]); - const shortcutLabel = (shortcut || '') - .split('+') - .map((p) => { - if (p === 'Meta') return '⌘'; - if (p === 'Ctrl') return 'Ctrl'; - if (p === 'Alt') return '⌥'; - if (p === 'Shift') return '⇧'; - return p.toUpperCase(); - }) - .join(''); React.useImperativeHandle(ref, () => containerRef.current!, []); @@ -416,7 +365,6 @@ const DashboardToolbar = React.forwardRef( } }, [handleHistoryLoadMore]); - const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = []; return ( <> @@ -427,12 +375,13 @@ const DashboardToolbar = React.forwardRef( style={{ display: 'flex', flexDirection: 'column', - // Drop toolbar card chrome when popover is open so we don't double-card; popover supplies its own surface. - background: historyOpen ? 'transparent' : c.bg.surface, - border: historyOpen ? '1px solid transparent' : `1px solid ${c.border.subtle}`, + // Drop toolbar card chrome when popover is open (popover supplies its own surface) and when + // collapsed (the spawn pill carries its own dark glass). + background: historyOpen || !isExpanded ? 'transparent' : c.bg.surface, + border: historyOpen || !isExpanded ? '1px solid transparent' : `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.xl}px`, - boxShadow: historyOpen ? 'none' : c.shadow.lg, - padding: isExpanded ? '6px' : '5px', + boxShadow: historyOpen || !isExpanded ? 'none' : c.shadow.lg, + padding: isExpanded ? '6px' : '0px', userSelect: 'none' as const, overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden', // historyOpen: width owned by SchedulePopover; leave undefined so framer-motion measures intrinsic size. @@ -457,6 +406,7 @@ const DashboardToolbar = React.forwardRef( thinkingLevel={thinkingLevel} onThinkingLevelChange={handleThinkingLevelChange} prefillPrompt={prefillPrompt} + placeholderOverride="What should I do sir..." /> ) : historyOpen ? ( @@ -613,259 +563,17 @@ const DashboardToolbar = React.forwardRef( ) : ( -
- - { - if (newAgentBounce) onNewAgentBounceEnd?.(); - onNewAgent(); - }} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: BTN, - height: BTN, - borderRadius: `${c.radius.lg}px`, - bgcolor: c.accent.primary, - color: '#fff', - cursor: 'pointer', - transition: 'background-color 0.15s', - '&:hover': { bgcolor: c.accent.hover }, - '&:active': { bgcolor: c.accent.pressed }, - // Pop in first; the empty-canvas bounce takes over once the row has settled. - animation: `toolbar-pop 0.4s cubic-bezier(0.2, 1.4, 0.4, 1) both${newAgentBounce ? ', new-agent-bounce 1.6s ease-out 0.6s infinite' : ''}`, - '@keyframes toolbar-pop': { - from: { opacity: 0, transform: 'translateY(14px)' }, - to: { opacity: 1, transform: 'translateY(0)' }, - }, - '@keyframes new-agent-bounce': { - '0%': { transform: 'translateY(0)' }, - '15%': { transform: 'translateY(-10px)' }, - '30%': { transform: 'translateY(0)' }, - '42%': { transform: 'translateY(-4px)' }, - '55%': { transform: 'translateY(0)' }, - '100%': { transform: 'translateY(0)' }, - }, - }} - > - - - - - - Add App ⌘M - - } - > - - - - - - - Browser ⌘N - - } - > - - - - - - - Workflows - Schedule and calendar - - } - > - dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: BTN, - height: BTN, - borderRadius: `${c.radius.md}px`, - color: workflowsHubOpen ? c.accent.primary : c.text.tertiary, - bgcolor: workflowsHubOpen ? c.bg.secondary : 'transparent', - cursor: 'pointer', - transition: 'opacity 0.15s, background-color 0.15s', - '&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary }, - ...popIn(3), - }} - > - - - - - - Add note - Sticky note on the canvas - - } - > - - - - - - - History ⌘O - - } - > - - - - - - {placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => ( - - {label} - {sub} - - } - > - - - - - ))} -
+ { + if (newAgentBounce) onNewAgentBounceEnd?.(); + onNewAgent(); + }} + onAddNote={onAddNote} + onAddBrowser={onAddBrowser} + onAddApp={handleOpenViewPicker} + onWorkflows={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())} + onHistory={handleOpenHistory} + /> )} ; @@ -378,6 +379,11 @@ const DashboardCanvas: React.FC = ({ onNewAgentBounceEnd={onNewAgentBounceEnd} onFitToView={onFitToView} onTidy={onTidy} + onDeleteSelected={() => { + deleteSelectedCards(selection.selectedIds, dispatch); + selection.deselectAll(); + }} + hasSelection={selection.selectedIds.size > 0} onSearchPaletteClose={onSearchPaletteClose} toolbarPrefill={toolbarPrefill} toolbarPrefillMode={toolbarPrefillMode} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 2611e144..8ee02284 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -2,6 +2,7 @@ import React, { type RefObject } from 'react'; import Box from '@mui/material/Box'; import DashboardToolbar from '../DashboardToolbar'; import CanvasControls from '../controls/CanvasControls'; +import HelpPill from '../desktop/HelpPill'; import CardSearchPalette from '../controls/CardSearchPalette'; import DirectionHints from '../controls/DirectionHints'; import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast'; @@ -50,6 +51,8 @@ interface DashboardOverlaysProps { onNewAgentBounceEnd: () => void; onFitToView: () => void; onTidy: () => void; + onDeleteSelected: () => void; + hasSelection: boolean; onSearchPaletteClose: () => void; toolbarPrefill?: string; toolbarPrefillMode?: string; @@ -81,6 +84,8 @@ const DashboardOverlays: React.FC = ({ onNewAgentBounceEnd, onFitToView, onTidy, + onDeleteSelected, + hasSelection, onSearchPaletteClose, toolbarPrefill, toolbarPrefillMode, @@ -107,6 +112,11 @@ const DashboardOverlays: React.FC = ({ />
+ {/* Desktop help pill */} + + + + {/* Arrow navigation hints when zoomed in on a card */} {focusedCardId && canvas.zoom >= 0.4 && ( = ({ actions={canvas.actions} onFitToView={onFitToView} onTidy={onTidy} + onDeleteSelected={onDeleteSelected} + hasSelection={hasSelection} minimapProps={{ panX: canvas.panX, panY: canvas.panY, diff --git a/frontend/src/app/pages/Dashboard/controls/CanvasControls.tsx b/frontend/src/app/pages/Dashboard/controls/CanvasControls.tsx index 84ff7152..db8fab0e 100644 --- a/frontend/src/app/pages/Dashboard/controls/CanvasControls.tsx +++ b/frontend/src/app/pages/Dashboard/controls/CanvasControls.tsx @@ -1,14 +1,12 @@ import React, { useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; import RemoveIcon from '@mui/icons-material/Remove'; import AddIcon from '@mui/icons-material/Add'; -import FitScreenIcon from '@mui/icons-material/FitScreen'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import SpaceDashboardOutlinedIcon from '@mui/icons-material/SpaceDashboardOutlined'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import MapIcon from '@mui/icons-material/Map'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import type { CanvasActions } from '../hooks/interaction/useCanvasControls'; import Minimap from './Minimap'; import type { MinimapProps } from './Minimap'; @@ -18,6 +16,8 @@ interface Props { actions: CanvasActions; onFitToView: () => void; onTidy: () => void; + onDeleteSelected: () => void; + hasSelection: boolean; minimapProps: Omit; onMinimapPan: (panX: number, panY: number) => void; } @@ -33,8 +33,27 @@ function readMinimapPref(): boolean { } } -const CanvasControls: React.FC = ({ zoom, actions, onFitToView, onTidy, minimapProps, onMinimapPan }) => { - const c = useClaudeTokens(); +const GLASS = 'rgba(22,12,34,0.66)'; +const GLASS_BLUR = 'blur(20px) saturate(160%)'; + +const circleSx = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 30, + height: 30, + borderRadius: '50%', + background: GLASS, + backdropFilter: GLASS_BLUR, + WebkitBackdropFilter: GLASS_BLUR, + boxShadow: '0 6px 20px rgba(0,0,0,0.3)', + color: 'rgba(255,255,255,0.72)', + cursor: 'pointer', + transition: 'color 0.15s', + '&:hover': { color: '#fff' }, +}; + +const CanvasControls: React.FC = ({ zoom, actions, onFitToView, onTidy, onDeleteSelected, hasSelection, minimapProps, onMinimapPan }) => { const pct = Math.round(zoom * 100); const [minimapOpen, setMinimapOpen] = useState(() => readMinimapPref()); const setAndPersistMinimap = (next: boolean) => { @@ -53,10 +72,11 @@ const CanvasControls: React.FC = ({ zoom, actions, onFitToView, onTidy, m sx={{ width: 200, height: 140, - bgcolor: c.bg.surface, - border: `1px solid ${c.border.medium}`, - borderRadius: `${c.radius.lg}px`, - boxShadow: c.shadow.md, + background: GLASS, + backdropFilter: GLASS_BLUR, + WebkitBackdropFilter: GLASS_BLUR, + borderRadius: '12px', + boxShadow: '0 8px 28px rgba(0,0,0,0.35)', overflow: 'hidden', }} > @@ -64,87 +84,82 @@ const CanvasControls: React.FC = ({ zoom, actions, onFitToView, onTidy, m
)} - - - - - - - - - - {pct}% - - - - - - - - - - - - - - - - + + setAndPersistMinimap(!minimapOpen)} + data-onboarding="canvas-minimap-toggle" + sx={{ ...circleSx, width: 26, height: 26, borderRadius: '8px', ...(minimapOpen && { color: '#fff' }) }} + > + + + + - - - + + + - - - - setAndPersistMinimap(!minimapOpen)} - sx={{ color: minimapOpen ? c.accent.primary : c.text.muted }} - data-onboarding="canvas-minimap-toggle" + + { if (hasSelection) onDeleteSelected(); }} + sx={{ ...circleSx, ...(!hasSelection && { color: 'rgba(255,255,255,0.35)', cursor: 'default', '&:hover': { color: 'rgba(255,255,255,0.35)' } }) }} > - - + + + + + + + + + + + + + {pct}% + + + + + + + + + ); diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopSpawnPill.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopSpawnPill.tsx new file mode 100644 index 00000000..b0af5641 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopSpawnPill.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import AddRounded from '@mui/icons-material/AddRounded'; +import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; +import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; +import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined'; +import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded'; +import EventRepeatIcon from '@mui/icons-material/EventRepeat'; +import LanguageIcon from '@mui/icons-material/Language'; + +interface DesktopSpawnPillProps { + onOpenComposer: () => void; + onAddNote: () => void; + onAddBrowser: () => void; + onAddApp: () => void; + onWorkflows: () => void; + onHistory: () => void; +} + +const MENU_ITEMS: Array<{ key: string; label: string; icon: React.ElementType }> = [ + { key: 'note', label: 'Add note', icon: StickyNote2OutlinedIcon }, + { key: 'browser', label: 'Browser', icon: LanguageIcon }, + { key: 'app', label: 'Add app', icon: GridViewRoundedIcon }, + { key: 'workflows', label: 'Workflows', icon: EventRepeatIcon }, + { key: 'history', label: 'History', icon: HistoryRoundedIcon }, +]; + +/** Collapsed desktop composer: one dark pill that spawns an agent; + tucks the add actions away. */ +function DesktopSpawnPill({ + onOpenComposer, + onAddNote, + onAddBrowser, + onAddApp, + onWorkflows, + onHistory, +}: DesktopSpawnPillProps): React.ReactElement { + const [menuOpen, setMenuOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return undefined; + const onDown = (e: MouseEvent): void => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false); + }; + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + }, [menuOpen]); + + const actions: Record void> = { + note: onAddNote, + browser: onAddBrowser, + app: onAddApp, + workflows: onWorkflows, + history: onHistory, + }; + + return ( + + {menuOpen && ( + + {MENU_ITEMS.map(({ key, label, icon: ItemIcon }) => ( + { + setMenuOpen(false); + actions[key](); + }} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1.25, + px: 1.25, + py: 0.75, + borderRadius: '9px', + cursor: 'pointer', + '&:hover': { background: 'rgba(255,255,255,0.1)' }, + }} + > + + + {label} + + + ))} + + )} + + + + Spawn an agent... + + { + e.stopPropagation(); + setMenuOpen((v) => !v); + }} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 22, + height: 22, + borderRadius: '50%', + cursor: 'pointer', + color: 'rgba(255,255,255,0.6)', + '&:hover': { color: '#fff', background: 'rgba(255,255,255,0.12)' }, + }} + > + + + + + + + + + + ); +} + +export default DesktopSpawnPill; diff --git a/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx b/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx new file mode 100644 index 00000000..eba61551 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/HelpPill.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; +import { useAppDispatch } from '@/shared/hooks'; +import { addBrowserCard } from '@/shared/state/dashboardLayoutSlice'; + +const HELP_URL = 'https://openswarm.com'; + +/** Top-right desktop help pill: opens the docs site in an in-app browser card. */ +function HelpPill(): React.ReactElement { + const dispatch = useAppDispatch(); + return ( + dispatch(addBrowserCard({ url: HELP_URL }))} + > + + Help + + + e.stopPropagation()}> + + + + + ); +} + +export default HelpPill; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts new file mode 100644 index 00000000..64e5a844 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/deleteSelectedCards.ts @@ -0,0 +1,35 @@ +import { closeSession } from '@/shared/state/agentsSlice'; +import { removeNote, removeWorkflowCard, closeWorkflowsHub, recordClosedCard } from '@/shared/state/dashboardLayoutSlice'; +import { closeWorkflowCard } from '@/shared/state/workflowsSlice'; +import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; +import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import type { AppDispatch } from '@/shared/state/store'; +import type { CardType } from '../state/useDashboardSelection'; + +/** Close every selected card, recording each so Cmd+Shift+T can bring it back. */ +export function deleteSelectedCards(selectedIds: Map, dispatch: AppDispatch): void { + const viewIds: string[] = []; + for (const [id, type] of selectedIds) { + if (type === 'agent') { + dispatch(recordClosedCard({ kind: 'agent', id })); + dispatch(closeSession({ sessionId: id })); + } else if (type === 'view') { + dispatch(recordClosedCard({ kind: 'view', id })); + viewIds.push(id); + } else if (type === 'browser') { + dispatch(recordClosedCard({ kind: 'browser', id })); + removeBrowserCardCleanly(id, dispatch); + } else if (type === 'note') { + dispatch(recordClosedCard({ kind: 'note', id })); + dispatch(removeNote(id)); + } else if (type === 'workflow') { + dispatch(recordClosedCard({ kind: 'workflow', id })); + dispatch(removeWorkflowCard(id)); + dispatch(closeWorkflowCard(id)); + } else if (type === 'workflows-hub') { + dispatch(closeWorkflowsHub()); + } + } + // Tear view cards down ONE AT A TIME (each quiesces its GPU surface first); ripping several large app webviews out in one frame is what piles up "non-existent mailbox" errors and kills the GPU process. + void (async () => { for (const id of viewIds) await removeViewCardCleanly(id, dispatch); })(); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts index 96d5da6f..83b40876 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts @@ -1,11 +1,9 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react'; import { report } from '@/shared/serviceClient'; import { useAppDispatch } from '@/shared/hooks'; -import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice'; -import { removeNote, removeWorkflowCard, closeWorkflowsHub, recordClosedCard, reopenLastClosed } from '@/shared/state/dashboardLayoutSlice'; -import { closeWorkflowCard } from '@/shared/state/workflowsSlice'; -import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; -import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import { toggleExpandSession } from '@/shared/state/agentsSlice'; +import { reopenLastClosed } from '@/shared/state/dashboardLayoutSlice'; +import { deleteSelectedCards } from './deleteSelectedCards'; import { getLastInteractedBrowser } from '@/shared/browserFocus'; import { getWebview } from '@/shared/browserRegistry'; import type { useDashboardSelection } from '../state/useDashboardSelection'; @@ -76,30 +74,7 @@ export function useDashboardShortcuts({ if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return; if (selection.selectedIds.size === 0) return; e.preventDefault(); - const viewIds: string[] = []; - for (const [id, type] of selection.selectedIds) { - if (type === 'agent') { - dispatch(recordClosedCard({ kind: 'agent', id })); - dispatch(closeSession({ sessionId: id })); - } else if (type === 'view') { - dispatch(recordClosedCard({ kind: 'view', id })); - viewIds.push(id); - } else if (type === 'browser') { - dispatch(recordClosedCard({ kind: 'browser', id })); - removeBrowserCardCleanly(id, dispatch); - } else if (type === 'note') { - dispatch(recordClosedCard({ kind: 'note', id })); - dispatch(removeNote(id)); - } else if (type === 'workflow') { - dispatch(recordClosedCard({ kind: 'workflow', id })); - dispatch(removeWorkflowCard(id)); - dispatch(closeWorkflowCard(id)); - } else if (type === 'workflows-hub') { - dispatch(closeWorkflowsHub()); - } - } - // Tear view cards down ONE AT A TIME (each quiesces its GPU surface first); ripping several large app webviews out in one frame is what piles up "non-existent mailbox" errors and kills the GPU process. - void (async () => { for (const id of viewIds) await removeViewCardCleanly(id, dispatch); })(); + deleteSelectedCards(selection.selectedIds, dispatch); selection.deselectAll(); }; window.addEventListener('keydown', handleDelete); From 7fd29735b52c7af24e15e398360009b2d8548ac3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 20:37:34 -0700 Subject: [PATCH 04/26] [eric] dashboard: open composer wears desktop dark glass via DarkTokensScope --- .../app/pages/Dashboard/DashboardToolbar.tsx | 43 +++++++++++-------- frontend/src/shared/styles/ThemeContext.tsx | 11 +++++ 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 24adb1c8..b65e55ea 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -15,7 +15,7 @@ import SchedulePopover from '@/app/pages/Workflows/SchedulePopover'; import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice'; import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice'; import { updateSettingsPatch, AppSettings } from '@/shared/state/settingsSlice'; @@ -376,11 +376,14 @@ const DashboardToolbar = React.forwardRef( display: 'flex', flexDirection: 'column', // Drop toolbar card chrome when popover is open (popover supplies its own surface) and when - // collapsed (the spawn pill carries its own dark glass). - background: historyOpen || !isExpanded ? 'transparent' : c.bg.surface, - border: historyOpen || !isExpanded ? '1px solid transparent' : `1px solid ${c.border.subtle}`, + // collapsed (the spawn pill carries its own dark glass). The open composer wears the same + // desktop dark glass as the rest of the shell. + background: historyOpen ? 'transparent' : viewPickerOpen ? c.bg.surface : inputOpen ? 'rgba(22,12,34,0.82)' : 'transparent', + backdropFilter: inputOpen && !historyOpen && !viewPickerOpen ? 'blur(20px) saturate(160%)' : undefined, + WebkitBackdropFilter: inputOpen && !historyOpen && !viewPickerOpen ? 'blur(20px) saturate(160%)' : undefined, + border: viewPickerOpen ? `1px solid ${c.border.subtle}` : '1px solid transparent', borderRadius: `${c.radius.xl}px`, - boxShadow: historyOpen || !isExpanded ? 'none' : c.shadow.lg, + boxShadow: historyOpen || !isExpanded ? 'none' : '0 12px 32px rgba(0,0,0,0.4)', padding: isExpanded ? '6px' : '0px', userSelect: 'none' as const, overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden', @@ -394,20 +397,22 @@ const DashboardToolbar = React.forwardRef( data-onboarding-scope="dock" style={{ width: '100%', minHeight: 56, paddingBottom: 0, marginBottom: -4 }} > - + + + ) : historyOpen ? (
diff --git a/frontend/src/shared/styles/ThemeContext.tsx b/frontend/src/shared/styles/ThemeContext.tsx index f047f6e2..e6ecb367 100644 --- a/frontend/src/shared/styles/ThemeContext.tsx +++ b/frontend/src/shared/styles/ThemeContext.tsx @@ -131,6 +131,17 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre }; export const useClaudeTokens = (): ClaudeTokens => useContext(ThemeContext).tokens; + +/** Forces dark tokens for a subtree: desktop-shell glass panels stay dark even in light mode. */ +export const DarkTokensScope: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const ctx = useContext(ThemeContext); + const value = useMemo(() => ({ + ...ctx, + mode: 'dark' as ThemeMode, + tokens: withAccent(darkTokens, ctx.accent, 'dark'), + }), [ctx]); + return {children}; +}; export const useThemeMode = () => { const { mode, toggleMode, setMode } = useContext(ThemeContext); return { mode, toggleMode, setMode }; From 656576c181a58fcacacbca512bb42a4e924e9f69 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 20:38:49 -0700 Subject: [PATCH 05/26] [eric] shell: sidebar starts docked away + dashboard header hides behind a top-edge hover reveal --- frontend/src/app/components/Layout/AppShell.tsx | 6 +++--- .../app/pages/Dashboard/canvas/DashboardCanvas.tsx | 12 +++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 8d411f62..31ec0b9f 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -82,9 +82,9 @@ const AppShell: React.FC = () => { const canGoForward = historyIdx < maxHistoryIdx.current; const [dashboardsExpanded, setDashboardsExpanded] = useState(true); const [appsExpanded, setAppsExpanded] = useState(true); - // Starts collapsed so a fresh boot lands on a clean canvas; the toggle brings it back. - // Arc/Zen: the sidebar is the primary chrome (search + nav live here), shown by default. - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + // Desktop shell: the wallpaper canvas IS the home surface, so the sidebar starts docked away + // (left-edge hover peeks it; the pin toggle brings it back full-time). + const [sidebarCollapsed, setSidebarCollapsed] = useState(true); const [renamingDashboardId, setRenamingDashboardId] = useState(null); const [renamingAppId, setRenamingAppId] = useState(null); const [renameValue, setRenameValue] = useState(''); diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 41b071b8..fbe86dfd 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -170,6 +170,7 @@ const DashboardCanvas: React.FC = ({ // macOS full screen: one card owns the whole window, every piece of chrome steps aside; Esc exits. const dispatch = useAppDispatch(); const fullscreenCardId = useAppSelector(selectFullscreenCardId); + const [headerRevealed, setHeaderRevealed] = React.useState(false); useEffect(() => { if (!fullscreenCardId) return undefined; const onKey = (e: KeyboardEvent): void => { @@ -184,8 +185,14 @@ const DashboardCanvas: React.FC = ({ return ( <> + {/* Top-edge hover strip: the desktop shell keeps the top chromeless; grazing it reveals the header. */} + setHeaderRevealed(true)} + sx={{ position: 'absolute', top: 0, left: 0, right: 0, height: 22, zIndex: 9 }} + /> {/* Floating header overlay */} setHeaderRevealed(false)} sx={{ display: fullscreenCardId ? 'none' : undefined, position: 'absolute', @@ -193,7 +200,10 @@ const DashboardCanvas: React.FC = ({ left: 0, right: 0, zIndex: 10, - pointerEvents: 'none', + pointerEvents: headerRevealed ? undefined : 'none', + opacity: headerRevealed ? 1 : 0, + transform: headerRevealed ? 'translateY(0)' : 'translateY(-6px)', + transition: 'opacity 0.18s ease, transform 0.18s ease', // p: 3 (24px) was leaving a chunky air gap between the sidebar edge and the dashboard header that read as "two disconnected panels" rather than one continuous surface. 0.75 (6px) tightens the inset so the header floats just inside the content area without losing its breathing room from the top-most pixel. p: 0.75, pb: 0, From 1367547def56868b98ce8577d37a7453be44b101 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 20:50:21 -0700 Subject: [PATCH 06/26] [eric] agents: narrator-pill collapsed cards w/ live TodoWrite checklist; expanded chat = dark glass hover-header card; quiet 'N tool calls' rows --- .../src/app/pages/AgentChat/AgentChat.tsx | 2 +- .../pages/AgentChat/bubbles/MessageBubble.tsx | 2 +- .../tool-bubbles/ToolGroupBubble.tsx | 52 +++++-- .../pages/Dashboard/canvas/DashboardGlyph.tsx | 15 +- .../app/pages/Dashboard/cards/AgentCard.tsx | 118 ++++++++++++--- .../Dashboard/desktop/AgentNarratorPill.tsx | 134 ++++++++++++++++++ .../app/pages/Dashboard/desktop/agentTodos.ts | 28 ++++ 7 files changed, 309 insertions(+), 42 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx create mode 100644 frontend/src/app/pages/Dashboard/desktop/agentTodos.ts diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 097f6dd5..2e573e09 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -2258,7 +2258,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose sessionId={id} autoFocus={autoFocus} prefillPrompt={prefillPrompt} - placeholderOverride={runContext ? 'Ask about this run...' : undefined} + placeholderOverride={runContext ? 'Ask about this run...' : embedded ? 'Send a message...' : undefined} runContext={runContext} onClearRunContext={onClearRunContext} thinkingLevel={session?.thinking_level ?? 'auto'} diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index 9f849251..bf9cfa42 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -1059,7 +1059,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o ...(isOversized ? { width: '85%' } : {}), bgcolor: isUser ? c.user.bubble : c.bg.surface, border: isUser ? (isFailed ? `1px solid ${c.status.error}` : 'none') : `1px solid ${c.border.subtle}`, - borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px', + borderRadius: isUser ? '18px' : '16px 16px 16px 4px', px: 2, py: 1.25, boxShadow: isUser ? 'none' : c.shadow.sm, diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx index 8da2895a..e1458ab1 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx @@ -98,7 +98,6 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = })(); const displayName = workflowGroupLabel || meta?.name || group.label; const hasSvg = !!meta?.svg && !workflowGroupLabel; - const canToggleGroup = group.pairs.length > 1; return ( = React.memo(({ group, isSessionRunning = > + {/* Collapsed = the quiet "N tool calls ›" line; the detail card only materializes on expand. */} + {!expanded ? ( + setExpanded(true)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + py: 0.4, + cursor: 'pointer', + color: c.text.tertiary, + '&:hover': { color: c.text.secondary }, + }} + > + + {group.callCount} tool call{group.callCount === 1 ? '' : 's'} + + {!allDone && ( + + {completedCount}/{group.callCount} + + )} + {deniedCount > 0 && ( + + {deniedCount} denied + + )} + + + ) : ( setExpanded(!expanded) : undefined} + onClick={() => setExpanded(false)} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.7, - cursor: canToggleGroup ? 'pointer' : 'default', - '&:hover': canToggleGroup ? { bgcolor: 'rgba(0,0,0,0.02)' } : undefined, + cursor: 'pointer', + '&:hover': { bgcolor: 'rgba(0,0,0,0.02)' }, }} > {!meta ? ( @@ -178,12 +209,11 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = {completedCount}/{group.callCount} )} - {canToggleGroup && ( - {expanded ? : } + - )} + )} = { archive: Folder, collection: Folder, library: Folder, inventory: Package, stock: Package, package: Package, supply: Package, delivery: Truck, shipping: Truck, logistics: Truck, truck: Truck, fleet: Truck, + weather: CloudSun, forecast: CloudSun, temperature: CloudSun, }; function pickIcon(title: string): LucideIcon | null { @@ -76,21 +77,23 @@ function pickIcon(title: string): LucideIcon | null { interface DashboardGlyphProps { name: string | undefined; size?: number; + color?: string; } -const DashboardGlyph: React.FC = ({ name, size = 16 }) => { +const DashboardGlyph: React.FC = ({ name, size = 16, color }) => { const c = useClaudeTokens(); + const glyphColor = color || c.accent.primary; const title = (name || '').trim(); const Icon = useMemo(() => (title ? pickIcon(title) : null), [title]); if (Icon) { - return ; + return ; } // No keyword hit: a tinted monogram of the first letter. Honest identity, never a misleading icon. A title with no latin letters falls back to the glyph. const letter = title.match(/[a-z0-9]/i)?.[0]?.toUpperCase(); if (!letter) { - return ; + return ; } return ( = ({ name, size = 16 }) => { width: size, height: size, borderRadius: '4px', - bgcolor: `${c.accent.primary}1F`, - color: c.accent.primary, + bgcolor: color ? 'rgba(255,255,255,0.16)' : `${c.accent.primary}1F`, + color: glyphColor, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 5b4fc18f..c064dbc7 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -35,11 +35,13 @@ import { } from '@/shared/state/dashboardLayoutSlice'; import WindowControls from './WindowControls'; import { useTiledStyle } from './tileZones'; +import AgentNarratorPill from '../desktop/AgentNarratorPill'; +import { extractLatestTodos } from '../desktop/agentTodos'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; import { parseMcpToolName, getMcpShortAction } from '@/shared/mcpToolMeta'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext'; import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; import { useStreamingMessage } from '@/shared/state/streamingSlice'; @@ -681,6 +683,13 @@ const AgentCard: React.FC = ({ const hasPending = session.pending_approvals.length > 0; const pendingReq = session.pending_approvals[0]; + // Desktop-shell narrator pill: a collapsed card with nothing to ask renders as the minimal pill + // (live turn label + plan checklist); approvals and drafts keep the full card so their UI has a home. + const todos = useMemo(() => extractLatestTodos(session.messages || []), [session.messages]); + const pillMode = !expanded && !hasPending && !isDraft && !tileZone; + const pillLabel = session.turn_label?.label || displayChatTitle(session); + const pillRunning = session.status === 'running'; + const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; @@ -752,7 +761,7 @@ const AgentCard: React.FC = ({ contain: 'layout style', // Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas. willChange: 'transform', - width: tiledStyle ? tiledStyle.width : (localResize ? activeW : Math.max(cardWidth, MIN_W)), + width: pillMode ? 'fit-content' : tiledStyle ? tiledStyle.width : (localResize ? activeW : Math.max(cardWidth, MIN_W)), height: tiledStyle ? tiledStyle.height : (localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto')), transform: tiledStyle ? tiledStyle.transform : undefined, transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, @@ -841,9 +850,28 @@ const AgentCard: React.FC = ({ borderColor: hasPending ? c.status.warning : c.border.strong, }, }), + // Narrator pill sheds every bit of card chrome; the pill body draws its own glass + ring. + ...(pillMode && { + bgcolor: 'transparent', + border: 'none', + boxShadow: 'none', + p: 0, + overflow: 'visible', + cursor: isDragging ? 'grabbing' : 'grab', + '&:hover': {}, + }), + // Expanded chat wears the desktop dark glass; the header only surfaces on hover. + ...(expanded && !tiledStyle && { + bgcolor: 'rgba(26,16,34,0.85)', + backdropFilter: 'blur(24px) saturate(150%)', + WebkitBackdropFilter: 'blur(24px) saturate(150%)', + border: isSelected ? '2px solid #3b82f6' : '1px solid rgba(255,255,255,0.08)', + borderRadius: '20px', + boxShadow: '0 18px 48px rgba(0,0,0,0.4)', + }), }} > - {HANDLE_DEFS.map(({ dir, sx }) => ( + {!pillMode && HANDLE_DEFS.map(({ dir, sx }) => ( = ({ /> )} - {/* Drag zone: header + metadata , entire region above separator is draggable */} + {pillMode && ( + + + + )} + + {/* Drag zone: header + metadata , entire region above separator is draggable. + Expanded desktop cards float it as a hover-reveal overlay so the chat reads chromeless. */} + {!pillMode && ( = ({ + )} {expanded && ( = ({ sx={{ mx: -2, mb: -2, + mt: -2, flex: 1, minHeight: 0, - borderTop: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column', overflow: 'hidden', + borderRadius: tiledStyle ? undefined : '20px', }} > - dispatch(collapseSession(session.id))} - embedded - autoFocus={autoFocusInput} - isGlowing={isGlowingRedux && !glowFading} - onDismissGlow={dismissGlow} - onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined} - /> + + dispatch(collapseSession(session.id))} + embedded + autoFocus={autoFocusInput} + isGlowing={isGlowingRedux && !glowFading} + onDismissGlow={dismissGlow} + onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined} + /> + )} - {!expanded && ( + {!expanded && !pillMode && ( <> {previewContent && ( diff --git a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx new file mode 100644 index 00000000..6f1d0d59 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CheckIcon from '@mui/icons-material/Check'; +import DashboardGlyph from '../canvas/DashboardGlyph'; +import type { AgentTodoItem } from './agentTodos'; + +interface AgentNarratorPillProps { + label: string; + running: boolean; + todos: AgentTodoItem[] | null; + selected: boolean; + highlighted: boolean; +} + +const GLASS = 'rgba(24,14,32,0.8)'; +const GLASS_BLUR = 'blur(18px) saturate(150%)'; +const MAX_VISIBLE_TODOS = 4; + +/** Collapsed running agent as the desktop narrator pill, with its live plan hanging below. */ +function AgentNarratorPill({ label, running, todos, selected, highlighted }: AgentNarratorPillProps): React.ReactElement { + const visibleTodos = (todos || []).slice(0, MAX_VISIBLE_TODOS); + const hiddenCount = (todos?.length || 0) - visibleTodos.length; + const ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined; + + return ( + + + + + {label} + + + + {visibleTodos.length > 0 ? ( + + + {visibleTodos.length > 1 && ( + + )} + {visibleTodos.map((todo, i) => { + const done = todo.status === 'completed'; + const active = todo.status === 'in_progress'; + return ( + + + {done && } + + + {todo.content} + + + ); + })} + + {hiddenCount > 0 && ( + + ... {hiddenCount} more + + )} + + ) : running ? ( + + + Thinking... + + + ) : null} + + ); +} + +export default AgentNarratorPill; diff --git a/frontend/src/app/pages/Dashboard/desktop/agentTodos.ts b/frontend/src/app/pages/Dashboard/desktop/agentTodos.ts new file mode 100644 index 00000000..34fff2f2 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/agentTodos.ts @@ -0,0 +1,28 @@ +export interface AgentTodoItem { + content: string; + status: 'pending' | 'in_progress' | 'completed'; +} + +function isTodoStatus(v: unknown): v is AgentTodoItem['status'] { + return v === 'pending' || v === 'in_progress' || v === 'completed'; +} + +/** Latest TodoWrite payload in the transcript = the agent's live plan; null when it never wrote one. */ +export function extractLatestTodos(messages: Array<{ role: string; content: any }>): AgentTodoItem[] | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== 'tool_call') continue; + const tool = typeof msg.content === 'object' ? String(msg.content?.tool || '') : ''; + if (!/todowrite$/i.test(tool)) continue; + const raw = msg.content?.input?.todos; + if (!Array.isArray(raw)) continue; + const items: AgentTodoItem[] = []; + for (const t of raw) { + const content = typeof t?.content === 'string' ? t.content : (typeof t?.activeForm === 'string' ? t.activeForm : ''); + if (!content) continue; + items.push({ content, status: isTodoStatus(t?.status) ? t.status : 'pending' }); + } + if (items.length > 0) return items; + } + return null; +} From 80a164c77c3ec433f1da003b5a3e43351adbe284 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 21:02:03 -0700 Subject: [PATCH 07/26] [eric] chat: ShowUI widget tier (display-only MCP tool + inline weather/plan/stats/links components, schema-checked w/ plain-bubble fallback) --- .../permissions/build_effective_tool_lists.py | 8 + .../manager/register_builtin_mcp_servers.py | 13 ++ backend/apps/agents/show_ui_mcp_server.py | 132 +++++++++++++++ .../src/app/pages/AgentChat/AgentChat.tsx | 21 ++- .../pages/AgentChat/tool-ui/LinksWidget.tsx | 56 +++++++ .../pages/AgentChat/tool-ui/PlanWidget.tsx | 72 +++++++++ .../pages/AgentChat/tool-ui/StatsWidget.tsx | 58 +++++++ .../pages/AgentChat/tool-ui/ToolUiBubble.tsx | 36 +++++ .../pages/AgentChat/tool-ui/WeatherWidget.tsx | 92 +++++++++++ .../pages/AgentChat/tool-ui/showUiPayload.ts | 151 ++++++++++++++++++ 10 files changed, 637 insertions(+), 2 deletions(-) create mode 100644 backend/apps/agents/show_ui_mcp_server.py create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/LinksWidget.tsx create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/PlanWidget.tsx create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/StatsWidget.tsx create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index 7d308f6e..3dc7f3bf 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -72,6 +72,14 @@ def build_effective_tool_lists( effective_disallowed.append("mcp__openswarm-skill__Skill") continue + if name == "openswarm-ui": + policy = builtin_perms.get("ShowUI", "always_allow") + if policy == "always_allow": + effective_allowed.append("mcp__openswarm-ui__ShowUI") + else: + effective_disallowed.append("mcp__openswarm-ui__ShowUI") + continue + if name == "openswarm-web": # Expose our DDG-backed web tools under an MCP prefix. Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either. for wt in ("WebSearch", "WebFetch"): diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index a954ca13..75bd4d5a 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -142,6 +142,19 @@ def register_builtin_mcp_servers( "type": "stdio", } + # Display-only ShowUI server: renders rich inline components (weather, plan, stats, links) + # in the transcript. Pure display, no state mutation; the frontend renders from the + # tool_call input, the server only validates. Gated on the ShowUI builtin perm. + show_ui_denied = builtin_perms.get("ShowUI", "always_allow") == "deny" + if not show_ui_denied: + show_ui_server_path = os.path.join(agents_dir, "show_ui_mcp_server.py") + mcp_servers["openswarm-ui"] = { + "command": sys.executable, + "args": [show_ui_server_path], + "env": {}, + "type": "stdio", + } + # Always-on schedule server: ScheduleWorkflow + CRUD + AddWorkflowStep/EditWorkflowStep so the agent (and the workflow Edit Agent) can build and schedule recurring work via the native scheduler instead of cron/launchctl. The 4 scheduling tools are force-gated in path_gate; Cron* is denied in build_effective_tool_lists. schedule_server_path = os.path.join( agents_dir, "schedule_mcp_server.py" diff --git a/backend/apps/agents/show_ui_mcp_server.py b/backend/apps/agents/show_ui_mcp_server.py new file mode 100644 index 00000000..dfc52719 --- /dev/null +++ b/backend/apps/agents/show_ui_mcp_server.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Stdio MCP server exposing ShowUI: render a rich inline component in the chat transcript. + +Display-only. The frontend renders the component straight from the tool_call input it already +has in the transcript, so this server just validates the payload and acknowledges; there is no +backend round-trip and nothing here can mutate state. +""" + +import json +import sys + +MAX_PROPS_BYTES = 20_000 + +COMPONENT_SPECS = { + "weather": "props: {location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high: number, low?: number}] (max 7)}", + "plan": "props: {title?: str, steps: [{label: str, status: 'pending'|'in_progress'|'completed'}] (max 20)}", + "stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}", + "links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}", +} + +TOOLS = [ + { + "name": "ShowUI", + "description": ( + "Render a rich inline UI component in the chat instead of describing data as text. " + "Use it whenever a result fits one of the shapes. Supported components:\n" + + "\n".join(f"- '{name}': {spec}" for name, spec in COMPONENT_SPECS.items()) + + "\nCall it with the component name and a props object matching that shape. " + "The component renders in place of raw text; still give a one-line text summary after." + ), + "inputSchema": { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": list(COMPONENT_SPECS.keys()), + "description": "Which component to render.", + }, + "props": { + "type": "object", + "description": "Data for the component, matching its documented shape.", + }, + }, + "required": ["component", "props"], + }, + }, +] + + +def send_response(id_, result=None, error=None): + msg = {"jsonrpc": "2.0", "id": id_} + if error is not None: + msg["error"] = error + else: + msg["result"] = result + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + + +def validate(component: str, props: dict) -> str: + if component not in COMPONENT_SPECS: + return f"Unknown component {component!r}. Supported: {', '.join(COMPONENT_SPECS)}." + try: + size = len(json.dumps(props)) + except (TypeError, ValueError): + return "props must be JSON-serializable." + if size > MAX_PROPS_BYTES: + return f"props too large ({size} bytes; max {MAX_PROPS_BYTES})." + if component == "weather" and not (isinstance(props.get("location"), str) and isinstance(props.get("temp"), (int, float))): + return f"weather needs at least location + temp. {COMPONENT_SPECS['weather']}" + if component == "plan" and not (isinstance(props.get("steps"), list) and props["steps"]): + return f"plan needs a non-empty steps list. {COMPONENT_SPECS['plan']}" + if component == "stats" and not (isinstance(props.get("stats"), list) and props["stats"]): + return f"stats needs a non-empty stats list. {COMPONENT_SPECS['stats']}" + if component == "links" and not (isinstance(props.get("links"), list) and props["links"]): + return f"links needs a non-empty links list. {COMPONENT_SPECS['links']}" + return "" + + +def handle_tool_call(tool_name: str, arguments: dict) -> dict: + if tool_name != "ShowUI": + return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} + component = str(arguments.get("component", "")).strip() + props = arguments.get("props") + if not isinstance(props, dict): + return {"content": [{"type": "text", "text": "props must be an object."}], "isError": True} + problem = validate(component, props) + if problem: + return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}], "isError": True} + return {"content": [{"type": "text", "text": f"Rendered a '{component}' component inline."}]} + + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + + method = msg.get("method") + id_ = msg.get("id") + params = msg.get("params", {}) or {} + + if method == "initialize": + send_response(id_, { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": { + "name": "openswarm-ui", + "version": "1.0.0", + }, + }) + elif method == "notifications/initialized": + pass + elif method == "tools/list": + send_response(id_, {"tools": TOOLS}) + elif method == "tools/call": + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) or {} + result = handle_tool_call(tool_name, arguments) + send_response(id_, result) + elif method == "ping": + send_response(id_, {}) + elif id_ is not None: + send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"}) + + +if __name__ == "__main__": + main() diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 2e573e09..ffa6a056 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -58,6 +58,8 @@ import CompactionMarker from './bubbles/CompactionMarker'; import MessageActionBar from './shell/MessageActionBar'; import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble'; import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble'; +import ToolUiBubble from './tool-ui/ToolUiBubble'; +import { isShowUiPair } from './tool-ui/showUiPayload'; import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar'; import ForceStopAgentBar from './ForceStopAgentBar'; import { RateLimitPill } from './shell/RateLimitPill'; @@ -1063,15 +1065,21 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose i++; } - const calls = group.filter((m) => m.role === 'tool_call'); + const allCalls = group.filter((m) => m.role === 'tool_call'); const results = group.filter((m) => m.role === 'tool_result'); - const pairs: ToolPair[] = calls.map((call, idx) => ({ + const allPairs: ToolPair[] = allCalls.map((call, idx) => ({ type: 'tool_pair' as const, id: `pair-${call.id}`, call, result: results[idx] || null, })); + // ShowUI calls render as inline components, never buried inside a collapsed group. + // They typically cap a run of work, so the quiet group row stays above the widget. + const showUiPairs = allPairs.filter(isShowUiPair); + const pairs = allPairs.filter((p) => !isShowUiPair(p)); + const calls = pairs.map((p) => p.call); + const mcpServers = new Set( calls.map((m) => { const tool = typeof m.content === 'object' ? m.content.tool || '' : ''; @@ -1113,6 +1121,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose callCount: calls.length, } satisfies ToolGroup); } + items.push(...showUiPairs); } else { if (!msg.hidden) { items.push(msg); @@ -1593,6 +1602,14 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } if (isToolPair(item)) { const isPending = item.result === null && sessionRunning; + if (isShowUiPair(item)) { + return ( + + + {compactionChip} + + ); + } return ( diff --git a/frontend/src/app/pages/AgentChat/tool-ui/LinksWidget.tsx b/frontend/src/app/pages/AgentChat/tool-ui/LinksWidget.tsx new file mode 100644 index 00000000..8d980491 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/LinksWidget.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { LinksProps } from './showUiPayload'; + +function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } +} + +/** Tool-UI-style link previews: domain, title, description; opens like any transcript link. */ +function LinksWidget({ props }: { props: LinksProps }): React.ReactElement { + const c = useClaudeTokens(); + return ( + + {props.links.map((l, i) => ( + + + {hostOf(l.url)} + + + {l.title} + + {l.description && ( + + {l.description} + + )} + + ))} + + ); +} + +export default LinksWidget; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/PlanWidget.tsx b/frontend/src/app/pages/AgentChat/tool-ui/PlanWidget.tsx new file mode 100644 index 00000000..92b6fd8a --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/PlanWidget.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; +import CircularProgress from '@mui/material/CircularProgress'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { PlanProps } from './showUiPayload'; + +const MAX_VISIBLE = 6; + +/** Tool-UI-style plan card: progress summary bar + step checklist. */ +function PlanWidget({ props }: { props: PlanProps }): React.ReactElement { + const c = useClaudeTokens(); + const done = props.steps.filter((s) => s.status === 'completed').length; + const visible = props.steps.slice(0, MAX_VISIBLE); + const hidden = props.steps.length - visible.length; + + return ( + + {props.title && ( + + {props.title} + + )} + + {done} of {props.steps.length} complete + + + + + {visible.map((step, i) => ( + + {step.status === 'completed' ? ( + + ) : step.status === 'in_progress' ? ( + + ) : ( + + )} + + {step.label} + + + ))} + {hidden > 0 && ( + + ... {hidden} more + + )} + + ); +} + +export default PlanWidget; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/StatsWidget.tsx b/frontend/src/app/pages/AgentChat/tool-ui/StatsWidget.tsx new file mode 100644 index 00000000..22ddaf15 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/StatsWidget.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { StatsProps } from './showUiPayload'; + +/** Tool-UI-style stat tiles: label, value, optional signed delta. */ +function StatsWidget({ props }: { props: StatsProps }): React.ReactElement { + const c = useClaudeTokens(); + return ( + + {props.title && ( + + {props.title} + + )} + + {props.stats.map((s, i) => ( + + + {s.label} + + + {s.value} + + {s.delta && ( + + {s.direction === 'down' ? ( + + ) : ( + + )} + + {s.delta} + + + )} + + ))} + + + ); +} + +export default StatsWidget; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx new file mode 100644 index 00000000..c8567566 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import ToolCallBubble from '../tool-bubbles/ToolCallBubble'; +import type { ToolPair } from '../tool-bubbles/ToolCallBubble'; +import { parseShowUiPayload } from './showUiPayload'; +import WeatherWidget from './WeatherWidget'; +import PlanWidget from './PlanWidget'; +import StatsWidget from './StatsWidget'; +import LinksWidget from './LinksWidget'; + +interface ToolUiBubbleProps { + pair: ToolPair; + sessionId: string; + isPending: boolean; + suppressReveal: boolean; +} + +/** Renders a ShowUI call as its inline component; any schema mismatch falls back to the plain tool bubble. */ +function ToolUiBubble({ pair, sessionId, isPending, suppressReveal }: ToolUiBubbleProps): React.ReactElement { + const payload = parseShowUiPayload(pair); + if (!payload) { + return ( + + ); + } + return ( + + {payload.component === 'weather' && } + {payload.component === 'plan' && } + {payload.component === 'stats' && } + {payload.component === 'links' && } + + ); +} + +export default ToolUiBubble; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx b/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx new file mode 100644 index 00000000..e7a4fe30 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import WbSunnyOutlinedIcon from '@mui/icons-material/WbSunnyOutlined'; +import CloudOutlinedIcon from '@mui/icons-material/CloudOutlined'; +import GrainIcon from '@mui/icons-material/Grain'; +import AcUnitIcon from '@mui/icons-material/AcUnit'; +import ThunderstormOutlinedIcon from '@mui/icons-material/ThunderstormOutlined'; +import type { WeatherProps } from './showUiPayload'; + +function conditionIcon(condition: string | undefined, size: number): React.ReactElement { + const cond = (condition || '').toLowerCase(); + const sx = { fontSize: size, color: 'rgba(255,255,255,0.92)' }; + if (/thunder|storm/.test(cond)) return ; + if (/rain|drizzle|shower/.test(cond)) return ; + if (/snow|sleet|ice/.test(cond)) return ; + if (/cloud|overcast|fog|mist/.test(cond)) return ; + return ; +} + +/** iOS-style weather card: dusk-sky art, big thin temperature, five-day strip. */ +function WeatherWidget({ props }: { props: WeatherProps }): React.ReactElement { + const unit = props.unit || 'F'; + return ( + + + {props.location} + + + + {Math.round(props.temp)} + + °{unit} + + {(props.high != null || props.low != null) && ( + + {props.high != null && ( + + H {Math.round(props.high)}° + + )} + {props.low != null && ( + + L {Math.round(props.low)}° + + )} + + )} + {props.forecast && props.forecast.length > 0 && ( + + {props.forecast.slice(0, 5).map((d, i) => ( + + + {d.day} + + {conditionIcon(d.condition, 16)} + {Math.round(d.high)}° + {d.low != null && ( + {Math.round(d.low)}° + )} + + ))} + + )} + + ); +} + +export default WeatherWidget; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts new file mode 100644 index 00000000..4f13fe1a --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -0,0 +1,151 @@ +import type { ToolPair } from '../tool-bubbles/ToolCallBubble'; + +export interface WeatherForecastDay { + day: string; + condition?: string; + high: number; + low?: number; +} + +export interface WeatherProps { + location: string; + temp: number; + unit?: 'F' | 'C'; + high?: number; + low?: number; + condition?: string; + forecast?: WeatherForecastDay[]; +} + +export interface PlanStep { + label: string; + status: 'pending' | 'in_progress' | 'completed'; +} + +export interface PlanProps { + title?: string; + steps: PlanStep[]; +} + +export interface StatItem { + label: string; + value: string; + delta?: string; + direction?: 'up' | 'down'; +} + +export interface StatsProps { + title?: string; + stats: StatItem[]; +} + +export interface LinkItem { + title: string; + url: string; + description?: string; +} + +export interface LinksProps { + links: LinkItem[]; +} + +export type ShowUiPayload = + | { component: 'weather'; props: WeatherProps } + | { component: 'plan'; props: PlanProps } + | { component: 'stats'; props: StatsProps } + | { component: 'links'; props: LinksProps }; + +function num(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v); +} + +function str(v: unknown): v is string { + return typeof v === 'string' && v.length > 0; +} + +export function isShowUiPair(pair: ToolPair): boolean { + const tool = typeof pair.call.content === 'object' ? String(pair.call.content?.tool || '') : ''; + return /(^|__)ShowUI$/.test(tool); +} + +/** Strict parse of a ShowUI tool_call's input; null on any mismatch so the caller falls back to the plain bubble. */ +export function parseShowUiPayload(pair: ToolPair): ShowUiPayload | null { + const content = typeof pair.call.content === 'object' ? pair.call.content : null; + const input = content?.input; + if (!input || typeof input !== 'object') return null; + const component = String((input as { component?: unknown }).component || ''); + const props = (input as { props?: unknown }).props; + if (!props || typeof props !== 'object') return null; + const p = props as Record; + + if (component === 'weather') { + if (!str(p.location) || !num(p.temp)) return null; + const forecast = Array.isArray(p.forecast) + ? (p.forecast as Array>) + .filter((d) => str(d.day) && num(d.high)) + .slice(0, 7) + .map((d) => ({ + day: d.day as string, + condition: str(d.condition) ? d.condition : undefined, + high: d.high as number, + low: num(d.low) ? d.low : undefined, + })) + : undefined; + return { + component: 'weather', + props: { + location: p.location, + temp: p.temp, + unit: p.unit === 'C' ? 'C' : 'F', + high: num(p.high) ? p.high : undefined, + low: num(p.low) ? p.low : undefined, + condition: str(p.condition) ? p.condition : undefined, + forecast, + }, + }; + } + + if (component === 'plan') { + if (!Array.isArray(p.steps)) return null; + const steps = (p.steps as Array>) + .filter((s) => str(s.label)) + .slice(0, 20) + .map((s) => ({ + label: s.label as string, + status: (s.status === 'completed' || s.status === 'in_progress' ? s.status : 'pending') as PlanStep['status'], + })); + if (steps.length === 0) return null; + return { component: 'plan', props: { title: str(p.title) ? p.title : undefined, steps } }; + } + + if (component === 'stats') { + if (!Array.isArray(p.stats)) return null; + const stats = (p.stats as Array>) + .filter((s) => str(s.label) && str(s.value)) + .slice(0, 8) + .map((s) => ({ + label: s.label as string, + value: s.value as string, + delta: str(s.delta) ? s.delta : undefined, + direction: (s.direction === 'up' || s.direction === 'down' ? s.direction : undefined) as StatItem['direction'], + })); + if (stats.length === 0) return null; + return { component: 'stats', props: { title: str(p.title) ? p.title : undefined, stats } }; + } + + if (component === 'links') { + if (!Array.isArray(p.links)) return null; + const links = (p.links as Array>) + .filter((l) => str(l.title) && str(l.url) && /^https?:\/\//i.test(l.url as string)) + .slice(0, 10) + .map((l) => ({ + title: l.title as string, + url: l.url as string, + description: str(l.description) ? l.description : undefined, + })); + if (links.length === 0) return null; + return { component: 'links', props: { links } }; + } + + return null; +} From 121dacd679d2422f4c63afb477550627e2acce79 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 21:08:51 -0700 Subject: [PATCH 08/26] [eric] browser: macOS-window chrome (traffic lights, fixed light strip, centered URL) + minimize to a right-edge thumbnail stack --- .../Dashboard/canvas/DashboardCanvas.tsx | 11 ++ .../app/pages/Dashboard/cards/BrowserCard.tsx | 120 +++++++++++++----- .../Dashboard/desktop/MinimizedStack.tsx | 88 +++++++++++++ .../pages/Dashboard/desktop/minimizedShots.ts | 19 +++ 4 files changed, 203 insertions(+), 35 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx create mode 100644 frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index fbe86dfd..cd4296ae 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -9,6 +9,7 @@ import DashboardOverlays from './DashboardOverlays'; import DashboardEmptyState from './DashboardEmptyState'; import DesktopWallpaper from '../desktop/DesktopWallpaper'; import DesktopDock from '../desktop/DesktopDock'; +import MinimizedStack from '../desktop/MinimizedStack'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; import { GRAIN_URL } from '@/shared/styles/grainTexture'; @@ -230,6 +231,16 @@ const DashboardCanvas: React.FC = ({ + {!fullscreenCardId && ( + { + canvas.actions.fitToCards([rect], 1.15, true); + onHighlightCard?.(cardId); + }} + /> + )} + {!fullscreenCardId && ( => ({ + width: 12, + height: 12, + p: 0, + borderRadius: '50%', + border: '0.5px solid rgba(0,0,0,0.08)', + background: '#d6d3cd', + cursor: 'pointer', + transition: 'background 150ms', + '.osw-card:hover &': { background: color }, +}); import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -206,6 +228,7 @@ const BrowserCard: React.FC = ({ [browserId], ); const browserAgentSession = useAppSelector(selectBrowserAgentSession); + const isMinimized = useAppSelector((s) => Boolean(s.dashboardLayout.minimizedCards[browserId])); const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]); const endingState = useAppSelector((state) => state.dashboardLayout.endingBrowserCards[browserId]); @@ -496,6 +519,22 @@ const BrowserCard: React.FC = ({ dispatch(addBrowserTab({ browserId, url: browserHomepage })); }, [dispatch, browserId, browserHomepage]); + // Yellow light: snapshot the live page first so the right-edge stack shows a real thumbnail, + // then park the card (webContents stays mounted, same as the keep-alive off-screen park). + const handleMinimize = useCallback(() => { + const wv = webviewMap.current.get(activeTabId); + const capture = wv?.capturePage?.(); + const park = (): void => { dispatch(toggleMinimizeCard({ cardId: browserId })); }; + if (capture && typeof (capture as Promise).then === 'function') { + (capture as Promise<{ toDataURL(): string }>) + .then((img) => { saveMinimizedShot(browserId, img.toDataURL()); }) + .catch(() => undefined) + .finally(park); + } else { + park(); + } + }, [dispatch, browserId, activeTabId]); + const handleCloseTab = useCallback((tabId: string, e: React.MouseEvent) => { e.stopPropagation(); // Closing the last tab destroys the whole card, so record it as a browser-card close (reopen brings the card back), not a tab close. @@ -819,11 +858,12 @@ const BrowserCard: React.FC = ({ return ( { onBringToFront?.(browserId, 'browser'); // Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path. @@ -840,12 +880,12 @@ const BrowserCard: React.FC = ({ sx={{ position: 'absolute', // Kept-alive card from another dashboard: parked far off-screen so its webview surface can't bleed onto the dashboard you're viewing; click-through, webContents stays mounted. - pointerEvents: keepAliveHidden ? 'none' : undefined, + pointerEvents: keepAliveHidden || isMinimized ? 'none' : undefined, // contain: webview repaints don't shake neighbor cards. contain: 'layout style', // Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale. willChange: 'transform', - left: keepAliveHidden ? -100000 : displayX, + left: keepAliveHidden || isMinimized ? -100000 : displayX, top: displayY, width: displayW, height: displayH, @@ -893,8 +933,9 @@ const BrowserCard: React.FC = ({ zIndex: 16, display: 'flex', alignItems: 'stretch', - bgcolor: agentActive ? `${accentColor}0a` : c.bg.secondary, - borderBottom: `1px solid ${agentActive ? `${accentColor}30` : c.border.subtle}`, + // Real-browser-window chrome stays light in both app themes, like the window it imitates. + bgcolor: agentActive ? `${accentColor}14` : CHROME_BG, + borderBottom: `1px solid ${agentActive ? `${accentColor}30` : CHROME_BORDER}`, cursor: isDragging ? 'grabbing' : 'grab', flexShrink: 0, minHeight: 34, @@ -903,6 +944,25 @@ const BrowserCard: React.FC = ({ overflow: 'hidden', }} > + e.stopPropagation()} + sx={{ display: 'flex', alignItems: 'center', gap: '7px', pl: 1.25, pr: 0.75, flexShrink: 0 }} + > + + { e.stopPropagation(); handleMinimize(); }} + sx={{ ...browserLightSx('#febc2e'), }} + /> + = ({ maxWidth: 180, flex: '0 1 180px', position: 'relative', - borderRight: `1px solid ${c.border.subtle}`, - bgcolor: isActive ? c.bg.surface : 'transparent', + borderRight: `1px solid ${CHROME_BORDER}`, + bgcolor: isActive ? CHROME_SURFACE : 'transparent', cursor: isBeingDragged ? 'grabbing' : 'pointer', transform: isBeingDragged ? `translateX(${dragTabOffset}px)` : 'none', transition: isBeingDragged ? 'none' : 'background 0.15s ease, transform 0.2s ease', zIndex: isBeingDragged ? 10 : 1, - '&:hover': { bgcolor: isActive ? c.bg.surface : c.bg.secondary }, + '&:hover': { bgcolor: isActive ? CHROME_SURFACE : 'rgba(0,0,0,0.04)' }, '&:hover .tab-close': { opacity: 1 }, ...(isActive && { '&::after': { @@ -968,7 +1028,7 @@ const BrowserCard: React.FC = ({ onError={(e: any) => { e.target.style.display = 'none'; }} /> ) : ( - + )} @@ -977,7 +1037,7 @@ const BrowserCard: React.FC = ({ flex: 1, fontSize: '0.7rem', fontWeight: isActive ? 600 : 400, - color: isActive ? c.text.primary : c.text.muted, + color: isActive ? CHROME_TEXT : CHROME_TEXT_MUTED, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -1003,10 +1063,10 @@ const BrowserCard: React.FC = ({ opacity: isActive ? 0.6 : 0, cursor: 'pointer', transition: 'opacity 0.15s, background 0.15s', - '&:hover': { bgcolor: `${c.text.muted}25`, opacity: 1 }, + '&:hover': { bgcolor: 'rgba(0,0,0,0.09)', opacity: 1 }, }} > - + ); @@ -1026,10 +1086,10 @@ const BrowserCard: React.FC = ({ mx: 0.25, my: 0.5, transition: 'background 0.15s', - '&:hover': { bgcolor: `${c.text.muted}15` }, + '&:hover': { bgcolor: 'rgba(0,0,0,0.06)' }, }} > - + @@ -1072,16 +1132,6 @@ const BrowserCard: React.FC = ({ )} - - e.stopPropagation()} - sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }} - > - - - @@ -1093,8 +1143,8 @@ const BrowserCard: React.FC = ({ gap: 0.25, px: 0.5, py: 0.25, - bgcolor: c.bg.page, - borderBottom: `1px solid ${c.border.subtle}`, + bgcolor: CHROME_PAGE, + borderBottom: `1px solid ${CHROME_BORDER}`, flexShrink: 0, }} > @@ -1105,7 +1155,7 @@ const BrowserCard: React.FC = ({ onClick={handleBack} onPointerDown={(e) => e.stopPropagation()} disabled={!activeLocal.canGoBack} - sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} + sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }} > @@ -1119,7 +1169,7 @@ const BrowserCard: React.FC = ({ onClick={handleForward} onPointerDown={(e) => e.stopPropagation()} disabled={!activeLocal.canGoForward} - sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} + sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }} > @@ -1131,7 +1181,7 @@ const BrowserCard: React.FC = ({ size="small" onClick={handleRefresh} onPointerDown={(e) => e.stopPropagation()} - sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} + sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }} > @@ -1147,13 +1197,13 @@ const BrowserCard: React.FC = ({ ml: 0.5, px: 1, py: 0.2, - bgcolor: c.bg.secondary, + bgcolor: '#eceaf1', borderRadius: `${c.radius.md}px`, - border: `1px solid ${c.border.subtle}`, + border: `1px solid ${CHROME_BORDER}`, }} > {isSearch ? ( - + ) : isSecure ? ( ) : null} @@ -1169,10 +1219,10 @@ const BrowserCard: React.FC = ({ flex: 1, fontSize: '0.74rem', fontFamily: c.font.mono, - color: c.text.secondary, + color: CHROME_TEXT, py: 0, - '& input': { py: '2px' }, - '& input::placeholder': { color: c.text.ghost, opacity: 1 }, + '& input': { py: '2px', textAlign: 'center' }, + '& input::placeholder': { color: CHROME_TEXT_MUTED, opacity: 1 }, }} /> diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx new file mode 100644 index 00000000..abe96e3b --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import LanguageIcon from '@mui/icons-material/Language'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { toggleMinimizeCard } from '@/shared/state/dashboardLayoutSlice'; +import { getMinimizedShot, dropMinimizedShot } from './minimizedShots'; +import type { BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; + +interface CardRect { + x: number; + y: number; + width: number; + height: number; +} + +interface MinimizedStackProps { + browserCards: Record; + onRestore: (id: string, rect: CardRect) => void; +} + +const THUMB_W = 96; + +/** Right-edge stack of minimized browser windows; click restores the card where it was. */ +function MinimizedStack({ browserCards, onRestore }: MinimizedStackProps): React.ReactElement | null { + const dispatch = useAppDispatch(); + const minimized = useAppSelector((s) => s.dashboardLayout.minimizedCards); + const entries = Object.values(browserCards).filter((bc) => minimized[bc.browser_id]); + if (entries.length === 0) return null; + + return ( + + {entries.map((bc) => { + const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0]; + const shot = getMinimizedShot(bc.browser_id); + return ( + { + dropMinimizedShot(bc.browser_id); + dispatch(toggleMinimizeCard({ cardId: bc.browser_id })); + onRestore(bc.browser_id, bc); + }} + title={activeTab?.title || 'Browser'} + sx={{ + width: THUMB_W, + borderRadius: '8px', + overflow: 'hidden', + cursor: 'pointer', + boxShadow: '0 6px 20px rgba(0,0,0,0.3)', + background: '#fff', + transition: 'transform 0.15s ease, box-shadow 0.15s ease', + '&:hover': { transform: 'scale(1.06)', boxShadow: '0 10px 28px rgba(0,0,0,0.4)' }, + }} + > + {shot ? ( + + ) : ( + + {activeTab?.favicon ? ( + + ) : ( + + )} + + {activeTab?.title || 'Browser'} + + + )} + + ); + })} + + ); +} + +export default MinimizedStack; diff --git a/frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts b/frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts new file mode 100644 index 00000000..8a086620 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/minimizedShots.ts @@ -0,0 +1,19 @@ +/** Last visual of a card captured at minimize time; in-memory only, keyed by card id. */ +const shots = new Map(); +const CAP = 40; + +export function saveMinimizedShot(cardId: string, dataUrl: string): void { + if (shots.size >= CAP && !shots.has(cardId)) { + const oldest = shots.keys().next().value; + if (oldest) shots.delete(oldest); + } + shots.set(cardId, dataUrl); +} + +export function getMinimizedShot(cardId: string): string | undefined { + return shots.get(cardId); +} + +export function dropMinimizedShot(cardId: string): void { + shots.delete(cardId); +} From 043ed3ff646ebcb24c49dcd906dcfd427c915e75 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 21:13:02 -0700 Subject: [PATCH 09/26] [eric] dashboard: Applications launcher window (real /Applications via scan, electron icons+launch, category chips) + dock tile --- electron/main.js | 35 +++ electron/preload.js | 2 + .../Dashboard/canvas/DashboardCanvas.tsx | 7 + .../Dashboard/desktop/ApplicationsWindow.tsx | 204 ++++++++++++++++++ .../pages/Dashboard/desktop/DesktopDock.tsx | 28 ++- 5 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx diff --git a/electron/main.js b/electron/main.js index d89f755e..80d3fa55 100644 --- a/electron/main.js +++ b/electron/main.js @@ -3054,6 +3054,41 @@ ipcMain.handle('open-external', (_event, url) => { } }); +// Applications launcher support. Names are bare .app basenames from the local scan; both +// handlers hard-validate the name and resolve strictly inside /Applications so a hostile +// renderer string can't traverse anywhere else. +const APP_NAME_RE = /^[\w .&'()+-]{1,80}$/; +const appIconCache = new Map(); +function resolveApplicationPath(name) { + if (typeof name !== 'string' || !APP_NAME_RE.test(name) || name.includes('..')) return null; + const path = require('path'); + const resolved = path.join('/Applications', `${name}.app`); + if (path.dirname(resolved) !== '/Applications') return null; + return resolved; +} + +ipcMain.handle('get-app-icon', async (_event, name) => { + const target = resolveApplicationPath(name); + if (!target) return null; + if (appIconCache.has(name)) return appIconCache.get(name); + try { + const icon = await app.getFileIcon(target, { size: 'large' }); + const dataUrl = icon && !icon.isEmpty() ? icon.toDataURL() : null; + appIconCache.set(name, dataUrl); + return dataUrl; + } catch (_) { + appIconCache.set(name, null); + return null; + } +}); + +ipcMain.handle('open-application', (_event, name) => { + const target = resolveApplicationPath(name); + if (!target) return false; + shell.openPath(target); + return true; +}); + // Affiliate install state. Returns the persisted install.json contents so // the renderer can attach the referral code to authenticated cloud calls // (Stripe checkout, sign-in events) for downstream attribution. diff --git a/electron/preload.js b/electron/preload.js index e82275ca..b34f065a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -75,6 +75,8 @@ contextBridge.exposeInMainWorld('openswarm', { cdpRoutesGet: (wcId, originFilter) => ipcRenderer.invoke('cdp-routes-get', wcId, originFilter), getWebviewConsole: (wcId) => ipcRenderer.invoke('get-webview-console', wcId), capturePage: (rect) => ipcRenderer.invoke('capture-page', rect), + getAppIcon: (name) => ipcRenderer.invoke('get-app-icon', name), + openApplication: (name) => ipcRenderer.invoke('open-application', name), getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), getCrashRecoveryInfo: () => ipcRenderer.invoke('get-crash-recovery-info'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index cd4296ae..5a58c40f 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -10,6 +10,7 @@ import DashboardEmptyState from './DashboardEmptyState'; import DesktopWallpaper from '../desktop/DesktopWallpaper'; import DesktopDock from '../desktop/DesktopDock'; import MinimizedStack from '../desktop/MinimizedStack'; +import ApplicationsWindow from '../desktop/ApplicationsWindow'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; import { GRAIN_URL } from '@/shared/styles/grainTexture'; @@ -172,6 +173,7 @@ const DashboardCanvas: React.FC = ({ const dispatch = useAppDispatch(); const fullscreenCardId = useAppSelector(selectFullscreenCardId); const [headerRevealed, setHeaderRevealed] = React.useState(false); + const [appsWindowOpen, setAppsWindowOpen] = React.useState(false); useEffect(() => { if (!fullscreenCardId) return undefined; const onKey = (e: KeyboardEvent): void => { @@ -255,9 +257,14 @@ const DashboardCanvas: React.FC = ({ canvas.actions.fitToCards([rect], 1.15, true); onHighlightCard?.(cardId); }} + onApplications={() => setAppsWindowOpen((v) => !v)} /> )} + {appsWindowOpen && !fullscreenCardId && ( + setAppsWindowOpen(false)} /> + )} + {/* Canvas viewport */} void; +} + +const CATEGORY_RULES: Array<{ label: string; re: RegExp }> = [ + { label: 'Developer Tools', re: /code|cursor|docker|terminal|xcode|git|iterm|studio|postman|figma|utm|dev/i }, + { label: 'Productivity & Finance', re: /notion|calendar|mail|numbers|pages|keynote|excel|word|slides|office|linear|wallet|slack|zoom|meet|drive|todo|remind/i }, + { label: 'Social', re: /message|discord|telegram|whatsapp|signal|wechat|facetime|x\b|instagram/i }, + { label: 'Entertainment', re: /spotify|music|tv|netflix|youtube|game|steam|chess|vlc|iina|podcast/i }, + { label: 'Utilities', re: /calculator|clock|settings|finder|preview|utility|cleaner|monitor|keychain|archive|font/i }, + { label: 'Travel', re: /maps|weather|flight|uber|airbnb/i }, + { label: 'Creativity', re: /photo|imovie|garageband|final cut|logic|premiere|illustrator|sketch|blender|procreate|paint|davinci/i }, + { label: 'Information', re: /news|books|stocks|dictionary|wiki|safari|chrome|edge|firefox|arc|browser/i }, +]; + +function categorize(name: string): string { + for (const rule of CATEGORY_RULES) if (rule.re.test(name)) return rule.label; + return 'Other'; +} + +function LetterTile({ name }: { name: string }): React.ReactElement { + const letter = name.match(/[a-z0-9]/i)?.[0]?.toUpperCase() || '?'; + return ( + + {letter} + + ); +} + +/** Launchpad-style window over the canvas: the user's real /Applications, categorized. */ +function ApplicationsWindow({ onClose }: ApplicationsWindowProps): React.ReactElement { + const [apps, setApps] = useState(null); + const [error, setError] = useState(false); + const [icons, setIcons] = useState>({}); + const [category, setCategory] = useState('All'); + + useEffect(() => { + let cancelled = false; + fetch(`${API_BASE}/onboarding/scan`, { method: 'POST' }) + .then((r) => r.json()) + .then((d) => { + if (cancelled) return; + const names: string[] = Array.isArray(d?.apps) ? d.apps : []; + setApps(names); + }) + .catch(() => { if (!cancelled) setError(true); }); + return () => { cancelled = true; }; + }, []); + + const getIcon = (window as unknown as { openswarm?: { getAppIcon?: (n: string) => Promise } }).openswarm?.getAppIcon; + useEffect(() => { + if (!apps || !getIcon) return; + let cancelled = false; + (async () => { + for (const name of apps.slice(0, 60)) { + if (cancelled) return; + try { + const dataUrl = await getIcon(name); + if (cancelled) return; + if (dataUrl) setIcons((prev) => (prev[name] ? prev : { ...prev, [name]: dataUrl })); + } catch { + /* icon-less tile falls back to the letter */ + } + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apps]); + + const categories = useMemo(() => { + if (!apps) return []; + const present = new Set(apps.map(categorize)); + return ['All', ...CATEGORY_RULES.map((r) => r.label).filter((l) => present.has(l)), ...(present.has('Other') ? ['Other'] : [])]; + }, [apps]); + + const visible = useMemo(() => { + if (!apps) return []; + return category === 'All' ? apps : apps.filter((a) => categorize(a) === category); + }, [apps, category]); + + const openApp = (window as unknown as { openswarm?: { openApplication?: (n: string) => Promise } }).openswarm?.openApplication; + + return ( + <> + + + + 🐙 + + Applications + + + + {categories.length > 1 && ( + + {categories.map((cat) => ( + setCategory(cat)} + sx={{ + px: 1.25, + py: 0.4, + borderRadius: 999, + flexShrink: 0, + cursor: 'pointer', + fontSize: '0.72rem', + fontWeight: 500, + color: category === cat ? '#fff' : 'rgba(255,255,255,0.6)', + background: category === cat ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', + '&:hover': { background: 'rgba(255,255,255,0.16)' }, + }} + > + {cat} + + ))} + + )} + + + {!apps && !error && ( + + + + )} + {error && ( + + Could not read /Applications. + + )} + {apps && ( + + {visible.map((name) => ( + { if (openApp) void openApp(name); }} + title={name} + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 0.75, + py: 0.75, + borderRadius: '10px', + cursor: openApp ? 'pointer' : 'default', + '&:hover': openApp ? { background: 'rgba(255,255,255,0.08)' } : undefined, + }} + > + {icons[name] ? ( + + ) : ( + + )} + + {name} + + + ))} + + )} + + + + ); +} + +export default ApplicationsWindow; diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index f4e90965..d720d29d 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -4,6 +4,7 @@ import Typography from '@mui/material/Typography'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import LanguageIcon from '@mui/icons-material/Language'; import SettingsIcon from '@mui/icons-material/Settings'; +import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; import EditNoteIcon from '@mui/icons-material/EditNote'; import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; import CoPresentIcon from '@mui/icons-material/CoPresent'; @@ -50,6 +51,7 @@ interface DesktopDockProps { outputs: Record; selectedIds: string[]; onFocusCard: (id: string, rect: CardRect) => void; + onApplications: () => void; } const TILE = 30; @@ -66,6 +68,7 @@ function DesktopDock({ outputs, selectedIds, onFocusCard, + onApplications, }: DesktopDockProps): React.ReactElement | null { const dispatch = useAppDispatch(); const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null); @@ -158,8 +161,6 @@ function DesktopDock({ setLiveShot(null); }, []); - if (entries.length === 0) return null; - const hoveredEntry = hovered ? entries.find((e) => e.id === hovered.id) : undefined; const previewImage = hoveredEntry ? (liveShot?.id === hoveredEntry.id ? liveShot.dataUrl : hoveredEntry.thumbnail || undefined) @@ -226,7 +227,9 @@ function DesktopDock({ ); })} - + {entries.length > 0 && ( + + )} dispatch(openSettingsModal(undefined))} onMouseEnter={endHover} @@ -246,6 +249,25 @@ function DesktopDock({ > + + + {hoveredEntry && ( Date: Sun, 19 Jul 2026 21:45:04 -0700 Subject: [PATCH 10/26] [eric] tests: app_deps_changed test attaches a preview runtime (broadcast gates on runtime, not mode, post-merge) --- backend/tests/test_tool_result_hook.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_tool_result_hook.py b/backend/tests/test_tool_result_hook.py index e5d52398..7175f9d5 100644 --- a/backend/tests/test_tool_result_hook.py +++ b/backend/tests/test_tool_result_hook.py @@ -50,11 +50,16 @@ async def test_view_builder_dep_install_broadcasts_app_deps_changed(): registry: dict = {} ctx = p_ctx(registry) ctx.session.mode = "view-builder" - with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send: + # The broadcast gates on an attached preview runtime (not the mode), so agent-mode CreateApp builds get it too. + fake_runtime_manager = MagicMock() + fake_runtime_manager.get.return_value = object() + with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send, \ + patch("backend.apps.outputs.runtime.manager", fake_runtime_manager): await tool_result_hook.post_tool_hook( ctx, {"tool_name": "Bash", "tool_response": "added 3 packages", "tool_input": {"command": "npm install recharts"}}, "tu1", None ) + view_builder_state.view_builder_dirty_sessions.discard(ctx.session_id) events = [c.args[1] for c in send.await_args_list] assert "agent:app_deps_changed" in events From 27ff21176c04427b30c0cd881b7a03bde06aecd9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 22:02:08 -0700 Subject: [PATCH 11/26] [eric] agents: collapsed pill pins the session's artifact (latest ShowUI widget, else live spawned-browser shot) under the narrator pill --- .../AgentChat/tool-ui/ShowUiWidgetView.tsx | 17 ++++++++++ .../pages/AgentChat/tool-ui/ToolUiBubble.tsx | 10 ++---- .../pages/AgentChat/tool-ui/showUiPayload.ts | 18 +++++++++- .../app/pages/Dashboard/cards/AgentCard.tsx | 34 +++++++++++++++++++ .../Dashboard/desktop/AgentNarratorPill.tsx | 19 +++++++++-- 5 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx new file mode 100644 index 00000000..e62f0b02 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import WeatherWidget from './WeatherWidget'; +import PlanWidget from './PlanWidget'; +import StatsWidget from './StatsWidget'; +import LinksWidget from './LinksWidget'; +import type { ShowUiPayload } from './showUiPayload'; + +/** One switch for every surface that renders a ShowUI payload (chat bubble, pill artifact). */ +function ShowUiWidgetView({ payload }: { payload: ShowUiPayload }): React.ReactElement | null { + if (payload.component === 'weather') return ; + if (payload.component === 'plan') return ; + if (payload.component === 'stats') return ; + if (payload.component === 'links') return ; + return null; +} + +export default ShowUiWidgetView; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx index c8567566..292b55aa 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx @@ -3,10 +3,7 @@ import Box from '@mui/material/Box'; import ToolCallBubble from '../tool-bubbles/ToolCallBubble'; import type { ToolPair } from '../tool-bubbles/ToolCallBubble'; import { parseShowUiPayload } from './showUiPayload'; -import WeatherWidget from './WeatherWidget'; -import PlanWidget from './PlanWidget'; -import StatsWidget from './StatsWidget'; -import LinksWidget from './LinksWidget'; +import ShowUiWidgetView from './ShowUiWidgetView'; interface ToolUiBubbleProps { pair: ToolPair; @@ -25,10 +22,7 @@ function ToolUiBubble({ pair, sessionId, isPending, suppressReveal }: ToolUiBubb } return ( - {payload.component === 'weather' && } - {payload.component === 'plan' && } - {payload.component === 'stats' && } - {payload.component === 'links' && } + ); } diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts index 4f13fe1a..21f962ee 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -68,10 +68,26 @@ export function isShowUiPair(pair: ToolPair): boolean { return /(^|__)ShowUI$/.test(tool); } +/** Latest ShowUI payload anywhere in a transcript; the collapsed card pins this artifact under its pill. */ +export function extractLatestShowUi(messages: Array<{ role: string; content: any }>): ShowUiPayload | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== 'tool_call') continue; + const tool = typeof msg.content === 'object' ? String(msg.content?.tool || '') : ''; + if (!/(^|__)ShowUI$/.test(tool)) continue; + const parsed = parseShowUiInput(msg.content?.input); + if (parsed) return parsed; + } + return null; +} + /** Strict parse of a ShowUI tool_call's input; null on any mismatch so the caller falls back to the plain bubble. */ export function parseShowUiPayload(pair: ToolPair): ShowUiPayload | null { const content = typeof pair.call.content === 'object' ? pair.call.content : null; - const input = content?.input; + return parseShowUiInput(content?.input); +} + +function parseShowUiInput(input: unknown): ShowUiPayload | null { if (!input || typeof input !== 'object') return null; const component = String((input as { component?: unknown }).component || ''); const props = (input as { props?: unknown }).props; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index e0a0a1e9..41042f74 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -37,6 +37,8 @@ import WindowControls from './WindowControls'; import { useTiledStyle } from './tileZones'; import AgentNarratorPill from '../desktop/AgentNarratorPill'; import { extractLatestTodos } from '../desktop/agentTodos'; +import { extractLatestShowUi } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; +import { getWebview } from '@/shared/browserRegistry'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; @@ -691,10 +693,40 @@ const AgentCard: React.FC = ({ // Desktop-shell narrator pill: a collapsed card with nothing to ask renders as the minimal pill // (live turn label + plan checklist); approvals and drafts keep the full card so their UI has a home. const todos = useMemo(() => extractLatestTodos(session.messages || []), [session.messages]); + const pillArtifact = useMemo(() => extractLatestShowUi(session.messages || []), [session.messages]); const pillMode = !expanded && !hasPending && !isDraft && !tileZone; const pillLabel = session.turn_label?.label || displayChatTitle(session); const pillRunning = session.status === 'running'; + // f7's collapsed state: a session that spawned a browser shows that window under the pill. + const spawnedBrowserId = useAppSelector((s) => { + for (const bc of Object.values(s.dashboardLayout.browserCards)) { + if (bc.spawned_by === session.id) return bc.browser_id; + } + return null; + }); + const [browserShot, setBrowserShot] = useState(null); + useEffect(() => { + if (!pillMode || pillArtifact || !spawnedBrowserId) { + setBrowserShot(null); + return undefined; + } + let cancelled = false; + const capture = (): void => { + const wv = getWebview(spawnedBrowserId); + const p = wv?.capturePage?.(); + if (p && typeof (p as Promise).then === 'function') { + (p as Promise<{ toDataURL(): string }>) + .then((img) => { if (!cancelled) setBrowserShot(img.toDataURL()); }) + .catch(() => undefined); + } + }; + capture(); + // Refresh while the agent is driving so the shot tracks the page; parked cards keep the last frame. + const timer = pillRunning ? window.setInterval(capture, 5000) : null; + return () => { cancelled = true; if (timer) window.clearInterval(timer); }; + }, [pillMode, pillArtifact, spawnedBrowserId, pillRunning]); + const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; @@ -926,6 +958,8 @@ const AgentCard: React.FC = ({ label={pillLabel} running={pillRunning} todos={todos} + artifact={pillArtifact} + browserShot={browserShot} selected={isSelected} highlighted={isHighlighted} /> diff --git a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx index 6f1d0d59..bf5d8302 100644 --- a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx @@ -3,12 +3,16 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CheckIcon from '@mui/icons-material/Check'; import DashboardGlyph from '../canvas/DashboardGlyph'; +import ShowUiWidgetView from '@/app/pages/AgentChat/tool-ui/ShowUiWidgetView'; +import type { ShowUiPayload } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import type { AgentTodoItem } from './agentTodos'; interface AgentNarratorPillProps { label: string; running: boolean; todos: AgentTodoItem[] | null; + artifact: ShowUiPayload | null; + browserShot: string | null; selected: boolean; highlighted: boolean; } @@ -17,8 +21,8 @@ const GLASS = 'rgba(24,14,32,0.8)'; const GLASS_BLUR = 'blur(18px) saturate(150%)'; const MAX_VISIBLE_TODOS = 4; -/** Collapsed running agent as the desktop narrator pill, with its live plan hanging below. */ -function AgentNarratorPill({ label, running, todos, selected, highlighted }: AgentNarratorPillProps): React.ReactElement { +/** Collapsed agent as the desktop narrator pill; below it, the best artifact wins: widget > browser shot > plan > Thinking. */ +function AgentNarratorPill({ label, running, todos, artifact, browserShot, selected, highlighted }: AgentNarratorPillProps): React.ReactElement { const visibleTodos = (todos || []).slice(0, MAX_VISIBLE_TODOS); const hiddenCount = (todos?.length || 0) - visibleTodos.length; const ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined; @@ -48,7 +52,16 @@ function AgentNarratorPill({ label, running, todos, selected, highlighted }: Age - {visibleTodos.length > 0 ? ( + {artifact ? ( + + ) : browserShot ? ( + + ) : visibleTodos.length > 0 ? ( Date: Sun, 19 Jul 2026 22:31:00 -0700 Subject: [PATCH 12/26] [eric] chat: vendor tool-ui component library (21 components, MIT) behind ShowUI; scoped Tailwind v4 utilities, upstream zod contracts, React 18 ports --- backend/apps/agents/show_ui_mcp_server.py | 24 + frontend/package-lock.json | 2841 ++++++++++++++++- frontend/package.json | 14 +- .../AgentChat/tool-ui/ShowUiWidgetView.tsx | 2 + .../pages/AgentChat/tool-ui/showUiPayload.ts | 12 +- frontend/src/toolui/LICENSE.md | 21 + frontend/src/toolui/VendoredToolUi.tsx | 59 + .../toolui/components/approval-card/README.md | 19 + .../components/approval-card/_adapter.tsx | 11 + .../approval-card/approval-card.tsx | 212 ++ .../toolui/components/approval-card/index.tsx | 7 + .../toolui/components/approval-card/schema.ts | 54 + .../src/toolui/components/citation/README.md | 19 + .../toolui/components/citation/_adapter.tsx | 18 + .../components/citation/citation-list.tsx | 460 +++ .../toolui/components/citation/citation.tsx | 259 ++ .../src/toolui/components/citation/index.ts | 9 + .../src/toolui/components/citation/schema.ts | 52 + .../toolui/components/data-table/README.md | 19 + .../toolui/components/data-table/_adapter.tsx | 44 + .../components/data-table/data-table.tsx | 936 ++++++ .../components/data-table/formatters.tsx | 473 +++ .../toolui/components/data-table/index.tsx | 29 + .../toolui/components/data-table/schema.ts | 345 ++ .../src/toolui/components/data-table/types.ts | 262 ++ .../toolui/components/data-table/utilities.ts | 299 ++ .../toolui/components/image-gallery/README.md | 19 + .../components/image-gallery/_adapter.tsx | 13 + .../components/image-gallery/context.tsx | 184 ++ .../components/image-gallery/gallery-grid.tsx | 133 + .../image-gallery/gallery-lightbox.tsx | 146 + .../image-gallery/image-gallery.tsx | 75 + .../toolui/components/image-gallery/index.tsx | 6 + .../toolui/components/image-gallery/schema.ts | 59 + .../components/image-gallery/styles.css | 25 + .../src/toolui/components/image/README.md | 19 + .../src/toolui/components/image/_adapter.tsx | 11 + .../src/toolui/components/image/image.tsx | 206 ++ frontend/src/toolui/components/image/index.ts | 3 + .../src/toolui/components/image/schema.ts | 50 + .../components/instagram-post/README.md | 19 + .../components/instagram-post/_adapter.tsx | 19 + .../toolui/components/instagram-post/index.ts | 8 + .../instagram-post/instagram-post.tsx | 305 ++ .../components/instagram-post/schema.ts | 57 + .../toolui/components/item-carousel/README.md | 19 + .../components/item-carousel/_adapter.tsx | 12 + .../toolui/components/item-carousel/index.tsx | 8 + .../components/item-carousel/item-card.tsx | 110 + .../item-carousel/item-carousel.tsx | 404 +++ .../toolui/components/item-carousel/schema.ts | 77 + .../toolui/components/link-preview/README.md | 19 + .../components/link-preview/_adapter.tsx | 6 + .../toolui/components/link-preview/index.ts | 3 + .../components/link-preview/link-preview.tsx | 141 + .../toolui/components/link-preview/schema.ts | 43 + .../toolui/components/linkedin-post/README.md | 19 + .../components/linkedin-post/_adapter.tsx | 19 + .../toolui/components/linkedin-post/index.ts | 9 + .../linkedin-post/linkedin-post.tsx | 283 ++ .../toolui/components/linkedin-post/schema.ts | 59 + .../toolui/components/message-draft/README.md | 19 + .../components/message-draft/_adapter.tsx | 12 + .../toolui/components/message-draft/index.tsx | 10 + .../message-draft/message-draft.tsx | 511 +++ .../toolui/components/message-draft/schema.ts | 83 + .../toolui/components/option-list/README.md | 19 + .../components/option-list/_adapter.tsx | 14 + .../toolui/components/option-list/index.tsx | 7 + .../components/option-list/option-list.tsx | 625 ++++ .../toolui/components/option-list/schema.ts | 210 ++ .../components/option-list/selection.ts | 35 + .../toolui/components/order-summary/README.md | 19 + .../components/order-summary/_adapter.tsx | 16 + .../toolui/components/order-summary/index.tsx | 14 + .../order-summary/order-summary.tsx | 296 ++ .../toolui/components/order-summary/schema.ts | 108 + .../components/parameter-slider/README.md | 19 + .../components/parameter-slider/_adapter.tsx | 16 + .../components/parameter-slider/index.tsx | 7 + .../components/parameter-slider/math.ts | 42 + .../parameter-slider/parameter-slider.tsx | 821 +++++ .../components/parameter-slider/schema.ts | 114 + frontend/src/toolui/components/plan/README.md | 19 + .../src/toolui/components/plan/_adapter.tsx | 32 + frontend/src/toolui/components/plan/index.tsx | 7 + frontend/src/toolui/components/plan/plan.tsx | 428 +++ .../src/toolui/components/plan/progress.ts | 29 + frontend/src/toolui/components/plan/schema.ts | 69 + .../components/preferences-panel/README.md | 19 + .../components/preferences-panel/_adapter.tsx | 28 + .../components/preferences-panel/index.tsx | 10 + .../preferences-panel/preferences-panel.tsx | 681 ++++ .../components/preferences-panel/schema.ts | 144 + .../components/preferences-panel/signature.ts | 37 + .../components/progress-tracker/README.md | 19 + .../components/progress-tracker/_adapter.tsx | 10 + .../components/progress-tracker/index.tsx | 7 + .../progress-tracker/progress-tracker.tsx | 381 +++ .../components/progress-tracker/schema.ts | 76 + .../toolui/components/question-flow/README.md | 19 + .../components/question-flow/_adapter.tsx | 14 + .../toolui/components/question-flow/index.tsx | 15 + .../question-flow/question-flow.tsx | 793 +++++ .../toolui/components/question-flow/schema.ts | 131 + .../src/toolui/components/shared/README.md | 19 + .../src/toolui/components/shared/_adapter.tsx | 12 + .../components/shared/action-buttons.tsx | 100 + .../components/shared/actions-config.ts | 48 + .../src/toolui/components/shared/contract.ts | 19 + .../components/shared/decision-actions.tsx | 87 + .../components/shared/embedded-actions.ts | 17 + .../src/toolui/components/shared/index.ts | 27 + .../components/shared/local-actions.tsx | 54 + .../components/shared/media/aspect-ratio.ts | 27 + .../components/shared/media/format-utils.ts | 35 + .../toolui/components/shared/media/index.ts | 19 + .../shared/media/overlay-gradient.ts | 25 + .../shared/media/safe-navigation.ts | 23 + .../components/shared/media/sanitize-href.ts | 36 + .../src/toolui/components/shared/parse.ts | 51 + .../components/shared/pierre-dark-theme.js | 1374 ++++++++ .../components/shared/pierre-light-theme.js | 1374 ++++++++ .../src/toolui/components/shared/schema.ts | 159 + .../components/shared/tool-ui-context.tsx | 27 + .../src/toolui/components/shared/tool-ui.tsx | 89 + .../src/toolui/components/shared/toolkit.tsx | 20 + .../components/shared/use-action-buttons.tsx | 153 + .../shared/use-controllable-state.ts | 54 + .../shared/use-copy-to-clipboard.ts | 59 + .../components/shared/use-signature-reset.ts | 16 + .../src/toolui/components/shared/utils.ts | 29 + .../toolui/components/stats-display/README.md | 19 + .../components/stats-display/_adapter.tsx | 18 + .../toolui/components/stats-display/index.tsx | 10 + .../toolui/components/stats-display/schema.ts | 91 + .../components/stats-display/sparkline.tsx | 128 + .../stats-display/stats-display.tsx | 281 ++ .../src/toolui/components/terminal/README.md | 19 + .../toolui/components/terminal/_adapter.tsx | 14 + .../src/toolui/components/terminal/index.tsx | 2 + .../src/toolui/components/terminal/schema.ts | 43 + .../toolui/components/terminal/terminal.tsx | 283 ++ .../src/toolui/components/video/README.md | 19 + .../src/toolui/components/video/_adapter.tsx | 7 + .../src/toolui/components/video/context.tsx | 53 + frontend/src/toolui/components/video/index.ts | 3 + .../src/toolui/components/video/schema.ts | 50 + .../toolui/components/video/video-helpers.ts | 57 + .../src/toolui/components/video/video.tsx | 283 ++ .../weather-runtime-core.generated.ts | 67 + .../components/weather-widget/runtime.ts | 18 + .../weather-widget/schema-runtime.ts | 72 + .../weather-widget/weather-data-overlay.tsx | 586 ++++ .../weather-widget-container.tsx | 144 + .../src/toolui/components/x-post/README.md | 19 + .../src/toolui/components/x-post/_adapter.tsx | 19 + .../src/toolui/components/x-post/index.ts | 9 + .../src/toolui/components/x-post/schema.ts | 68 + .../src/toolui/components/x-post/x-post.tsx | 348 ++ frontend/src/toolui/lib/utils.ts | 6 + frontend/src/toolui/registry.tsx | 102 + frontend/src/toolui/toolui.css | 141 + frontend/src/toolui/ui/accordion.tsx | 76 + frontend/src/toolui/ui/alert.tsx | 68 + frontend/src/toolui/ui/avatar.tsx | 53 + frontend/src/toolui/ui/badge.tsx | 48 + frontend/src/toolui/ui/button-group.tsx | 83 + frontend/src/toolui/ui/button.tsx | 63 + frontend/src/toolui/ui/card.tsx | 92 + frontend/src/toolui/ui/collapsible.tsx | 39 + frontend/src/toolui/ui/dialog.tsx | 143 + frontend/src/toolui/ui/dropdown-menu.tsx | 257 ++ frontend/src/toolui/ui/input-group.tsx | 170 + frontend/src/toolui/ui/input.tsx | 21 + frontend/src/toolui/ui/item.tsx | 193 ++ frontend/src/toolui/ui/label.tsx | 24 + frontend/src/toolui/ui/popover.tsx | 48 + frontend/src/toolui/ui/radio-group.tsx | 45 + frontend/src/toolui/ui/select.tsx | 187 ++ frontend/src/toolui/ui/separator.tsx | 28 + frontend/src/toolui/ui/sheet.tsx | 139 + frontend/src/toolui/ui/skeleton.tsx | 13 + frontend/src/toolui/ui/slider.tsx | 63 + frontend/src/toolui/ui/switch.tsx | 31 + frontend/src/toolui/ui/table.tsx | 81 + frontend/src/toolui/ui/tabs.tsx | 66 + frontend/src/toolui/ui/textarea.tsx | 18 + frontend/src/toolui/ui/toggle-group.tsx | 83 + frontend/src/toolui/ui/toggle.tsx | 47 + frontend/src/toolui/ui/tooltip.tsx | 61 + frontend/tsconfig.json | 3 +- frontend/webpack.config.js | 12 +- 193 files changed, 24236 insertions(+), 24 deletions(-) create mode 100644 frontend/src/toolui/LICENSE.md create mode 100644 frontend/src/toolui/VendoredToolUi.tsx create mode 100644 frontend/src/toolui/components/approval-card/README.md create mode 100644 frontend/src/toolui/components/approval-card/_adapter.tsx create mode 100644 frontend/src/toolui/components/approval-card/approval-card.tsx create mode 100644 frontend/src/toolui/components/approval-card/index.tsx create mode 100644 frontend/src/toolui/components/approval-card/schema.ts create mode 100644 frontend/src/toolui/components/citation/README.md create mode 100644 frontend/src/toolui/components/citation/_adapter.tsx create mode 100644 frontend/src/toolui/components/citation/citation-list.tsx create mode 100644 frontend/src/toolui/components/citation/citation.tsx create mode 100644 frontend/src/toolui/components/citation/index.ts create mode 100644 frontend/src/toolui/components/citation/schema.ts create mode 100644 frontend/src/toolui/components/data-table/README.md create mode 100644 frontend/src/toolui/components/data-table/_adapter.tsx create mode 100644 frontend/src/toolui/components/data-table/data-table.tsx create mode 100644 frontend/src/toolui/components/data-table/formatters.tsx create mode 100644 frontend/src/toolui/components/data-table/index.tsx create mode 100644 frontend/src/toolui/components/data-table/schema.ts create mode 100644 frontend/src/toolui/components/data-table/types.ts create mode 100644 frontend/src/toolui/components/data-table/utilities.ts create mode 100644 frontend/src/toolui/components/image-gallery/README.md create mode 100644 frontend/src/toolui/components/image-gallery/_adapter.tsx create mode 100644 frontend/src/toolui/components/image-gallery/context.tsx create mode 100644 frontend/src/toolui/components/image-gallery/gallery-grid.tsx create mode 100644 frontend/src/toolui/components/image-gallery/gallery-lightbox.tsx create mode 100644 frontend/src/toolui/components/image-gallery/image-gallery.tsx create mode 100644 frontend/src/toolui/components/image-gallery/index.tsx create mode 100644 frontend/src/toolui/components/image-gallery/schema.ts create mode 100644 frontend/src/toolui/components/image-gallery/styles.css create mode 100644 frontend/src/toolui/components/image/README.md create mode 100644 frontend/src/toolui/components/image/_adapter.tsx create mode 100644 frontend/src/toolui/components/image/image.tsx create mode 100644 frontend/src/toolui/components/image/index.ts create mode 100644 frontend/src/toolui/components/image/schema.ts create mode 100644 frontend/src/toolui/components/instagram-post/README.md create mode 100644 frontend/src/toolui/components/instagram-post/_adapter.tsx create mode 100644 frontend/src/toolui/components/instagram-post/index.ts create mode 100644 frontend/src/toolui/components/instagram-post/instagram-post.tsx create mode 100644 frontend/src/toolui/components/instagram-post/schema.ts create mode 100644 frontend/src/toolui/components/item-carousel/README.md create mode 100644 frontend/src/toolui/components/item-carousel/_adapter.tsx create mode 100644 frontend/src/toolui/components/item-carousel/index.tsx create mode 100644 frontend/src/toolui/components/item-carousel/item-card.tsx create mode 100644 frontend/src/toolui/components/item-carousel/item-carousel.tsx create mode 100644 frontend/src/toolui/components/item-carousel/schema.ts create mode 100644 frontend/src/toolui/components/link-preview/README.md create mode 100644 frontend/src/toolui/components/link-preview/_adapter.tsx create mode 100644 frontend/src/toolui/components/link-preview/index.ts create mode 100644 frontend/src/toolui/components/link-preview/link-preview.tsx create mode 100644 frontend/src/toolui/components/link-preview/schema.ts create mode 100644 frontend/src/toolui/components/linkedin-post/README.md create mode 100644 frontend/src/toolui/components/linkedin-post/_adapter.tsx create mode 100644 frontend/src/toolui/components/linkedin-post/index.ts create mode 100644 frontend/src/toolui/components/linkedin-post/linkedin-post.tsx create mode 100644 frontend/src/toolui/components/linkedin-post/schema.ts create mode 100644 frontend/src/toolui/components/message-draft/README.md create mode 100644 frontend/src/toolui/components/message-draft/_adapter.tsx create mode 100644 frontend/src/toolui/components/message-draft/index.tsx create mode 100644 frontend/src/toolui/components/message-draft/message-draft.tsx create mode 100644 frontend/src/toolui/components/message-draft/schema.ts create mode 100644 frontend/src/toolui/components/option-list/README.md create mode 100644 frontend/src/toolui/components/option-list/_adapter.tsx create mode 100644 frontend/src/toolui/components/option-list/index.tsx create mode 100644 frontend/src/toolui/components/option-list/option-list.tsx create mode 100644 frontend/src/toolui/components/option-list/schema.ts create mode 100644 frontend/src/toolui/components/option-list/selection.ts create mode 100644 frontend/src/toolui/components/order-summary/README.md create mode 100644 frontend/src/toolui/components/order-summary/_adapter.tsx create mode 100644 frontend/src/toolui/components/order-summary/index.tsx create mode 100644 frontend/src/toolui/components/order-summary/order-summary.tsx create mode 100644 frontend/src/toolui/components/order-summary/schema.ts create mode 100644 frontend/src/toolui/components/parameter-slider/README.md create mode 100644 frontend/src/toolui/components/parameter-slider/_adapter.tsx create mode 100644 frontend/src/toolui/components/parameter-slider/index.tsx create mode 100644 frontend/src/toolui/components/parameter-slider/math.ts create mode 100644 frontend/src/toolui/components/parameter-slider/parameter-slider.tsx create mode 100644 frontend/src/toolui/components/parameter-slider/schema.ts create mode 100644 frontend/src/toolui/components/plan/README.md create mode 100644 frontend/src/toolui/components/plan/_adapter.tsx create mode 100644 frontend/src/toolui/components/plan/index.tsx create mode 100644 frontend/src/toolui/components/plan/plan.tsx create mode 100644 frontend/src/toolui/components/plan/progress.ts create mode 100644 frontend/src/toolui/components/plan/schema.ts create mode 100644 frontend/src/toolui/components/preferences-panel/README.md create mode 100644 frontend/src/toolui/components/preferences-panel/_adapter.tsx create mode 100644 frontend/src/toolui/components/preferences-panel/index.tsx create mode 100644 frontend/src/toolui/components/preferences-panel/preferences-panel.tsx create mode 100644 frontend/src/toolui/components/preferences-panel/schema.ts create mode 100644 frontend/src/toolui/components/preferences-panel/signature.ts create mode 100644 frontend/src/toolui/components/progress-tracker/README.md create mode 100644 frontend/src/toolui/components/progress-tracker/_adapter.tsx create mode 100644 frontend/src/toolui/components/progress-tracker/index.tsx create mode 100644 frontend/src/toolui/components/progress-tracker/progress-tracker.tsx create mode 100644 frontend/src/toolui/components/progress-tracker/schema.ts create mode 100644 frontend/src/toolui/components/question-flow/README.md create mode 100644 frontend/src/toolui/components/question-flow/_adapter.tsx create mode 100644 frontend/src/toolui/components/question-flow/index.tsx create mode 100644 frontend/src/toolui/components/question-flow/question-flow.tsx create mode 100644 frontend/src/toolui/components/question-flow/schema.ts create mode 100644 frontend/src/toolui/components/shared/README.md create mode 100644 frontend/src/toolui/components/shared/_adapter.tsx create mode 100644 frontend/src/toolui/components/shared/action-buttons.tsx create mode 100644 frontend/src/toolui/components/shared/actions-config.ts create mode 100644 frontend/src/toolui/components/shared/contract.ts create mode 100644 frontend/src/toolui/components/shared/decision-actions.tsx create mode 100644 frontend/src/toolui/components/shared/embedded-actions.ts create mode 100644 frontend/src/toolui/components/shared/index.ts create mode 100644 frontend/src/toolui/components/shared/local-actions.tsx create mode 100644 frontend/src/toolui/components/shared/media/aspect-ratio.ts create mode 100644 frontend/src/toolui/components/shared/media/format-utils.ts create mode 100644 frontend/src/toolui/components/shared/media/index.ts create mode 100644 frontend/src/toolui/components/shared/media/overlay-gradient.ts create mode 100644 frontend/src/toolui/components/shared/media/safe-navigation.ts create mode 100644 frontend/src/toolui/components/shared/media/sanitize-href.ts create mode 100644 frontend/src/toolui/components/shared/parse.ts create mode 100644 frontend/src/toolui/components/shared/pierre-dark-theme.js create mode 100644 frontend/src/toolui/components/shared/pierre-light-theme.js create mode 100644 frontend/src/toolui/components/shared/schema.ts create mode 100644 frontend/src/toolui/components/shared/tool-ui-context.tsx create mode 100644 frontend/src/toolui/components/shared/tool-ui.tsx create mode 100644 frontend/src/toolui/components/shared/toolkit.tsx create mode 100644 frontend/src/toolui/components/shared/use-action-buttons.tsx create mode 100644 frontend/src/toolui/components/shared/use-controllable-state.ts create mode 100644 frontend/src/toolui/components/shared/use-copy-to-clipboard.ts create mode 100644 frontend/src/toolui/components/shared/use-signature-reset.ts create mode 100644 frontend/src/toolui/components/shared/utils.ts create mode 100644 frontend/src/toolui/components/stats-display/README.md create mode 100644 frontend/src/toolui/components/stats-display/_adapter.tsx create mode 100644 frontend/src/toolui/components/stats-display/index.tsx create mode 100644 frontend/src/toolui/components/stats-display/schema.ts create mode 100644 frontend/src/toolui/components/stats-display/sparkline.tsx create mode 100644 frontend/src/toolui/components/stats-display/stats-display.tsx create mode 100644 frontend/src/toolui/components/terminal/README.md create mode 100644 frontend/src/toolui/components/terminal/_adapter.tsx create mode 100644 frontend/src/toolui/components/terminal/index.tsx create mode 100644 frontend/src/toolui/components/terminal/schema.ts create mode 100644 frontend/src/toolui/components/terminal/terminal.tsx create mode 100644 frontend/src/toolui/components/video/README.md create mode 100644 frontend/src/toolui/components/video/_adapter.tsx create mode 100644 frontend/src/toolui/components/video/context.tsx create mode 100644 frontend/src/toolui/components/video/index.ts create mode 100644 frontend/src/toolui/components/video/schema.ts create mode 100644 frontend/src/toolui/components/video/video-helpers.ts create mode 100644 frontend/src/toolui/components/video/video.tsx create mode 100644 frontend/src/toolui/components/weather-widget/generated/weather-runtime-core.generated.ts create mode 100644 frontend/src/toolui/components/weather-widget/runtime.ts create mode 100644 frontend/src/toolui/components/weather-widget/schema-runtime.ts create mode 100644 frontend/src/toolui/components/weather-widget/weather-data-overlay.tsx create mode 100644 frontend/src/toolui/components/weather-widget/weather-widget-container.tsx create mode 100644 frontend/src/toolui/components/x-post/README.md create mode 100644 frontend/src/toolui/components/x-post/_adapter.tsx create mode 100644 frontend/src/toolui/components/x-post/index.ts create mode 100644 frontend/src/toolui/components/x-post/schema.ts create mode 100644 frontend/src/toolui/components/x-post/x-post.tsx create mode 100644 frontend/src/toolui/lib/utils.ts create mode 100644 frontend/src/toolui/registry.tsx create mode 100644 frontend/src/toolui/toolui.css create mode 100644 frontend/src/toolui/ui/accordion.tsx create mode 100644 frontend/src/toolui/ui/alert.tsx create mode 100644 frontend/src/toolui/ui/avatar.tsx create mode 100644 frontend/src/toolui/ui/badge.tsx create mode 100644 frontend/src/toolui/ui/button-group.tsx create mode 100644 frontend/src/toolui/ui/button.tsx create mode 100644 frontend/src/toolui/ui/card.tsx create mode 100644 frontend/src/toolui/ui/collapsible.tsx create mode 100644 frontend/src/toolui/ui/dialog.tsx create mode 100644 frontend/src/toolui/ui/dropdown-menu.tsx create mode 100644 frontend/src/toolui/ui/input-group.tsx create mode 100644 frontend/src/toolui/ui/input.tsx create mode 100644 frontend/src/toolui/ui/item.tsx create mode 100644 frontend/src/toolui/ui/label.tsx create mode 100644 frontend/src/toolui/ui/popover.tsx create mode 100644 frontend/src/toolui/ui/radio-group.tsx create mode 100644 frontend/src/toolui/ui/select.tsx create mode 100644 frontend/src/toolui/ui/separator.tsx create mode 100644 frontend/src/toolui/ui/sheet.tsx create mode 100644 frontend/src/toolui/ui/skeleton.tsx create mode 100644 frontend/src/toolui/ui/slider.tsx create mode 100644 frontend/src/toolui/ui/switch.tsx create mode 100644 frontend/src/toolui/ui/table.tsx create mode 100644 frontend/src/toolui/ui/tabs.tsx create mode 100644 frontend/src/toolui/ui/textarea.tsx create mode 100644 frontend/src/toolui/ui/toggle-group.tsx create mode 100644 frontend/src/toolui/ui/toggle.tsx create mode 100644 frontend/src/toolui/ui/tooltip.tsx diff --git a/backend/apps/agents/show_ui_mcp_server.py b/backend/apps/agents/show_ui_mcp_server.py index dfc52719..e00504d3 100644 --- a/backend/apps/agents/show_ui_mcp_server.py +++ b/backend/apps/agents/show_ui_mcp_server.py @@ -16,6 +16,28 @@ COMPONENT_SPECS = { "plan": "props: {title?: str, steps: [{label: str, status: 'pending'|'in_progress'|'completed'}] (max 20)}", "stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}", "links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}", + # tool-ui vendored set: props follow the upstream Serializable contracts (https://tool-ui.com); + # the client validates strictly and shows a validation note instead of rendering on mismatch. + "data-table": "tabular results. props: {id: str, columns: [{key: str, label: str}], data: [{: str|number|bool}]}", + "citation": "sourced claims. props: {id: str, citations: [{id: str, title: str, url?: str, snippet?: str}]}", + "item-carousel": "browsable items. props: {id: str, items: [{id: str, title: str, description?: str, imageUrl?: str, badge?: str}]}", + "link-preview": "one rich link card. props: {id: str, url: str, title: str, description?: str, imageUrl?: str, siteName?: str}", + "progress-tracker": "multi-stage progress. props: {id: str, stages: [{id: str, label: str, status: 'pending'|'active'|'complete'|'error'}]}", + "order-summary": "purchase/receipt breakdown. props: {id: str, items: [{id: str, label: str, amount: number}], total?: number, currency?: str}", + "terminal": "command output. props: {id: str, command?: str, output: str}", + "image": "single image. props: {id: str, src: str, alt?: str, caption?: str}", + "image-gallery": "several images. props: {id: str, images: [{src: str, alt?: str}]}", + "video": "video embed. props: {id: str, src: str, poster?: str, title?: str}", + "message-draft": "email/message draft for review. props: {id: str, to?: [str], subject?: str, body: str}", + "x-post": "an X/Twitter post preview. props: {id: str, author: {name: str, handle: str}, text: str}", + "linkedin-post": "a LinkedIn post preview. props: {id: str, author: {name: str, headline?: str}, text: str}", + "instagram-post": "an Instagram post preview. props: {id: str, username: str, imageUrl: str, caption?: str}", + "option-list": "choices for the user (display for now). props: {id: str, options: [{id: str, label: str, description?: str}], selectionMode?: 'single'|'multi'}", + "question-flow": "step-by-step question sequence (display for now). props follow the upstream question-flow contract", + "parameter-slider": "adjustable parameters (display for now). props: {id: str, parameters: [{id: str, label: str, min: number, max: number, value: number, step?: number}]}", + "preferences-panel": "grouped preference toggles (display for now). props follow the upstream preferences-panel contract", + "approval-card": "an approve/reject summary card. props follow the upstream approval-card contract", + "stats-display": "upstream stats-display contract (prefer 'stats' unless you need its exact shape)", } TOOLS = [ @@ -74,6 +96,8 @@ def validate(component: str, props: dict) -> str: return f"stats needs a non-empty stats list. {COMPONENT_SPECS['stats']}" if component == "links" and not (isinstance(props.get("links"), list) and props["links"]): return f"links needs a non-empty links list. {COMPONENT_SPECS['links']}" + # Vendored tool-ui components validate deeply client-side against their zod contracts; here we + # only shape-check so a wrong payload comes back as a teaching error instead of a dead render. return "" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92d511d7..33a69a28 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,23 +20,31 @@ "@mui/material": "^7.3.9", "@reduxjs/toolkit": "^2.8.2", "@types/react-syntax-highlighter": "^15.5.13", + "ansi-to-react": "^6.2.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "codemirror": "^6.0.2", "framer-motion": "^12.35.2", "html-to-image": "^1.11.13", "lucide-react": "^1.17.0", + "radix-ui": "^1.6.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "react-syntax-highlighter": "^16.1.1", - "remark-gfm": "^4.0.1" + "recharts": "^3.9.2", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", + "@tailwindcss/postcss": "^4.3.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/react-redux": "^7.1.34", @@ -45,15 +53,32 @@ "css-loader": "^6.8.0", "css-modules-types-loader": "^0.6.10", "html-webpack-plugin": "^5.5.0", + "postcss": "^8.5.20", + "postcss-loader": "^8.2.1", "sass": "^1.89.2", "sass-loader": "^16.0.5", "style-loader": "^3.3.0", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0", "typescript": "^5.0.0", "webpack": "^5.88.0", "webpack-cli": "^5.1.0", "webpack-dev-server": "^4.15.0" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2052,6 +2077,44 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3077,6 +3140,1502 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.6.tgz", + "integrity": "sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.12.tgz", + "integrity": "sha512-Y0zhCQ/XUdTom5hAxvE8RlXqR4hZmKGK6g2//LfgHmb88PJFOpXSh9B/7FlfYXezVY5FKGjRYWCYz5FXxZ9WZQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.17.tgz", + "integrity": "sha512-l3Dmp+qPPc3SqT8+SPnxIgoWBEU2MMBxcQ7BsoRgak2UT75xY83SFvFcrUkUAWukOV3LFF+BQ9aBIFtZsIG8yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collapsible": "1.1.17", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.20.tgz", + "integrity": "sha512-Ft1W+jPqSh5BKfSTe4dpq6UYQKKQJ5Tvq3wfux+WVlg7nPwFK/3pIlHTb3Rbe+b/tNurx8YGXD9em91ujmgwuQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dialog": "1.1.20", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.12.tgz", + "integrity": "sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.12.tgz", + "integrity": "sha512-Sok2IBJxA1XO4pU3ldzZMwUBMumIt64EY8zOUlVq5CdS+i0FrEbajVslfDB+YGWLMsrjY2kZQB0DgkrZXLZvcg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.3.tgz", + "integrity": "sha512-peavtnApRB1tABx42tHw+rPU83GSg5tXicMYO/Xi1/lqNcRsF6jkr6L7Njo7gj4q/xtDRDKBkqJvbMtoOMYWtA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.8.tgz", + "integrity": "sha512-wfN60IGuxynWK7rP4Ks2p7u9G7gqirzkAiFptuzVbsR1ot2/K+PavNUAtxiKxyRfLOvSbVfvvm9m3rFqLEXz7A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.17.tgz", + "integrity": "sha512-DJgqGsNXa0df3ifz9PFNgvgj/bzIu5QTVWCt5nQWaUkM6y0EarUv4QG4s6mCoeQdOIyVOT/Q1osFuEGub2TDXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", + "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", + "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.4.tgz", + "integrity": "sha512-eO9tkvHvo4dNwb+lytEcKWjy8c8To+ttLwNt0f9XzzsVFIaspqt3i1/c0JaaksxBB5G//zPo9CCgn39huWQyBA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-menu": "2.1.21", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.20.tgz", + "integrity": "sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.16.tgz", + "integrity": "sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-effect-event": "0.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.21.tgz", + "integrity": "sha512-gavFM1iWLmWdxWNdGJHVeWeSQul5WE/0pxfvWWt1QnD71hyyujyMCDVacqBomaSOjdxwDzYB+Ng4+MxOvrFB1A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.21", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.13.tgz", + "integrity": "sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.13.tgz", + "integrity": "sha512-PopvWqiutoZh5TJXk9EV9Wh+khbp+LQ+A0H4uHocIjVcKIi6gMlBy4sAaW15thwUSc6PrR8J62nB2uM+htqrcg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-label": "2.1.12", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.20.tgz", + "integrity": "sha512-UPmdiR8NsngWjG/y9mClzFg+Rbbpy8u0p0SKM+t7mfH4V07TiLsuylqR0RhJiRibopsawoTtMQudm/TxwHWa9w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.12.tgz", + "integrity": "sha512-dxioNQ7VOrYKKWJIxMRmJPDSWQN0gNCUy3zaqUSBwsuFAiFzI0yLGJr2q3ml07k/HlOk55N8KEfwa1ZgfprJ3w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.21.tgz", + "integrity": "sha512-2BHtaJHvvoWTECyrja1mOjN6z2dWdpeHL6b8PxqZYgex8J8xakT2KAchpZIaMwNPauIRHH/VlPJYhSSKe8lz2g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.21.tgz", + "integrity": "sha512-uQONG1qM4D8FSEt0xRs5yDpzeSWggf8lOKqHa84NvqoVoc1qJ6XN+gdkrJuQCyEY55793gLlQI3wjgWO5A/Oqg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.21", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.19.tgz", + "integrity": "sha512-58OVQUrpWx/zGVV3lxGUyAtjX4n0305Z8xIdUAq2QlFO2m2hd1eBS4x1yIVtV8bzCQJja0TJttWcwiPI6y6tmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.13.tgz", + "integrity": "sha512-reLtbZtEBsMcqXkjd/wOga4e8t9uxzFHdX9W/j/ZfGznTNJxLGjRrDNGnGOOWcBazMH1BI/b7Cx+hblSWSD7aw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.8.tgz", + "integrity": "sha512-NH9puF7Es5Loh8vFELm+SyayzV27nyBw8kiP/uD9wbkwgq359FfbkKEvccrNk75z0LiSqC4REWk1iL9xdeWJkQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.20.tgz", + "integrity": "sha512-/PYqbsyuDkNj+IxMcRx71qNt6GelnuNulMwdCV7AtFEhUyK6XkbwreEN6CCLydMeTiDozBV4uv5aF5d12dDH7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.4.tgz", + "integrity": "sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.14.tgz", + "integrity": "sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.8.tgz", + "integrity": "sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.13.tgz", + "integrity": "sha512-1dUdKDd63Tz9FfbTw20MVr28ohG4v7HOJ1dsavGBBPBS3KGzLOyLKiMJAC1OdgiY18nTSHpD4fULGK5gsLY/ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.4.tgz", + "integrity": "sha512-OpbUmp/korY+tjEQmHwGyQ+QQ3LBlCPC70z03Q/NSqGaHf2EijuwpjQPnswrH6cZLWyT2J6FmB+kzRoMUtPBig==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.16.tgz", + "integrity": "sha512-w7lLsTSd3940vFYEshKkHw+NGf7H0QDJPHYsy8NRjDCVbO6ZdKW1X/xoJSYHZtttnrdZiYqbN2O/2uHGB0zasw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.15.tgz", + "integrity": "sha512-JVBHNfTBbGd9hhq/xZZOgmVnBCXhLs8PJJ8vMzgwI0pLZNsKckW9pkoqHyxokUCt1hoxbwDNvF9DItEeZsG68g==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.4.tgz", + "integrity": "sha512-E2JxqAvaTUEhWtBptWo02g8FnLYPymv9ahEvW/cZQPPV4ySeyo0M8n3sXccsLUAIfMbexnfXt91qF7UjTbTMMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.12.tgz", + "integrity": "sha512-2hezgFBBR5jU3S9L9bIZ9Uag6LnvxuFBNsLCfTR8qx+NshuvFmpL4C72+5zMS3Z6UgHNSU1thOw2UaBBPEDpsQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.4.tgz", + "integrity": "sha512-8dUytW34KoJaB22ctfP7hqUCuyYa8xn2w7H8kCneeOtS5oM7UBivcnZtR8P4kPYMgdoAZlkMhE9/qkYZ5MlRzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.4.tgz", + "integrity": "sha512-7iGMj1SfZBAc6xRiy0Y3Wr/v52viQeDhOmaM3fNRyNf2nbYooZA3kKoEDGLPtrDv8JitpzcueqdZLusVMojLdQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.18.tgz", + "integrity": "sha512-1zq2XkQkK/KfbZn84edytYpOLquhNalra5LXc3NAMKhNRSGtyXqjMv6OyC9jlSuNKpqvQtsb57WKoICNk1v/sQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.20.tgz", + "integrity": "sha512-S28OtO1IvYSpWfaUBtiYCTTwRLF8doafj+a+uQw8rc8dLINS52uuG3CIPCeZc3Jfdb/S7o7HhlQxLoXlIYRu6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.15.tgz", + "integrity": "sha512-tyCejFjhJ51UKFVIG8jh9nTdRIsFPxrgrI4IdlxuJeP+AKTfTko+0gBueyBFLHqsyE71Aj9PKHjMnG+YRPyKhA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.16.tgz", + "integrity": "sha512-uil+A0Um3LaZQJkMap4nIg0VgqWc0j3iNU4AXf9a/zHOgPHNYWfVk5WVsG2296Y8HLv1bxiN7uQJblHc1+00tw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-toggle": "1.1.15", + "@radix-ui/react-use-controllable-state": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.16.tgz", + "integrity": "sha512-ZnvUAH+ftoRYzUzFQ8gqKnQ1lUFYb3amguGu+BXpfjvLIkjmXCcHCJlQeBLBlJCOtGNVtP+wHrZaUCC/zYKQMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-separator": "1.1.12", + "@radix-ui/react-toggle-group": "1.1.16" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.13.tgz", + "integrity": "sha512-56XPNYGMnGBcPyiBTaEXB7IGPybbsdNkFgSv90SCrHkXnu2Av1HhsyZMegzXlTu/QHA3V6/l22GZCv9iEoiqmQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.4.tgz", + "integrity": "sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", + "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.8.tgz", + "integrity": "sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -3115,6 +4674,277 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3157,6 +4987,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3383,7 +5276,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -3851,6 +5744,12 @@ "ajv": "^8.8.2" } }, + "node_modules/anser": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz", + "integrity": "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==", + "license": "MIT" + }, "node_modules/ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", @@ -3874,6 +5773,21 @@ "node": ">=8" } }, + "node_modules/ansi-to-react": { + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/ansi-to-react/-/ansi-to-react-6.2.6.tgz", + "integrity": "sha512-Eqi0iaMK5OZ3jsVFxWvU2B74UZBnGuHlkflKMX6wTOeH+luy9KE2O0gUkc2PxhIP1R4IO0xohv62UMFInQOSeg==", + "license": "BSD-3-Clause", + "dependencies": { + "anser": "^2.3.2", + "escape-carriage": "^1.3.1", + "linkify-it": "^3.0.3" + }, + "peerDependencies": { + "react": "^16.3.2 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.3.2 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3901,6 +5815,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -4316,6 +6249,18 @@ "node": ">=6.0" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -4733,6 +6678,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4750,6 +6816,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4822,7 +6894,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -4834,6 +6905,12 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -4990,14 +7067,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -5013,6 +7090,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/envinfo": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", @@ -5074,6 +7161,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5084,6 +7181,12 @@ "node": ">=6" } }, + "node_modules/escape-carriage": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz", + "integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==", + "license": "MIT" + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -5627,6 +7730,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -6147,9 +8259,9 @@ } }, "node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", "license": "MIT", "funding": { "type": "opencollective", @@ -6303,6 +8415,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/interpret": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", @@ -6540,12 +8661,45 @@ "node": ">= 10.13.0" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6605,12 +8759,282 @@ "shell-quote": "^1.8.3" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/loader-runner": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", @@ -6720,6 +9144,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -7774,9 +10208,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -8221,9 +10655,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", "dev": true, "funding": [ { @@ -8241,7 +10675,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8249,6 +10683,78 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-loader": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.2.1.tgz", + "integrity": "sha512-k98jtRzthjj3f76MYTs9JTpRqV1RaaMhEU0Lpw9OTmQZQdppg4B30VZ74BojuBHt3F4KyubHJoXCMUeM8Bqeow==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^9.0.0", + "jiti": "^2.5.1", + "semver": "^7.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/postcss-loader/node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss-loader/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss-modules-extract-imports": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", @@ -8427,6 +10933,83 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/radix-ui": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.3.tgz", + "integrity": "sha512-KmhSq0NfxIwN9q6ZpEaZ+J0hiVFQcGyrPYYhbxg34q9B8CIrQoccLJ3mJ9znLRslLoaogsP2ml8JKOVoKMXgvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.6", + "@radix-ui/react-accessible-icon": "1.1.12", + "@radix-ui/react-accordion": "1.2.17", + "@radix-ui/react-alert-dialog": "1.1.20", + "@radix-ui/react-arrow": "1.1.12", + "@radix-ui/react-aspect-ratio": "1.1.12", + "@radix-ui/react-avatar": "1.2.3", + "@radix-ui/react-checkbox": "1.3.8", + "@radix-ui/react-collapsible": "1.1.17", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-context-menu": "2.3.4", + "@radix-ui/react-dialog": "1.1.20", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.16", + "@radix-ui/react-dropdown-menu": "2.1.21", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.13", + "@radix-ui/react-form": "0.1.13", + "@radix-ui/react-hover-card": "1.1.20", + "@radix-ui/react-label": "2.1.12", + "@radix-ui/react-menu": "2.1.21", + "@radix-ui/react-menubar": "1.1.21", + "@radix-ui/react-navigation-menu": "1.2.19", + "@radix-ui/react-one-time-password-field": "0.1.13", + "@radix-ui/react-password-toggle-field": "0.1.8", + "@radix-ui/react-popover": "1.1.20", + "@radix-ui/react-popper": "1.3.4", + "@radix-ui/react-portal": "1.1.14", + "@radix-ui/react-presence": "1.1.8", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-progress": "1.1.13", + "@radix-ui/react-radio-group": "1.4.4", + "@radix-ui/react-roving-focus": "1.1.16", + "@radix-ui/react-scroll-area": "1.2.15", + "@radix-ui/react-select": "2.3.4", + "@radix-ui/react-separator": "1.1.12", + "@radix-ui/react-slider": "1.4.4", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-switch": "1.3.4", + "@radix-ui/react-tabs": "1.1.18", + "@radix-ui/react-toast": "1.2.20", + "@radix-ui/react-toggle": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.16", + "@radix-ui/react-toolbar": "1.1.16", + "@radix-ui/react-tooltip": "1.2.13", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-escape-keydown": "1.1.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8534,6 +11117,53 @@ } } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.14.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", @@ -8572,6 +11202,28 @@ "react-dom": ">=18" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", @@ -8637,6 +11289,42 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -8847,9 +11535,9 @@ "license": "MIT" }, "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, "node_modules/resolve": { @@ -9598,6 +12286,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -9679,6 +12384,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -9745,6 +12456,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -9773,6 +12494,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -9952,6 +12679,49 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -10033,6 +12803,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -10446,6 +13238,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7eacee24..b9b9ad14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,23 +21,31 @@ "@mui/material": "^7.3.9", "@reduxjs/toolkit": "^2.8.2", "@types/react-syntax-highlighter": "^15.5.13", + "ansi-to-react": "^6.2.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "codemirror": "^6.0.2", "framer-motion": "^12.35.2", "html-to-image": "^1.11.13", "lucide-react": "^1.17.0", + "radix-ui": "^1.6.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "react-syntax-highlighter": "^16.1.1", - "remark-gfm": "^4.0.1" + "recharts": "^3.9.2", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", + "@tailwindcss/postcss": "^4.3.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/react-redux": "^7.1.34", @@ -46,9 +54,13 @@ "css-loader": "^6.8.0", "css-modules-types-loader": "^0.6.10", "html-webpack-plugin": "^5.5.0", + "postcss": "^8.5.20", + "postcss-loader": "^8.2.1", "sass": "^1.89.2", "sass-loader": "^16.0.5", "style-loader": "^3.3.0", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0", "typescript": "^5.0.0", "webpack": "^5.88.0", "webpack-cli": "^5.1.0", diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx index e62f0b02..583a8ac3 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx @@ -3,6 +3,7 @@ import WeatherWidget from './WeatherWidget'; import PlanWidget from './PlanWidget'; import StatsWidget from './StatsWidget'; import LinksWidget from './LinksWidget'; +import VendoredToolUi from '@toolui/VendoredToolUi'; import type { ShowUiPayload } from './showUiPayload'; /** One switch for every surface that renders a ShowUI payload (chat bubble, pill artifact). */ @@ -11,6 +12,7 @@ function ShowUiWidgetView({ payload }: { payload: ShowUiPayload }): React.ReactE if (payload.component === 'plan') return ; if (payload.component === 'stats') return ; if (payload.component === 'links') return ; + if (payload.component === 'vendored') return ; return null; } diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts index 21f962ee..09537faa 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -1,4 +1,5 @@ import type { ToolPair } from '../tool-bubbles/ToolCallBubble'; +import { isToolUiComponent } from '@toolui/registry'; export interface WeatherForecastDay { day: string; @@ -53,7 +54,8 @@ export type ShowUiPayload = | { component: 'weather'; props: WeatherProps } | { component: 'plan'; props: PlanProps } | { component: 'stats'; props: StatsProps } - | { component: 'links'; props: LinksProps }; + | { component: 'links'; props: LinksProps } + | { component: 'vendored'; name: string; props: Record }; function num(v: unknown): v is number { return typeof v === 'number' && Number.isFinite(v); @@ -89,6 +91,14 @@ export function parseShowUiPayload(pair: ToolPair): ShowUiPayload | null { function parseShowUiInput(input: unknown): ShowUiPayload | null { if (!input || typeof input !== 'object') return null; + { + const name = String((input as { component?: unknown }).component || ''); + const rawProps = (input as { props?: unknown }).props; + if (isToolUiComponent(name) && rawProps && typeof rawProps === 'object') { + // Vendored components carry their own zod contract; deep validation happens at render. + return { component: 'vendored', name, props: rawProps as Record }; + } + } const component = String((input as { component?: unknown }).component || ''); const props = (input as { props?: unknown }).props; if (!props || typeof props !== 'object') return null; diff --git a/frontend/src/toolui/LICENSE.md b/frontend/src/toolui/LICENSE.md new file mode 100644 index 00000000..1be0da05 --- /dev/null +++ b/frontend/src/toolui/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 AgentbaseAI Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx new file mode 100644 index 00000000..09a33234 --- /dev/null +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -0,0 +1,59 @@ +import React, { Suspense, useEffect, useState } from 'react'; +import { useThemeMode } from '@/shared/styles/ThemeContext'; +import { TOOL_UI_REGISTRY } from './registry'; + +interface VendoredToolUiProps { + name: string; + props: Record; +} + +type Gate = 'pending' | 'ok' | 'bad'; + +/** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */ +function VendoredToolUi({ name, props }: VendoredToolUiProps): React.ReactElement | null { + const { mode } = useThemeMode(); + const entry = TOOL_UI_REGISTRY[name]; + const [gate, setGate] = useState('pending'); + const [problem, setProblem] = useState(''); + + useEffect(() => { + let cancelled = false; + if (!entry) return undefined; + entry + .loadSchema() + .then((schema) => { + if (cancelled) return; + const result = schema.safeParse(props); + if (result.success) { + setGate('ok'); + } else { + setGate('bad'); + setProblem(result.error.issues.slice(0, 2).map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')); + } + }) + .catch(() => { if (!cancelled) { setGate('bad'); setProblem('component failed to load'); } }); + return () => { cancelled = true; }; + }, [entry, props]); + + if (!entry) return null; + if (gate === 'bad') { + return ( +
+ {name} payload didn't validate ({problem}) +
+ ); + } + if (gate === 'pending') { + return
; + } + const Component = entry.Component; + return ( +
+ }> + + +
+ ); +} + +export default VendoredToolUi; diff --git a/frontend/src/toolui/components/approval-card/README.md b/frontend/src/toolui/components/approval-card/README.md new file mode 100644 index 00000000..070bdded --- /dev/null +++ b/frontend/src/toolui/components/approval-card/README.md @@ -0,0 +1,19 @@ +# Approval Card + +Implementation for the "approval-card" Tool UI surface. + +## Files + +- public exports: components/tool-ui/approval-card/index.tsx +- serializable schema + parse helpers: components/tool-ui/approval-card/schema.ts + +## Companion assets + +- Docs page: app/docs/approval-card/content.mdx +- Preset payload: lib/presets/approval-card.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/approval-card/_adapter.tsx b/frontend/src/toolui/components/approval-card/_adapter.tsx new file mode 100644 index 00000000..770d1920 --- /dev/null +++ b/frontend/src/toolui/components/approval-card/_adapter.tsx @@ -0,0 +1,11 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + */ + +export { cn } from "@toolui/lib/utils"; +export { Separator } from "@toolui/ui/separator"; diff --git a/frontend/src/toolui/components/approval-card/approval-card.tsx b/frontend/src/toolui/components/approval-card/approval-card.tsx new file mode 100644 index 00000000..f0ea1ed9 --- /dev/null +++ b/frontend/src/toolui/components/approval-card/approval-card.tsx @@ -0,0 +1,212 @@ +"use client"; + +import * as React from "react"; +import { cn, Separator } from "./_adapter"; +import type { ApprovalCardProps, ApprovalDecision } from "./schema"; +import { ActionButtons } from "../shared/action-buttons"; +import { type Action } from "../shared/schema"; + +import { icons, Check, X } from "lucide-react"; + +type LucideIcon = React.ComponentType<{ className?: string }>; + +function getLucideIcon(name: string): LucideIcon | null { + const pascalName = name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + + const Icon = icons[pascalName as keyof typeof icons]; + return Icon ?? null; +} + +interface ApprovalCardReceiptProps { + id: string; + title: string; + choice: ApprovalDecision; + actionLabel?: string; + className?: string; +} + +function ApprovalCardReceipt({ + id, + title, + choice, + actionLabel, + className, +}: ApprovalCardReceiptProps) { + const isApproved = choice === "approved"; + const displayLabel = actionLabel ?? (isApproved ? "Approved" : "Denied"); + + return ( +
+
+ + {isApproved ? : } + +
+ {displayLabel} + {title} +
+
+
+ ); +} + +export function ApprovalCard({ + id, + title, + description, + icon, + metadata, + variant, + confirmLabel, + cancelLabel, + className, + choice, + onConfirm, + onCancel, +}: ApprovalCardProps) { + const resolvedVariant = variant ?? "default"; + const resolvedConfirmLabel = confirmLabel ?? "Approve"; + const resolvedCancelLabel = cancelLabel ?? "Deny"; + const Icon = icon ? getLucideIcon(icon) : null; + + const handleAction = React.useCallback( + async (actionId: string) => { + if (actionId === "confirm") { + await onConfirm?.(); + } else if (actionId === "cancel") { + await onCancel?.(); + } + }, + [onConfirm, onCancel], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel?.(); + } + }, + [onCancel], + ); + + const isDestructive = resolvedVariant === "destructive"; + + const actions: Action[] = [ + { + id: "cancel", + label: resolvedCancelLabel, + variant: "ghost", + }, + { + id: "confirm", + label: resolvedConfirmLabel, + variant: isDestructive ? "destructive" : "default", + }, + ]; + + const viewKey = choice ? `receipt-${choice}` : "interactive"; + + return ( +
+ {choice ? ( + + ) : ( +
+
+
+ {Icon && ( + + + + )} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ + {metadata && metadata.length > 0 && ( + <> + +
+ {metadata.map((item, index) => ( +
+
+ {item.key} +
+
{item.value}
+
+ ))} +
+ + )} +
+
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/toolui/components/approval-card/index.tsx b/frontend/src/toolui/components/approval-card/index.tsx new file mode 100644 index 00000000..1bbfb6a6 --- /dev/null +++ b/frontend/src/toolui/components/approval-card/index.tsx @@ -0,0 +1,7 @@ +export { ApprovalCard } from "./approval-card"; +export { + type SerializableApprovalCard, + type ApprovalCardProps, + type ApprovalDecision, + type MetadataItem, +} from "./schema"; diff --git a/frontend/src/toolui/components/approval-card/schema.ts b/frontend/src/toolui/components/approval-card/schema.ts new file mode 100644 index 00000000..9371e726 --- /dev/null +++ b/frontend/src/toolui/components/approval-card/schema.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema"; +import { defineToolUiContract } from "../shared/contract"; + +export const MetadataItemSchema = z.object({ + key: z.string().min(1), + value: z.string(), +}); + +export type MetadataItem = z.infer; + +export const ApprovalDecisionSchema = z.enum(["approved", "denied"]); + +export type ApprovalDecision = z.infer; + +export const SerializableApprovalCardSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + + title: z.string().min(1), + description: z.string().optional(), + icon: z.string().optional(), + metadata: z.array(MetadataItemSchema).optional(), + + variant: z.enum(["default", "destructive"]).optional(), + + confirmLabel: z.string().optional(), + cancelLabel: z.string().optional(), + + choice: ApprovalDecisionSchema.optional(), +}); + +export type SerializableApprovalCard = z.infer< + typeof SerializableApprovalCardSchema +>; + +const SerializableApprovalCardSchemaContract = defineToolUiContract( + "ApprovalCard", + SerializableApprovalCardSchema, +); + +export const parseSerializableApprovalCard: ( + input: unknown, +) => SerializableApprovalCard = SerializableApprovalCardSchemaContract.parse; + +export const safeParseSerializableApprovalCard: ( + input: unknown, +) => SerializableApprovalCard | null = + SerializableApprovalCardSchemaContract.safeParse; +export interface ApprovalCardProps extends SerializableApprovalCard { + className?: string; + onConfirm?: () => void | Promise; + onCancel?: () => void | Promise; +} diff --git a/frontend/src/toolui/components/citation/README.md b/frontend/src/toolui/components/citation/README.md new file mode 100644 index 00000000..e248e34d --- /dev/null +++ b/frontend/src/toolui/components/citation/README.md @@ -0,0 +1,19 @@ +# Citation + +Implementation for the "citation" Tool UI surface. + +## Files + +- public exports: components/tool-ui/citation/index.ts +- serializable schema + parse helpers: components/tool-ui/citation/schema.ts + +## Companion assets + +- Docs page: app/docs/citation/content.mdx +- Preset payload: lib/presets/citation.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/citation/_adapter.tsx b/frontend/src/toolui/components/citation/_adapter.tsx new file mode 100644 index 00000000..33f4cdc4 --- /dev/null +++ b/frontend/src/toolui/components/citation/_adapter.tsx @@ -0,0 +1,18 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Tooltip → shadcn/ui Tooltip (only needed for variant="inline") + * Popover → shadcn/ui Popover (only needed for CitationList) + */ +"use client"; + +export { cn } from "@toolui/lib/utils"; +export { + Popover, + PopoverContent, + PopoverTrigger, +} from "@toolui/ui/popover"; diff --git a/frontend/src/toolui/components/citation/citation-list.tsx b/frontend/src/toolui/components/citation/citation-list.tsx new file mode 100644 index 00000000..db586c7e --- /dev/null +++ b/frontend/src/toolui/components/citation/citation-list.tsx @@ -0,0 +1,460 @@ +"use client"; + +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { + FileText, + Globe, + Code2, + Newspaper, + Database, + File, + ExternalLink, +} from "lucide-react"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; +import { Citation } from "./citation"; +import type { + SerializableCitation, + CitationType, + CitationVariant, +} from "./schema"; +import { + openSafeNavigationHref, + resolveSafeNavigationHref, +} from "../shared/media"; + +const TYPE_ICONS: Record = { + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, +}; + +function useHoverPopover(delay = 100) { + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + const containerRef = React.useRef(null); + + const handleMouseEnter = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(true), delay); + }, [delay]); + + const handleMouseLeave = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, [delay]); + + const handleFocus = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setOpen(true); + }, []); + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null; + if (containerRef.current?.contains(relatedTarget)) { + return; + } + if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) { + return; + } + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, + [delay], + ); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { + open, + setOpen, + containerRef, + handleMouseEnter, + handleMouseLeave, + handleFocus, + handleBlur, + }; +} + +export interface CitationListProps { + id: string; + citations: SerializableCitation[]; + variant?: CitationVariant; + maxVisible?: number; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +export function CitationList(props: CitationListProps) { + const { + id, + citations, + variant = "default", + maxVisible, + className, + onNavigate, + } = props; + + const shouldTruncate = + maxVisible !== undefined && citations.length > maxVisible; + const visibleCitations = shouldTruncate + ? citations.slice(0, maxVisible) + : citations; + const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : []; + const overflowCount = overflowCitations.length; + + const wrapperClass = + variant === "inline" + ? "flex flex-wrap items-center gap-1.5" + : "flex flex-col gap-2"; + + // Stacked variant: overlapping favicons with popover + if (variant === "stacked") { + return ( + + ); + } + + if (variant === "default") { + return ( +
+ {visibleCitations.map((citation) => ( + + ))} + {shouldTruncate && ( + + )} +
+ ); + } + + return ( +
+ {visibleCitations.map((citation) => ( + + ))} + {shouldTruncate && ( + + )} +
+ ); +} + +interface OverflowIndicatorProps { + citations: SerializableCitation[]; + count: number; + variant: CitationVariant; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +function OverflowIndicator({ + citations, + count, + variant, + onNavigate, +}: OverflowIndicatorProps) { + const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover(); + + const handleClick = (citation: SerializableCitation) => { + const href = resolveSafeNavigationHref(citation.href); + if (!href) return; + if (onNavigate) { + onNavigate(href, citation); + } else { + openSafeNavigationHref(href); + } + }; + + const popoverContent = ( +
+ {citations.map((citation) => ( + handleClick(citation)} + /> + ))} +
+ ); + + if (variant === "inline") { + return ( + + + + + e.preventDefault()} + > + {popoverContent} + + + ); + } + + // Default variant + return ( + + + + + e.preventDefault()} + > + {popoverContent} + + + ); +} + +interface OverflowItemProps { + citation: SerializableCitation; + onClick: () => void; +} + +function OverflowItem({ citation, onClick }: OverflowItemProps) { + const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe; + + return ( + + ); +} + +interface StackedCitationsProps { + id: string; + citations: SerializableCitation[]; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +function StackedCitations({ + id, + citations, + className, + onNavigate, +}: StackedCitationsProps) { + const { + open, + setOpen, + containerRef, + handleMouseEnter, + handleMouseLeave, + handleBlur, + } = useHoverPopover(); + const maxIcons = 4; + const visibleCitations = citations.slice(0, maxIcons); + const remainingCount = Math.max(0, citations.length - maxIcons); + + const handleClick = (citation: SerializableCitation) => { + const href = resolveSafeNavigationHref(citation.href); + if (!href) return; + if (onNavigate) { + onNavigate(href, citation); + } else { + openSafeNavigationHref(href); + } + }; + + return ( +
+ + + + + setOpen(false)} + > +
+ {citations.map((citation) => ( + handleClick(citation)} + /> + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/toolui/components/citation/citation.tsx b/frontend/src/toolui/components/citation/citation.tsx new file mode 100644 index 00000000..551f82a7 --- /dev/null +++ b/frontend/src/toolui/components/citation/citation.tsx @@ -0,0 +1,259 @@ +"use client"; + +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { + FileText, + Globe, + Code2, + Newspaper, + Database, + File, + ExternalLink, +} from "lucide-react"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; + +import { openSafeNavigationHref, sanitizeHref } from "../shared/media"; +import type { + SerializableCitation, + CitationType, + CitationVariant, +} from "./schema"; + +const FALLBACK_LOCALE = "en-US"; + +const TYPE_ICONS: Record = { + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, +}; + +function extractDomain(url: string): string | undefined { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +function formatDate(isoString: string, locale: string): string { + try { + const date = new Date(isoString); + return date.toLocaleDateString(locale, { + year: "numeric", + month: "short", + }); + } catch { + return isoString; + } +} + +function useHoverPopover(delay = 100) { + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + + const handleMouseEnter = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(true), delay); + }, [delay]); + + const handleMouseLeave = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, [delay]); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { open, setOpen, handleMouseEnter, handleMouseLeave }; +} + +export interface CitationProps extends SerializableCitation { + variant?: CitationVariant; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +export function Citation(props: CitationProps) { + const { variant = "default", className, onNavigate, ...serializable } = props; + + const { + id, + href: rawHref, + title, + snippet, + domain: providedDomain, + favicon, + author, + publishedAt, + type = "webpage", + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + const sanitizedHref = sanitizeHref(rawHref); + const domain = providedDomain ?? extractDomain(rawHref); + + const citationData: SerializableCitation = { + ...serializable, + href: sanitizedHref ?? rawHref, + domain, + locale, + }; + + const TypeIcon = TYPE_ICONS[type] ?? Globe; + + const handleClick = () => { + if (!sanitizedHref) return; + if (onNavigate) { + onNavigate(sanitizedHref, citationData); + } else { + openSafeNavigationHref(sanitizedHref); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (sanitizedHref && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleClick(); + } + }; + + const iconElement = favicon ? ( + + ) : ( +
+ )} +
+ + ); +} diff --git a/frontend/src/toolui/components/item-carousel/item-carousel.tsx b/frontend/src/toolui/components/item-carousel/item-carousel.tsx new file mode 100644 index 00000000..6c0b601b --- /dev/null +++ b/frontend/src/toolui/components/item-carousel/item-carousel.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { useRef, useState, useEffect, useCallback } from "react"; +import { cn, Button, Card, ChevronLeft, ChevronRight } from "./_adapter"; +import { ItemCard } from "./item-card"; +import { prefersReducedMotion } from "../shared/utils"; +import type { ItemCarouselProps } from "./schema"; + +const SCROLL_PADDING_STYLE = { scrollPaddingInline: "1rem" }; + +const SCROLL_EDGE_THRESHOLD_PX = 8; +const SNAP_EPSILON_PX = 5; +const SCROLL_ANIMATION_DURATION_MS = 300; +const PAGE_SCROLL_RATIO = 0.8; +const PAGE_SCROLL_BREAKPOINT_PX = 640; + +type ScrollDirection = "left" | "right"; + +interface ScrollAnimationState { + target: number; + start: number; + startTime: number; + duration: number; + onComplete?: () => void; +} + +function useSmoothScroll() { + const animationRef = useRef(null); + const frameRef = useRef(null); + + const cancelAnimation = useCallback(() => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + animationRef.current = null; + }, []); + + useEffect(() => cancelAnimation, [cancelAnimation]); + + const scrollTo = useCallback( + ( + element: HTMLElement, + target: number, + duration = SCROLL_ANIMATION_DURATION_MS, + onComplete?: () => void, + ) => { + if (prefersReducedMotion() || duration <= 0) { + element.scrollLeft = target; + onComplete?.(); + return; + } + + cancelAnimation(); + + animationRef.current = { + target, + start: element.scrollLeft, + startTime: performance.now(), + duration, + onComplete, + }; + + element.style.scrollSnapType = "none"; + + const step = () => { + const anim = animationRef.current; + if (!anim) return; + + const elapsed = performance.now() - anim.startTime; + const progress = Math.min(elapsed / anim.duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + + element.scrollLeft = anim.start + (anim.target - anim.start) * eased; + + if (progress < 1) { + frameRef.current = requestAnimationFrame(step); + return; + } + + element.scrollLeft = anim.target; + const callback = anim.onComplete; + cancelAnimation(); + + requestAnimationFrame(() => { + element.style.scrollSnapType = ""; + callback?.(); + }); + }; + + frameRef.current = requestAnimationFrame(step); + }, + [cancelAnimation], + ); + + const isAnimating = useCallback( + () => animationRef.current !== null && frameRef.current !== null, + [], + ); + + return { scrollTo, isAnimating, cancelAnimation }; +} + +function useScrollEdgeState( + scrollRef: React.RefObject, + itemCount: number, +) { + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateState = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + + const scrollLeft = Math.round(container.scrollLeft); + const maxScroll = Math.max( + 0, + Math.round(container.scrollWidth - container.clientWidth), + ); + + setCanScrollLeft(scrollLeft > SCROLL_EDGE_THRESHOLD_PX); + setCanScrollRight(scrollLeft < maxScroll - SCROLL_EDGE_THRESHOLD_PX); + }, [scrollRef]); + + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + + let rafId: number | null = null; + + const scheduleUpdate = () => { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + rafId = null; + updateState(); + }); + }; + + scheduleUpdate(); + + container.addEventListener("scroll", scheduleUpdate, { passive: true }); + const resizeObserver = new ResizeObserver(scheduleUpdate); + resizeObserver.observe(container); + + return () => { + container.removeEventListener("scroll", scheduleUpdate); + resizeObserver.disconnect(); + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }, [scrollRef, updateState, itemCount]); + + return { canScrollLeft, canScrollRight }; +} + +function CarouselNavButton({ + direction, + visible, + onClick, +}: { + direction: ScrollDirection; + visible: boolean; + onClick: () => void; +}) { + const isLeft = direction === "left"; + const Icon = isLeft ? ChevronLeft : ChevronRight; + + return ( + + ); +} + +interface ItemCarouselHeaderProps { + title?: string; + description?: string; +} + +function ItemCarouselHeader({ title, description }: ItemCarouselHeaderProps) { + if (!title && !description) return null; + + return ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

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

No items to display

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

+ {title} +

+ )} + {description && ( +

+ {description} +

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

{body}

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

+ {draft.subject} +

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

{parts.join(" · ")}

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

+ {title} +

+

+ Unable to render order summary +

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

+ {isReceipt && ( +

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