@workerdeck/ui 0.13.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 (69) hide show
  1. package/README.md +72 -0
  2. package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
  3. package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
  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 +494 -45
  9. package/build/index.mjs +182 -24
  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 +13 -1
  14. package/build/workspace.mjs +111 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +14 -7
  17. package/src/components/agent/Composer.tsx +251 -84
  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 -43
  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/SessionBrowser.tsx +35 -25
  28. package/src/components/agent/SessionPanel.tsx +312 -63
  29. package/src/components/agent/SessionWorkspace.tsx +36 -0
  30. package/src/components/agent/StatusBar.tsx +70 -15
  31. package/src/components/agent/ToolCallCard.tsx +17 -111
  32. package/src/components/agent/Transcript.tsx +710 -203
  33. package/src/components/agent/UsageDialog.tsx +20 -106
  34. package/src/components/agent/UsageMeters.tsx +133 -0
  35. package/src/components/agent/pulse.tsx +3 -2
  36. package/src/components/agent/transcript-rows.ts +82 -0
  37. package/src/components/agent/transcript-variant.tsx +43 -51
  38. package/src/components/agent/use-height-epoch.ts +60 -0
  39. package/src/components/agent/use-path-links.ts +147 -0
  40. package/src/components/agent/use-transcript-jumps.ts +190 -0
  41. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  42. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  43. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  44. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  45. package/src/components/terminal/StatusLine.tsx +159 -0
  46. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  47. package/src/components/terminal/affordances.tsx +118 -0
  48. package/src/components/terminal/diff.tsx +130 -0
  49. package/src/components/terminal/height.ts +727 -0
  50. package/src/components/terminal/items.tsx +449 -0
  51. package/src/components/terminal/markdown.tsx +191 -0
  52. package/src/components/terminal/press.tsx +120 -0
  53. package/src/components/terminal/prompt.tsx +343 -0
  54. package/src/components/terminal/result-preview.ts +72 -0
  55. package/src/components/terminal/row.tsx +132 -0
  56. package/src/components/terminal/scrubber.tsx +663 -0
  57. package/src/components/terminal/surface.tsx +80 -0
  58. package/src/components/terminal/tool-run.ts +91 -0
  59. package/src/components/ui/Badge.tsx +6 -1
  60. package/src/components/ui/Empty.tsx +56 -0
  61. package/src/components/ui/Splitter.tsx +14 -0
  62. package/src/index.ts +36 -0
  63. package/src/lib/status.ts +59 -3
  64. package/src/lib/tool-icon.ts +14 -0
  65. package/src/styles/terminal.css +1011 -0
  66. package/src/styles/theme.css +73 -0
  67. package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
  68. package/build/format.mjs.map +0 -1
  69. package/src/components/agent/line-prompt.tsx +0 -249
@@ -1,4 +1,12 @@
1
- import { useImperativeHandle, useMemo, useRef, useState, type ReactNode, type Ref } from 'react'
1
+ import {
2
+ useImperativeHandle,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ type DragEvent,
7
+ type ReactNode,
8
+ type Ref,
9
+ } from 'react'
2
10
  import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
3
11
  import type { StagedAttachment, UseAttachmentsResult } from '@workerdeck/react'
