@workerdeck/ui 0.7.0 → 0.11.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 (61) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-CyhygZx_.d.mts +277 -0
  3. package/build/SessionPanel-_U8tjX29.mjs +8409 -0
  4. package/build/SessionPanel-_U8tjX29.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +2 -0
  9. package/build/format.mjs +2 -0
  10. package/build/index.d.mts +615 -87
  11. package/build/index.mjs +6 -5081
  12. package/build/index.mjs.map +1 -1
  13. package/build/workspace.d.mts +199 -0
  14. package/build/workspace.mjs +849 -0
  15. package/build/workspace.mjs.map +1 -0
  16. package/package.json +22 -4
  17. package/src/components/agent/CodeEditor.tsx +300 -0
  18. package/src/components/agent/Composer.tsx +522 -87
  19. package/src/components/agent/ContextDialog.tsx +99 -0
  20. package/src/components/agent/Conversation.tsx +11 -3
  21. package/src/components/agent/EditorTabs.tsx +165 -0
  22. package/src/components/agent/FileCard.tsx +26 -0
  23. package/src/components/agent/FileTree.tsx +287 -0
  24. package/src/components/agent/FileViewer.tsx +148 -0
  25. package/src/components/agent/HostFilesDialog.tsx +218 -0
  26. package/src/components/agent/Loader.tsx +120 -14
  27. package/src/components/agent/McpDialog.tsx +363 -0
  28. package/src/components/agent/Message.tsx +51 -17
  29. package/src/components/agent/ModelSelect.tsx +34 -6
  30. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  31. package/src/components/agent/PermissionPrompt.tsx +164 -6
  32. package/src/components/agent/PromptTokenText.tsx +39 -0
  33. package/src/components/agent/QuestionPrompt.tsx +122 -0
  34. package/src/components/agent/Reasoning.tsx +20 -5
  35. package/src/components/agent/Response.tsx +128 -0
  36. package/src/components/agent/SessionEmptyState.tsx +65 -0
  37. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  38. package/src/components/agent/SessionPanel.tsx +756 -90
  39. package/src/components/agent/SessionWorkspace.tsx +282 -0
  40. package/src/components/agent/SkillsDialog.tsx +195 -0
  41. package/src/components/agent/StatusBar.tsx +85 -18
  42. package/src/components/agent/ToolCallCard.tsx +243 -30
  43. package/src/components/agent/Transcript.tsx +540 -27
  44. package/src/components/agent/UsageDialog.tsx +168 -0
  45. package/src/components/agent/line-prompt.tsx +249 -0
  46. package/src/components/agent/transcript-variant.tsx +61 -0
  47. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  48. package/src/components/prompt-area/types.ts +15 -0
  49. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  50. package/src/components/ui/CodeBlock.tsx +40 -2
  51. package/src/components/ui/CopyButton.tsx +28 -3
  52. package/src/components/ui/Dialog.tsx +92 -0
  53. package/src/components/ui/Menu.tsx +55 -0
  54. package/src/components/ui/Splitter.tsx +133 -0
  55. package/src/components/ui/Tooltip.tsx +22 -5
  56. package/src/format.ts +10 -0
  57. package/src/index.ts +63 -2
  58. package/src/lib/clipboard.ts +56 -0
  59. package/src/lib/format.ts +114 -0
  60. package/src/lib/tool-icon.ts +96 -0
  61. package/src/workspace.ts +28 -0
