@workerdeck/ui 0.15.0 → 0.16.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 (65) hide show
  1. package/README.md +53 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
  4. package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +442 -45
  9. package/build/index.mjs +105 -2
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -1
  14. package/build/workspace.mjs +108 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +14 -7
  17. package/src/components/agent/Composer.tsx +189 -89
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -46
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/QuestionPrompt.tsx +0 -122
  25. package/src/components/agent/Reasoning.tsx +5 -19
  26. package/src/components/agent/Response.tsx +1 -132
  27. package/src/components/agent/SessionPanel.tsx +224 -28
  28. package/src/components/agent/SessionWorkspace.tsx +29 -0
  29. package/src/components/agent/StatusBar.tsx +20 -4
  30. package/src/components/agent/ToolCallCard.tsx +17 -111
  31. package/src/components/agent/Transcript.tsx +710 -203
  32. package/src/components/agent/UsageDialog.tsx +20 -106
  33. package/src/components/agent/UsageMeters.tsx +133 -0
  34. package/src/components/agent/pulse.tsx +3 -2
  35. package/src/components/agent/transcript-rows.ts +82 -0
  36. package/src/components/agent/transcript-variant.tsx +29 -51
  37. package/src/components/agent/use-height-epoch.ts +60 -0
  38. package/src/components/agent/use-path-links.ts +147 -0
  39. package/src/components/agent/use-transcript-jumps.ts +190 -0
  40. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  41. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  42. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  43. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  44. package/src/components/terminal/StatusLine.tsx +159 -0
  45. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  46. package/src/components/terminal/affordances.tsx +118 -0
  47. package/src/components/terminal/diff.tsx +130 -0
  48. package/src/components/terminal/height.ts +727 -0
  49. package/src/components/terminal/items.tsx +449 -0
  50. package/src/components/terminal/markdown.tsx +191 -0
  51. package/src/components/terminal/press.tsx +120 -0
  52. package/src/components/terminal/prompt.tsx +343 -0
  53. package/src/components/terminal/result-preview.ts +72 -0
  54. package/src/components/terminal/row.tsx +132 -0
  55. package/src/components/terminal/scrubber.tsx +663 -0
  56. package/src/components/terminal/surface.tsx +80 -0
  57. package/src/components/terminal/tool-run.ts +91 -0
  58. package/src/index.ts +34 -0
  59. package/src/lib/status.ts +59 -3
  60. package/src/lib/tool-icon.ts +14 -0
  61. package/src/styles/terminal.css +1011 -0
  62. package/src/styles/theme.css +41 -0
  63. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  64. package/build/format.mjs.map +0 -1
  65. package/src/components/agent/line-prompt.tsx +0 -249
@@ -24,7 +24,10 @@ import { Spinner } from '../ui/Spinner.tsx'
24
24
  import { PromptArea } from '../prompt-area/prompt-area.tsx'
25
25
  import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