4
12
  import {
@@ -16,7 +24,10 @@ import { Spinner } from '../ui/Spinner.tsx'
16
24
  import { PromptArea } from '../prompt-area/prompt-area.tsx'
17
25
  import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
18
26
  import { commandTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
19
- 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'
20
31
  import type { TriggerSuggestion } from '../prompt-area/types.ts'
21
32
  import { cn } from '../../lib/utils.ts'
22
33
  import { formatBytes } from '../../lib/format.ts'
@@ -69,6 +80,19 @@ export interface ComposerProps {
69
80
  * empty composer would otherwise spend two rows saying nothing.
70
81
  */
71
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
72
96
  className?: string
73
97
  ref?: Ref<ComposerHandle>
74
98
  }
@@ -137,13 +161,16 @@ export function Composer({
137
161
  attachments,
138
162
  toolbar,
139
163
  layout = 'stacked',
164
+ fontSize,
165
+ lineHeight,
166
+ affordances,
140
167
  className,
141
168
  ref,
142
169
  }: ComposerProps) {
143
170
  const inline = layout === 'inline'
144
171
  // The transcript's variant reaches here through the panel-wide context, so
145
172
  // the composer matches the rows above it without a prop chain.
146
- const lines = useLines()
173
+ const terminal = useTranscriptVariant() === 'terminal'
147
174
  const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState()
148
175
  const fileInput = useRef<HTMLInputElement>(null)
149
176
  const [dragging, setDragging] = useState(false)
@@ -314,36 +341,69 @@ export function Composer({
314
341
  const attach = canAttach ? (
315
342
  <>
316
343
  {fileField}
317
- {inline ? (
318
- // The glyph sits in the same 14px gutter the transcript's markers use,
319
- // so the typed line starts on the column the conversation does.
320
- <GlyphButton label='Attach files' disabled={disabled} onClick={() => fileInput.current?.click()}>
321
- +
322
- </GlyphButton>
323
- ) : (
324
- <Button
325
- variant='ghost'
326
- size='icon-sm'
327
- aria-label='Attach files'
328
- disabled={disabled}
329
- onClick={() => fileInput.current?.click()}>
330
- <Paperclip className='size-4' />
331
- </Button>
332
- )}
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>
333
352
  </>
334
353
  ) : null
335
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
+
336
399
  const interrupting = busy && !canSend
337
- const submitButton = inline ? (
400
+ const submitButton = terminal ? (
338
401
  // Terminal furniture rather than chat furniture: a glyph that lights up on
339
- // hover/focus instead of a filled pill. `↵` is what sends, `■` is what stops
340
- // — the same two symbols the keyboard and a shell already use.
341
- <GlyphButton
342
- label={interrupting ? 'Interrupt' : 'Send'}
343
- disabled={!interrupting && !canSend}
344
- onClick={interrupting ? onInterrupt : submit}
345
- className={interrupting ? 'text-warning' : canSend ? 'text-accent' : undefined}>
346
- {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
+
347
407
  </GlyphButton>
348
408
  ) : interrupting ? (
349
409
  <Button
@@ -365,36 +425,119 @@ export function Composer({
365
425
  </Button>
366
426
  )
367
427
 
428
+ const dropHandlers = {
429
+ onDragOver: (e: DragEvent) => {
430
+ if (attachments && !attachments.disabled) {
431
+ e.preventDefault()
432
+ setDragging(true)
433
+ }
434
+ },
435
+ onDragLeave: () => setDragging(false),
436
+ onDrop: (e: DragEvent) => {
437
+ if (!attachments || attachments.disabled) return
438
+ e.preventDefault()
439
+ setDragging(false)
440
+ pick(e.dataTransfer.files)
441
+ },
442
+ }
443
+
444
+ const errorRow = attachments?.error ? (
445
+ <div
446
+ className={cn(
447
+ 'mx-auto mt-1 flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-danger',
448
+ )}>
449
+ <TriangleAlert className='size-3 shrink-0' />
450
+ <span className='min-w-0 flex-1'>{attachments.error}</span>
451
+ <button
452
+ type='button'
453
+ onClick={attachments.dismissError}
454
+ aria-label='Dismiss'
455
+ className='shrink-0 opacity-70 hover:opacity-100'>
456
+ <X className='size-3' />
457
+ </button>
458
+ </div>
459
+ ) : null
460
+
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
469
+ return (
470
+ <div data-slot='composer' className={cn('shrink-0', className)}>
471
+ <TerminalSurface
472
+ {...dropHandlers}
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'>
480
+ {staged.length > 0 && attachments ? (
481
+ <AttachmentStrip attachments={attachments} />
482
+ ) : null}
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>
515
+ </div>
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'`. */}
519
+ {toolbar ? (
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>
524
+ ) : null}
525
+ </div>
526
+ </TerminalSurface>
527
+ {errorRow}
528
+ </div>
529
+ )
530
+ }
531
+
368
532
  return (
369
533
  <div data-slot='composer' className={cn('px-3 pb-3', className)}>
370
534
  <div
371
- onDragOver={(e) => {
372
- if (attachments && !attachments.disabled) {
373
- e.preventDefault()
374
- setDragging(true)
375
- }
376
- }}
377
- onDragLeave={() => setDragging(false)}
378
- onDrop={(e) => {
379
- if (!attachments || attachments.disabled) return
380
- e.preventDefault()
381
- setDragging(false)
382
- pick(e.dataTransfer.files)
383
- }}
535
+ {...dropHandlers}
384
536
  className={cn(
385
537
  'mx-auto w-full max-w-[var(--wd-content-max-w,48rem)] overflow-hidden border border-border bg-bg',
386
- 'transition-colors',
387
- // VS Code's input: a flat, near-square box that gains a 1px accent
388
- // border on focus. No radius worth the name, no shadow, no glow ring —
389
- // an editor's chrome sits *in* the surface rather than floating above
390
- // it, and the `lines` transcript above it has no rounded box either.
391
- lines
392
- ? 'rounded-sm focus-within:border-accent'
393
- : cn(
394
- 'rounded-lg shadow-(--shadow-xs)',
395
- 'focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
396
- ),
397
- dragging && (lines ? 'border-accent' : 'border-ring ring-2 ring-ring/30'),
538
+ 'transition-colors rounded-lg shadow-(--shadow-xs)',
539
+ 'focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
540
+ dragging && 'border-ring ring-2 ring-ring/30',
398
541
  disabled && 'opacity-60',
399
542
  )}>
400
543
  {/* Above the field, like the picture you are talking about should be. */}
@@ -412,6 +555,9 @@ export function Composer({
412
555
  <PromptArea
413
556
  {...bind}
414
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}
415
561
  onSubmit={submit}
416
562
  disabled={disabled}
417
563
  placeholder={disabled ? 'Session ended' : placeholder}
@@ -428,6 +574,9 @@ export function Composer({
428
574
  <PromptArea
429
575
  {...bind}
430
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}
431
580
  onSubmit={submit}
432
581
  disabled={disabled}
433
582
  placeholder={disabled ? 'Session ended' : placeholder}
@@ -447,40 +596,36 @@ export function Composer({
447
596
  </>
448
597
  )}
449
598
  </div>
450
- {attachments?.error ? (
451
- <div className='mx-auto mt-1 flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-danger'>
452
- <TriangleAlert className='size-3 shrink-0' />
453
- <span className='min-w-0 flex-1'>{attachments.error}</span>
454
- <button
455
- type='button'
456
- onClick={attachments.dismissError}
457
- aria-label='Dismiss'
458
- className='shrink-0 opacity-70 hover:opacity-100'>
459
- <X className='size-3' />
460
- </button>
461
- </div>
462
- ) : null}
599
+ {errorRow}
463
600
  </div>
464
601
  )
465
602
  }
466
603
 
467
604
  /**
468
- * A composer action as a **character**, for the inline (terminal) layout: no
469
- * pill, no border, nothing drawn until you reach for it — the surface only
470
- * appears on hover/focus/press, which is how a terminal's own affordances
471
- * behave. Sized to the transcript's gutter glyph (14px) so the row's furniture
472
- * 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.
473
611
  */
474
612
  function GlyphButton({
613
+ tone,
475
614
  label,
476
615
  disabled,
477
616
  onClick,
617
+ gutter,
478
618
  className,
479
619
  children,
480
620
  }: {
621
+ tone?: 'blue' | 'yellow'
481
622
  label: string
482
623
  disabled?: boolean
483
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
484
629
  className?: string
485
630
  children: ReactNode
486
631
  }) {
@@ -491,13 +636,9 @@ function GlyphButton({
491
636
  title={label}
492
637
  disabled={disabled}
493
638
  onClick={onClick}
494
- className={cn(
495
- 'flex size-6 shrink-0 items-center justify-center rounded-sm font-mono text-body-sm leading-none',
496
- 'text-fg-3 transition-colors outline-none select-none',
497
- 'hover:bg-surface-hover hover:text-fg-1 focus-visible:bg-surface-hover focus-visible:text-fg-1',
498
- 'active:bg-accent-bg disabled:pointer-events-none disabled:opacity-40',
499
- className,
500
- )}>
639
+ data-tone={tone}
640
+ data-gutter={gutter || undefined}
641
+ className={cn('term-glyph', className)}>
501
642
  {children}
502
643
  </button>
503
644
  )
@@ -507,8 +648,16 @@ function GlyphButton({
507
648
  * the local blob, so nothing here waits on the network; the upload's state rides
508
649
  * on top of it and the ✕ takes it back off. */
509
650
  function AttachmentStrip({ attachments }: { attachments: UseAttachmentsResult }) {
651
+ const terminal = useTranscriptVariant() === 'terminal'
510
652
  return (
511
- <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
+ )}>
512
661
  {attachments.items.map((item) => (
513
662
  <AttachmentChip
514
663
  key={item.key}
@@ -531,15 +680,23 @@ function AttachmentChip({
531
680
  onRemove: () => void
532
681
  }) {
533
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'
534
688
  return (
535
689
  <div
536
690
  className='group relative shrink-0'
537
691
  title={failed ? `${item.name} — ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`}>
538
692
  <div
539
693
  className={cn(
540
- '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',
541
697
  failed && 'border-danger/50',
542
- )}>
698
+ )}
699
+ data-failed={terminal && failed ? '' : undefined}>
543
700
  {item.previewUrl ? (
544
701
  <img src={item.previewUrl} alt={item.name} className='size-full object-cover' />
545
702
  ) : (
@@ -552,7 +709,7 @@ function AttachmentChip({
552
709
  )}
553
710
  </div>
554
711
  {item.status === 'uploading' ? (
555
- <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)}>
556
713
  <Spinner className='size-4 text-white' />
557
714
  </div>
558
715
  ) : null}
@@ -561,7 +718,10 @@ function AttachmentChip({
561
718
  type='button'
562
719
  onClick={onRetry}
563
720
  aria-label={`Retry ${item.name}`}
564
- 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
+ )}>
565
725
  <RotateCw className='size-4' />
566
726
  </button>
567
727
  ) : null}
@@ -569,7 +729,14 @@ function AttachmentChip({
569
729
  type='button'
570
730
  onClick={onRemove}
571
731
  aria-label={`Remove ${item.name}`}
572
- 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
+ )}>
573
740
  <X className='size-2.5' />
574
741
  </button>
575
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
  }