@@ -0,0 +1,99 @@
1
+ import type { ContextUsage } from '@workerdeck/protocol'
2
+ import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { formatTokens } from '../../lib/format.ts'
5
+
6
+ export interface ContextDialogProps {
7
+ usage?: ContextUsage
8
+ open: boolean
9
+ onOpenChange: (open: boolean) => void
10
+ }
11
+
12
+ /** The CLI reports category colors as its own theme token names ('inactive',
13
+ * 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
14
+ const cssColor = (color: string): string | undefined =>
15
+ typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
16
+
17
+ const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
18
+
19
+ /**
20
+ * What is in the model's context window right now, category by category.
21
+ *
22
+ * One of the three panels the status bar opens. Context, usage and session info
23
+ * are different questions asked at different moments, so they are different
24
+ * screens rather than one "details" list you scroll past two answers to reach.
25
+ */
26
+ export function ContextDialog({ usage, open, onOpenChange }: ContextDialogProps) {
27
+ return (
28
+ <Dialog open={open} onOpenChange={onOpenChange}>
29
+ <DialogContent>
30
+ <DialogHeader title='Context' description={usage?.model} />
31
+ <DialogBody>
32
+ {!usage ? (
33
+ <p className='py-6 text-center text-body-sm text-fg-4'>
34
+ No reading yet — the context window is measured after a turn completes.
35
+ </p>
36
+ ) : (
37
+ <>
38
+ <div className='flex items-baseline justify-between gap-4'>
39
+ <span className='text-label text-fg-3'>Used</span>
40
+ <span className='font-mono text-body-sm text-fg-1'>
41
+ {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} ·{' '}
42
+ {usage.percentage.toFixed(0)}%
43
+ </span>
44
+ </div>
45
+ <div className='mt-2 h-2 overflow-hidden rounded-full bg-border'>
46
+ <div
47
+ className={cn('h-full rounded-full', usageTint(usage.percentage))}
48
+ style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
49
+ />
50
+ </div>
51
+ {/* Codex reports occupancy with no breakdown, so an engine can send
52
+ a real reading and an empty `categories`. A "Breakdown" heading
53
+ over nothing reads as a failed load; the row above already says
54
+ everything that is known. */}
55
+ {usage.categories.length > 0 ? (
56
+ <div className='mt-5'>
57
+ <h3 className='text-label font-medium text-fg-3'>Breakdown</h3>
58
+ <div className='mt-2 flex flex-col gap-3'>
59
+ {usage.categories.map((category) => {
60
+ const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
61
+ const color = cssColor(category.color)
62
+ return (
63
+ <div key={category.name}>
64
+ <div className='flex items-baseline justify-between gap-3'>
65
+ <span className='flex min-w-0 items-center gap-2'>
66
+ <span
67
+ className='size-2 shrink-0 rounded-full bg-fg-4'
68
+ style={color ? { backgroundColor: color } : undefined}
69
+ />
70
+ <span className='truncate text-body-sm text-fg-1'>
71
+ {category.name}
72
+ </span>
73
+ </span>
74
+ <span className='shrink-0 font-mono text-label text-fg-3'>
75
+ {formatTokens(category.tokens)}
76
+ </span>
77
+ </div>
78
+ <div className='mt-1 h-1 overflow-hidden rounded-full bg-border'>
79
+ <div
80
+ className='h-full rounded-full bg-fg-4'
81
+ style={{
82
+ width: `${Math.min(100, share)}%`,
83
+ backgroundColor: color,
84
+ }}
85
+ />
86
+ </div>
87
+ </div>
88
+ )
89
+ })}
90
+ </div>
91
+ </div>
92
+ ) : null}
93
+ </>
94
+ )}
95
+ </DialogBody>
96
+ </DialogContent>
97
+ </Dialog>
98
+ )
99
+ }
@@ -6,17 +6,25 @@ import { cn } from '../../lib/utils.ts'
6
6
 
7
7
  export interface ConversationProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
8
8
  children: ReactNode
9
+ /**
10
+ * How the view follows content that grows. `'smooth'` is right for a live
11
+ * turn — the text scrolls as it is written. `'instant'` is right while a
12
+ * transcript is *filling* (an attach replaying a whole session), where
13
+ * animating each arrival makes opening a session a several-second journey
14
+ * from its first row to its last.
15
+ */
16
+ resize?: 'smooth' | 'instant'
9
17
  }
10
18
 
11
19
  /** Scroll container that stays pinned to the bottom while streaming, unless the user
12
20
  * scrolls up — plus a floating scroll-to-bottom button. */
