@toolr/ui-design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +63 -0
  2. package/components/content/info-panel-primitives.tsx +297 -0
  3. package/components/diagrams/diagram-utils.tsx +908 -0
  4. package/components/hooks/use-click-outside.ts +27 -0
  5. package/components/hooks/use-dropdown-max-height.ts +20 -0
  6. package/components/hooks/use-navigation-history.ts +94 -0
  7. package/components/lib/ai-tools.tsx +44 -0
  8. package/components/lib/cn.ts +6 -0
  9. package/components/lib/form-colors.ts +32 -0
  10. package/components/lib/theme-engine.ts +97 -0
  11. package/components/lib/toolr-brand.tsx +31 -0
  12. package/components/sections/ai-tools-paths/index.ts +37 -0
  13. package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
  14. package/components/sections/ai-tools-paths/types.ts +111 -0
  15. package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
  16. package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
  17. package/components/sections/captured-issues/index.ts +38 -0
  18. package/components/sections/captured-issues/types.ts +113 -0
  19. package/components/sections/captured-issues/use-captured-issues.ts +111 -0
  20. package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
  21. package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
  22. package/components/sections/golden-snapshots/index.ts +145 -0
  23. package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
  24. package/components/sections/golden-snapshots/status-overview.tsx +305 -0
  25. package/components/sections/golden-snapshots/types.ts +288 -0
  26. package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
  27. package/components/sections/golden-snapshots/version-manager.tsx +186 -0
  28. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
  29. package/components/sections/prompt-editor/index.ts +121 -0
  30. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
  31. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
  32. package/components/sections/prompt-editor/types.ts +101 -0
  33. package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
  34. package/components/sections/report-bug/error-logger.ts +392 -0
  35. package/components/sections/report-bug/index.ts +59 -0
  36. package/components/sections/report-bug/issue-reporter-api.ts +83 -0
  37. package/components/sections/report-bug/report-bug-form.tsx +282 -0
  38. package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
  39. package/components/sections/report-bug/use-report-bug.ts +170 -0
  40. package/components/sections/snapshot-browser/index.ts +53 -0
  41. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
  42. package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
  43. package/components/sections/snapshot-browser/types.ts +106 -0
  44. package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
  45. package/components/sections/snippets-editor/index.ts +31 -0
  46. package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
  47. package/components/sections/snippets-editor/types.ts +48 -0
  48. package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
  49. package/components/ui/action-dialog.tsx +309 -0
  50. package/components/ui/ai-action-button.tsx +137 -0
  51. package/components/ui/ai-execution-action-buttons.tsx +106 -0
  52. package/components/ui/badge.tsx +67 -0
  53. package/components/ui/bottom-panel-header.tsx +240 -0
  54. package/components/ui/breadcrumb.tsx +168 -0
  55. package/components/ui/checkbox.tsx +102 -0
  56. package/components/ui/collapsible-section.tsx +100 -0
  57. package/components/ui/confirm-badge.tsx +71 -0
  58. package/components/ui/detail-section.tsx +67 -0
  59. package/components/ui/detail-view-wrapper.tsx +55 -0
  60. package/components/ui/editor-placeholder-card.tsx +197 -0
  61. package/components/ui/editor-toolbar.tsx +123 -0
  62. package/components/ui/execution-details-panel.tsx +93 -0
  63. package/components/ui/extension-list-card.tsx +105 -0
  64. package/components/ui/file-structure-section.tsx +373 -0
  65. package/components/ui/file-tree.tsx +171 -0
  66. package/components/ui/files-panel.tsx +251 -0
  67. package/components/ui/filter-dropdown.tsx +173 -0
  68. package/components/ui/form-actions.tsx +127 -0
  69. package/components/ui/frontmatter-form-header.tsx +80 -0
  70. package/components/ui/icon-button.tsx +388 -0
  71. package/components/ui/input.tsx +211 -0
  72. package/components/ui/label.tsx +159 -0
  73. package/components/ui/layout-tab-bar.tsx +289 -0
  74. package/components/ui/modal.tsx +194 -0
  75. package/components/ui/nav-card.tsx +81 -0
  76. package/components/ui/navigation-bar.tsx +285 -0
  77. package/components/ui/number-input.tsx +165 -0
  78. package/components/ui/registry-browser.tsx +261 -0
  79. package/components/ui/registry-card.tsx +710 -0
  80. package/components/ui/registry-detail.tsx +224 -0
  81. package/components/ui/resizable-textarea.tsx +290 -0
  82. package/components/ui/scope-badge.tsx +67 -0
  83. package/components/ui/segmented-toggle.tsx +133 -0
  84. package/components/ui/select.tsx +172 -0
  85. package/components/ui/selection-grid.tsx +313 -0
  86. package/components/ui/setting-row.tsx +97 -0
  87. package/components/ui/snapshot-card.tsx +107 -0
  88. package/components/ui/snippets-panel.tsx +161 -0
  89. package/components/ui/sort-dropdown.tsx +109 -0
  90. package/components/ui/status-card.tsx +96 -0
  91. package/components/ui/tab-bar.tsx +340 -0
  92. package/components/ui/toggle.tsx +142 -0
  93. package/components/ui/tooltip.tsx +326 -0
  94. package/dist/content.d.ts +110 -0
  95. package/dist/content.js +195 -0
  96. package/dist/diagrams.d.ts +371 -0
  97. package/dist/diagrams.js +702 -0
  98. package/dist/index.d.ts +2714 -0
  99. package/dist/index.js +11220 -0
  100. package/dist/preset.d.ts +24 -0
  101. package/dist/preset.js +17 -0
  102. package/dist/tokens/tokens/primitives.css +45 -0
  103. package/dist/tokens/tokens/semantic.css +46 -0
  104. package/dist/tokens/tokens/theme.css +11 -0
  105. package/dist/tokens/tokens/tokens.json +65 -0
  106. package/index.ts +123 -0
  107. package/package.json +63 -0
  108. package/tailwind-preset.ts +22 -0
  109. package/tokens/primitives.css +45 -0
  110. package/tokens/semantic.css +46 -0
  111. package/tokens/theme.css +11 -0
  112. package/tokens/tokens.json +65 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * useSnippetsEditor — Form state and CRUD hook for the snippets editor
