@toolr/ui-design 0.1.8 → 0.1.9

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 (100) hide show
  1. package/ai-manifest.json +35 -20
  2. package/components/composites/dashboard-list-item.tsx +172 -0
  3. package/components/composites/dashboard-panel.tsx +218 -0
  4. package/components/content/info-panel-primitives.tsx +9 -8
  5. package/components/diagrams/diagram-utils.tsx +2 -1
  6. package/components/hooks/use-dropdown-portal.ts +39 -0
  7. package/components/lib/accent-context.ts +10 -0
  8. package/components/lib/{ai-tools.tsx → coding-agents.tsx} +23 -8
  9. package/components/lib/custom-icons.tsx +37 -0
  10. package/components/lib/git-providers.tsx +39 -0
  11. package/components/lib/theme-engine.ts +59 -10
  12. package/components/lib/toolr-brand.tsx +23 -9
  13. package/components/sections/captured-issues/captured-issues-panel.tsx +17 -8
  14. package/components/sections/{ai-tools-paths/tools-paths-panel.tsx → coding-agent-paths/agent-paths-panel.tsx} +70 -62
  15. package/components/sections/coding-agent-paths/index.ts +37 -0
  16. package/components/sections/{ai-tools-paths → coding-agent-paths}/types.ts +28 -28
  17. package/components/sections/coding-agent-paths/use-agent-paths.ts +159 -0
  18. package/components/sections/golden-snapshots/file-diff-viewer.tsx +10 -9
  19. package/components/sections/golden-snapshots/golden-sync-panel.tsx +12 -3
  20. package/components/sections/golden-snapshots/snapshot-manager.tsx +9 -7
  21. package/components/sections/golden-snapshots/status-overview.tsx +8 -8
  22. package/components/sections/golden-snapshots/version-manager.tsx +6 -6
  23. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +3 -3
  24. package/components/sections/prompt-editor/index.ts +1 -1
  25. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +13 -5
  26. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +18 -10
  27. package/components/sections/prompt-editor/types.ts +2 -2
  28. package/components/sections/report-bug/report-bug-form.tsx +12 -4
  29. package/components/sections/report-bug/screenshot-uploader.tsx +11 -3
  30. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +12 -4
  31. package/components/sections/snapshot-browser/snapshot-tree.tsx +5 -4
  32. package/components/sections/snapshot-browser/types.ts +1 -1
  33. package/components/sections/snippets-editor/snippets-editor.tsx +16 -9
  34. package/components/settings/SettingsHeader.tsx +2 -2
  35. package/components/settings/SettingsPanel.tsx +11 -3
  36. package/components/settings/SettingsTreeNav.tsx +15 -9
  37. package/components/ui/action-dialog.tsx +24 -30
  38. package/components/ui/ai-action-button.tsx +10 -7
  39. package/components/ui/ai-execution-action-buttons.tsx +13 -5
  40. package/components/ui/badge.tsx +7 -4
  41. package/components/ui/bottom-panel-header.tsx +9 -5
  42. package/components/ui/breadcrumb.tsx +9 -1
  43. package/components/ui/{extension-list-card.tsx → capability-list-card.tsx} +13 -5
  44. package/components/ui/checkbox.tsx +6 -3
  45. package/components/ui/collapsible-section.tsx +38 -29
  46. package/components/ui/confirm-badge.tsx +7 -4
  47. package/components/ui/cookie-consent.tsx +13 -7
  48. package/components/ui/detail-section.tsx +24 -16
  49. package/components/ui/detail-view-wrapper.tsx +30 -22
  50. package/components/ui/editor-placeholder-card.tsx +28 -24
  51. package/components/ui/editor-toolbar.tsx +7 -4
  52. package/components/ui/execution-details-panel.tsx +10 -5
  53. package/components/ui/file-structure-section.tsx +3 -3
  54. package/components/ui/file-tree.tsx +3 -1
  55. package/components/ui/files-panel.tsx +147 -27
  56. package/components/ui/filter-dropdown.tsx +84 -74
  57. package/components/ui/form-actions.tsx +14 -6
  58. package/components/ui/frontmatter-form-header.tsx +10 -2
  59. package/components/ui/icon-button.tsx +22 -9
  60. package/components/ui/input.tsx +7 -4
  61. package/components/ui/label.tsx +5 -5
  62. package/components/ui/layout-tab-bar.tsx +7 -5
  63. package/components/ui/modal.tsx +18 -4
  64. package/components/ui/nav-card.tsx +6 -3
  65. package/components/ui/navigation-bar.tsx +37 -9
  66. package/components/ui/number-input.tsx +8 -4
  67. package/components/ui/project-explorer.tsx +666 -0
  68. package/components/ui/registry-browser.tsx +12 -1
  69. package/components/ui/registry-card.tsx +49 -42
  70. package/components/ui/registry-detail.tsx +34 -11
  71. package/components/ui/resizable-textarea.tsx +18 -11
  72. package/components/ui/scope-badge.tsx +18 -11
  73. package/components/ui/segmented-toggle.tsx +5 -2
  74. package/components/ui/select.tsx +12 -9
  75. package/components/ui/selection-grid.tsx +36 -37
  76. package/components/ui/setting-row.tsx +2 -2
  77. package/components/ui/settings-card.tsx +10 -3
  78. package/components/ui/settings-info-box.tsx +9 -5
  79. package/components/ui/settings-section-title.tsx +14 -2
  80. package/components/ui/snapshot-card.tsx +10 -2
  81. package/components/ui/snippets-panel.tsx +4 -2
  82. package/components/ui/sort-dropdown.tsx +39 -29
  83. package/components/ui/status-card.tsx +9 -1
  84. package/components/ui/tab-bar.tsx +12 -9
  85. package/components/ui/toggle.tsx +13 -7
  86. package/components/ui/tooltip.tsx +9 -1
  87. package/dist/content.js +8 -8
  88. package/dist/diagrams.d.ts +0 -1
  89. package/dist/index.d.ts +421 -182
  90. package/dist/index.js +2984 -1691
  91. package/dist/tokens/primitives.css +28 -6
  92. package/dist/tokens/semantic.css +15 -15
  93. package/dist/tokens/theme.css +23 -0
  94. package/index.ts +25 -11
  95. package/package.json +1 -1
  96. package/tokens/primitives.css +28 -6
  97. package/tokens/semantic.css +15 -15
  98. package/tokens/theme.css +23 -0
  99. package/components/sections/ai-tools-paths/index.ts +0 -37
  100. package/components/sections/ai-tools-paths/use-tools-paths.ts +0 -159