13
- export function Conversation({ className, children, ...props }: ConversationProps) {
21
+ export function Conversation({ className, children, resize = 'smooth', ...props }: ConversationProps) {
14
22
  return (
15
23
  <StickToBottom
16
24
  data-slot='conversation'
17
25
  className={cn('relative flex-1 overflow-y-auto', className)}
18
26
  initial='instant'
19
- resize='smooth'
27
+ resize={resize}
20
28
  role='log'
21
29
  {...props}>
22
30
  {children}
@@ -32,7 +40,7 @@ export function ConversationContent({
32
40
  return (
33
41
  <StickToBottom.Content
34
42
  data-slot='conversation-content'
35
- className={cn('mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-4', className)}
43
+ className={cn('mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-4 px-4 py-4', className)}
36
44
  {...props}>
37
45
  {children}
38
46
  </StickToBottom.Content>
@@ -0,0 +1,165 @@
1
+ import { useEffect, useRef, type MouseEvent as ReactMouseEvent } from 'react'
2
+ import { isDirty, type OpenFile } from '@workerdeck/react'
3
+ import { X } from 'lucide-react'
4
+ import { cn } from '../../lib/utils.ts'
5
+ import { Tip, TooltipProvider } from '../ui/Tooltip.tsx'
6
+ import { formatBytes } from '../../lib/format.ts'
7
+
8
+ export interface EditorTabsProps {
9
+ files: OpenFile[]
10
+ activePath?: string
11
+ onActivate: (path: string) => void
12
+ onClose: (path: string) => void
13
+ className?: string
14
+ }
15
+
16
+ /**
17
+ * The open-file tab strip.
18
+ *
19
+ * Hand-rolled rather than built on `@base-ui/react`'s `Tabs`: a VS Code tab
20
+ * carries a close button, and a `<button>` inside a `<button>` is invalid HTML —
21
+ * getting the primitive to render something else costs more than the roving
22
+ * tabindex it would have provided. So that part is here, explicitly, along with
23
+ * the two affordances that actually make a tab strip feel right: middle-click to
24
+ * close, and the active tab scrolling itself into view.
25
+ *
26
+ * Deliberately state-free. Which files are open, which is focused and what
27
+ * closing does are all decided by `useOpenFiles`.
28
+ */
29
+ export function EditorTabs({ files, activePath, onActivate, onClose, className }: EditorTabsProps) {
30
+ return (
31
+ // Grouped, so moving along the strip shows each tab's path immediately
32
+ // instead of re-serving the open delay on every tab.
33
+ <TooltipProvider delay={500} closeDelay={0}>
34
+ <div
35
+ data-slot='editor-tabs'
36
+ role='tablist'
37
+ aria-label='Open files'
38
+ className={cn(
39
+ 'flex shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface',
40
+ className,
41
+ )}>
42
+ {files.map((file) => (
43
+ <Tab
44
+ key={file.path}
45
+ file={file}
46
+ active={file.path === activePath}
47
+ onActivate={() => onActivate(file.path)}
48
+ onClose={() => onClose(file.path)}
49
+ onArrow={(direction) => {
50
+ const index = files.findIndex((f) => f.path === file.path)
51
+ const next = files[index + direction]
52
+ if (next) onActivate(next.path)
53
+ }}
54
+ />
55
+ ))}
56
+ </div>
57
+ </TooltipProvider>
58
+ )
59
+ }
60
+
61
+ function Tab({
62
+ file,
63
+ active,
64
+ onActivate,
65
+ onClose,
66
+ onArrow,
67
+ }: {
68
+ file: OpenFile
69
+ active: boolean
70
+ onActivate: () => void
71
+ onClose: () => void
72
+ onArrow: (direction: 1 | -1) => void
73
+ }) {
74
+ const dirty = isDirty(file)
75
+ const ref = useRef<HTMLDivElement>(null)
76
+ // Opening a file from the tree can push the new tab off the end of the strip;
77
+ // the point of opening it was to look at it. `nearest` scrolls the minimum, so
78
+ // a tab already on screen stays exactly where it is.
79
+ useEffect(() => {
80
+ if (active) ref.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
81
+ }, [active])
82
+
83
+ const onAuxClick = (event: ReactMouseEvent) => {
84
+ // Middle click. `auxclick` rather than `mousedown` so it matches how every
85
+ // other middle-click target on the platform behaves.
86
+ if (event.button !== 1) return
87
+ event.preventDefault()
88
+ onClose()
89
+ }
90
+
91
+ return (
92
+ // The tab *is* the trigger (`render`), and it carries the full path — which
93
+ // is why the viewer no longer spends a whole row on a line of monospace
94
+ // nobody reads. No `title` alongside it: the browser's native tooltip would
95
+ // show up underneath this one.
96
+ <Tip
97
+ side='bottom'
98
+ render={
99
+ <div
100
+ ref={ref}
101
+ role='tab'
102
+ tabIndex={active ? 0 : -1}
103
+ aria-selected={active}
104
+ onClick={onActivate}
105
+ onAuxClick={onAuxClick}
106
+ onKeyDown={(event) => {
107
+ if (event.key === 'ArrowRight') onArrow(1)
108
+ else if (event.key === 'ArrowLeft') onArrow(-1)
109
+ else if (event.key === 'Enter' || event.key === ' ') onActivate()
110
+ else return
111
+ event.preventDefault()
112
+ }}
113
+ className={cn(
114
+ 'group flex min-w-0 shrink-0 cursor-pointer items-center gap-1.5 border-r border-border px-3 py-1.5 transition-colors',
115
+ 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-inset',
116
+ active ? 'bg-bg text-fg-1' : 'text-fg-3 hover:bg-surface-hover hover:text-fg-2',
117
+ )}
118
+ />
119
+ }
120
+ content={
121
+ <span className='flex flex-col gap-0.5'>
122
+ <span className='font-mono break-all'>{file.path}</span>
123
+ {file.bytes !== undefined ? (
124
+ <span className='text-fg-4'>{formatBytes(file.bytes)}</span>
125
+ ) : null}
126
+ </span>
127
+ }>
128
+ <span className={cn('max-w-40 truncate font-mono text-label', dirty && 'italic')}>
129
+ {file.name}
130
+ </span>
131
+ {/* Errors are the one state the strip shows, because a failed tab
132
+ otherwise looks identical to a loaded one until you focus it. */}
133
+ {file.status === 'error' ? <span className='shrink-0 text-danger'>!</span> : null}
134
+ <button
135
+ type='button'
136
+ aria-label={dirty ? `Close ${file.name} (unsaved changes)` : `Close ${file.name}`}
137
+ onClick={(event) => {
138
+ // Without this the click also activates the tab being closed, which
139
+ // fights the reducer's focus-the-neighbour rule.
140
+ event.stopPropagation()
141
+ onClose()
142
+ }}
143
+ className={cn(
144
+ 'shrink-0 rounded p-0.5 text-fg-4 transition-opacity hover:bg-surface-hover hover:text-fg-1',
145
+ // Always reachable by keyboard and on touch; only *shown* on hover for
146
+ // the tab you are pointing at, as VS Code does.
147
+ active || dirty
148
+ ? 'opacity-70'
149
+ : 'opacity-0 group-hover:opacity-70 focus-visible:opacity-100',
150
+ )}>
151
+ {/* VS Code's move: a dirty tab shows a dot where the ✕ goes, and the ✕
152
+ comes back when you point at it — so unsaved work is visible at rest
153
+ without taking away the way to close it. */}
154
+ <span className={cn('block', dirty && 'group-hover:hidden')}>
155
+ {dirty ? <span className='block size-3 rounded-full bg-fg-2' /> : <X className='size-3' />}
156
+ </span>
157
+ {dirty ? (
158
+ <span className='hidden group-hover:block'>
159
+ <X className='size-3' />
160
+ </span>
161
+ ) : null}
162
+ </button>
163
+ </Tip>
164
+ )
165
+ }
@@ -2,6 +2,7 @@ import type { TranscriptItem } from '@workerdeck/react'
2
2
  import { Download, FileText } from 'lucide-react'
3
3
  import { cn } from '../../lib/utils.ts'
4
4
  import { formatBytes } from '../../lib/format.ts'
5
+ import { LineGlyph, useLines } from './transcript-variant.tsx'
5
6
 
6
7
  export type FileDeliveredItem = Extract<TranscriptItem, { kind: 'file_delivered' }>
7
8
 
@@ -17,7 +18,32 @@ export interface FileCardProps {
17
18
  * The file lives in the session's in-memory VFS — the link works while the
18
19
  * session lives. */
19
20
  export function FileCard({ item, href, className }: FileCardProps) {
21
+ const lines = useLines()
20
22
  const name = item.path.split('/').pop() || item.path
23
+ if (lines) {
24
+ // One row: what arrived, how big, and the link — no box around it.
25
+ return (
26
+ <div data-slot='file-delivered' className={cn('flex w-full items-baseline gap-2', className)}>
27
+ <LineGlyph className='text-fg-4'>◇</LineGlyph>
28
+ <span className='min-w-0 flex-1 truncate text-body-sm leading-5'>
29
+ <span className='font-medium text-fg-1'>{name}</span>
30
+ <span className='text-fg-4'>
31
+ {' · '}
32
+ {formatBytes(item.bytes)}
33
+ {item.description ? ` · ${item.description}` : ''}
34
+ </span>
35
+ </span>
36
+ {href ? (
37
+ <a
38
+ href={href}
39
+ download={name}
40
+ className='shrink-0 text-label text-fg-3 underline-offset-2 hover:text-fg-1 hover:underline'>
41
+ download
42
+ </a>
43
+ ) : null}
44
+ </div>
45
+ )
46
+ }
21
47
  return (
22
48
  <div
23
49
  data-slot='file-delivered'
@@ -0,0 +1,287 @@
1
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
2
+ import type { UseHostFileSearchResult, UseHostFileTreeResult } from '@workerdeck/react'
3
+ import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
4
+ import {
5
+ ChevronRight,
6
+ File,
7
+ Folder,
8
+ FolderOpen,
9
+ Link2,
10
+ PanelLeftClose,
11
+ RefreshCw,
12
+ Search,
13
+ X,
14
+ } from 'lucide-react'
15
+ import { cn } from '../../lib/utils.ts'
16
+ import { Button } from '../ui/Button.tsx'
17
+ import { Input } from '../ui/Input.tsx'
18
+ import { Spinner } from '../ui/Spinner.tsx'
19
+
20
+ export interface FileTreeProps {
21
+ /** Tree state from `useHostFileTree` — this component holds none of its own. */
22
+ tree: UseHostFileTreeResult
23
+ /** Optional search from `useHostFileSearch`; omit and the box is not offered. */
24
+ search?: UseHostFileSearchResult
25
+ /** Path of the focused file, highlighted in the tree. */
26
+ activePath?: string
27
+ onOpenFile: (path: string) => void
28
+ /** Offered as a button in the header when given. */
29
+ onCollapse?: () => void
30
+ style?: CSSProperties
31
+ className?: string
32
+ }
33
+
34
+ /** How far one level of nesting indents, in pixels. Inline rather than a Tailwind
35
+ * class because depth is a number at runtime and `pl-${n}` is not a class. */
36
+ const INDENT = 12
37
+
38
+ /**
39
+ * The workspace's left rail: an expandable tree of the session's project,
40
+ * with a search box over the same fuzzy route `@file` completion uses.
41
+ *
42
+ * Presentational by construction — every piece of state it renders comes from
43
+ * the hooks in `@workerdeck/react`, and the only thing it owns is the search
44
+ * query, which is the text in its own input.
45
+ *
46
+ * Searching replaces the tree with matches rather than filtering it: the route
47
+ * answers with paths from all over the project, and threading those back into
48
+ * tree positions would mean expanding a dozen directories to show six results.
49
+ */
50
+ export function FileTree({
51
+ tree,
52
+ search,
53
+ activePath,
54
+ onOpenFile,
55
+ onCollapse,
56
+ style,
57
+ className,
58
+ }: FileTreeProps) {
59
+ const [query, setQuery] = useState('')
60
+ const [matches, setMatches] = useState<HostFileMatch[] | undefined>()
61
+ const searching = query.trim().length > 0
62
+
63
+ // Debounced, and only while there is something to search for — an empty box
64
+ // means "show me the tree again", not "match everything".
65
+ useEffect(() => {
66
+ if (!search?.available) return
67
+ const q = query.trim()
68
+ if (!q) {
69
+ setMatches(undefined)
70
+ return
71
+ }
72
+ const controller = new AbortController()
73
+ const timer = setTimeout(() => {
74
+ void search.search(q, { limit: 60, signal: controller.signal }).then((found) => {
75
+ if (!controller.signal.aborted) setMatches(found)
76
+ })
77
+ }, 150)
78
+ return () => {
79
+ controller.abort()
80
+ clearTimeout(timer)
81
+ }
82
+ }, [search, query])
83
+
84
+ return (
85
+ <div
86
+ data-slot='file-tree'
87
+ style={style}
88
+ className={cn('flex min-h-0 min-w-0 flex-col bg-surface', className)}>
89
+ <div className='flex items-center gap-1 px-2 pt-2'>
90
+ {search?.available ? (
91
+ <div className='relative min-w-0 flex-1'>
92
+ <Search className='absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4' />
93
+ <Input
94
+ value={query}
95
+ onChange={(e) => setQuery(e.target.value)}
96
+ placeholder='Search files…'
97
+ className='h-7 pr-7 pl-7 text-label'
98
+ spellCheck={false}
99
+ />
100
+ {searching ? (
101
+ <button
102
+ type='button'
103
+ onClick={() => setQuery('')}
104
+ aria-label='Clear search'
105
+ className='absolute top-1/2 right-1.5 -translate-y-1/2 text-fg-4 transition-colors hover:text-fg-2'>
106
+ <X className='size-3.5' />
107
+ </button>
108
+ ) : null}
109
+ </div>
110
+ ) : (
111
+ <span className='min-w-0 flex-1 truncate px-1 font-mono text-label text-fg-4'>
112
+ {tree.root}
113
+ </span>
114
+ )}
115
+ <Button
116
+ variant='ghost'
117
+ size='icon-sm'
118
+ aria-label='Refresh the file tree'
119
+ onClick={() => tree.refresh()}>
120
+ <RefreshCw className='size-3.5 text-fg-3' />
121
+ </Button>
122
+ {onCollapse ? (
123
+ <Button variant='ghost' size='icon-sm' aria-label='Hide project files' onClick={onCollapse}>
124
+ <PanelLeftClose className='size-3.5 text-fg-3' />
125
+ </Button>
126
+ ) : null}
127
+ </div>
128
+
129
+ <div className='min-h-0 flex-1 overflow-auto px-1 py-1'>
130
+ {tree.error ? (
131
+ <p className='px-2 py-3 text-body-sm text-danger'>{tree.error}</p>
132
+ ) : searching ? (
133
+ matches === undefined ? (
134
+ <div className='py-6 text-center'>
135
+ <Spinner className='size-4 text-fg-4' />
136
+ </div>
137
+ ) : matches.length === 0 ? (
138
+ <p className='px-2 py-6 text-center text-body-sm text-fg-4'>No matching files.</p>
139
+ ) : (
140
+ <ul>
141
+ {matches.map((match) => (
142
+ <li key={match.path}>
143
+ <Row
144
+ label={fileName(match.relative)}
145
+ // The directory, dimmed and truncated separately, so a deep
146
+ // path cannot push the filename out of the row — a rail full
147
+ // of `apps/ios/DerivedData/B…` says nothing about which file
148
+ // each hit is.
149
+ detail={directoryOf(match.relative)}
150
+ title={match.relative}
151
+ icon={<File className='size-3.5 shrink-0 text-fg-4' />}
152
+ active={match.path === activePath}
153
+ onClick={() => onOpenFile(match.path)}
154
+ />
155
+ </li>
156
+ ))}
157
+ </ul>
158
+ )
159
+ ) : tree.loading ? (
160
+ <div className='py-6 text-center'>
161
+ <Spinner className='size-4 text-fg-4' />
162
+ </div>
163
+ ) : tree.rows.length === 0 ? (
164
+ <p className='px-2 py-6 text-center text-body-sm text-fg-4'>This project is empty.</p>
165
+ ) : (
166
+ <ul role='tree' aria-label='Project files'>
167
+ {tree.rows.map((row) => (
168
+ <li key={row.entry.path} role='treeitem' aria-expanded={row.expanded} aria-level={row.depth + 1}>
169
+ <Row
170
+ label={row.entry.name}
171
+ indent={row.depth}
172
+ active={row.entry.path === activePath}
173
+ icon={
174
+ row.entry.type === 'dir' ? (
175
+ row.loading ? (
176
+ <Spinner className='size-3.5 shrink-0 text-fg-4' />
177
+ ) : row.expanded ? (
178
+ <FolderOpen className='size-3.5 shrink-0 text-accent' />
179
+ ) : (
180
+ <Folder className='size-3.5 shrink-0 text-accent' />
181
+ )
182
+ ) : (
183
+ <EntryIcon type={row.entry.type} />
184
+ )
185
+ }
186
+ chevron={
187
+ row.entry.type === 'dir' ? (
188
+ <ChevronRight
189
+ className={cn(
190
+ 'size-3 shrink-0 text-fg-4 transition-transform',
191
+ row.expanded && 'rotate-90',
192
+ )}
193
+ />
194
+ ) : undefined
195
+ }
196
+ onClick={() =>
197
+ row.entry.type === 'dir'
198
+ ? tree.toggle(row.entry.path)
199
+ : onOpenFile(row.entry.path)
200
+ }
201
+ />
202
+ {row.truncated ? (
203
+ <p
204
+ className='truncate py-0.5 text-label text-fg-4'
205
+ style={{ paddingLeft: (row.depth + 1) * INDENT + 22 }}>
206
+ More entries than the server will return — use search.
207
+ </p>
208
+ ) : null}
209
+ </li>
210
+ ))}
211
+ </ul>
212
+ )}
213
+ </div>
214
+ </div>
215
+ )
216
+ }
217
+
218
+ /** One clickable line. A directory and a file differ only in what the click does
219
+ * and whether there is a chevron — visually they are the same row. */
220
+ function Row({
221
+ label,
222
+ detail,
223
+ title,
224
+ icon,
225
+ chevron,
226
+ indent = 0,
227
+ active,
228
+ onClick,
229
+ }: {
230
+ label: string
231
+ /** Secondary text after the label, dimmed and the first thing to be truncated. */
232
+ detail?: string
233
+ title?: string
234
+ icon: ReactNode
235
+ chevron?: ReactNode
236
+ indent?: number
237
+ active?: boolean
238
+ onClick: () => void
239
+ }) {
240
+ // Scroll a row that became active elsewhere (a search hit, a `reveal`) into
241
+ // view, but never yank the list while someone is reading it — `nearest` moves
242
+ // the minimum and does nothing when the row is already visible.
243
+ const ref = useRef<HTMLButtonElement>(null)
244
+ useEffect(() => {
245
+ if (active) ref.current?.scrollIntoView({ block: 'nearest' })
246
+ }, [active])
247
+
248
+ return (
249
+ <button
250
+ ref={ref}
251
+ type='button'
252
+ onClick={onClick}
253
+ title={title ?? label}
254
+ style={{ paddingLeft: indent * INDENT + 4 }}
255
+ className={cn(
256
+ 'flex w-full items-center gap-1 rounded py-1 pr-2 text-left transition-colors',
257
+ active ? 'bg-surface-hover text-fg-1' : 'text-fg-2 hover:bg-surface-hover',
258
+ )}>
259
+ <span className='flex size-3 shrink-0 items-center justify-center'>{chevron}</span>
260
+ {icon}
261
+ {/* `shrink-0` on the name and `min-w-0` on the detail: when the row runs
262
+ out of room the directory gives way and the filename stays whole. */}
263
+ <span className='shrink-0 truncate font-mono text-label'>{label}</span>
264
+ {detail ? (
265
+ <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-4'>{detail}</span>
266
+ ) : null}
267
+ </button>
268
+ )
269
+ }
270
+
271
+ /** Last segment of a relative match. */
272
+ function fileName(relative: string): string {
273
+ return relative.slice(relative.lastIndexOf('/') + 1)
274
+ }
275
+
276
+ /** Everything before it, or `undefined` for a file at the search root. */
277
+ function directoryOf(relative: string): string | undefined {
278
+ const cut = relative.lastIndexOf('/')
279
+ return cut === -1 ? undefined : relative.slice(0, cut)
280
+ }
281
+
282
+ /** A symlink is reported as itself and never silently resolved — following it is
283
+ * the next request's problem, and that request is refused if it escapes the roots. */
284
+ function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
285
+ if (type === 'symlink') return <Link2 className='size-3.5 shrink-0 text-fg-4' />
286
+ return <File className='size-3.5 shrink-0 text-fg-4' />
287
+ }