3
+ *
4
+ * Part of: Sections > Snippets Editor
5
+ *
6
+ * Manages selected snippet, form data, edit/add mode, validation, search,
7
+ * and CRUD operations via the provided API.
8
+ *
9
+ * AI agent notes:
10
+ * - This hook is used internally by SnippetsEditor but can also be used
11
+ * standalone for custom UIs
12
+ * - Validation checks: required fields, SNIPPET_NAME_REGEX, duplicate names
13
+ * - The hook does NOT hold the snippets array — it receives it as a param
14
+ * so the consuming app owns the source of truth
15
+ */
16
+
17
+ import { useState, useCallback, useMemo } from 'react'
18
+ import { SNIPPET_NAME_REGEX, type SnippetData, type SnippetsEditorApi } from './types.ts'
19
+
20
+ interface SnippetFormData {
21
+ name: string
22
+ description: string
23
+ value: string
24
+ }
25
+
26
+ const EMPTY_FORM: SnippetFormData = { name: '', description: '', value: '' }
27
+
28
+ export interface UseSnippetsEditorOptions {
29
+ api: SnippetsEditorApi
30
+ snippets: SnippetData[]
31
+ }
32
+
33
+ export interface UseSnippetsEditorReturn {
34
+ // Selection
35
+ selectedName: string | null
36
+ selectSnippet: (name: string | null) => void
37
+
38
+ // Search
39
+ searchQuery: string
40
+ setSearchQuery: (query: string) => void
41
+ filteredSnippets: SnippetData[]
42
+
43
+ // Form
44
+ formData: SnippetFormData
45
+ setFormField: (field: keyof SnippetFormData, value: string) => void
46
+ formError: string | null
47
+ isEditing: boolean
48
+ isAdding: boolean
49
+
50
+ // Actions
51
+ startAdd: () => void
52
+ startEdit: (snippet: SnippetData) => void
53
+ cancelForm: () => void
54
+ save: () => Promise<void>
55
+ remove: (name: string) => Promise<void>
56
+ resetForm: () => void
57
+
58
+ // State
59
+ isSaving: boolean
60
+ }
61
+
62
+ export function useSnippetsEditor({ api, snippets }: UseSnippetsEditorOptions): UseSnippetsEditorReturn {
63
+ const [selectedName, setSelectedName] = useState<string | null>(null)
64
+ const [searchQuery, setSearchQuery] = useState('')
65
+ const [formData, setFormData] = useState<SnippetFormData>(EMPTY_FORM)
66
+ const [formError, setFormError] = useState<string | null>(null)
67
+ const [isAdding, setIsAdding] = useState(false)
68
+ const [editingOriginalName, setEditingOriginalName] = useState<string | null>(null)
69
+ const [isSaving, setIsSaving] = useState(false)
70
+
71
+ const isEditing = editingOriginalName !== null
72
+
73
+ const filteredSnippets = useMemo(() => {
74
+ if (!searchQuery.trim()) return snippets
75
+ const q = searchQuery.toLowerCase()
76
+ return snippets.filter(
77
+ (s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
78
+ )
79
+ }, [snippets, searchQuery])
80
+
81
+ const selectSnippet = useCallback((name: string | null) => {
82
+ setSelectedName(name)
83
+ setIsAdding(false)
84
+ setEditingOriginalName(null)
85
+ setFormError(null)
86
+ if (name) {
87
+ const snippet = snippets.find((s) => s.name === name)
88
+ if (snippet) {
89
+ setFormData({ name: snippet.name, description: snippet.description, value: snippet.value })
90
+ setEditingOriginalName(snippet.name)
91
+ }
92
+ } else {
93
+ setFormData(EMPTY_FORM)
94
+ }
95
+ }, [snippets])
96
+
97
+ const setFormField = useCallback((field: keyof SnippetFormData, value: string) => {
98
+ setFormData((prev: SnippetFormData) => ({ ...prev, [field]: field === 'name' ? value.toUpperCase() : value }))
99
+ setFormError(null)
100
+ }, [])
101
+
102
+ const startAdd = useCallback(() => {
103
+ setIsAdding(true)
104
+ setEditingOriginalName(null)
105
+ setSelectedName(null)
106
+ setFormData(EMPTY_FORM)
107
+ setFormError(null)
108
+ }, [])
109
+
110
+ const startEdit = useCallback((snippet: SnippetData) => {
111
+ setSelectedName(snippet.name)
112
+ setEditingOriginalName(snippet.name)
113
+ setIsAdding(false)
114
+ setFormData({ name: snippet.name, description: snippet.description, value: snippet.value })
115
+ setFormError(null)
116
+ }, [])
117
+
118
+ const cancelForm = useCallback(() => {
119
+ setIsAdding(false)
120
+ setEditingOriginalName(null)
121
+ setSelectedName(null)
122
+ setFormData(EMPTY_FORM)
123
+ setFormError(null)
124
+ }, [])
125
+
126
+ const resetForm = useCallback(() => {
127
+ if (editingOriginalName) {
128
+ const snippet = snippets.find((s) => s.name === editingOriginalName)
129
+ if (snippet) {
130
+ setFormData({ name: snippet.name, description: snippet.description, value: snippet.value })
131
+ setFormError(null)
132
+ }
133
+ } else {
134
+ setFormData(EMPTY_FORM)
135
+ setFormError(null)
136
+ }
137
+ }, [editingOriginalName, snippets])
138
+
139
+ const validate = useCallback((data: SnippetFormData): string | null => {
140
+ if (!data.name.trim()) return 'Snippet name is required'
141
+ if (!SNIPPET_NAME_REGEX.test(data.name)) return 'Name must be uppercase with underscores (e.g., MY_SNIPPET)'
142
+ const isDuplicate = snippets.some(
143
+ (s) => s.name.toUpperCase() === data.name.toUpperCase() && s.name !== editingOriginalName,
144
+ )
145
+ if (isDuplicate) return 'A snippet with this name already exists'
146
+ if (!data.description.trim()) return 'Description is required'
147
+ return null
148
+ }, [snippets, editingOriginalName])
149
+
150
+ const save = useCallback(async () => {
151
+ const error = validate(formData)
152
+ if (error) {
153
+ setFormError(error)
154
+ return
155
+ }
156
+
157
+ const snippet: SnippetData = {
158
+ name: formData.name.toUpperCase(),
159
+ description: formData.description.trim(),
160
+ value: formData.value,
161
+ }
162
+
163
+ setIsSaving(true)
164
+ try {
165
+ if (editingOriginalName) {
166
+ await api.updateSnippet(editingOriginalName, snippet)
167
+ } else {
168
+ await api.addSnippet(snippet)
169
+ }
170
+ setSelectedName(snippet.name)
171
+ setEditingOriginalName(snippet.name)
172
+ setIsAdding(false)
173
+ setFormError(null)
174
+ } catch (err) {
175
+ setFormError(`Failed to save: ${err}`)
176
+ } finally {
177
+ setIsSaving(false)
178
+ }
179
+ }, [formData, editingOriginalName, api, validate])
180
+
181
+ const remove = useCallback(async (name: string) => {
182
+ setIsSaving(true)
183
+ try {
184
+ await api.removeSnippet(name)
185
+ if (selectedName === name) {
186
+ setSelectedName(null)
187
+ setEditingOriginalName(null)
188
+ setFormData(EMPTY_FORM)
189
+ setFormError(null)
190
+ }
191
+ } catch (err) {
192
+ setFormError(`Failed to delete: ${err}`)
193
+ } finally {
194
+ setIsSaving(false)
195
+ }
196
+ }, [api, selectedName])
197
+
198
+ return {
199
+ selectedName,
200
+ selectSnippet,
201
+ searchQuery,
202
+ setSearchQuery,
203
+ filteredSnippets,
204
+ formData,
205
+ setFormField,
206
+ formError,
207
+ isEditing,
208
+ isAdding,
209
+ startAdd,
210
+ startEdit,
211
+ cancelForm,
212
+ save,
213
+ remove,
214
+ resetForm,
215
+ isSaving,
216
+ }
217
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * ActionDialog - Standard dialog for AI execution workflows
3
+ *
4
+ * Built-in sections:
5
+ * 1. SelectionGrid - tool/item selection (single/multi) when items provided
6
+ * 2. Scenarios - second SelectionGrid for scenario selection with "Select All"
7
+ * 3. children - additional content
8
+ * 4. ExecutionDetailsPanel - mandatory execution configuration display
9
+ *
10
+ * Header: icon, title, subtitle, optional settings button
11
+ * Footer: FormActions (status text, cancel/submit)
12
+ */
13
+
14
+ import { useEffect } from 'react'
15
+ import { createPortal } from 'react-dom'
16
+ import {
17
+ ArrowLeft, ArrowRight, ArrowUp, ArrowDown,
18
+ ChevronLeft, ChevronRight, ChevronUp, ChevronDown,
19
+ Check, X, Plus, Minus, Pencil, Trash2, Copy, Save,
20
+ RefreshCw, RotateCcw, Undo2, Redo2,
21
+ Search, Filter, Download, Upload, ExternalLink, Link2,
22
+ Eye, EyeOff, Lock, Unlock, Settings, MoreHorizontal, MoreVertical,
23
+ Info, HelpCircle,
24
+ User, Users, Folder, File, Image, Code, Terminal,
25
+ Star, Heart, Bell, Bookmark, Tag, Pin, Mail, Send,
26
+ Globe, Database, Cloud,
27
+ Wand2, Shield, ShieldCheck, Zap, Sparkles,
28
+ Play, Pause, Square, StopCircle,
29
+ Menu, GripVertical, Maximize2, Minimize2,
30
+ Scan, Webhook, Bot, Puzzle, Plug,
31
+ } from 'lucide-react'
32
+ import type { LucideIcon } from 'lucide-react'
33
+ import { IconButton } from './icon-button.tsx'
34
+ import type { IconName } from './icon-button.tsx'
35
+ import { FormActions } from './form-actions.tsx'
36
+ import { SelectionGrid, type SelectionCardItem, type CodingToolPresetConfig } from './selection-grid.tsx'
37
+ import { ExecutionDetailsPanel, type ExecutionDetailRow } from './execution-details-panel.tsx'
38
+ import { cn } from '../lib/cn.ts'
39
+
40
+ const dialogIconMap: Record<string, LucideIcon> = {
41
+ 'arrow-left': ArrowLeft, 'arrow-right': ArrowRight, 'arrow-up': ArrowUp, 'arrow-down': ArrowDown,
42
+ 'chevron-left': ChevronLeft, 'chevron-right': ChevronRight, 'chevron-up': ChevronUp, 'chevron-down': ChevronDown,
43
+ 'check': Check, 'x': X, 'plus': Plus, 'minus': Minus, 'pencil': Pencil, 'trash': Trash2, 'copy': Copy, 'save': Save,
44
+ 'refresh': RefreshCw, 'rotate': RotateCcw, 'undo': Undo2, 'redo': Redo2,
45
+ 'search': Search, 'filter': Filter, 'download': Download, 'upload': Upload, 'external-link': ExternalLink, 'link': Link2,
46
+ 'eye': Eye, 'eye-off': EyeOff, 'lock': Lock, 'unlock': Unlock, 'settings': Settings, 'more-h': MoreHorizontal, 'more-v': MoreVertical,
47
+ 'info': Info, 'help': HelpCircle,
48
+ 'user': User, 'users': Users, 'folder': Folder, 'file': File, 'image': Image, 'code': Code, 'terminal': Terminal,
49
+ 'star': Star, 'heart': Heart, 'bell': Bell, 'bookmark': Bookmark, 'tag': Tag, 'pin': Pin, 'mail': Mail, 'send': Send,
50
+ 'globe': Globe, 'database': Database, 'cloud': Cloud,
51
+ 'wand': Wand2, 'shield': Shield, 'shield-check': ShieldCheck, 'zap': Zap, 'sparkles': Sparkles,
52
+ 'play': Play, 'pause': Pause, 'stop': Square, 'stop-circle': StopCircle, 'scan': Scan,
53
+ 'menu': Menu, 'grip': GripVertical, 'maximize': Maximize2, 'minimize': Minimize2,
54
+ 'webhook': Webhook, 'bot': Bot, 'puzzle': Puzzle, 'plug': Plug,
55
+ }
56
+
57
+ export interface ActionDialogProps {
58
+ /** Dialog title */
59
+ title: string
60
+ /** Subtitle text next to title */
61
+ subtitle?: string
62
+ /** Icon displayed before title */
63
+ icon?: IconName
64
+ /** Icon color */
65
+ iconColor?: string
66
+ /** Settings gear button callback */
67
+ onSettings?: () => void
68
+ /** Cancel/close callback */
69
+ onCancel?: () => void
70
+ /** Submit/action callback */
71
+ onSubmit?: () => void
72
+ /** Submit button label (shown in tooltip) */
73
+ submitLabel?: string
74
+ /** Submit button icon */
75
+ submitIcon?: IconName
76
+ /** Submit button color variant */
77
+ submitColor?: string
78
+ /** Disable submit button */
79
+ submitDisabled?: boolean
80
+ /** Status text shown in footer left */
81
+ statusText?: string
82
+
83
+ // ── Selection section (optional) ─────────────────────────────────────────
84
+ /** Label above the selection grid (e.g. "AI Tool:") */
85
+ selectionLabel?: string
86
+ /** Custom selection items - renders SelectionGrid when provided */
87
+ items?: SelectionCardItem[]
88
+ /** Built-in AI tool presets. Ignored when `items` is provided. */
89
+ presets?: CodingToolPresetConfig[]
90
+ /** Currently selected item IDs */
91
+ selectedIds?: string[]
92
+ /** Selection change handler */
93
+ onSelect?: (ids: string[]) => void
94
+ /** Single or multiple selection */
95
+ selectionMode?: 'single' | 'multiple'
96
+ /** Grid (icon-top cards) or list (icon-left rows) */
97
+ selectionLayout?: 'grid' | 'list'
98
+ /** Grid column count (auto if not set) */
99
+ selectionColumns?: number
100
+
101
+ // ── Scenarios section (optional) ─────────────────────────────────────────
102
+ /** Label above scenarios (e.g. "Select scenarios to run:") */
103
+ scenarioLabel?: string
104
+ /** Scenario items - renders second SelectionGrid when provided */
105
+ scenarios?: SelectionCardItem[]
106
+ /** Currently selected scenario IDs */
107
+ selectedScenarioIds?: string[]
108
+ /** Scenario selection change handler */
109
+ onSelectScenarios?: (ids: string[]) => void
110
+ /** Scenario grid layout */
111
+ scenarioLayout?: 'grid' | 'list'
112
+ /** Scenario grid column count */
113
+ scenarioColumns?: number
114
+
115
+ // ── Execution details section (mandatory) ────────────────────────────────
116
+ /** Execution detail rows (Tool, Permissions, Output, CLI Flags, Changes) */
117
+ executionDetails: ExecutionDetailRow[]
118
+ /** Whether direct file edits are allowed */
119
+ allowDirectEdits?: boolean
120
+ /** Callback to toggle direct edits - shows the toggle when provided */
121
+ onAllowDirectEditsChange?: (value: boolean) => void
122
+ /** Warning message shown in the execution details section */
123
+ executionWarning?: string
124
+
125
+ // ── Additional content ───────────────────────────────────────────────────
126
+ /** Additional content rendered between scenarios and execution details */
127
+ children?: React.ReactNode
128
+ /** Additional className */
129
+ className?: string
130
+ }
131
+
132
+ export function ActionDialog({
133
+ title,
134
+ subtitle,
135
+ icon,
136
+ iconColor,
137
+ onSettings,
138
+ onCancel,
139
+ onSubmit,
140
+ submitLabel = 'Submit',
141
+ submitIcon = 'chevron-right',
142
+ submitColor = 'blue',
143
+ submitDisabled = false,
144
+ statusText,
145
+ selectionLabel,
146
+ items,
147
+ presets,
148
+ selectedIds,
149
+ onSelect,
150
+ selectionMode = 'multiple',
151
+ selectionLayout = 'grid',
152
+ selectionColumns,
153
+ scenarioLabel = 'Select scenarios to run:',
154
+ scenarios,
155
+ selectedScenarioIds,
156
+ onSelectScenarios,
157
+ scenarioLayout = 'list',
158
+ scenarioColumns = 2,
159
+ executionDetails,
160
+ allowDirectEdits,
161
+ onAllowDirectEditsChange,
162
+ executionWarning,
163
+ children,
164
+ className,
165
+ }: ActionDialogProps) {
166
+ useEffect(() => {
167
+ const handleEscape = (e: KeyboardEvent) => {
168
+ if (e.key === 'Escape') onCancel?.()
169
+ }
170
+ document.addEventListener('keydown', handleEscape)
171
+ return () => document.removeEventListener('keydown', handleEscape)
172
+ }, [onCancel])
173
+
174
+ useEffect(() => {
175
+ document.body.style.overflow = 'hidden'
176
+ return () => { document.body.style.overflow = '' }
177
+ }, [])
178
+
179
+ const Icon = icon ? dialogIconMap[icon] : null
180
+ const hasSelection = ((items && items.length > 0) || (presets && presets.length > 0)) && selectedIds && onSelect
181
+ const hasScenarios = scenarios && scenarios.length > 0 && selectedScenarioIds && onSelectScenarios
182
+ const hasExecutionDetails = executionDetails.length > 0 || onAllowDirectEditsChange || executionWarning
183
+
184
+ const allScenariosSelected = hasScenarios && selectedScenarioIds!.length === scenarios!.filter(s => !s.disabled).length
185
+
186
+ function handleSelectAllScenarios() {
187
+ if (!scenarios || !onSelectScenarios) return
188
+ if (allScenariosSelected) {
189
+ onSelectScenarios([])
190
+ } else {
191
+ onSelectScenarios(scenarios.filter(s => !s.disabled).map(s => s.id))
192
+ }
193
+ }
194
+
195
+ return createPortal(
196
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
197
+ <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
198
+ <div
199
+ className={cn(
200
+ 'relative bg-neutral-950 border border-neutral-700 rounded-xl shadow-2xl w-full max-w-[800px] mx-4 flex flex-col',
201
+ 'max-h-[80vh]',
202
+ className,
203
+ )}
204
+ >
205
+ {/* Header */}
206
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-neutral-700 flex-shrink-0">
207
+ {Icon && (
208
+ <Icon
209
+ className="w-4 h-4 flex-shrink-0"
210
+ style={iconColor ? { color: iconColor } : undefined}
211
+ />
212
+ )}
213
+ <div className="flex flex-col">
214
+ <span className="text-sm font-semibold text-neutral-200">
215
+ {title}
216
+ </span>
217
+ {subtitle && (
218
+ <span className="text-xs text-neutral-500">{subtitle}</span>
219
+ )}
220
+ </div>
221
+ <div className="flex-1" />
222
+ {onSettings && (
223
+ <IconButton
224
+ icon="settings"
225
+ size="sm"
226
+ color="neutral"
227
+ onClick={onSettings}
228
+ tooltip={{ description: 'Open settings' }}
229
+ />
230
+ )}
231
+ </div>
232
+
233
+ {/* Content */}
234
+ <div className="px-4 py-4 overflow-y-auto flex-1 min-h-0 space-y-4">
235
+ {/* Built-in SelectionGrid for tools/items */}
236
+ {hasSelection && (
237
+ <div>
238
+ {selectionLabel && (
239
+ <div className="text-xs text-neutral-500 mb-2">{selectionLabel}</div>
240
+ )}
241
+ <SelectionGrid
242
+ items={items}
243
+ presets={!items ? presets : undefined}
244
+ selectedIds={selectedIds}
245
+ onSelect={onSelect}
246
+ mode={selectionMode}
247
+ layout={selectionLayout}
248
+ columns={selectionColumns}
249
+ />
250
+ </div>
251
+ )}
252
+
253
+ {/* Built-in SelectionGrid for scenarios */}
254
+ {hasScenarios && (
255
+ <div>
256
+ <div className="flex items-center justify-between mb-2">
257
+ <span className="text-xs text-neutral-500">{scenarioLabel}</span>
258
+ <button
259
+ type="button"
260
+ onClick={handleSelectAllScenarios}
261
+ className="text-xs text-blue-400 hover:text-blue-300 transition-colors cursor-pointer"
262
+ >
263
+ {allScenariosSelected ? 'Deselect All' : 'Select All'}
264
+ </button>
265
+ </div>
266
+ <SelectionGrid
267
+ items={scenarios}
268
+ selectedIds={selectedScenarioIds}
269
+ onSelect={onSelectScenarios}
270
+ mode="multiple"
271
+ layout={scenarioLayout}
272
+ columns={scenarioColumns}
273
+ />
274
+ </div>
275
+ )}
276
+
277
+ {/* Additional content */}
278
+ {children}
279
+
280
+ {/* Built-in ExecutionDetailsPanel */}
281
+ {hasExecutionDetails && (
282
+ <ExecutionDetailsPanel
283
+ details={executionDetails}
284
+ allowDirectEdits={allowDirectEdits}
285
+ onAllowDirectEditsChange={onAllowDirectEditsChange}
286
+ warningMessage={executionWarning}
287
+ />
288
+ )}
289
+ </div>
290
+
291
+ {/* Footer */}
292
+ <div className="flex-shrink-0">
293
+ <FormActions
294
+ padding="modal"
295
+ statusText={statusText}
296
+ onCancel={onCancel}
297
+ cancelTooltip="Close this dialog"
298
+ onConfirm={onSubmit}
299
+ confirmTooltip={submitLabel}
300
+ confirmIcon={submitIcon}
301
+ confirmColor={submitColor as 'blue'}
302
+ confirmDisabled={submitDisabled}
303
+ />
304
+ </div>
305
+ </div>
306
+ </div>,
307
+ document.body,
308
+ )
309
+ }
@@ -0,0 +1,137 @@
1
+ import { useMemo } from 'react'
2
+ import { IconButton, type IconButtonProps, type IconName } from './icon-button.tsx'
3
+
4
+ export type AiActionStatus = 'idle' | 'running' | 'completed'
5
+ export type AiCompletionResult = 'success' | 'partial' | 'error'
6
+
7
+ export interface AiActionButtonProps {
8
+ /** Current execution status */
9
+ status?: AiActionStatus
10
+ /** Result when status is 'completed' */
11
+ completionResult?: AiCompletionResult
12
+
13
+ /** Icon shown in default state */
14
+ icon: IconName
15
+ /** Icon shown when running (defaults to 'loader') */
16
+ runningIcon?: IconName
17
+ /** Icon shown when completed successfully (defaults to 'check-circle') */
18
+ completedIcon?: IconName
19
+
20
+ /** Base tooltip (title/description modified based on state) */
21
+ tooltip: { title: string; description: string }
22
+ /** Tooltip title when running (defaults to "Show {title} Progress") */
23
+ runningTooltipTitle?: string
24
+ /** Tooltip title when completed (defaults to "View {title} Results") */
25
+ completedTooltipTitle?: string
26
+
27
+ onClick?: () => void
28
+
29
+ /** Force disabled state (in addition to automatic running state) */
30
+ disabled?: boolean
31
+ /** Custom reason when force-disabled */
32
+ disabledReason?: string
33
+
34
+ /** Color in default state */
35
+ color?: IconButtonProps['color']
36
+ /** Color when completed successfully (defaults to 'green') */
37
+ completedColor?: IconButtonProps['color']
38
+
39
+ size?: IconButtonProps['size']
40
+ className?: string
41
+ testId?: string
42
+ }
43
+
44
+ const COMPLETION_COLORS: Record<AiCompletionResult, IconButtonProps['color']> = {
45
+ success: 'green',
46
+ partial: 'amber',
47
+ error: 'red',
48
+ }
49
+
50
+ const COMPLETION_ICONS: Record<AiCompletionResult, IconName> = {
51
+ success: 'check-circle',
52
+ partial: 'alert-triangle',
53
+ error: 'x-circle',
54
+ }
55
+
56
+ export function AiActionButton({
57
+ status = 'idle',
58
+ completionResult,
59
+ icon,
60
+ runningIcon,
61
+ completedIcon,
62
+ tooltip,
63
+ runningTooltipTitle,
64
+ completedTooltipTitle,
65
+ onClick,
66
+ disabled: forceDisabled = false,
67
+ disabledReason,
68
+ color = 'neutral',
69
+ completedColor = 'green',
70
+ size = 'sm',
71
+ className,
72
+ testId,
73
+ }: AiActionButtonProps) {
74
+ const isRunning = status === 'running'
75
+ const isCompleted = status === 'completed'
76
+
77
+ const resolvedIcon: IconName = useMemo(() => {
78
+ if (isRunning) return runningIcon ?? 'loader'
79
+ if (isCompleted && completionResult) {
80
+ return completionResult === 'success'
81
+ ? (completedIcon ?? COMPLETION_ICONS[completionResult])
82
+ : COMPLETION_ICONS[completionResult]
83
+ }
84
+ return icon
85
+ }, [isRunning, isCompleted, completionResult, icon, runningIcon, completedIcon])
86
+
87
+ const resolvedColor = useMemo(() => {
88
+ if (isCompleted && completionResult) {
89
+ return completionResult === 'success'
90
+ ? completedColor
91
+ : COMPLETION_COLORS[completionResult]
92
+ }
93
+ return color
94
+ }, [isCompleted, completionResult, completedColor, color])
95
+
96
+ const resolvedTooltip = useMemo(() => {
97
+ if (forceDisabled && disabledReason) {
98
+ return {
99
+ title: `${tooltip.title} Unavailable`,
100
+ description: disabledReason,
101
+ }
102
+ }
103
+
104
+ if (isRunning) {
105
+ return {
106
+ title: runningTooltipTitle ?? `Show ${tooltip.title} Progress`,
107
+ description: 'Click to view current progress',
108
+ }
109
+ }
110
+
111
+ if (isCompleted) {
112
+ return {
113
+ title: completedTooltipTitle ?? `View ${tooltip.title} Results`,
114
+ description: 'Click to view completed results',
115
+ }
116
+ }
117
+
118
+ return tooltip
119
+ }, [tooltip, forceDisabled, disabledReason, isRunning, isCompleted, runningTooltipTitle, completedTooltipTitle])
120
+
121
+ const isDisabled = forceDisabled
122
+ const blinkClass = isCompleted ? 'animate-pulse' : ''
123
+
124
+ return (
125
+ <IconButton
126
+ icon={resolvedIcon}
127
+ color={resolvedColor}
128
+ size={size}
129
+ disabled={isDisabled}
130
+ onClick={isDisabled ? () => {} : onClick}
131
+ tooltip={resolvedTooltip}
132
+ active={isRunning}
133
+ className={`${className ?? ''} ${blinkClass}`.trim() || undefined}
134
+ testId={testId}
135
+ />
136
+ )
137
+ }