@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
@@ -0,0 +1,73 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { Clock } from 'lucide-react'
3
+
4
+ export type HeldSend = { text: string; attachmentIds: string[] }
5
+
6
+ export type HeldSends = {
7
+ held: HeldSend[]
8
+ submit: (text: string, attachmentIds: string[]) => void
9
+ flush: () => void
10
+ }
11
+
12
+ // Catch-up mode is the engine's behaviour, not ours: a message sent mid-turn is folded into the
13
+ // running turn. Holding it here is the only way to turn that off, and it is a client preference,
14
+ // so nothing about it travels on the wire.
15
+ export function useHeldSends(options: { hold: boolean; busy: boolean; send: (text: string, attachmentIds: string[]) => void }): HeldSends {
16
+ const [held, setHeld] = useState<HeldSend[]>([])
17
+ // The queue is a ref and the state is its shadow: flushing sends, and a state updater that
18
+ // sends would fire twice under StrictMode.
19
+ const queue = useRef<HeldSend[]>([])
20
+ const sendRef = useRef(options.send)
21
+ sendRef.current = options.send
22
+
23
+ const flush = useCallback(() => {
24
+ const queued = queue.current
25
+ if (queued.length === 0) {
26
+ return
27
+ }
28
+ queue.current = []
29
+ setHeld([])
30
+ for (const item of queued) {
31
+ sendRef.current(item.text, item.attachmentIds)
32
+ }
33
+ }, [])
34
+
35
+ useEffect(() => {
36
+ if (!options.busy) {
37
+ flush()
38
+ }
39
+ }, [options.busy, flush])
40
+
41
+ const submit = useCallback(
42
+ (text: string, attachmentIds: string[]) => {
43
+ if (!options.hold || !options.busy) {
44
+ sendRef.current(text, attachmentIds)
45
+ return
46
+ }
47
+ queue.current = [...queue.current, { text, attachmentIds }]
48
+ setHeld(queue.current)
49
+ },
50
+ [options.hold, options.busy],
51
+ )
52
+
53
+ return { held, submit, flush }
54
+ }
55
+
56
+ export function HeldSendsBar({ held, onSendNow }: { held: HeldSend[]; onSendNow: () => void }) {
57
+ if (held.length === 0) {
58
+ return null
59
+ }
60
+ return (
61
+ <div className="px-3 pt-2">
62
+ <div className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-label text-fg-3">
63
+ <Clock className="size-3.5 shrink-0" />
64
+ <span className="min-w-0 flex-1 truncate">
65
+ {held.length === 1 ? '1 message' : `${held.length} messages`} waiting for this turn to end — {held[held.length - 1]!.text}
66
+ </span>
67
+ <button type="button" onClick={onSendNow} className="shrink-0 underline-offset-2 hover:underline">
68
+ Send now
69
+ </button>
70
+ </div>
71
+ </div>
72
+ )
73
+ }
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import { toolTitle } from '@workerdeck/protocol'
3
+
4
+ const TitlesContext = createContext<Record<string, string> | undefined>(undefined)
5
+
6
+ export function ToolTitleProvider({ value, children }: { value: Record<string, string> | undefined; children: ReactNode }) {
7
+ return <TitlesContext.Provider value={value}>{children}</TitlesContext.Provider>
8
+ }
9
+
10
+ export function useToolTitle(name: string): string | undefined {
11
+ return toolTitle(name, useContext(TitlesContext))
12
+ }
@@ -3,21 +3,19 @@ import { subagentLabel, type SessionInfo } from '@workerdeck/protocol'
3
3
  import type { TranscriptItem } from '@workerdeck/react'
4
4
  import { subagentItems, type ToolCallItem } from '../terminal/blocks.ts'
5
5
 