26
26
  import { commandTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
27
- import { useLines } from './transcript-variant.tsx'
27
+ import { useTranscriptVariant } from './transcript-variant.tsx'
28
+ import type { TerminalAffordances } from '../terminal/affordances.tsx'
29
+ import { PROMPT_GLYPH } from '../terminal/items.tsx'
30
+ import { TerminalSurface } from '../terminal/surface.tsx'
28
31
  import type { TriggerSuggestion } from '../prompt-area/types.ts'
29
32
  import { cn } from '../../lib/utils.ts'
30
33
  import { formatBytes } from '../../lib/format.ts'
@@ -77,6 +80,19 @@ export interface ComposerProps {
77
80
  * empty composer would otherwise spend two rows saying nothing.
78
81
  */
79
82
  layout?: 'stacked' | 'inline'
83
+ /**
84
+ * Terminal theme only: the cell the composer draws on. Passed straight
85
+ * through to its own {@link TerminalSurface}, and it has to be passed — the
86
+ * composer sits outside the transcript's scroller, so it establishes a second
87
+ * surface, and a surface handed no metrics falls back to the 13/18 default. A
88
+ * host running the transcript at its editor's size and the composer at the
89
+ * default would have the caret land on a different column from the text above
90
+ * it, which is the one thing this theme exists to prevent.
91
+ */
92
+ fontSize?: number
93
+ lineHeight?: number
94
+ /** Terminal theme only; see {@link TerminalAffordances}. */
95
+ affordances?: TerminalAffordances | boolean
80
96
  className?: string
81
97
  ref?: Ref<ComposerHandle>
82
98
  }
@@ -145,13 +161,16 @@ export function Composer({
145
161
  attachments,
146
162
  toolbar,
147
163
  layout = 'stacked',
164
+ fontSize,
165
+ lineHeight,
166
+ affordances,
148
167
  className,
149
168
  ref,
150
169
  }: ComposerProps) {
151
170
  const inline = layout === 'inline'
152
171
  // The transcript's variant reaches here through the panel-wide context, so
153
172
  // the composer matches the rows above it without a prop chain.
154
- const lines = useLines()
173
+ const terminal = useTranscriptVariant() === 'terminal'
155
174
  const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState()
156
175
  const fileInput = useRef<HTMLInputElement>(null)
157
176
  const [dragging, setDragging] = useState(false)
@@ -322,36 +341,69 @@ export function Composer({
322
341
  const attach = canAttach ? (
323
342
  <>
324
343
  {fileField}
325
- {lines ? (
326
- // The glyph sits in the same 14px gutter the transcript's markers use,
327
- // so the typed line starts on the column the conversation does.
328
- <GlyphButton label='Attach files' disabled={disabled} onClick={() => fileInput.current?.click()}>
329
- +
330
- </GlyphButton>
331
- ) : (
332
- <Button
333
- variant='ghost'
334
- size='icon-sm'
335
- aria-label='Attach files'
336
- disabled={disabled}
337
- onClick={() => fileInput.current?.click()}>
338
- <Paperclip className='size-4' />
339
- </Button>
340
- )}
344
+ <Button
345
+ variant='ghost'
346
+ size='icon-sm'
347
+ aria-label='Attach files'
348
+ disabled={disabled}
349
+ onClick={() => fileInput.current?.click()}>
350
+ <Paperclip className='size-4' />
351
+ </Button>
341
352
  </>
342
353
  ) : null
343
354
 
355
+ /**
356
+ * The terminal composer's **gutter cell** — the one column every transcript
357
+ * row's marker sits in, so whatever stands here cannot move the text beside
358
+ * it. It holds one of three things, in this order:
359
+ *
360
+ * `✕` **while the session is working**, because the gutter is where the eye
361
+ * already is (it is the column the pulse and every marker share) and stop is
362
+ * the only action that matters mid-run. Note the condition is `busy` alone,
363
+ * not the old `busy && !canSend`: with send living on the other side of the
364
+ * field there is no longer a slot to compete for, so typing a follow-up while
365
+ * a turn runs no longer hides the way to stop it.
366
+ *
367
+ * A cross rather than the `■` this started as: the square reads as a *state*
368
+ * ("stopped") in a column where `●` and `◆` really are states, so it looked
369
+ * like a status marker rather than something to press. A cross reads as an
370
+ * action and collides with nothing else on the column. It is also one of the
371
+ * few candidates that measures exactly 1ch in JetBrains Mono — `⏹`, `⏸` and
372
+ * `⏻`, the obvious picks, are 1.05–1.31 cells and would break the grid.
373
+ *
374
+ * `+` **otherwise**, when there is anything to attach — the composer's own
375
+ * affordance, in the composer's own gutter.
376
+ *
377
+ * `❯` when neither applies, so the column is never empty and the typed line
378
+ * never shifts. Blue, not the brand's coral: coral is the *working* mark, and
379
+ * a prompt waiting for you is not the session working.
380
+ */
381
+ const gutter = busy ? (
382
+ <GlyphButton gutter label='Interrupt' tone='yellow' onClick={onInterrupt}>
383
+
384
+ </GlyphButton>
385
+ ) : canAttach ? (
386
+ <GlyphButton
387
+ gutter
388
+ label='Attach files'
389
+ disabled={disabled}
390
+ onClick={() => fileInput.current?.click()}>
391
+ +
392
+ </GlyphButton>
393
+ ) : (
394
+ <span aria-hidden className='term-gutter' data-tone='blue'>
395
+ {PROMPT_GLYPH}
396
+ </span>
397
+ )
398
+
344
399
  const interrupting = busy && !canSend
345
- const submitButton = lines ? (
400
+ const submitButton = terminal ? (
346
401
  // Terminal furniture rather than chat furniture: a glyph that lights up on
347
- // hover/focus instead of a filled pill. `↵` is what sends, `■` is what stops
348
- // — the same two symbols the keyboard and a shell already use.
349
- <GlyphButton
350
- label={interrupting ? 'Interrupt' : 'Send'}
351
- disabled={!interrupting && !canSend}
352
- onClick={interrupting ? onInterrupt : submit}
353
- className={interrupting ? 'text-warning' : canSend ? 'text-accent' : undefined}>
354
- {interrupting ? '■' : '↵'}
402
+ // hover/focus instead of a filled pill. `↵` is the only thing on this side
403
+ // now stop moved to the gutter so it means one thing at all times, and
404
+ // a reader never has to check which symbol is currently under their cursor.
405
+ <GlyphButton label='Send' disabled={!canSend} onClick={submit} tone={canSend ? 'blue' : undefined}>
406
+
355
407
  </GlyphButton>
356
408
  ) : interrupting ? (
357
409
  <Button
@@ -393,7 +445,6 @@ export function Composer({
393
445
  <div
394
446
  className={cn(
395
447
  'mx-auto mt-1 flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-danger',
396
- lines && 'px-2',
397
448
  )}>
398
449
  <TriangleAlert className='size-3 shrink-0' />
399
450
  <span className='min-w-0 flex-1'>{attachments.error}</span>
@@ -407,59 +458,72 @@ export function Composer({
407
458
  </div>
408
459
  ) : null
409
460
 
410
- if (lines) {
411
- // Docked, not floating: the composer is the foot of the panel rather than a
412
- // card sitting on it. No gutter, no radius, no shadow, and **one** border —
413
- // the rule along the top which turns accent while anything inside has
414
- // focus. That single blue line is the whole affordance, which is what VS
415
- // Code's own agent view does and what the transcript above (no boxes) asks
416
- // for. The bar spans the panel; its contents stay in the transcript's
417
- // column, so the typed line starts where the conversation does.
461
+ if (terminal) {
462
+ // The CLI's own prompt, on the CLI's own grid: `>` sits in the gutter cell
463
+ // every transcript row's marker sits in, so what you type starts on the
464
+ // column what you are reading starts on. That alignment is the entire
465
+ // reason this is its own branch rather than the docked chrome restyled
466
+ // the composer is outside the transcript's scroller, so it needs a surface
467
+ // of its own, at the same metrics.
468
+ const line = lineHeight ?? 18
418
469
  return (
419
470
  <div data-slot='composer' className={cn('shrink-0', className)}>
420
- <div
471
+ <TerminalSurface
421
472
  {...dropHandlers}
422
- className={cn(
423
- // `min-h`, not `h`: one line matches the status bar below it exactly
424
- // — two strips of one piece of chrome — and it still grows with the
425
- // message, which a fixed height would not.
426
- 'flex min-h-[38px] flex-col justify-center',
427
- 'border-t border-border bg-bg transition-colors',
428
- 'focus-within:border-t-accent',
429
- dragging && 'border-t-accent',
430
- disabled && 'opacity-60',
431
- )}>
432
- <div className='mx-auto w-full max-w-[var(--wd-content-max-w,48rem)] px-2 py-1'>
473
+ fontSize={fontSize}
474
+ lineHeight={lineHeight}
475
+ affordances={affordances}
476
+ bleed='1ch'
477
+ data-dragging={dragging || undefined}
478
+ className={cn('term-composer', disabled && 'opacity-60')}>
479
+ <div className='term-composer-body'>
433
480
  {staged.length > 0 && attachments ? (
434
481
  <AttachmentStrip attachments={attachments} />
435
482
  ) : null}
436
- {/* One row until the message needs more: the field grows into the
437
- space rather than the frame reserving it, and the buttons stay
438
- bottom-aligned as it does. */}
439
- <div className='flex items-end gap-1'>
440
- {attach}
441
- <PromptArea
442
- {...bind}
443
- triggers={triggers}
444
- onSubmit={submit}
445
- disabled={disabled}
446
- placeholder={disabled ? 'Session ended' : placeholder}
447
- minHeight={20}
448
- maxHeight={192}
449
- aria-label='Message the agent'
450
- className='min-w-0 flex-1 py-0.5 text-body-sm text-text'
451
- onImagePaste={(file) => attachments?.add([file])}
452
- />
453
- {submitButton}
483
+ <div className='term-row'>
484
+ {/* The hidden input only its trigger lives in the gutter. */}
485
+ {canAttach ? fileField : null}
486
+ {gutter}
487
+ <div className='flex min-w-0 items-start'>
488
+ <PromptArea
489
+ {...bind}
490
+ triggers={triggers}
491
+ // What the agent receives is markdown, so the marker the user
492
+ // typed is the marker that has to survive. Left on (the
493
+ // default), the editor rewrites `- ` to `• ` in the *model*,
494
+ // not just on screen — so a bulleted message reached the agent
495
+ // as `• item`, which is not a list in any markdown parser, and
496
+ // drew a glyph the character grid has no cell for. Off, the
497
+ // convenience stays and only the rewrite goes:
498
+ // `insertListContinuation` keys on `[•\-*] ` and reuses the
499
+ // line's own marker, so Enter after `- item` still inserts
500
+ // `\n- `, and Enter on an empty item still leaves the list.
501
+ normalizeBullets={false}
502
+ onSubmit={submit}
503
+ disabled={disabled}
504
+ placeholder={disabled ? 'Session ended' : placeholder}
505
+ // The field's own metrics are the cell's: one row is one line,
506
+ // and it grows in whole lines from there.
507
+ minHeight={line}
508
+ maxHeight={line * 10}
509
+ aria-label='Message the agent'
510
+ className='term-composer-field min-w-0 flex-1'
511
+ onImagePaste={(file) => attachments?.add([file])}
512
+ />
513
+ {submitButton}
514
+ </div>
454
515
  </div>
455
- {/* Model and mode under the field, where an editor's chat puts them.
456
- Absent entirely under `controlsSurface: 'external'`, which is how
457
- the VS Code panel keeps the composer one row tall. */}
516
+ {/* Model and mode below the prompt, on the body column so they read
517
+ as a continuation of the line rather than new chrome. Absent
518
+ under `controlsSurface: 'external'`. */}
458
519
  {toolbar ? (
459
- <div className='flex min-w-0 items-center gap-1 pt-1'>{toolbar}</div>
520
+ <div className='term-row'>
521
+ <span aria-hidden className='term-gutter' />
522
+ <div className='flex min-w-0 items-center gap-[1ch]'>{toolbar}</div>
523
+ </div>
460
524
  ) : null}
461
525
  </div>
462
- </div>
526
+ </TerminalSurface>
463
527
  {errorRow}
464
528
  </div>
465
529
  )
@@ -491,6 +555,9 @@ export function Composer({
491
555
  <PromptArea
492
556
  {...bind}
493
557
  triggers={triggers}
558
+ // See the terminal composer above: `•` is not a markdown list
559
+ // marker, and this rewrite reaches the sent message.
560
+ normalizeBullets={false}
494
561
  onSubmit={submit}
495
562
  disabled={disabled}
496
563
  placeholder={disabled ? 'Session ended' : placeholder}
@@ -507,6 +574,9 @@ export function Composer({
507
574
  <PromptArea
508
575
  {...bind}
509
576
  triggers={triggers}
577
+ // See the terminal composer above: `•` is not a markdown list
578
+ // marker, and this rewrite reaches the sent message.
579
+ normalizeBullets={false}
510
580
  onSubmit={submit}
511
581
  disabled={disabled}
512
582
  placeholder={disabled ? 'Session ended' : placeholder}
@@ -532,22 +602,30 @@ export function Composer({
532
602
  }
533
603
 
534
604
  /**
535
- * A composer action as a **character**, for the inline (terminal) layout: no
536
- * pill, no border, nothing drawn until you reach for it — the surface only
537
- * appears on hover/focus/press, which is how a terminal's own affordances
538
- * behave. Sized to the transcript's gutter glyph (14px) so the row's furniture
539
- * lines up with the conversation above it rather than towering over it.
605
+ * A composer action as a **character**, for the terminal composer: no pill, no
606
+ * border, nothing drawn until you reach for it — the surface only appears on
607
+ * hover/focus, which is how a terminal's own affordances behave.
608
+ *
609
+ * One cell wide and one line tall (`term-glyph` in `terminal.css`), so it sits
610
+ * on the same grid as the row it shares and removing it would move nothing.
540
611
  */
541
612
  function GlyphButton({
613
+ tone,
542
614
  label,
543
615
  disabled,
544
616
  onClick,
617
+ gutter,
545
618
  className,
546
619
  children,
547
620
  }: {
621
+ tone?: 'blue' | 'yellow'
548
622
  label: string
549
623
  disabled?: boolean
550
624
  onClick: () => void
625
+ /** Standing in the gutter cell rather than at the row's trailing edge, so the
626
+ * glyph aligns to the column's start like every transcript marker instead of
627
+ * centring in the 2ch cell — see `.term-glyph[data-gutter]`. */
628
+ gutter?: boolean
551
629
  className?: string
552
630
  children: ReactNode
553
631
  }) {
@@ -558,13 +636,9 @@ function GlyphButton({
558
636
  title={label}
559
637
  disabled={disabled}
560
638
  onClick={onClick}
561
- className={cn(
562
- 'flex size-6 shrink-0 items-center justify-center rounded-sm font-mono text-body-sm leading-none',
563
- 'text-fg-3 transition-colors outline-none select-none',
564
- 'hover:bg-surface-hover hover:text-fg-1 focus-visible:bg-surface-hover focus-visible:text-fg-1',
565
- 'active:bg-accent-bg disabled:pointer-events-none disabled:opacity-40',
566
- className,
567
- )}>
639
+ data-tone={tone}
640
+ data-gutter={gutter || undefined}
641
+ className={cn('term-glyph', className)}>
568
642
  {children}
569
643
  </button>
570
644
  )
@@ -574,8 +648,16 @@ function GlyphButton({
574
648
  * the local blob, so nothing here waits on the network; the upload's state rides
575
649
  * on top of it and the ✕ takes it back off. */
576
650
  function AttachmentStrip({ attachments }: { attachments: UseAttachmentsResult }) {
651
+ const terminal = useTranscriptVariant() === 'terminal'
577
652
  return (
578
- <div className='flex gap-2 overflow-x-auto border-b border-border px-2 py-2'>
653
+ <div
654
+ className={cn(
655
+ 'flex gap-2 overflow-x-auto',
656
+ // The terminal form draws no rule of its own: the composer's frame is
657
+ // already directly below this strip, and two rules a few pixels apart
658
+ // is the box the theme has none of. Geometry in `terminal.css`.
659
+ terminal ? 'term-attachments' : 'border-b border-border px-2 py-2',
660
+ )}>
579
661
  {attachments.items.map((item) => (
580
662
  <AttachmentChip
581
663
  key={item.key}
@@ -598,15 +680,23 @@ function AttachmentChip({
598
680
  onRemove: () => void
599
681
  }) {
600
682
  const failed = item.status === 'failed'
683
+ const terminal = useTranscriptVariant() === 'terminal'
684
+ // Corners are the whole difference: rounded and floating in `cards`, square
685
+ // and inside the frame here. Kept as one flag rather than four so a thumbnail
686
+ // and the overlays stacked on it cannot disagree about their own shape.
687
+ const round = terminal ? '' : 'rounded-md'
601
688
  return (
602
689
  <div
603
690
  className='group relative shrink-0'
604
691
  title={failed ? `${item.name} — ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`}>
605
692
  <div
606
693
  className={cn(
607
- 'flex size-14 items-center justify-center overflow-hidden rounded-md border border-border bg-surface',
694
+ 'flex size-14 items-center justify-center overflow-hidden bg-surface',
695
+ round,
696
+ terminal ? 'term-attachment' : 'border border-border',
608
697
  failed && 'border-danger/50',
609
- )}>
698
+ )}
699
+ data-failed={terminal && failed ? '' : undefined}>
610
700
  {item.previewUrl ? (
611
701
  <img src={item.previewUrl} alt={item.name} className='size-full object-cover' />
612
702
  ) : (
@@ -619,7 +709,7 @@ function AttachmentChip({
619
709
  )}
620
710
  </div>
621
711
  {item.status === 'uploading' ? (
622
- <div className='absolute inset-0 flex items-center justify-center rounded-md bg-black/35'>
712
+ <div className={cn('absolute inset-0 flex items-center justify-center bg-black/35', round)}>
623
713
  <Spinner className='size-4 text-white' />
624
714
  </div>
625
715
  ) : null}
@@ -628,7 +718,10 @@ function AttachmentChip({
628
718
  type='button'
629
719
  onClick={onRetry}
630
720
  aria-label={`Retry ${item.name}`}
631
- className='absolute inset-0 flex items-center justify-center rounded-md bg-black/45 text-warning'>
721
+ className={cn(
722
+ 'absolute inset-0 flex items-center justify-center bg-black/45 text-warning',
723
+ round,
724
+ )}>
632
725
  <RotateCw className='size-4' />
633
726
  </button>
634
727
  ) : null}
@@ -636,7 +729,14 @@ function AttachmentChip({
636
729
  type='button'
637
730
  onClick={onRemove}
638
731
  aria-label={`Remove ${item.name}`}
639
- className='absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full border border-border bg-surface text-fg-3 shadow-(--shadow-xs) hover:text-fg-1'>
732
+ className={cn(
733
+ 'absolute flex size-4 items-center justify-center bg-surface text-fg-3 hover:text-fg-1',
734
+ // Cards hang it off the corner as a round badge; the terminal tucks it
735
+ // inside, squared, with two rules for its corner (`terminal.css`).
736
+ terminal
737
+ ? 'term-attachment-remove'
738
+ : '-top-1 -right-1 rounded-full border border-border shadow-(--shadow-xs)',
739
+ )}>
640
740
  <X className='size-2.5' />
641
741
  </button>
642
742
  </div>
@@ -6,25 +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'
17
9
  }
18
10
 
19
11
  /** Scroll container that stays pinned to the bottom while streaming, unless the user
20
- * scrolls up — plus a floating scroll-to-bottom button. */
21
- export function Conversation({ className, children, resize = 'smooth', ...props }: ConversationProps) {
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
+ export function Conversation({ className, children, ...props }: ConversationProps) {
22
22
  return (
23
23
  <StickToBottom
24
24
  data-slot='conversation'
25
25
  className={cn('relative flex-1 overflow-y-auto', className)}
26
26
  initial='instant'
27
- resize={resize}
27
+ resize='instant'
28
28
  role='log'
29
29
  {...props}>
30
30
  {children}
@@ -60,7 +60,7 @@ export function ConversationScrollButton({ className }: { className?: string })
60
60
  'absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full shadow-(--shadow-md)',
61
61
  className,
62
62
  )}
63
- onClick={() => void scrollToBottom()}>
63
+ onClick={() => void scrollToBottom('instant')}>
64
64
  <ArrowDown className='size-4' />
65
65
  </Button>
66
66
  )
@@ -2,7 +2,6 @@ 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'
6
5
 
7
6
  export type FileDeliveredItem = Extract<TranscriptItem, { kind: 'file_delivered' }>
8
7
 
@@ -18,32 +17,7 @@ export interface FileCardProps {
18
17
  * The file lives in the session's in-memory VFS — the link works while the
19
18
  * session lives. */
20
19
  export function FileCard({ item, href, className }: FileCardProps) {
21
- const lines = useLines()
22
20
  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
- }
47
21
  return (
48
22
  <div
49
23
  data-slot='file-delivered'
@@ -108,9 +108,7 @@ export function FileTree({
108
108
  ) : null}
109
109
  </div>
110
110
  ) : (
111
- <span className='min-w-0 flex-1 truncate px-1 font-mono text-label text-fg-4'>
112
- {tree.root}
113
- </span>
111
+ <span className='min-w-0 flex-1 truncate px-1 text-label text-fg-4'>{tree.root}</span>
114
112
  )}
115
113
  <Button
116
114
  variant='ghost'
@@ -259,11 +257,14 @@ function Row({
259
257
  <span className='flex size-3 shrink-0 items-center justify-center'>{chevron}</span>
260
258
  {icon}
261
259
  {/* `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}
260
+ out of room the directory gives way and the filename stays whole.
261
+ The rail reads in the **UI font**, never mono: it is workbench chrome
262
+ a list you scan — and the editors it sits beside (VS Code, Finder)
263
+ all set filenames in their UI face. Mono is for content on a grid, and
264
+ nothing here is on one. It is also independent of `transcriptFont`,
265
+ which scopes to the session panel alone. */}
266
+ <span className='shrink-0 truncate text-label'>{label}</span>
267
+ {detail ? <span className='min-w-0 flex-1 truncate text-label text-fg-4'>{detail}</span> : null}
267
268
  </button>
268
269
  )
269
270
  }
@@ -1,92 +1,42 @@
1
1
  import { cn } from '../../lib/utils.ts'
2
- import { formatDuration, formatTokens } from '../../lib/format.ts'
3
- import { usePulse } from './pulse.tsx'
4
- import { LineGlyph, useLines } from './transcript-variant.tsx'
5
2
 
6
3
  export interface LoaderProps {
7
4
  /** Overrides the cycling verb — for a state that has one true name
8
5
  * ("Starting session…"). */
9
6
  label?: string
10
- /** When the current run began, for the elapsed clock. Absent = no clock. */
7
+ /** When the current run began, for the elapsed clock. Absent = no clock.
8
+ * Unused by the card treatment; kept so the `cards` and `terminal` loaders
9
+ * take the same call site in `Transcript`. */
11
10
  startedAt?: number
12
- /** Context tokens in play, shown beside the clock. */
11
+ /** Context tokens in play. Same story as `startedAt`. */
13
12
  tokens?: number
14
13
  className?: string
15
14
  }
16
15
 
17
- /**
18
- * What it says it is doing while it hasn't said anything yet. Cycled on a slow
19
- * clock so a long turn doesn't sit under one frozen word — a still label reads
20
- * as a hung process, which is exactly what this is meant to disprove.
21
- */
22
- const VERBS = [
23
- 'Working',
24
- 'Thinking',
25
- 'Churning',
26
- 'Pondering',
27
- 'Whirring',
28
- 'Computing',
29
- 'Percolating',
30
- 'Tinkering',
31
- 'Deliberating',
32
- 'Simmering',
33
- 'Crunching',
34
- 'Noodling',
35
- ]
36
- const VERB_MS = 4000
37
-
38
16
  /**
39
17
  * "The agent is working and hasn't produced output yet."
40
18
  *
41
- * `lines`: a terminal working line — the mark's own pulse in the gutter (see
42
- * `pulse.tsx`), a verb, and the readings that answer "should I still be
43
- * waiting?" in one parenthesis. `cards`: the three-dot pulse, unchanged the
44
- * dashboard's loader is not a gutter glyph and has no column to pulse in.
19
+ * Three dots pulsing in CSS — the chat convention, for the `cards` variant. The
20
+ * terminal theme has its own working line (`terminal/items.tsx`'s `WorkingRow`,
21
+ * with the mark's pulse in the gutter and the run's readings beside the verb),
22
+ * because there it is a *row of the transcript* rather than a spinner floating
23
+ * over one.
45
24
  */
46
- export function Loader({ label, startedAt, tokens, className }: LoaderProps) {
47
- const lines = useLines()
48
- // Only the line variant animates a glyph; cards pulse three dots in CSS.
49
- const pulse = usePulse(lines)
50
-
51
- if (!lines) {
52
- return (
53
- <div
54
- data-slot='loader'
55
- className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
56
- <span className='flex items-center gap-1'>
57
- {[0, 1, 2].map((i) => (
58
- <span
59
- key={i}
60
- className='size-1.5 animate-pulse rounded-full bg-fg-4'
61
- style={{ animationDelay: `${i * 160}ms` }}
62
- />
63
- ))}
64
- </span>
65
- {label ? <span>{label}</span> : null}
66
- </div>
67
- )
68
- }
69
-
70
- // Derived from the clock rather than kept in state: the frame tick is already
71
- // re-rendering, so the seconds and the verb come along for free.
72
- const elapsed = startedAt === undefined ? undefined : Date.now() - startedAt
73
- const verb =
74
- label ?? VERBS[Math.floor((elapsed ?? 0) / VERB_MS) % VERBS.length] ?? VERBS[0]
75
- const readings = [
76
- elapsed !== undefined ? formatDuration(elapsed) : undefined,
77
- tokens !== undefined ? `↓ ${formatTokens(tokens)}` : undefined,
78
- ].filter(Boolean)
79
-
25
+ export function Loader({ label, className }: LoaderProps) {
80
26
  return (
81
- <div data-slot='loader' className={cn('flex items-baseline gap-2', className)}>
82
- {/* `lines` is monochrome: the terminal treatment earns its calm by
83
- spending no colour on decoration, and a pulsing glyph is already the
84
- loudest thing on a still page. `cards` keeps the brand accent. */}
85
- <LineGlyph className={lines ? 'text-fg-3' : 'text-accent'}>{pulse}</LineGlyph>
86
- <span className='min-w-0 flex-1 text-body-sm leading-5 text-fg-3'>
87
- {verb}…{' '}
88
- {readings.length ? <span className='text-label text-fg-4'>({readings.join(' · ')})</span> : null}
27
+ <div
28
+ data-slot='loader'
29
+ className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
30
+ <span className='flex items-center gap-1'>
31
+ {[0, 1, 2].map((i) => (
32
+ <span
33
+ key={i}
34
+ className='size-1.5 animate-pulse rounded-full bg-fg-4'
35
+ style={{ animationDelay: `${i * 160}ms` }}
36
+ />
37
+ ))}
89
38
  </span>
39
+ {label ? <span>{label}</span> : null}
90
40
  </div>
91
41
  )
92
42
  }