@workerdeck/ui 1.1.0 → 1.3.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 (43) hide show
  1. package/README.md +4 -0
  2. package/build/{SessionPanel-yNO60Tzw.d.mts → SessionPanel-BSwUJDwg.d.mts} +3 -8
  3. package/build/{SessionPanel-I5QSLLb0.mjs → SessionPanel-DQp2oStr.mjs} +409 -267
  4. package/build/SessionPanel-DQp2oStr.mjs.map +1 -0
  5. package/build/{format-C_lpFq0l.d.mts → format-TVRJzYUj.d.mts} +3 -2
  6. package/build/format.d.mts +2 -7
  7. package/build/format.mjs +2 -2
  8. package/build/index.d.mts +34 -9
  9. package/build/index.mjs +38 -12
  10. package/build/index.mjs.map +1 -1
  11. package/build/scoped.css +11 -0
  12. package/build/{status-Dm9INVTt.mjs → status-C4TAgVSc.mjs} +3 -7
  13. package/build/status-C4TAgVSc.mjs.map +1 -0
  14. package/build/workspace.d.mts +3 -2
  15. package/build/workspace.mjs +4 -3
  16. package/build/workspace.mjs.map +1 -1
  17. package/package.json +4 -4
  18. package/src/components/agent/Composer.tsx +2 -4
  19. package/src/components/agent/ContextDialog.tsx +17 -2
  20. package/src/components/agent/ContextRing.tsx +36 -7
  21. package/src/components/agent/EntryIcon.tsx +2 -4
  22. package/src/components/agent/McpDialog.tsx +2 -0
  23. package/src/components/agent/SessionItem.tsx +1 -1
  24. package/src/components/agent/SessionPanel.tsx +198 -186
  25. package/src/components/agent/SessionWorkspace.tsx +3 -0
  26. package/src/components/agent/StatusBar.tsx +12 -4
  27. package/src/components/agent/ToolCallCard.tsx +6 -1
  28. package/src/components/agent/TranscriptItemView.tsx +15 -2
  29. package/src/components/agent/TranscriptRows.tsx +4 -6
  30. package/src/components/agent/held-sends.tsx +73 -0
  31. package/src/components/agent/tool-titles.tsx +12 -0
  32. package/src/components/agent/use-subagent-frame.ts +8 -10
  33. package/src/components/agent/use-transcript-jumps.ts +7 -9
  34. package/src/components/terminal/TerminalTranscript.tsx +15 -1
  35. package/src/components/terminal/height.ts +4 -1
  36. package/src/components/terminal/items.tsx +14 -3
  37. package/src/index.ts +2 -0
  38. package/src/lib/context-note.ts +24 -0
  39. package/src/lib/css.ts +3 -5
  40. package/src/lib/format.ts +3 -0
  41. package/src/lib/status.ts +3 -5
  42. package/build/SessionPanel-I5QSLLb0.mjs.map +0 -1
  43. package/build/status-Dm9INVTt.mjs.map +0 -1
@@ -46,7 +46,9 @@ import { BookmarkProvider, type BookmarkHandle, type TerminalAffordances } from
46
46
  import { SessionInfoDialog } from './SessionInfoDialog.tsx'
47
47
  import { StatusBar } from './StatusBar.tsx'
48
48
  import { Transcript } from './Transcript.tsx'
49
+ import { HeldSendsBar, useHeldSends } from './held-sends.tsx'
49
50
  import { ToolResultFetchProvider } from './tool-result-fetch.tsx'
51
+ import { ToolTitleProvider } from './tool-titles.tsx'
50
52
  import { ToolResultImageProvider, useToolResultImages } from './tool-result-image.tsx'