@@ -0,0 +1,666 @@
1
+ import React, { type ReactNode, memo, useState, useCallback } from 'react'
2
+ import { IconButton } from './icon-button.tsx'
3
+ import { Input } from './input.tsx'
4
+ import { Tooltip } from './tooltip.tsx'
5
+ import { iconMap } from './icon-button.tsx'
6
+ import { useAccentColor } from '../lib/accent-context.ts'
7
+ import type { FormColor } from '../lib/form-colors.ts'
8
+ import { FolderOpen as FolderOpenIcon } from 'lucide-react'
9
+
10
+ const SearchIcon = iconMap['search']
11
+ const XIcon = iconMap['x']
12
+ const FolderIcon = iconMap['folder']
13
+ const ChevronRightIcon = iconMap['chevron-right']
14
+ const ChevronDownIcon = iconMap['chevron-down']
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface ExplorerNode {
21
+ name: string
22
+ path: string
23
+ children: ExplorerNode[]
24
+ project?: { id: string; name: string; path: string; [key: string]: unknown }
25
+ }
26
+
27
+ export interface ExplorerTopItem {
28
+ id: string
29
+ icon: ReactNode
30
+ label: string
31
+ selected: boolean
32
+ selectedClass: string
33
+ onClick: () => void
34
+ actions?: ReactNode
35
+ }
36
+
37
+ export interface ExplorerContextMenuInfo {
38
+ x: number
39
+ y: number
40
+ project: ExplorerNode['project'] | null
41
+ nodeName: string
42
+ nodePath: string
43
+ }
44
+
45
+ export interface ProjectExplorerProps {
46
+ tree: ExplorerNode[]
47
+ selectedProjectId: string | null
48
+ onSelectProject: (id: string) => void
49
+
50
+ topItems?: ExplorerTopItem[]
51
+
52
+ searchQuery: string
53
+ onSearchChange: (query: string) => void
54
+
55
+ collapsedPaths: string[]
56
+ onTogglePath: (path: string) => void
57
+ expandablePaths: string[]
58
+ onExpandAll: () => void
59
+ onCollapseAll: () => void
60
+
61
+ width: number
62
+ onWidthChange: (width: number) => void
63
+ collapsed: boolean
64
+ onToggleCollapsed: () => void
65
+
66
+ onAddProject: () => void
67
+ isAddingProject?: boolean
68
+ onScanProjects?: () => void
69
+
70
+ onMiddleClick?: (project: ExplorerNode['project']) => void
71
+ renderProjectActions?: (project: ExplorerNode['project']) => ReactNode
72
+ renderContextMenu?: (info: ExplorerContextMenuInfo, onClose: () => void) => ReactNode
73
+ isProjectHighlighted?: (project: ExplorerNode['project']) => boolean
74
+
75
+ accentColor?: FormColor
76
+ emptyMessage?: string
77
+ noMatchMessage?: string
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Accent color class maps
82
+ // ---------------------------------------------------------------------------
83
+
84
+ const COLLAPSED_BUTTON: Record<string, string> = {
85
+ blue: 'text-blue-500', green: 'text-green-500', red: 'text-red-500', orange: 'text-orange-500',
86
+ cyan: 'text-cyan-500', yellow: 'text-yellow-500', purple: 'text-purple-500', indigo: 'text-indigo-500',
87
+ emerald: 'text-emerald-500', amber: 'text-amber-500', violet: 'text-violet-500', neutral: 'text-neutral-500',
88
+ sky: 'text-sky-500', pink: 'text-pink-500', teal: 'text-teal-500',
89
+ }
90
+
91
+ const RESIZE_HOVER: Record<string, string> = {
92
+ blue: 'hover:bg-blue-500', green: 'hover:bg-green-500', red: 'hover:bg-red-500', orange: 'hover:bg-orange-500',
93
+ cyan: 'hover:bg-cyan-500', yellow: 'hover:bg-yellow-500', purple: 'hover:bg-purple-500', indigo: 'hover:bg-indigo-500',
94
+ emerald: 'hover:bg-emerald-500', amber: 'hover:bg-amber-500', violet: 'hover:bg-violet-500', neutral: 'hover:bg-neutral-500',
95
+ sky: 'hover:bg-sky-500', pink: 'hover:bg-pink-500', teal: 'hover:bg-teal-500',
96
+ }
97
+
98
+ const RESIZE_ACTIVE: Record<string, string> = {
99
+ blue: 'bg-blue-500', green: 'bg-green-500', red: 'bg-red-500', orange: 'bg-orange-500',
100
+ cyan: 'bg-cyan-500', yellow: 'bg-yellow-500', purple: 'bg-purple-500', indigo: 'bg-indigo-500',
101
+ emerald: 'bg-emerald-500', amber: 'bg-amber-500', violet: 'bg-violet-500', neutral: 'bg-neutral-500',
102
+ sky: 'bg-sky-500', pink: 'bg-pink-500', teal: 'bg-teal-500',
103
+ }
104
+
105
+ const RESIZE_IDLE_HOVER: Record<string, string> = {
106
+ blue: 'hover:bg-blue-500/50', green: 'hover:bg-green-500/50', red: 'hover:bg-red-500/50', orange: 'hover:bg-orange-500/50',
107
+ cyan: 'hover:bg-cyan-500/50', yellow: 'hover:bg-yellow-500/50', purple: 'hover:bg-purple-500/50', indigo: 'hover:bg-indigo-500/50',
108
+ emerald: 'hover:bg-emerald-500/50', amber: 'hover:bg-amber-500/50', violet: 'hover:bg-violet-500/50', neutral: 'hover:bg-neutral-500/50',
109
+ sky: 'hover:bg-sky-500/50', pink: 'hover:bg-pink-500/50', teal: 'hover:bg-teal-500/50',
110
+ }
111
+
112
+ const SELECTION: Record<string, string> = {
113
+ blue: 'bg-blue-500/20 text-white ring-1 ring-blue-500/30', green: 'bg-green-500/20 text-white ring-1 ring-green-500/30',
114
+ red: 'bg-red-500/20 text-white ring-1 ring-red-500/30', orange: 'bg-orange-500/20 text-white ring-1 ring-orange-500/30',
115
+ cyan: 'bg-cyan-500/20 text-white ring-1 ring-cyan-500/30', yellow: 'bg-yellow-500/20 text-white ring-1 ring-yellow-500/30',
116
+ purple: 'bg-purple-500/20 text-white ring-1 ring-purple-500/30', indigo: 'bg-indigo-500/20 text-white ring-1 ring-indigo-500/30',
117
+ emerald: 'bg-emerald-500/20 text-white ring-1 ring-emerald-500/30', amber: 'bg-amber-500/20 text-white ring-1 ring-amber-500/30',
118
+ violet: 'bg-violet-500/20 text-white ring-1 ring-violet-500/30', neutral: 'bg-neutral-500/20 text-white ring-1 ring-neutral-500/30',
119
+ sky: 'bg-sky-500/20 text-white ring-1 ring-sky-500/30', pink: 'bg-pink-500/20 text-white ring-1 ring-pink-500/30',
120
+ teal: 'bg-teal-500/20 text-white ring-1 ring-teal-500/30',
121
+ }
122
+
123
+ const FOLDER_ACCENT: Record<string, string> = {
124
+ blue: 'text-blue-500', green: 'text-green-500', red: 'text-red-500', orange: 'text-orange-500',
125
+ cyan: 'text-cyan-500', yellow: 'text-yellow-500', purple: 'text-purple-500', indigo: 'text-indigo-500',
126
+ emerald: 'text-emerald-500', amber: 'text-amber-500', violet: 'text-violet-500', neutral: 'text-neutral-500',
127
+ sky: 'text-sky-500', pink: 'text-pink-500', teal: 'text-teal-500',
128
+ }
129
+
130
+ const PANEL_BG = ''
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Tree utilities
134
+ // ---------------------------------------------------------------------------
135
+
136
+ function findCommonPrefixLength(paths: string[][]): number {
137
+ if (paths.length === 0) return 0
138
+ const first = paths[0]
139
+ let len = 0
140
+ outer: for (let i = 0; i < first.length; i++) {
141
+ for (const p of paths) {
142
+ if (p[i] !== first[i]) break outer
143
+ }
144
+ len = i + 1
145
+ }
146
+ return len
147
+ }
148
+
149
+ function sortTreeNodes(nodes: ExplorerNode[]): ExplorerNode[] {
150
+ return nodes
151
+ .map((n) => ({ ...n, children: sortTreeNodes(n.children) }))
152
+ .sort((a, b) => {
153
+ const aFolder = a.children.length > 0 && !a.project
154
+ const bFolder = b.children.length > 0 && !b.project
155
+ if (aFolder && !bFolder) return -1
156
+ if (!aFolder && bFolder) return 1
157
+ return a.name.localeCompare(b.name)
158
+ })
159
+ }
160
+
161
+ function insertIntoTree<P extends { id: string; path: string }>(
162
+ root: ExplorerNode[],
163
+ project: P,
164
+ commonPrefixLength: number,
165
+ ): void {
166
+ const parts = project.path.split('/').filter(Boolean)
167
+ const relevant = parts.slice(commonPrefixLength)
168
+ const prefixPath = parts.slice(0, commonPrefixLength).join('/')
169
+ let currentPath = prefixPath ? '/' + prefixPath : ''
170
+ let level = root
171
+
172
+ for (let i = 0; i < relevant.length; i++) {
173
+ const part = relevant[i]
174
+ currentPath = currentPath + '/' + part
175
+ const isLast = i === relevant.length - 1
176
+
177
+ let existing = level.find((n) => n.name === part)
178
+ if (!existing) {
179
+ existing = {
180
+ name: part,
181
+ path: currentPath,
182
+ children: [],
183
+ project: isLast ? (project as unknown as ExplorerNode['project']) : undefined,
184
+ }
185
+ level.push(existing)
186
+ }
187
+ if (isLast) {
188
+ existing.project = project as unknown as ExplorerNode['project']
189
+ }
190
+ level = existing.children
191
+ }
192
+ }
193
+
194
+ export function buildExplorerTree<P extends { id: string; path: string }>(
195
+ projects: P[],
196
+ isSpecial?: (p: P) => boolean,
197
+ ): ExplorerNode[] {
198
+ const regular = isSpecial ? projects.filter((p) => !isSpecial(p)) : projects
199
+ const paths = regular.map((p) => p.path.split('/').filter(Boolean))
200
+ if (paths.length === 0) return []
201
+
202
+ const commonLen = findCommonPrefixLength(paths)
203
+ const root: ExplorerNode[] = []
204
+ for (const project of regular) {
205
+ insertIntoTree(root, project, commonLen)
206
+ }
207
+ return sortTreeNodes(root)
208
+ }
209
+
210
+ export function filterExplorerTree(nodes: ExplorerNode[], query: string): ExplorerNode[] {
211
+ const lq = query.toLowerCase()
212
+ return nodes.reduce<ExplorerNode[]>((acc, node) => {
213
+ if (node.name.toLowerCase().includes(lq) || node.path.toLowerCase().includes(lq)) {
214
+ acc.push(node)
215
+ } else {
216
+ const filtered = filterExplorerTree(node.children, query)
217
+ if (filtered.length > 0) acc.push({ ...node, children: filtered })
218
+ }
219
+ return acc
220
+ }, [])
221
+ }
222
+
223
+ export function collectExplorerExpandablePaths(nodes: ExplorerNode[]): string[] {
224
+ const paths: string[] = []
225
+ for (const node of nodes) {
226
+ if (node.children.length > 0) {
227
+ paths.push(node.path)
228
+ paths.push(...collectExplorerExpandablePaths(node.children))
229
+ }
230
+ }
231
+ return paths
232
+ }
233
+
234
+ export function findPathToNode(nodes: ExplorerNode[], projectId: string): string[] | null {
235
+ for (const node of nodes) {
236
+ if (node.project?.id === projectId) return []
237
+ if (node.children.length === 0) continue
238
+ const child = findPathToNode(node.children, projectId)
239
+ if (child !== null) return [node.path, ...child]
240
+ }
241
+ return null
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Internal: highlight match
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function highlightMatch(text: string, query: string): ReactNode {
249
+ if (!query) return text
250
+ const idx = text.toLowerCase().indexOf(query.toLowerCase())
251
+ if (idx === -1) return text
252
+ return (
253
+ <>
254
+ {text.slice(0, idx)}
255
+ <span className="bg-yellow-500/30 text-yellow-200">{text.slice(idx, idx + query.length)}</span>
256
+ {text.slice(idx + query.length)}
257
+ </>
258
+ )
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Internal: ExplorerTreeNodeRow
263
+ // ---------------------------------------------------------------------------
264
+
265
+ interface TreeNodeProps {
266
+ node: ExplorerNode
267
+ depth: number
268
+ selectedProjectId: string | null
269
+ collapsedPaths: string[]
270
+ onSelectProject: (id: string) => void
271
+ onToggle: (path: string) => void
272
+ onMiddleClick?: (project: ExplorerNode['project']) => void
273
+ onContextMenu?: (info: ExplorerContextMenuInfo) => void
274
+ renderProjectActions?: (project: ExplorerNode['project']) => ReactNode
275
+ isProjectHighlighted?: (project: ExplorerNode['project']) => boolean
276
+ searchQuery?: string
277
+ accentColor: string
278
+ }
279
+
280
+ const ExplorerTreeNodeRow = memo(function ExplorerTreeNodeRow({
281
+ node, depth, selectedProjectId, collapsedPaths,
282
+ onSelectProject, onToggle, onMiddleClick, onContextMenu,
283
+ renderProjectActions, isProjectHighlighted, searchQuery, accentColor,
284
+ }: TreeNodeProps) {
285
+ const hasChildren = node.children.length > 0
286
+ const isProject = !!node.project
287
+ const isCollapsed = collapsedPaths.includes(node.path)
288
+ const isSelected = isProject && node.project?.id === selectedProjectId
289
+
290
+ const handleClick = useCallback(() => {
291
+ if (isProject && node.project) onSelectProject(node.project.id)
292
+ else if (hasChildren) onToggle(node.path)
293
+ }, [isProject, hasChildren, node, onSelectProject, onToggle])
294
+
295
+ const handleChevronClick = useCallback((e: React.MouseEvent) => {
296
+ e.stopPropagation()
297
+ onToggle(node.path)
298
+ }, [node.path, onToggle])
299
+
300
+ const handleAuxClick = useCallback((e: React.MouseEvent) => {
301
+ if (e.button !== 1 || !node.project || !onMiddleClick) return
302
+ e.preventDefault()
303
+ onMiddleClick(node.project)
304
+ }, [node.project, onMiddleClick])
305
+
306
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
307
+ if (!onContextMenu) return
308
+ e.preventDefault()
309
+ onContextMenu({
310
+ x: e.clientX, y: e.clientY,
311
+ project: node.project ?? null,
312
+ nodeName: node.name, nodePath: node.path,
313
+ })
314
+ }, [node, onContextMenu])
315
+
316
+ const displayName = searchQuery ? highlightMatch(node.name, searchQuery) : node.name
317
+ const selectionClass = SELECTION[accentColor] ?? SELECTION.blue
318
+ const CurrentFolderIcon = isCollapsed ? FolderIcon : FolderOpenIcon
319
+ const highlighted = isProject && (!isProjectHighlighted || isProjectHighlighted(node.project))
320
+ const folderColor = highlighted ? (FOLDER_ACCENT[accentColor] ?? FOLDER_ACCENT.blue) : 'text-neutral-500'
321
+
322
+ return (
323
+ <>
324
+ <div
325
+ role="button"
326
+ tabIndex={0}
327
+ onClick={handleClick}
328
+ onAuxClick={handleAuxClick}
329
+ onKeyDown={(e) => e.key === 'Enter' && handleClick()}
330
+ onContextMenu={handleContextMenu}
331
+ className={`w-full relative flex items-center gap-1 py-1 rounded-lg text-left text-sm transition-colors cursor-pointer select-none ${
332
+ isSelected ? selectionClass : 'text-neutral-400 hover:text-white hover:bg-neutral-960/50'
333
+ }`}
334
+ style={{ paddingLeft: `${depth * 12 + 8}px`, paddingRight: '8px' }}
335
+ data-testid={isProject && node.project ? `explorer-item-${node.project.id}` : undefined}
336
+ >
337
+ {hasChildren ? (
338
+ <button
339
+ type="button"
340
+ className="w-[18px] h-[18px] flex items-center justify-center flex-shrink-0 rounded text-neutral-500 hover:text-neutral-300 transition-colors cursor-pointer"
341
+ onClick={handleChevronClick}
342
+ >
343
+ {isCollapsed ? <ChevronRightIcon className="w-4 h-4" /> : <ChevronDownIcon className="w-4 h-4" />}
344
+ </button>
345
+ ) : (
346
+ <span className="w-[18px] flex-shrink-0" />
347
+ )}
348
+ <CurrentFolderIcon className={`w-4 h-4 flex-shrink-0 ${folderColor}`} />
349
+ <span className="truncate flex-1">{displayName}</span>
350
+ {isProject && Array.isArray((node.project as Record<string, unknown>)?.worktrees) &&
351
+ ((node.project as Record<string, unknown>).worktrees as unknown[]).length > 0 ? (
352
+ <span className="text-xs text-violet-400/80 bg-violet-400/10 px-1 rounded ml-1 flex-shrink-0">
353
+ worktree
354
+ </span>
355
+ ) : null}
356
+ {isProject && node.project && renderProjectActions?.(node.project)}
357
+ </div>
358
+ {hasChildren && !isCollapsed && (
359
+ <div>
360
+ {node.children.map((child) => (
361
+ <ExplorerTreeNodeRow
362
+ key={child.path}
363
+ node={child}
364
+ depth={depth + 1}
365
+ selectedProjectId={selectedProjectId}
366
+ collapsedPaths={collapsedPaths}
367
+ onSelectProject={onSelectProject}
368
+ onToggle={onToggle}
369
+ onMiddleClick={onMiddleClick}
370
+ onContextMenu={onContextMenu}
371
+ renderProjectActions={renderProjectActions}
372
+ isProjectHighlighted={isProjectHighlighted}
373
+ searchQuery={searchQuery}
374
+ accentColor={accentColor}
375
+ />
376
+ ))}
377
+ </div>
378
+ )}
379
+ </>
380
+ )
381
+ })
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Internal: TopItemsSection
385
+ // ---------------------------------------------------------------------------
386
+
387
+ function TopItemRow({ item }: { item: ExplorerTopItem }) {
388
+ return (
389
+ <div
390
+ role="button"
391
+ tabIndex={0}
392
+ onClick={item.onClick}
393
+ onKeyDown={(e) => e.key === 'Enter' && item.onClick()}
394
+ className={`flex items-center gap-1 py-1 rounded-lg text-sm cursor-pointer select-none transition-colors ${
395
+ item.selected ? item.selectedClass : 'text-neutral-300 hover:text-white hover:bg-neutral-960/50'
396
+ }`}
397
+ style={{ paddingLeft: '8px', paddingRight: '8px' }}
398
+ >
399
+ {item.icon}
400
+ <span className="truncate flex-1 ml-1">{item.label}</span>
401
+ {item.actions}
402
+ </div>
403
+ )
404
+ }
405
+
406
+ function TopItemSpacer() {
407
+ return <div className="py-1" style={{ paddingLeft: '8px', paddingRight: '8px' }}>&nbsp;</div>
408
+ }
409
+
410
+ function TopItemsSection({ items }: { items: ExplorerTopItem[] }) {
411
+ const row1 = items[0]
412
+ const row2 = items[1]
413
+ return (
414
+ <div className="flex-shrink-0 px-1">
415
+ {row1 ? <TopItemRow item={row1} /> : <TopItemSpacer />}
416
+ {row2 ? <TopItemRow item={row2} /> : <TopItemSpacer />}
417
+ <div className="mx-2 my-1 border-t border-neutral-800" />
418
+ </div>
419
+ )
420
+ }
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // Internal: CollapsedExplorer
424
+ // ---------------------------------------------------------------------------
425
+
426
+ function CollapsedExplorer({ onToggle, accentColor }: { onToggle: () => void; accentColor: string }) {
427
+ return (
428
+ <aside
429
+ aria-label="Project explorer"
430
+ className={`flex flex-col items-center py-3 px-1.5 ${PANEL_BG} border-r border-neutral-960 h-full w-[40px] flex-shrink-0`}
431
+ >
432
+ <Tooltip
433
+ content={{ title: 'Projects', description: 'Expand sidebar' }}
434
+ position="right"
435
+ align="center"
436
+ >
437
+ <button
438
+ onClick={onToggle}
439
+ className={`w-full py-2 flex flex-col items-center justify-center gap-0.5 transition-colors cursor-pointer ${COLLAPSED_BUTTON[accentColor] ?? COLLAPSED_BUTTON.blue}`}
440
+ >
441
+ <FolderIcon className="w-4 h-4" />
442
+ </button>
443
+ </Tooltip>
444
+ <div className="flex-1 flex items-center justify-center">
445
+ <span
446
+ className="text-sm font-semibold text-neutral-500 uppercase tracking-widest"
447
+ style={{ writingMode: 'vertical-rl', textOrientation: 'mixed', transform: 'rotate(180deg)' }}
448
+ >
449
+ Explorer
450
+ </span>
451
+ </div>
452
+ </aside>
453
+ )
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Internal: ResizeHandle
458
+ // ---------------------------------------------------------------------------
459
+
460
+ function ResizeHandle({ width, onWidthChange, accentColor }: {
461
+ width: number; onWidthChange: (w: number) => void; accentColor: string
462
+ }) {
463
+ const [isResizing, setIsResizing] = useState(false)
464
+
465
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
466
+ e.preventDefault()
467
+ setIsResizing(true)
468
+ const startX = e.clientX
469
+ const startWidth = width
470
+
471
+ const onMove = (ev: MouseEvent) => onWidthChange(startWidth + (ev.clientX - startX))
472
+ const onUp = () => {
473
+ setIsResizing(false)
474
+ document.removeEventListener('mousemove', onMove)
475
+ document.removeEventListener('mouseup', onUp)
476
+ }
477
+ document.addEventListener('mousemove', onMove, { passive: true })
478
+ document.addEventListener('mouseup', onUp, { passive: true })
479
+ }, [width, onWidthChange])
480
+
481
+ const hover = RESIZE_HOVER[accentColor] ?? RESIZE_HOVER.blue
482
+ const active = RESIZE_ACTIVE[accentColor] ?? RESIZE_ACTIVE.blue
483
+ const idleHover = RESIZE_IDLE_HOVER[accentColor] ?? RESIZE_IDLE_HOVER.blue
484
+
485
+ return (
486
+ <div
487
+ onMouseDown={handleMouseDown}
488
+ className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize ${hover} transition-colors z-10 ${
489
+ isResizing ? active : `bg-transparent ${idleHover}`
490
+ }`}
491
+ />
492
+ )
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Internal: SearchInput
497
+ // ---------------------------------------------------------------------------
498
+
499
+ function SearchInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
500
+ return (
501
+ <div className="flex-shrink-0 px-3 py-2">
502
+ <div className="relative">
503
+ <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
504
+ <Input
505
+ placeholder="Search projects..."
506
+ value={value}
507
+ onChange={onChange}
508
+ autoComplete="off"
509
+ autoCorrect="off"
510
+ autoCapitalize="off"
511
+ spellCheck={false}
512
+ data-form-type="other"
513
+ size="sm"
514
+ className="pl-8 pr-8"
515
+ style={{ height: '26px' }}
516
+ />
517
+ {value && (
518
+ <button
519
+ type="button"
520
+ onClick={() => onChange('')}
521
+ className="absolute right-2 top-1/2 -translate-y-1/2 w-[18px] h-[18px] flex items-center justify-center rounded-md text-neutral-400 hover:text-neutral-300 hover:bg-neutral-500/20 transition-colors"
522
+ >
523
+ <XIcon className="w-3 h-3" />
524
+ </button>
525
+ )}
526
+ </div>
527
+ </div>
528
+ )
529
+ }
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // ProjectExplorer
533
+ // ---------------------------------------------------------------------------
534
+
535
+ export function ProjectExplorer({
536
+ tree, selectedProjectId, onSelectProject,
537
+ topItems,
538
+ searchQuery, onSearchChange,
539
+ collapsedPaths, onTogglePath, expandablePaths, onExpandAll, onCollapseAll,
540
+ width, onWidthChange, collapsed, onToggleCollapsed,
541
+ onAddProject, isAddingProject, onScanProjects,
542
+ onMiddleClick, renderProjectActions, renderContextMenu, isProjectHighlighted,
543
+ accentColor: accentColorProp,
544
+ emptyMessage = 'No projects yet',
545
+ noMatchMessage = 'No matching projects',
546
+ }: ProjectExplorerProps) {
547
+ const ctxAccent = useAccentColor()
548
+ const accentColor = accentColorProp ?? ctxAccent ?? 'blue'
549
+
550
+ const [contextMenu, setContextMenu] = useState<ExplorerContextMenuInfo | null>(null)
551
+
552
+ const handleContextMenu = useCallback((info: ExplorerContextMenuInfo) => {
553
+ setContextMenu(info)
554
+ }, [])
555
+
556
+ const handleContextMenuClose = useCallback(() => {
557
+ setContextMenu(null)
558
+ }, [])
559
+
560
+ // Close context menu on any click
561
+ React.useEffect(() => {
562
+ if (!contextMenu) return
563
+ const close = () => setContextMenu(null)
564
+ document.addEventListener('click', close)
565
+ return () => document.removeEventListener('click', close)
566
+ }, [contextMenu])
567
+
568
+ const filteredTree = React.useMemo(() => {
569
+ if (!searchQuery.trim()) return tree
570
+ return filterExplorerTree(tree, searchQuery)
571
+ }, [tree, searchQuery])
572
+
573
+ const expandableSet = React.useMemo(() => new Set(expandablePaths), [expandablePaths])
574
+ const collapsedCount = collapsedPaths.filter((p) => expandableSet.has(p)).length
575
+ const allCollapsed = collapsedCount === expandablePaths.length && expandablePaths.length > 0
576
+
577
+ if (collapsed) {
578
+ return <CollapsedExplorer onToggle={onToggleCollapsed} accentColor={accentColor} />
579
+ }
580
+
581
+ return (
582
+ <aside
583
+ aria-label="Project explorer"
584
+ className={`${PANEL_BG} flex flex-col relative overflow-hidden flex-shrink-0`}
585
+ style={{ width }}
586
+ >
587
+ <ResizeHandle width={width} onWidthChange={onWidthChange} accentColor={accentColor} />
588
+
589
+ {/* Header */}
590
+ <div className="flex items-center justify-between px-3 h-[47px] border-b border-neutral-960">
591
+ <span className="text-sm font-semibold text-neutral-500 uppercase tracking-wider">Explorer</span>
592
+ <div className="flex items-center gap-1">
593
+ <IconButton
594
+ icon={isAddingProject ? 'loader-2' : 'plus'}
595
+ onClick={onAddProject}
596
+ size="sm"
597
+ accentColor="cyan"
598
+ disabled={isAddingProject}
599
+ tooltip={{ title: 'Add project', description: 'Open a folder to add as a project' }}
600
+ />
601
+ {onScanProjects && (
602
+ <IconButton
603
+ icon="folder-search"
604
+ onClick={onScanProjects}
605
+ size="sm"
606
+ accentColor="neutral"
607
+ tooltip={{ title: 'Scan for projects', description: 'Discover AI-configured projects on your machine' }}
608
+ />
609
+ )}
610
+ {expandablePaths.length > 0 && (
611
+ <IconButton
612
+ icon={allCollapsed ? 'chevrons-up-down' : 'chevrons-down-up'}
613
+ onClick={allCollapsed ? onExpandAll : onCollapseAll}
614
+ size="sm"
615
+ accentColor="neutral"
616
+ tooltip={{
617
+ title: allCollapsed ? 'Expand all' : 'Collapse all',
618
+ description: allCollapsed ? 'Expand all folders' : 'Collapse all folders',
619
+ }}
620
+ />
621
+ )}
622
+ <IconButton
623
+ icon="panel-left-close"
624
+ onClick={onToggleCollapsed}
625
+ size="sm"
626
+ accentColor="neutral"
627
+ tooltip={{ title: 'Collapse sidebar', description: 'Minimize to icon strip' }}
628
+ />
629
+ </div>
630
+ </div>
631
+
632
+ {topItems && topItems.length > 0 && <TopItemsSection items={topItems} />}
633
+
634
+ <SearchInput value={searchQuery} onChange={onSearchChange} />
635
+
636
+ {/* Tree */}
637
+ <nav className="flex-1 overflow-y-auto min-h-0 p-1">
638
+ {filteredTree.length === 0 && (
639
+ <div className="px-3 py-4 text-sm text-neutral-500 text-center">
640
+ {searchQuery ? noMatchMessage : emptyMessage}
641
+ </div>
642
+ )}
643
+ {filteredTree.map((node) => (
644
+ <ExplorerTreeNodeRow
645
+ key={node.path}
646
+ node={node}
647
+ depth={0}
648
+ selectedProjectId={selectedProjectId}
649
+ collapsedPaths={collapsedPaths}
650
+ onSelectProject={onSelectProject}
651
+ onToggle={onTogglePath}
652
+ onMiddleClick={onMiddleClick}
653
+ onContextMenu={renderContextMenu ? handleContextMenu : undefined}
654
+ renderProjectActions={renderProjectActions}
655
+ isProjectHighlighted={isProjectHighlighted}
656
+ searchQuery={searchQuery || undefined}
657
+ accentColor={accentColor}
658
+ />
659
+ ))}
660
+ </nav>
661
+
662
+ {/* Context menu */}
663
+ {contextMenu && renderContextMenu?.(contextMenu, handleContextMenuClose)}
664
+ </aside>
665
+ )
666
+ }