@workerdeck/ui 0.22.0 → 1.0.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 (145) hide show
  1. package/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +80 -24
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -124
  14. package/build/workspace.mjs +13 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +4 -6
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +345 -923
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +108 -215
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -2,6 +2,8 @@ import type { ContextUsage } from '@workerdeck/protocol'
2
2
  import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
3
3
  import { cn } from '../../lib/utils.ts'
4
4
  import { formatTokens } from '../../lib/format.ts'
5
+ import { cssColor } from '../../lib/css.ts'
6
+ import { meterTintClass } from '../../lib/status.ts'
5
7
 
6
8
  export interface ContextDialogProps {
7
9
  usage?: ContextUsage
@@ -10,75 +12,52 @@ export interface ContextDialogProps {
10
12
  className?: string
11
13
  }
12
14
 
13
- /** The CLI reports category colors as its own theme token names ('inactive',
14
- * 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
15
- const cssColor = (color: string): string | undefined =>
16
- typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
17
-
18
- const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
19
-
20
- /**
21
- * What is in the model's context window right now, category by category.
22
- *
23
- * One of the three panels the status bar opens. Context, usage and session info
24
- * are different questions asked at different moments, so they are different
25
- * screens rather than one "details" list you scroll past two answers to reach.
26
- */
27
15
  export function ContextDialog({ usage, open, onOpenChange, className }: ContextDialogProps) {
28
16
  return (
29
17
  <Dialog open={open} onOpenChange={onOpenChange}>
30
18
  <DialogContent className={className}>
31
- <DialogHeader title='Context' description={usage?.model} />
19
+ <DialogHeader title="Context" description={usage?.model} />
32
20
  <DialogBody>
33
21
  {!usage ? (
34
- <p className='py-6 text-center text-body-sm text-fg-4'>
22
+ <p className="py-6 text-center text-body-sm text-fg-4">
35
23
  No reading yet — the context window is measured after a turn completes.
36
24
  </p>
37
25
  ) : (
38
26
  <>
39
- <div className='flex items-baseline justify-between gap-4'>
40
- <span className='text-label text-fg-3'>Used</span>
41
- <span className='font-mono text-body-sm text-fg-1'>
42
- {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} ·{' '}
43
- {usage.percentage.toFixed(0)}%
27
+ <div className="flex items-baseline justify-between gap-4">
28
+ <span className="text-label text-fg-3">Used</span>
29
+ <span className="font-mono text-body-sm text-fg-1">
30
+ {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} · {usage.percentage.toFixed(0)}%
44
31
  </span>
45
32
  </div>
46
- <div className='mt-2 h-2 overflow-hidden rounded-full bg-border'>
33
+ <div className="mt-2 h-2 overflow-hidden rounded-full bg-border">
47
34
  <div
48
- className={cn('h-full rounded-full', usageTint(usage.percentage))}
35
+ className={cn('h-full rounded-full', meterTintClass(usage.percentage))}
49
36
  style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
50
37
  />
51
38
  </div>
52
- {/* Codex reports occupancy with no breakdown, so an engine can send
53
- a real reading and an empty `categories`. A "Breakdown" heading
54
- over nothing reads as a failed load; the row above already says
55
- everything that is known. */}
56
39
  {usage.categories.length > 0 ? (
57
- <div className='mt-5'>
58
- <h3 className='text-label font-medium text-fg-3'>Breakdown</h3>
59
- <div className='mt-2 flex flex-col gap-3'>
40
+ <div className="mt-5">
41
+ <h3 className="text-label font-medium text-fg-3">Breakdown</h3>
42
+ <div className="mt-2 flex flex-col gap-3">
60
43
  {usage.categories.map((category) => {
61
44
  const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
62
45
  const color = cssColor(category.color)
63
46
  return (
64
47
  <div key={category.name}>
65
- <div className='flex items-baseline justify-between gap-3'>
66
- <span className='flex min-w-0 items-center gap-2'>
48
+ <div className="flex items-baseline justify-between gap-3">
49
+ <span className="flex min-w-0 items-center gap-2">
67
50
  <span
68
- className='size-2 shrink-0 rounded-full bg-fg-4'
51
+ className="size-2 shrink-0 rounded-full bg-fg-4"
69
52
  style={color ? { backgroundColor: color } : undefined}
70
53
  />
71
- <span className='truncate text-body-sm text-fg-1'>
72
- {category.name}
73
- </span>
74
- </span>
75
- <span className='shrink-0 font-mono text-label text-fg-3'>
76
- {formatTokens(category.tokens)}
54
+ <span className="truncate text-body-sm text-fg-1">{category.name}</span>
77
55
  </span>
56
+ <span className="shrink-0 font-mono text-label text-fg-3">{formatTokens(category.tokens)}</span>
78
57
  </div>
79
- <div className='mt-1 h-1 overflow-hidden rounded-full bg-border'>
58
+ <div className="mt-1 h-1 overflow-hidden rounded-full bg-border">
80
59
  <div
81
- className='h-full rounded-full bg-fg-4'
60
+ className="h-full rounded-full bg-fg-4"
82
61
  style={{
83
62
  width: `${Math.min(100, share)}%`,
84
63
  backgroundColor: color,
@@ -4,37 +4,15 @@ import { cn } from '../../lib/utils.ts'
4
4
  import { formatTokens } from '../../lib/format.ts'
5
5
  import { meterColorClass } from '../../lib/status.ts'
6
6
 
7
- /**
8
- * How full a session's context window is, sized for a **list row**.
9
- *
10
- * The reading the session screen has always drawn, moved somewhere it answers a
11
- * question the session screen cannot: *which* of these thirty sessions is
12
- * bloating. That is also why it is a ring and not a number — across a list, a
13
- * ring is read at a glance and `71%` has to be read one row at a time.
14
- *
15
- * **Absent draws nothing, and absent is not zero.** A promptless session, a
16
- * parked one from before the field existed, or an engine that reports no window
17
- * has no reading; an empty ring would claim its context is empty rather than
18
- * unknown. The title carries the exact numbers for anyone who wants them.
19
- *
20
- * Shared rather than spelled per client: the dashboard's row and the VS Code
21
- * sidebar's card both draw it, and two copies would be two sets of thresholds
22
- * the day one of them is nudged.
23
- */
24
- export function ContextRing({
25
- usage,
26
- size = 11,
27
- className,
28
- }: {
29
- usage: ContextReading | undefined
30
- size?: number
31
- className?: string
32
- }) {
33
- if (usage === undefined) return null
7
+ export function ContextRing({ usage, size = 11, className }: { usage: ContextReading | undefined; size?: number; className?: string }) {
8
+ if (usage === undefined) {
9
+ return null
10
+ }
34
11
  return (
35
12
  <span
36
13
  title={`Context ${usage.percentage.toFixed(0)}% · ${formatTokens(usage.totalTokens)} / ${formatTokens(usage.maxTokens)}`}
37
- className={cn('shrink-0 leading-none', meterColorClass(usage.percentage), className)}>
14
+ className={cn('shrink-0 leading-none', meterColorClass(usage.percentage), className)}
15
+ >
38
16
  <ProgressRing value={usage.percentage} size={size} strokeWidth={2} />
39
17
  </span>
40
18
  )
@@ -8,40 +8,28 @@ export interface ConversationProps extends Omit<HTMLAttributes<HTMLDivElement>,
8
8
  children: ReactNode
9
9
  }
10
10
 
11
- /** Scroll container that stays pinned to the bottom while streaming, unless the user
12
- * scrolls up — plus a floating scroll-to-bottom button.
13
- *
14
- * **Nothing here animates its scroll position.** `initial` and `resize` are both
15
- * `'instant'` and are no longer configurable: the follow spring's job is to
16
- * *stay* at the bottom, not to travel there, and every travel a reader has ever
17
- * complained about on this surface was an animation we asked for. The `resize`
18
- * prop used to exist so a caller could pick, which required the caller to know
19
- * whether the transcript was streaming or replaying — a latch's worth of
20
- * machinery to decide something that has one right answer. */
21
11
  export function Conversation({ className, children, ...props }: ConversationProps) {
22
12
  return (
23
13
  <StickToBottom
24
- data-slot='conversation'
14
+ data-slot="conversation"
25
15
  className={cn('relative flex-1 overflow-y-auto', className)}
26
- initial='instant'
27
- resize='instant'
28
- role='log'
29
- {...props}>
16
+ initial="instant"
17
+ resize="instant"
18
+ role="log"
19
+ {...props}
20
+ >
30
21
  {children}
31
22
  </StickToBottom>
32
23
  )
33
24
  }
34
25
 
35
- export function ConversationContent({
36
- className,
37
- children,
38
- ...props
39
- }: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
26
+ export function ConversationContent({ className, children, ...props }: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
40
27
  return (
41
28
  <StickToBottom.Content
42
- data-slot='conversation-content'
29
+ data-slot="conversation-content"
43
30
  className={cn('mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-4 px-4 py-4', className)}
44
- {...props}>
31
+ {...props}
32
+ >
45
33
  {children}
46
34
  </StickToBottom.Content>
47
35
  )
@@ -49,19 +37,19 @@ export function ConversationContent({
49
37
 
50
38
  export function ConversationScrollButton({ className }: { className?: string }) {
51
39
  const { isAtBottom, scrollToBottom } = useStickToBottomContext()
52
- if (isAtBottom) return null
40
+ if (isAtBottom) {
41
+ return null
42
+ }
53
43
  return (
54
44
  <Button
55
- variant='outline'
56
- size='icon'
57
- aria-label='Scroll to bottom'
58
- data-slot='conversation-scroll-button'
59
- className={cn(
60
- 'absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full shadow-(--shadow-md)',
61
- className,
62
- )}
63
- onClick={() => void scrollToBottom('instant')}>
64
- <ArrowDown className='size-4' />
45
+ variant="outline"
46
+ size="icon"
47
+ aria-label="Scroll to bottom"
48
+ data-slot="conversation-scroll-button"
49
+ className={cn('absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full shadow-(--shadow-md)', className)}
50
+ onClick={() => void scrollToBottom('instant')}
51
+ >
52
+ <ArrowDown className="size-4" />
65
53
  </Button>
66
54
  )
67
55
  }
@@ -13,32 +13,15 @@ export interface EditorTabsProps {
13
13
  className?: string
14
14
  }
15
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
16
  export function EditorTabs({ files, activePath, onActivate, onClose, className }: EditorTabsProps) {
30
17
  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
18
  <TooltipProvider delay={500} closeDelay={0}>
34
19
  <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
- )}>
20
+ data-slot="editor-tabs"
21
+ role="tablist"
22
+ aria-label="Open files"
23
+ className={cn('flex shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface', className)}
24
+ >
42
25
  {files.map((file) => (
43
26
  <Tab
44
27
  key={file.path}
@@ -49,7 +32,9 @@ export function EditorTabs({ files, activePath, onActivate, onClose, className }
49
32
  onArrow={(direction) => {
50
33
  const index = files.findIndex((f) => f.path === file.path)
51
34
  const next = files[index + direction]
52
- if (next) onActivate(next.path)
35
+ if (next) {
36
+ onActivate(next.path)
37
+ }
53
38
  }}
54
39
  />
55
40
  ))}
@@ -73,41 +58,41 @@ function Tab({
73
58
  }) {
74
59
  const dirty = isDirty(file)
75
60
  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
61
  useEffect(() => {
80
- if (active) ref.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
62
+ if (active) {
63
+ ref.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
64
+ }
81
65
  }, [active])
82
66
 
83
67
  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
68
+ if (event.button !== 1) {
69
+ return
70
+ }
87
71
  event.preventDefault()
88
72
  onClose()
89
73
  }
90
74
 
91
75
  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
76
  <Tip
97
- side='bottom'
77
+ side="bottom"
98
78
  render={
99
79
  <div
100
80
  ref={ref}
101
- role='tab'
81
+ role="tab"
102
82
  tabIndex={active ? 0 : -1}
103
83
  aria-selected={active}
104
84
  onClick={onActivate}
105
85
  onAuxClick={onAuxClick}
106
86
  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
87
+ if (event.key === 'ArrowRight') {
88
+ onArrow(1)
89
+ } else if (event.key === 'ArrowLeft') {
90
+ onArrow(-1)
91
+ } else if (event.key === 'Enter' || event.key === ' ') {
92
+ onActivate()
93
+ } else {
94
+ return
95
+ }
111
96
  event.preventDefault()
112
97
  }}
113
98
  className={cn(
@@ -118,45 +103,32 @@ function Tab({
118
103
  />
119
104
  }
120
105
  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}
106
+ <span className="flex flex-col gap-0.5">
107
+ <span className="font-mono break-all">{file.path}</span>
108
+ {file.bytes !== undefined ? <span className="text-fg-4">{formatBytes(file.bytes)}</span> : null}
126
109
  </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}
110
+ }
111
+ >
112
+ <span className={cn('max-w-40 truncate font-mono text-label', dirty && 'italic')}>{file.name}</span>
113
+ {file.status === 'error' ? <span className="shrink-0 text-danger">!</span> : null}
134
114
  <button
135
- type='button'
115
+ type="button"
136
116
  aria-label={dirty ? `Close ${file.name} (unsaved changes)` : `Close ${file.name}`}
137
117
  onClick={(event) => {
138
- // Without this the click also activates the tab being closed, which
139
- // fights the reducer's focus-the-neighbour rule.
140
118
  event.stopPropagation()
141
119
  onClose()
142
120
  }}
143
121
  className={cn(
144
122
  '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. */}
123
+ active || dirty ? 'opacity-70' : 'opacity-0 group-hover:opacity-70 focus-visible:opacity-100',
124
+ )}
125
+ >
154
126
  <span className={cn('block', dirty && 'group-hover:hidden')}>
155
- {dirty ? <span className='block size-3 rounded-full bg-fg-2' /> : <X className='size-3' />}
127
+ {dirty ? <span className="block size-3 rounded-full bg-fg-2" /> : <X className="size-3" />}
156
128
  </span>
157
129
  {dirty ? (
158
- <span className='hidden group-hover:block'>
159
- <X className='size-3' />
130
+ <span className="hidden group-hover:block">
131
+ <X className="size-3" />
160
132
  </span>
161
133
  ) : null}
162
134
  </button>
@@ -1,22 +1,6 @@
1
1
  import { cn } from '../../lib/utils.ts'
2
2
 
3
- /**
4
- * The engine's own mark, monochrome.
5
- *
6
- * Inlined rather than depended on: the React package these come from
7
- * (`@lobehub/icons`) pulls antd, `@lobehub/ui` and a second copy of
8
- * lucide-react — an absurd amount of bundle for a few glyphs in a sidebar.
9
- * These are paths from `@lobehub/icons-static-svg` (MIT, © LobeHub), already
10
- * `fill="currentColor"` single-path marks, so they take the row's colour like
11
- * any other glyph and need no light/dark variants.
12
- *
13
- * The marks are trademarks of their owners (Anthropic, OpenAI, Google,
14
- * DeepSeek, Moonshot), used here only to identify which engine a session runs
15
- * on.
16
- *
17
- * Codex is drawn as OpenAI: the session's engine *is* OpenAI's, and OpenAI's
18
- * mark is the one a person recognises at 12px.
19
- */
3
+ // Paths from `@lobehub/icons-static-svg` (MIT, © LobeHub), inlined. The marks are trademarks of their owners, used only to identify a session's engine.
20
4
  const PATHS: Record<string, { title: string; d: string }> = {
21
5
  claude: {
22
6
  title: 'Claude',
@@ -40,44 +24,35 @@ const PATHS: Record<string, { title: string; d: string }> = {
40
24
  },
41
25
  }
42
26
 
43
- /**
44
- * Which mark a session wears.
45
- *
46
- * The two first-party engines are named directly. A `provider` session is
47
- * whatever the host wired up, so its *model* is the only thing that says whose
48
- * it is — sniffed loosely on purpose (`gemini-2.5-pro`, `deepseek-chat`,
49
- * `kimi-k2`), and falling through to no mark rather than guessing wrong.
50
- */
51
27
  export function engineMark(engine: string, model?: string): string | undefined {
52
- if (engine === 'claude') return 'claude'
53
- if (engine === 'codex') return 'codex'
28
+ if (engine === 'claude') {
29
+ return 'claude'
30
+ }
31
+ if (engine === 'codex') {
32
+ return 'codex'
33
+ }
54
34
  const id = model?.toLowerCase() ?? ''
55
- if (!id) return undefined
56
- if (id.includes('gemini')) return 'gemini'
57
- if (id.includes('deepseek')) return 'deepseek'
58
- if (id.includes('moonshot') || id.includes('kimi')) return 'moonshot'
59
- if (id.includes('claude')) return 'claude'
35
+ if (!id) {
36
+ return undefined
37
+ }
38
+ if (id.includes('gemini')) {
39
+ return 'gemini'
40
+ }
41
+ if (id.includes('deepseek')) {
42
+ return 'deepseek'
43
+ }
44
+ if (id.includes('moonshot') || id.includes('kimi')) {
45
+ return 'moonshot'
46
+ }
47
+ if (id.includes('claude')) {
48
+ return 'claude'
49
+ }
60
50
  if (id.startsWith('gpt') || id.startsWith('o1') || id.startsWith('o3') || id.includes('openai')) {
61
51
  return 'codex'
62
52
  }
63
53
  return undefined
64
54
  }
65
55
 
66
- /**
67
- * Which engines wear their vendor's colour, and **how far it reaches**.
68
- *
69
- * `MARK` is the glyph, `TEXT` is the model name beside it. They are the same for
70
- * Anthropic and deliberately not for OpenAI, because the two brands differ in
71
- * kind rather than in hue: coral is an accent, and OpenAI's guidelines forbid
72
- * adding colour to the mark at all, so theirs is pure white/black. At full
73
- * contrast that is right on a 12px glyph and wrong on an 11px label — a
74
- * pure-white model name is *brighter than the session title above it*, which
75
- * inverts the row's whole hierarchy to say something the mark has already said.
76
- * So OpenAI's name keeps the muted line it always had, which is also the most
77
- * literal reading of "don't add any colors".
78
- *
79
- * Anything absent falls through to muted on both counts.
80
- */
81
56
  const VENDOR_MARK: Record<string, string> = {
82
57
  claude: 'text-vendor-claude',
83
58
  codex: 'text-vendor-openai',
@@ -86,35 +61,16 @@ const VENDOR_TEXT: Record<string, string> = {
86
61
  claude: 'text-vendor-claude',
87
62
  }
88
63
 
89
- /** The colour a session's engine mark wears. Pass it to `EngineIcon`'s
90
- * `className` rather than to a parent: the svg carries its own `text-fg-3`,
91
- * and only a class on the element itself is merged over it. */
92
64
  export function vendorMarkClass(engine: string, model?: string): string {
93
65
  return VENDOR_MARK[engineMark(engine, model) ?? ''] ?? 'text-fg-3'
94
66
  }
95
67
 
96
- /** The colour the model *name* wears beside that mark — muted unless the vendor
97
- * is one whose accent survives at label size. See `VENDOR_TEXT`.
98
- *
99
- * The fallback is `text-fg-3` rather than the metadata line's own `text-fg-4`,
100
- * so an unbranded model still sits one step above the project and gateway
101
- * beside it — the order the spec lists them in, held without a colour. */
102
68
  export function vendorTextClass(engine: string, model?: string): string {
103
69
  return VENDOR_TEXT[engineMark(engine, model) ?? ''] ?? 'text-fg-3'
104
70
  }
105
71
 
106
- export function EngineIcon({
107
- engine,
108
- model,
109
- className,
110
- }: {
111
- engine: string
112
- model?: string
113
- className?: string
114
- }) {
72
+ export function EngineIcon({ engine, model, className }: { engine: string; model?: string; className?: string }) {
115
73
  const mark = PATHS[engineMark(engine, model) ?? '']
116
- // An engine this build has never heard of still gets a slot, so the second
117
- // line doesn't reflow around a missing glyph.
118
74
  if (!mark) {
119
75
  return (
120
76
  <span aria-hidden title={engine} className={cn('shrink-0 text-fg-3', className)}>
@@ -124,12 +80,13 @@ export function EngineIcon({
124
80
  }
125
81
  return (
126
82
  <svg
127
- viewBox='0 0 24 24'
128
- fill='currentColor'
129
- fillRule='evenodd'
130
- role='img'
83
+ viewBox="0 0 24 24"
84
+ fill="currentColor"
85
+ fillRule="evenodd"
86
+ role="img"
131
87
  aria-label={mark.title}
132
- className={cn('size-3 shrink-0 text-fg-3', className)}>
88
+ className={cn('size-3 shrink-0 text-fg-3', className)}
89
+ >
133
90
  <title>{mark.title}</title>
134
91
  <path d={mark.d} />
135
92
  </svg>
@@ -0,0 +1,16 @@
1
+ import { File, Folder, Link2 } from 'lucide-react'
2
+ import type { HostDirEntry } from '@workerdeck/protocol'
3
+
4
+ /**
5
+ * The icon for one host directory entry. A symlink is drawn as a link and never silently
6
+ * resolved — the caller decides what a symlink means, the tree only says it is one.
7
+ */
8
+ export function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
9
+ if (type === 'dir') {
10
+ return <Folder className="size-3.5 shrink-0 text-accent" />
11
+ }
12
+ if (type === 'symlink') {
13
+ return <Link2 className="size-3.5 shrink-0 text-fg-4" />
14
+ }
15
+ return <File className="size-3.5 shrink-0 text-fg-4" />
16
+ }
@@ -7,28 +7,21 @@ export type FileDeliveredItem = Extract<TranscriptItem, { kind: 'file_delivered'
7
7
 
8
8
  export interface FileCardProps {
9
9
  item: FileDeliveredItem
10
- /** Download URL for the delivered path (the server's session file route).
11
- * Without it the card renders informational only. */
12
10
  href?: string
13
11
  className?: string
14
12
  }
15
13
 
16
- /** A file the agent handed over (`file_delivered`): name, size, download link.
17
- * The file lives in the session's in-memory VFS — the link works while the
18
- * session lives. */
19
14
  export function FileCard({ item, href, className }: FileCardProps) {
20
15
  const name = item.path.split('/').pop() || item.path
21
16
  return (
22
17
  <div
23
- data-slot='file-delivered'
24
- className={cn(
25
- 'flex w-full items-center gap-3 rounded-lg border border-border bg-surface px-3 py-2.5',
26
- className,
27
- )}>
28
- <FileText className='size-4 shrink-0 text-fg-3' />
29
- <div className='min-w-0 flex-1'>
30
- <div className='truncate font-mono text-body-sm font-medium text-fg-1'>{name}</div>
31
- <div className='truncate text-label text-fg-4'>
18
+ data-slot="file-delivered"
19
+ className={cn('flex w-full items-center gap-3 rounded-lg border border-border bg-surface px-3 py-2.5', className)}
20
+ >
21
+ <FileText className="size-4 shrink-0 text-fg-3" />
22
+ <div className="min-w-0 flex-1">
23
+ <div className="truncate font-mono text-body-sm font-medium text-fg-1">{name}</div>
24
+ <div className="truncate text-label text-fg-4">
32
25
  {formatBytes(item.bytes)}
33
26
  {item.description ? ` · ${item.description}` : ''}
34
27
  </div>
@@ -40,8 +33,9 @@ export function FileCard({ item, href, className }: FileCardProps) {
40
33
  className={cn(
41
34
  'flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5',
42
35
  'text-body-sm text-fg-2 transition-colors hover:bg-surface-hover',
43
- )}>
44
- <Download className='size-3.5' />
36
+ )}
37
+ >
38
+ <Download className="size-3.5" />
45
39
  Download
46
40
  </a>
47
41
  ) : null}