51
53
  import {
52
54
  TranscriptDensityProvider,
@@ -99,7 +101,7 @@ export interface SessionPanelProps {
99
101
  affordances?: TerminalAffordances | boolean
100
102
  terminalMetrics?: TerminalMetrics
101
103
  scrubber?: boolean
102
- /** Bookmarked transcript item ids — the host owns membership and persistence. */
104
+ // Bookmarked transcript item ids — the host owns membership and persistence.
103
105
  bookmarks?: readonly string[]
104
106
  onToggleBookmark?: (itemId: string) => void
105
107
  reveal?: { toolUseId: string; nonce: number }
@@ -108,6 +110,9 @@ export interface SessionPanelProps {
108
110
  stickyPrompt?: boolean
109
111
  transcriptDensity?: TranscriptDensity
110
112
  transcriptFont?: TranscriptFont
113
+ // 'fold' sends a mid-turn message straight through, and the engine folds it into the running
114
+ // turn (catch-up mode). 'hold' keeps it here until the turn ends.
115
+ midTurnSend?: 'fold' | 'hold'
111
116
  controlsSurface?: 'internal' | 'external' | 'status'
112
117
  onControls?: (controls: SessionControls | undefined) => void
113
118
  focusComposerOnClick?: boolean
@@ -162,11 +167,9 @@ export type SessionVitals = {
162
167
  cwd: TranscriptState['cwd']
163
168
  contextUsage: TranscriptState['contextUsage']
164
169
  rateLimits: TranscriptState['rateLimits']
165
- /**
166
- * When the newest window in `rateLimits` was reported, as event time — not receive time. External chrome (the
167
- * VS Code status bar, iOS) cannot otherwise tell a live reading from one a days-old session just replayed.
168
- * Absent means unknown, which is not the same as fresh.
169
- */
170
+ // When the newest window in `rateLimits` was reported, as event time — not receive time. External chrome (the
171
+ // VS Code status bar, iOS) cannot otherwise tell a live reading from one a days-old session just replayed.
172
+ // Absent means unknown, which is not the same as fresh.
170
173
  rateLimitsUpdatedAt: number | undefined
171
174
  itemCount: number
172
175
  totalCostUsd: number
@@ -184,6 +187,7 @@ export function SessionPanel({
184
187
  transcriptVariant = 'cards',
185
188
  transcriptDensity = 'comfortable',
186
189
  transcriptFont = 'sans',
190
+ midTurnSend = 'fold',
187
191
  affordances,
188
192
  terminalMetrics,
189
193
  scrubber = false,
@@ -395,6 +399,8 @@ export function SessionPanel({
395
399
  return [{ name: 'model', description: 'Switch the model for this session', argumentHint: '<model>' }, ...state.commands]
396
400
  }, [state.commands])
397
401
 
402
+ const heldSends = useHeldSends({ hold: midTurnSend === 'hold', busy: busy && !ended, send })
403
+
398
404
  const handleSend = (text: string, attachmentIds: string[]) => {
399
405
  if (attachmentIds.length === 0) {
400
406
  const modelCommand = /^\/model\s+(\S+)$/.exec(text)
@@ -409,7 +415,7 @@ export function SessionPanel({
409
415
  }
410
416
  setCaughtUp(true)
411
417
  repinTranscript.current?.()
412
- send(text, attachmentIds)
418
+ heldSends.submit(text, attachmentIds)
413
419
  }
414
420
 
415
421
  const actionsMenu = (
@@ -540,196 +546,202 @@ export function SessionPanel({
540
546
  <TranscriptVariantProvider value={transcriptVariant}>
541
547
  <TranscriptDensityProvider value={transcriptDensity}>
542
548
  <ToolResultFetchProvider value={loadFullResult}>
543
- <ToolResultImageProvider value={resultImages}>
544
- <div
545
- ref={panelRef}
546
- data-slot="session-panel"
547
- data-agent-font={transcriptFont}
548
- onClick={handleClick}
549
- className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
550
- style={fontSize !== undefined ? ({ '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties) : undefined}
551
- >
552
- {headerTakesActions ? header({ actions: menu }) : header}
553
- {statusPlacement === 'top' ? statusBar : null}
554
- {protocolMismatch !== undefined ? (
555
- <Notice level="warning">
556
- Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some events may not render.
557
- </Notice>
558
- ) : null}
559
- {protocolError ? (
560
- <Notice level="error" onDismiss={() => setProtocolError(undefined)}>
561
- {protocolError}
562
- </Notice>
563
- ) : null}
564
- {subagentId !== undefined ? (
565
- <SubagentStrip
566
- task={subagentTask}
567
- items={subagentFrameItems}
568
- label={subagentFallbackLabel}
569
- onBack={leaveSubagent}
570
- terminal={terminal}
571
- fontSize={effectiveTermFontSize}
572
- lineHeight={effectiveTermLineHeight}
573
- />
574
- ) : null}
575
- <BookmarkProvider value={bookmarkHandle}>
576
- <Transcript
577
- key={subagentId ?? 'session'}
578
- state={state}
579
- fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
580
- attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
581
- canBrowseFiles={hostFiles.available}
582
- hostImage={hostImage}
583
- variant={transcriptVariant}
584
- density={transcriptDensity}
585
- fontSize={effectiveTermFontSize}
586
- lineHeight={effectiveTermLineHeight}
587
- affordances={affordances}
588
- stickyPrompt={stickyPrompt}
589
- scrubber={scrubber}
590
- bookmarks={bookmarks}
591
- replaying={replaying}
592
- catchUp={catchUp && newCount > 0 ? { from: catchUp.itemCount, since: catchUp.since } : undefined}
593
- reveal={returnReveal ?? reveal}
594
- frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
595
- onOpenSubagent={enterSubagent}
596
- emptyState={emptyState}
597
- jumpToRecapRef={jumpToRecap}
598
- repinRef={repinTranscript}
599
- />
600
- </BookmarkProvider>
601
- {catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
602
- <div className="px-3 pb-1">
603
- <div
604
- data-slot="catch-up"
605
- className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3"
606
- >
607
- <span aria-hidden className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
608
- ※
609
- </span>
610
- <span className="min-w-0 flex-1 truncate">
611
- {newCount} new {newCount === 1 ? 'row' : 'rows'}
612
- {catchUp.since !== undefined ? ` since you were last here` : ''}
613
- </span>
614
- <button
615
- type="button"
616
- onClick={() => jumpToRecap.current?.()}
617
- className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
618
- >
619
- jump
620
- </button>
621
- <button
622
- type="button"
623
- onClick={() => setCaughtUp(true)}
624
- className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
625
- >
626
- dismiss
627
- </button>
628
- </div>
629
- </div>
630
- ) : null}
631
- {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
632
- <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
633
- <PromptSurface
549
+ <ToolTitleProvider value={state.toolTitles}>
550
+ <ToolResultImageProvider value={resultImages}>
551
+ <div
552
+ ref={panelRef}
553
+ data-slot="session-panel"
554
+ data-agent-font={transcriptFont}
555
+ onClick={handleClick}
556
+ className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
557
+ style={fontSize !== undefined ? ({ '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties) : undefined}
558
+ >
559
+ {headerTakesActions ? header({ actions: menu }) : header}
560
+ {statusPlacement === 'top' ? statusBar : null}
561
+ {protocolMismatch !== undefined ? (
562
+ <Notice level="warning">
563
+ Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some events may not render.
564
+ </Notice>
565
+ ) : null}
566
+ {protocolError ? (
567
+ <Notice level="error" onDismiss={() => setProtocolError(undefined)}>
568
+ {protocolError}
569
+ </Notice>
570
+ ) : null}
571
+ {subagentId !== undefined ? (
572
+ <SubagentStrip
573
+ task={subagentTask}
574
+ items={subagentFrameItems}
575
+ label={subagentFallbackLabel}
576
+ onBack={leaveSubagent}
634
577
  terminal={terminal}
635
- metrics={{ fontSize: effectiveTermFontSize, lineHeight: effectiveTermLineHeight }}
578
+ fontSize={effectiveTermFontSize}
579
+ lineHeight={effectiveTermLineHeight}
580
+ />
581
+ ) : null}
582
+ <BookmarkProvider value={bookmarkHandle}>
583
+ <Transcript
584
+ key={subagentId ?? 'session'}
585
+ state={state}
586
+ fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
587
+ attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
588
+ canBrowseFiles={hostFiles.available}
589
+ hostImage={hostImage}
590
+ variant={transcriptVariant}
591
+ density={transcriptDensity}
592
+ fontSize={effectiveTermFontSize}
593
+ lineHeight={effectiveTermLineHeight}
636
594
  affordances={affordances}
637
- >
638
- {state.pendingApprovals.map((request) => {
639
- const isQuestion = request.toolName === 'AskUserQuestion' && parseUserQuestions(request.input).length > 0
640
- if (terminal) {
595
+ stickyPrompt={stickyPrompt}
596
+ scrubber={scrubber}
597
+ bookmarks={bookmarks}
598
+ replaying={replaying}
599
+ catchUp={catchUp && newCount > 0 ? { from: catchUp.itemCount, since: catchUp.since } : undefined}
600
+ reveal={returnReveal ?? reveal}
601
+ frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
602
+ onOpenSubagent={enterSubagent}
603
+ emptyState={emptyState}
604
+ jumpToRecapRef={jumpToRecap}
605
+ repinRef={repinTranscript}
606
+ />
607
+ </BookmarkProvider>
608
+ {catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
609
+ <div className="px-3 pb-1">
610
+ <div
611
+ data-slot="catch-up"
612
+ className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3"
613
+ >
614
+ <span aria-hidden className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
615
+ ※
616
+ </span>
617
+ <span className="min-w-0 flex-1 truncate">
618
+ {newCount} new {newCount === 1 ? 'row' : 'rows'}
619
+ {catchUp.since !== undefined ? ` since you were last here` : ''}
620
+ </span>
621
+ <button
622
+ type="button"
623
+ onClick={() => jumpToRecap.current?.()}
624
+ className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
625
+ >
626
+ jump
627
+ </button>
628
+ <button
629
+ type="button"
630
+ onClick={() => setCaughtUp(true)}
631
+ className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
632
+ >
633
+ dismiss
634
+ </button>
635
+ </div>
636
+ </div>
637
+ ) : null}
638
+ {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
639
+ <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
640
+ <PromptSurface
641
+ terminal={terminal}
642
+ metrics={{ fontSize: effectiveTermFontSize, lineHeight: effectiveTermLineHeight }}
643
+ affordances={affordances}
644
+ >
645
+ {state.pendingApprovals.map((request) => {
646
+ const isQuestion = request.toolName === 'AskUserQuestion' && parseUserQuestions(request.input).length > 0
647
+ if (terminal) {
648
+ return isQuestion ? (
649
+ <TerminalQuestionPrompt
650
+ key={request.id}
651
+ request={request}
652
+ onAnswer={approve}
653
+ onDismiss={(id) => deny(id, 'Question dismissed by user')}
654
+ />
655
+ ) : (
656
+ <TerminalPermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
657
+ )
658
+ }
641
659
  return isQuestion ? (
642
- <TerminalQuestionPrompt
660
+ <QuestionPrompt
643
661
  key={request.id}
644
662
  request={request}
645
663
  onAnswer={approve}
646
664
  onDismiss={(id) => deny(id, 'Question dismissed by user')}
647
665
  />
648
666
  ) : (
649
- <TerminalPermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
667
+ <PermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
650
668
  )
651
- }
652
- return isQuestion ? (
653
- <QuestionPrompt
654
- key={request.id}
655
- request={request}
656
- onAnswer={approve}
657
- onDismiss={(id) => deny(id, 'Question dismissed by user')}
658
- />
659
- ) : (
660
- <PermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
661
- )
662
- })}
663
- </PromptSurface>
664
- </div>
665
- ) : null}
666
- {readOnly || subagentId !== undefined ? null : (
667
- <Composer
668
- ref={composerRef}
669
- onSend={handleSend}
670
- onInterrupt={interrupt}
671
- busy={busy}
672
- disabled={ended || !sessionId}
673
- commands={capabilities.slashCommands ? commands : undefined}
674
- skills={capabilities.skillsList ? state.skills : undefined}
675
- attachments={attachments}
676
- draft={draft}
677
- onSearchFiles={hostFiles.available ? searchComposerFiles : undefined}
678
- layout={controlsExternal ? 'inline' : 'stacked'}
679
- toolbar={controlsExternal ? undefined : sessionControls}
680
- fontSize={effectiveTermFontSize}
681
- lineHeight={effectiveTermLineHeight}
682
- affordances={affordances}
683
- />
684
- )}
685
- {statusPlacement === 'bottom' ? statusBar : null}
669
+ })}
670
+ </PromptSurface>
671
+ </div>
672
+ ) : null}
673
+ {readOnly || subagentId !== undefined ? null : (
674
+ <>
675
+ <HeldSendsBar held={heldSends.held} onSendNow={heldSends.flush} />
676
+ <Composer
677
+ ref={composerRef}
678
+ onSend={handleSend}
679
+ onInterrupt={interrupt}
680
+ busy={busy}
681
+ disabled={ended || !sessionId}
682
+ commands={capabilities.slashCommands ? commands : undefined}
683
+ skills={capabilities.skillsList ? state.skills : undefined}
684
+ attachments={attachments}
685
+ draft={draft}
686
+ onSearchFiles={hostFiles.available ? searchComposerFiles : undefined}
687
+ layout={controlsExternal ? 'inline' : 'stacked'}
688
+ toolbar={controlsExternal ? undefined : sessionControls}
689
+ fontSize={effectiveTermFontSize}
690
+ lineHeight={effectiveTermLineHeight}
691
+ affordances={affordances}
692
+ />
693
+ </>
694
+ )}
695
+ {statusPlacement === 'bottom' ? statusBar : null}
686
696
 
687
- {!external ? (
688
- <>
689
- <SessionInfoDialog
690
- state={state}
691
- client={client}
692
- sessionId={sessionId}
693
- open={panel === 'info'}
694
- onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
695
- />
696
- <ContextDialog
697
- usage={state.contextUsage}
698
- open={panel === 'context'}
699
- onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
700
- />
701
- <UsageDialog
702
- rateLimits={windows}
703
- subscriptionType={state.subscriptionType}
704
- engine={state.engine ?? 'claude'}
705
- totalCostUsd={state.totalCostUsd}
706
- updatedAt={usageUpdatedAt}
707
- open={panel === 'usage'}
708
- onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
709
- />
710
- <McpDialog
711
- client={client}
712
- sessionId={sessionId}
713
- canManageServers={capabilities.mcpServerActions}
714
- open={panel === 'mcp'}
715
- onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
716
- />
717
- <SkillsDialog
718
- skills={state.skills}
719
- open={panel === 'skills'}
720
- onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
721
- onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
722
- />
723
- <HostFilesDialog
724
- client={client}
725
- cwd={state.cwd}
726
- open={panel === 'files'}
727
- onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
728
- />
729
- </>
730
- ) : null}
731
- </div>
732
- </ToolResultImageProvider>
697
+ {!external ? (
698
+ <>
699
+ <SessionInfoDialog
700
+ state={state}
701
+ client={client}
702
+ sessionId={sessionId}
703
+ open={panel === 'info'}
704
+ onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
705
+ />
706
+ <ContextDialog
707
+ usage={state.contextUsage}
708
+ engine={state.engine ?? 'claude'}
709
+ open={panel === 'context'}
710
+ onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
711
+ />
712
+ <UsageDialog
713
+ rateLimits={windows}
714
+ subscriptionType={state.subscriptionType}
715
+ engine={state.engine ?? 'claude'}
716
+ totalCostUsd={state.totalCostUsd}
717
+ updatedAt={usageUpdatedAt}
718
+ open={panel === 'usage'}
719
+ onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
720
+ />
721
+ <McpDialog
722
+ client={client}
723
+ sessionId={sessionId}
724
+ canManageServers={capabilities.mcpServerActions}
725
+ open={panel === 'mcp'}
726
+ onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
727
+ />
728
+ <SkillsDialog
729
+ skills={state.skills}
730
+ open={panel === 'skills'}
731
+ onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
732
+ onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
733
+ />
734
+ <HostFilesDialog
735
+ client={client}
736
+ cwd={state.cwd}
737
+ open={panel === 'files'}
738
+ onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
739
+ />
740
+ </>
741
+ ) : null}
742
+ </div>
743
+ </ToolResultImageProvider>
744
+ </ToolTitleProvider>
733
745
  </ToolResultFetchProvider>
734
746
  </TranscriptDensityProvider>
735
747
  </TranscriptVariantProvider>
@@ -19,6 +19,7 @@ export interface SessionWorkspaceProps {
19
19
  transcriptVariant?: SessionPanelProps['transcriptVariant']
20
20
  transcriptDensity?: SessionPanelProps['transcriptDensity']
21
21
  transcriptFont?: SessionPanelProps['transcriptFont']
22
+ midTurnSend?: SessionPanelProps['midTurnSend']
22
23
  scrubber?: SessionPanelProps['scrubber']
23
24
  bookmarks?: SessionPanelProps['bookmarks']
24
25
  onToggleBookmark?: SessionPanelProps['onToggleBookmark']
@@ -51,6 +52,7 @@ export function SessionWorkspace({
51
52
  transcriptVariant,
52
53
  transcriptDensity,
53
54
  transcriptFont,
55
+ midTurnSend,
54
56
  scrubber,
55
57
  bookmarks,
56
58
  onToggleBookmark,
@@ -209,6 +211,7 @@ export function SessionWorkspace({
209
211
  transcriptVariant={transcriptVariant}
210
212
  transcriptDensity={transcriptDensity}
211
213
  transcriptFont={transcriptFont}
214
+ midTurnSend={midTurnSend}
212
215
  scrubber={scrubber}
213
216
  bookmarks={bookmarks}
214
217
  onToggleBookmark={onToggleBookmark}
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState, type ReactNode } from 'react'
2
2
  import type { ConnectionState, TranscriptState } from '@workerdeck/react'
3
- import type { ContextUsage, RateLimitInfo } from '@workerdeck/protocol'
3
+ import type { ContextUsage, ProfileEngine, RateLimitInfo } from '@workerdeck/protocol'
4
4
  import { RefreshCw, WifiOff } from 'lucide-react'
5
5
  import { Badge } from '../ui/Badge.tsx'
6
6
  import { ProgressRing } from '../ui/ProgressRing.tsx'
@@ -8,6 +8,7 @@ import { Spinner } from '../ui/Spinner.tsx'
8
8
  import { Tip } from '../ui/Tooltip.tsx'
9
9
  import { cn } from '../../lib/utils.ts'
10
10
  import { formatCost, formatCountdown, formatTokens } from '../../lib/format.ts'
11
+ import { contextNote } from '../../lib/context-note.ts'
11
12
  import { meterColorClass } from '../../lib/status.ts'
12
13
  import { cssColor } from '../../lib/css.ts'
13
14
  import { STATUS_META } from './status.ts'
@@ -37,11 +38,12 @@ function useNow(intervalMs = 30_000): number {
37
38
  return now
38
39
  }
39
40
 
40
- function ContextMeter({ usage }: { usage: ContextUsage }) {
41
+ function ContextMeter({ usage, engine }: { usage: ContextUsage; engine: ProfileEngine | undefined }) {
42
+ const note = contextNote(engine)
41
43
  return (
42
44
  <Tip
43
45
  content={
44
- <div className="flex min-w-44 flex-col gap-1 py-0.5">
46
+ <div className="flex max-w-64 min-w-44 flex-col gap-1 py-0.5">
45
47
  {usage.categories.map((c) => (
46
48
  <div key={c.name} className="flex items-center gap-2">
47
49
  <span className="size-2 shrink-0 rounded-full bg-fg-4" style={cssColor(c.color) ? { backgroundColor: c.color } : undefined} />
@@ -55,6 +57,12 @@ function ContextMeter({ usage }: { usage: ContextUsage }) {
55
57
  {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} ({usage.percentage.toFixed(0)}%)
56
58
  </span>
57
59
  </div>
60
+ {note ? (
61
+ <div className="mt-0.5 flex flex-col gap-1 border-t border-border pt-1 text-fg-3">
62
+ <span>{note.summary}</span>
63
+ <span>{note.hint}</span>
64
+ </div>
65
+ ) : null}
58
66
  </div>
59
67
  }
60
68
  >
@@ -166,7 +174,7 @@ export function StatusBar({
166
174
  </Slot>
167
175
  {state.capabilities.contextUsage && state.contextUsage ? (
168
176
  <Slot onClick={onOpenContext} hint="Context breakdown">
169
- <ContextMeter usage={state.contextUsage} />
177
+ <ContextMeter usage={state.contextUsage} engine={state.engine} />
170
178
  </Slot>
171
179
  ) : null}
172
180
  {session || weekly ? (
@@ -8,6 +8,7 @@ import { cn } from '../../lib/utils.ts'
8
8
  import { toolInputPreview } from '../../lib/format.ts'
9
9
  import { toolIcon } from '../../lib/tool-icon.ts'
10
10
  import { useToolResultFetcher } from './tool-result-fetch.tsx'
11
+ import { useToolTitle } from './tool-titles.tsx'
11
12
  import { useToolResultImageSrc } from './tool-result-image.tsx'
12
13
  import { IMAGE_UNAVAILABLE, imagePlaceholder } from '../terminal/image-box.ts'
13
14
 
@@ -110,6 +111,7 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
110
111
  const badge = STATE_BADGE[status]
111
112
  const isError = status === 'failed' || item.result?.isError === true
112
113
  const Icon = toolIcon(item.name)
114
+ const title = useToolTitle(item.name)
113
115
 
114
116
  const resultText = item.result?.text ?? ''
115
117
  const clipped = !fullResult && resultText.length > RESULT_PREVIEW_CHARS
@@ -119,6 +121,7 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
119
121
 
120
122
  const details = open ? (
121
123
  <div className="flex flex-col gap-2 border-t border-border p-2.5">
124
+ {title ? <p className="font-mono text-label text-fg-4">{item.name}</p> : null}
122
125
  <PlainPayload code={JSON.stringify(item.input, null, 2)} language="json" label="Parameters" />
123
126
  {item.logs?.length ? <PlainPayload code={item.logs.join('\n')} label="Logs" /> : null}
124
127
  {item.result !== undefined ? (
@@ -177,7 +180,9 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
177
180
  )}
178
181
  >
179
182
  <Icon className="size-3.5 shrink-0 text-fg-3" />
180
- <span className="shrink-0 font-mono text-body-sm font-medium text-fg-1">{item.name}</span>
183
+ <span className={cn('shrink-0 text-body-sm font-medium text-fg-1', title === undefined && 'font-mono')} title={item.name}>
184
+ {title ?? item.name}
185
+ </span>
181
186
  {item.backend && item.backend !== 'server' ? (
182
187
  <Badge variant="neutral" className="shrink-0">
183
188
  {item.backend}
@@ -1,7 +1,7 @@
1
1
  import type { MessageAttachment } from '@workerdeck/protocol'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
3
  import { cn } from '../../lib/utils.ts'
4
- import { formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
4
+ import { COMPACTION_TEXT, formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
5
5
  import { FileCard } from './FileCard.tsx'
6
6
  import { Message, MessageContent } from './Message.tsx'
7
7
  import { PromptTokenText } from './PromptTokenText.tsx'
@@ -48,7 +48,7 @@ function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }>
48
48
  )
49
49
  }
50
50
 
51
- /** The item-view switch: one transcript item, drawn for the active variant. */
51
+ // The item-view switch: one transcript item, drawn for the active variant.
52
52
  export function TranscriptItemView({
53
53
  item,
54
54
  fileUrl,
@@ -99,6 +99,9 @@ export function TranscriptItemView({
99
99
  case 'notice': {
100
100
  return <NoticeRow item={item} />
101
101
  }
102
+ case 'compaction': {
103
+ return <CompactionRow />
104
+ }
102
105
  case 'file_delivered': {
103
106
  return <FileCard item={item} href={fileUrl?.(item.path)} />
104
107
  }
@@ -108,6 +111,16 @@ export function TranscriptItemView({
108
111
  }
109
112
  }
110
113
 
114
+ function CompactionRow() {
115
+ return (
116
+ <div data-slot="compaction" className="flex items-center gap-2 py-1">
117
+ <div className="h-px flex-1 bg-border" />
118
+ <span className="font-mono text-label text-fg-3">≡ {COMPACTION_TEXT}</span>
119
+ <div className="h-px flex-1 bg-border" />
120
+ </div>
121
+ )
122
+ }
123
+
111
124
  export function RecapRow({ line, since, terminal }: { line: string; since?: number; terminal?: boolean }) {
112
125
  const away = since === undefined ? undefined : formatRelativeTime(since)
113
126
  const text = away ? `${line} · last here ${away}` : line
@@ -90,12 +90,10 @@ function StickyPromptLane({
90
90
  )
91
91
  }
92
92
 
93
- /**
94
- * The virtualized row lane and its scroll-ownership regime. Two things want to write
95
- * `scrollTop` — the follow spring and the virtualizer's size-change corrections — and they are
96
- * split by regime here: pinned, corrections are suppressed outright; escaped, the virtualizer
97
- * corrects so the scrollback holds still (GOTCHAS "The transcript is virtualized").
98
- */
93
+ // The virtualized row lane and its scroll-ownership regime. Two things want to write
94
+ // `scrollTop` — the follow spring and the virtualizer's size-change corrections — and they are
95
+ // split by regime here: pinned, corrections are suppressed outright; escaped, the virtualizer
96
+ // corrects so the scrollback holds still (GOTCHAS "The transcript is virtualized").
99
97
  export function TranscriptRows({
100
98
  rows,
101
99
  boundary,