6
- /**
7
- * The sub-agent frame machine: which agent frame is on screen, how it is entered (a host
8
- * `openSubagent` request or a Task row in the transcript), how it is left (Escape, the strip's
9
- * Back, or the host withdrawing), and what the transcript reveals on the way out. The frame
10
- * round-trips through the host's URL — the anti-loop rules live in GOTCHAS ("The sub-agent
11
- * frame round-trips through the URL"); here they mean: entry keys on the nonce alone, and the
12
- * report is deduped through a ref, so an echo of our own report is inert on arrival.
13
- */
6
+ // The sub-agent frame machine: which agent frame is on screen, how it is entered (a host
7
+ // `openSubagent` request or a Task row in the transcript), how it is left (Escape, the strip's
8
+ // Back, or the host withdrawing), and what the transcript reveals on the way out. The frame
9
+ // round-trips through the host's URL — the anti-loop rules live in GOTCHAS ("The sub-agent
10
+ // frame round-trips through the URL"); here they mean: entry keys on the nonce alone, and the
11
+ // report is deduped through a ref, so an echo of our own report is inert on arrival.
14
12
  export function useSubagentFrame(options: {
15
13
  sessionId: string | undefined
16
14
  items: TranscriptItem[]
17
15
  session: SessionInfo | undefined
18
- /** Host request to reveal a transcript row; entering a frame must not eat a pending one. */
16
+ // Host request to reveal a transcript row; entering a frame must not eat a pending one.
19
17
  reveal?: { toolUseId: string; nonce: number }
20
- /** Host request to enter a frame; `toolUseId: undefined` withdraws it (Back/Forward). */
18
+ // Host request to enter a frame; `toolUseId: undefined` withdraws it (Back/Forward).
21
19
  openSubagent?: { toolUseId: string; nonce: number }
22
20
  onSubagentChange?: (toolUseId: string | undefined) => void
23
21
  }) {
@@ -7,19 +7,17 @@ import { gapBefore, type TranscriptRow } from './transcript-rows.ts'
7
7
  const AIM_PASSES = 4
8
8
  const AIM_SETTLE_MS = 50
9
9
 
10
- /** How long a send's re-pin outlasts the click — long enough to eat a trackpad's momentum tail. */
10
+ // How long a send's re-pin outlasts the click — long enough to eat a trackpad's momentum tail.
11
11
  export const REPIN_HOLD_MS = 750
12
12
 
13
13
  type RepinTarget = Pick<ReturnType<typeof useStickToBottomContext>, 'scrollToBottom' | 'state'>
14
14
 
15
- /**
16
- * The send re-pin: a held, escape-proof pin, not one `scrollToBottom('instant')` — a trackpad's
17
- * trailing momentum tick reads as escape intent and aborts the one-shot before its first frame
18
- * (GOTCHAS "The send re-pin is a held pin"). Every line is load-bearing: clear the stale escape
19
- * flag, hold through the momentum tail, seed the `ignoreEscapes` record the library only
20
- * installs one rAF too late, and press the first scroll synchronously. Deliberate detach
21
- * (`stopScroll`, drag-selection) still wins immediately.
22
- */
15
+ // The send re-pin: a held, escape-proof pin, not one `scrollToBottom('instant')` — a trackpad's
16
+ // trailing momentum tick reads as escape intent and aborts the one-shot before its first frame
17
+ // (GOTCHAS "The send re-pin is a held pin"). Every line is load-bearing: clear the stale escape
18
+ // flag, hold through the momentum tail, seed the `ignoreEscapes` record the library only
19
+ // installs one rAF too late, and press the first scroll synchronously. Deliberate detach
20
+ // (`stopScroll`, drag-selection) still wins immediately.
23
21
  export function repinToBottom(stick: RepinTarget): void {
24
22
  const { state } = stick
25
23
  state.escapedFromLock = false
@@ -3,7 +3,18 @@ import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
3
3
  import { cn } from '../../lib/utils.ts'
4
4
  import type { TerminalAffordances } from './affordances.tsx'
5
5
  import { OpenSubagentAction, WithActions } from './affordances.tsx'
6
- import { AssistantRow, FileRow, NoticeRow, ToolRunRow, ThinkingRow, ToolRow, TurnResultRow, UserRow, WorkingRow } from './items.tsx'
6
+ import {
7
+ AssistantRow,
8
+ CompactionRow,
9
+ FileRow,
10
+ NoticeRow,
11
+ ToolRunRow,
12
+ ThinkingRow,
13
+ ToolRow,
14
+ TurnResultRow,
15
+ UserRow,
16
+ WorkingRow,
17
+ } from './items.tsx'
7
18
  import { blockNeedsBlank, taskChildItems, terminalBlocks, type TaskBlock } from './blocks.ts'
8
19
  import { usePulse } from '../agent/pulse.tsx'
9
20
  import { Pressable, useRevealOnOpen } from './press.tsx'
@@ -41,6 +52,9 @@ export function TerminalItemView({ item, fileUrl }: { item: TranscriptItem; file
41
52
  case 'notice': {
42
53
  return <NoticeRow item={item} />
43
54
  }
55
+ case 'compaction': {
56
+ return <CompactionRow />
57
+ }
44
58
  case 'file_delivered': {
45
59
  return <FileRow item={item} href={fileUrl?.(item.path)} />
46
60
  }
@@ -1,6 +1,6 @@
1
1
  import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
- import { formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
3
+ import { COMPACTION_TEXT, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
4
4
  import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
5
5
  import { IMAGE_BOX_LINES } from './image-box.ts'
6
6
  import { collapsedResult } from './result-preview.ts'
@@ -622,6 +622,9 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
622
622
  case 'notice': {
623
623
  return rowH(item.text, m, { extraPx })
624
624
  }
625
+ case 'compaction': {
626
+ return rowH(COMPACTION_TEXT, m, { extraPx })
627
+ }
625
628
  case 'file_delivered': {
626
629
  const text = `${item.path} · ${formatBytes(item.bytes)}` + (item.description ? ` · ${item.description}` : '')
627
630
  return rowH(text, m, { extraPx })
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
- import { formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
3
+ import { COMPACTION_TEXT, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
4
4
  import { isMutatingTool } from '../../lib/tool-icon.ts'
5
5
  import { usePulse } from '../agent/pulse.tsx'
6
6
  import { BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
@@ -10,6 +10,7 @@ import { Pressable, useRevealOnOpen } from './press.tsx'
10
10
  import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, imagePlaceholder } from './image-box.ts'
11
11
  import { collapsedResult } from './result-preview.ts'
12
12
  import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
13
+ import { useToolTitle } from '../agent/tool-titles.tsx'
13
14
  import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
14
15
  import { runFailed, runSummary } from './tool-run.ts'
15
16
  import { todoLine, todoPreview, type TodoPreview, type TodoStatus } from './todos.ts'
@@ -112,6 +113,7 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
112
113
  const command = (item.input as { command?: unknown } | null)?.command
113
114
  const copyable = typeof command === 'string' ? command : text
114
115
  const todos = todoPreview(item.name, item.input)
116
+ const title = useToolTitle(item.name)
115
117
 
116
118
  return (
117
119
  <div ref={reveal} className={open ? 'term-open' : undefined}>
@@ -124,11 +126,12 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
124
126
  }
125
127
  >
126
128
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
127
- <Row glyph={busy ? pulse : '●'} glyphTone={tone} tone="fg">
129
+ <Row glyph={busy ? pulse : '●'} glyphTone={tone} tone="fg" title={title ? item.name : undefined}>
128
130
  <Ink bold tone="bright">
129
- {item.name}
131
+ {title ?? item.name}
130
132
  </Ink>
131
133
  <Ink tone="dim">({todos ? todos.summary : toolInputPreview(item.input)})</Ink>
134
+ {title && open ? <Ink tone="faint"> · {item.name}</Ink> : null}
132
135
  {item.backend && item.backend !== 'server' ? <Ink tone="faint"> · {item.backend}</Ink> : null}
133
136
  </Row>
134
137
  </Pressable>
@@ -278,6 +281,14 @@ export function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind:
278
281
  )
279
282
  }
280
283
 
284
+ export function CompactionRow() {
285
+ return (
286
+ <Row glyph="≡" glyphTone="yellow" tone="faint">
287
+ {COMPACTION_TEXT}
288
+ </Row>
289
+ )
290
+ }
291
+
281
292
  export function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
282
293
  const error = item.level === 'error'
283
294
  return (
package/src/index.ts CHANGED
@@ -74,6 +74,8 @@ export { Response, type ResponseProps } from './components/agent/Response.tsx'
74
74
  export { Reasoning, type ReasoningProps } from './components/agent/Reasoning.tsx'
75
75
  export { Loader } from './components/agent/Loader.tsx'
76
76
  export { ToolCallCard, type ToolCallCardProps, type ToolCallItem } from './components/agent/ToolCallCard.tsx'
77
+ export { ToolTitleProvider, useToolTitle } from './components/agent/tool-titles.tsx'
78
+ export { HeldSendsBar, useHeldSends, type HeldSend, type HeldSends } from './components/agent/held-sends.tsx'
77
79
  export { FileCard, type FileCardProps, type FileDeliveredItem } from './components/agent/FileCard.tsx'
78
80
  export { PermissionPrompt, type PermissionPromptProps } from './components/agent/PermissionPrompt.tsx'
79
81
  export {
@@ -0,0 +1,24 @@
1
+ import type { ProfileEngine } from '@workerdeck/protocol'
2
+
3
+ export interface ContextNote {
4
+ summary: string
5
+ hint: string
6
+ detail: string[]
7
+ caveat: string
8
+ }
9
+
10
+ const CODEX_CONTEXT_NOTE: ContextNote = {
11
+ summary:
12
+ 'Codex budgets 272K input by default and reports 95% of it, so a 1M-class model reads ~258k. That is codex’s price tier, not the model’s ceiling — and it is the number auto-compaction fires on, so the meter is honest.',
13
+ hint: 'Raising it is yours to set: model_context_window in ~/.codex/config.toml — and watch for auto-compaction failing afterwards.',
14
+ detail: [
15
+ 'To raise it, set model_context_window in ~/.codex/config.toml, up to 872000 (reported as 828400). Codex clamps to its own max_context_window, itself below OpenAI’s stated 922,000 max input.',
16
+ 'Above 272K input, a request bills at 2x input / 1.5x output for the whole request. WorkerDeck never writes this setting — it is a cost decision on your account.',
17
+ ],
18
+ caveat:
19
+ 'Raise it and watch for auto-compaction failing: openai/codex #16068 reports it stops firing after the first overflow. Unverified here on 0.149.0.',
20
+ }
21
+
22
+ export function contextNote(engine: ProfileEngine | undefined): ContextNote | undefined {
23
+ return (engine ?? 'claude') === 'codex' ? CODEX_CONTEXT_NOTE : undefined
24
+ }
package/src/lib/css.ts CHANGED
@@ -1,8 +1,6 @@
1
- /**
2
- * Pass a colour through only if this engine can actually parse it. Category colours arrive
3
- * from the CLI as untrusted strings, and an unparseable one must fall back to the token
4
- * colour rather than reaching the DOM.
5
- */
1
+ // Pass a colour through only if this engine can actually parse it. Category colours arrive
2
+ // from the CLI as untrusted strings, and an unparseable one must fall back to the token
3
+ // colour rather than reaching the DOM.
6
4
  export function cssColor(color: string): string | undefined {
7
5
  return typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
8
6
  }
package/src/lib/format.ts CHANGED
@@ -1,3 +1,6 @@
1
+ // Here rather than beside the row: height.ts must measure the string items.tsx draws, and cannot import a .tsx.
2
+ export const COMPACTION_TEXT = 'context compacted · earlier turns summarised to fit the window'
3
+
1
4
  export function formatCost(usd: number | undefined): string {
2
5
  if (usd === undefined || Number.isNaN(usd)) {
3
6
  return '—'
package/src/lib/status.ts CHANGED
@@ -133,11 +133,9 @@ export function meterColorClass(pct: number | undefined): string {
133
133
  return severity === 'error' ? 'text-danger' : severity === 'warning' ? 'text-warning' : 'text-fg-3'
134
134
  }
135
135
 
136
- /**
137
- * The fill colour for a meter bar. Deliberately a different ramp from
138
- * {@link meterColorClass}: a bar reads as alarming later than a number does, so the tint
139
- * turns at 70/90 where the text severity turns at 80/95.
140
- */
136
+ // The fill colour for a meter bar. Deliberately a different ramp from
137
+ // {@link meterColorClass}: a bar reads as alarming later than a number does, so the tint
138
+ // turns at 70/90 where the text severity turns at 80/95.
141
139
  export function meterTintClass(pct: number): string {
142
140
  return pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent'
143
141
  }