@workerdeck/ui 3.0.1 → 3.2.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 (55) hide show
  1. package/build/{SessionPanel-C1bU4yTM.d.mts → SessionPanel-BNNc8grK.d.mts} +9 -7
  2. package/build/index.d.mts +153 -45
  3. package/build/index.mjs +501 -226
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +15 -6
  6. package/build/{use-path-links-BEWxt0ZM.mjs → use-path-links-C6Fh5zdx.mjs} +1926 -2086
  7. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  8. package/build/workspace.d.mts +1 -1
  9. package/build/workspace.mjs +1 -1
  10. package/package.json +4 -4
  11. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  12. package/src/components/agent/Composer.tsx +25 -47
  13. package/src/components/agent/ContextDialog.tsx +75 -67
  14. package/src/components/agent/FrameStrip.tsx +74 -0
  15. package/src/components/agent/HostFilesDialog.tsx +3 -2
  16. package/src/components/agent/McpDialog.tsx +112 -78
  17. package/src/components/agent/PermissionPrompt.tsx +13 -22
  18. package/src/components/agent/QuestionPrompt.tsx +6 -41
  19. package/src/components/agent/Scrubber.tsx +6 -75
  20. package/src/components/agent/SessionBrowser.tsx +125 -214
  21. package/src/components/agent/SessionFilters.tsx +266 -0
  22. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  23. package/src/components/agent/SessionItem.tsx +27 -7
  24. package/src/components/agent/SessionPanel.tsx +244 -488
  25. package/src/components/agent/SessionSteps.tsx +54 -11
  26. package/src/components/agent/ShellStrip.tsx +27 -43
  27. package/src/components/agent/SubagentStrip.tsx +13 -30
  28. package/src/components/agent/Transcript.tsx +13 -0
  29. package/src/components/agent/TranscriptItemView.tsx +6 -36
  30. package/src/components/agent/composer-commands.ts +80 -1
  31. package/src/components/agent/markdown-image.tsx +49 -1
  32. package/src/components/agent/scrubber-marks.ts +33 -11
  33. package/src/components/agent/scrubber-peek.tsx +122 -0
  34. package/src/components/agent/session-panel-providers.tsx +41 -0
  35. package/src/components/agent/shell-actions.tsx +36 -1
  36. package/src/components/agent/use-catch-up.ts +20 -0
  37. package/src/components/agent/use-frame.ts +88 -0
  38. package/src/components/agent/use-host-image.ts +81 -0
  39. package/src/components/agent/use-shell-frame.ts +12 -70
  40. package/src/components/agent/use-subagent-frame.ts +19 -75
  41. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  42. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  43. package/src/components/terminal/PermissionPrompt.tsx +19 -32
  44. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  45. package/src/components/terminal/TerminalTranscript.tsx +3 -86
  46. package/src/components/terminal/items.tsx +5 -43
  47. package/src/components/terminal/scrubber.tsx +25 -272
  48. package/src/components/ui/Popover.tsx +29 -0
  49. package/src/components/ui/Select.tsx +25 -0
  50. package/src/index.ts +46 -8
  51. package/src/lib/file-link.ts +3 -16
  52. package/src/lib/permission-prompt.ts +57 -0
  53. package/src/lib/question-answers.ts +65 -0
  54. package/build/use-path-links-BEWxt0ZM.mjs.map +0 -1
  55. package/src/components/prompt-area/use-markdown-mode.ts +0 -103
@@ -1,4 +1,4 @@
1
- import { i as SessionPanelProps } from "./SessionPanel-C1bU4yTM.mjs";
1
+ import { r as SessionPanelProps } from "./SessionPanel-BNNc8grK.mjs";
2
2
  import { CSSProperties } from "react";
3
3
  import { ShellInfo } from "@workerdeck/protocol";
4
4
  import { OpenFile, UseHostFileSearchResult, UseHostFileTreeResult } from "@workerdeck/react";
@@ -1,4 +1,4 @@
1
- import { $t as Tip, A as ShellTerminal, Dn as cn, Jt as Splitter, Sn as Input, Tn as Button, U as EntryIcon, Xt as Spinner, i as SessionPanel, n as usePathLinks, t as resolveAgainstCwd, tn as TooltipProvider } from "./use-path-links-BEWxt0ZM.mjs";
1
+ import { $t as Spinner, En as Input, N as ShellTerminal, Zt as Splitter, i as SessionPanel, in as TooltipProvider, jn as cn, kn as Button, n as usePathLinks, nn as Tip, q as EntryIcon, t as resolveAgainstCwd } from "./use-path-links-C6Fh5zdx.mjs";
2
2
  import { m as formatBytes } from "./status-kmsBhuFG.mjs";
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
4
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workerdeck/ui",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "description": "Styled agent-control component library for WorkerDeck: session panel, transcript with streaming, tool-call cards, permission prompts, session list, composer. Tailwind v4 + Base UI + cva. Pairs with the headless @workerdeck/react layer.",
6
6
  "license": "MIT",
@@ -39,9 +39,9 @@
39
39
  "streamdown": "^2.6.0",
40
40
  "tailwind-merge": "^3.7.0",
41
41
  "use-stick-to-bottom": "^1.1.6",
42
- "@workerdeck/client": "3.0.1",
43
- "@workerdeck/react": "3.0.1",
44
- "@workerdeck/protocol": "3.0.1"
42
+ "@workerdeck/client": "3.2.0",
43
+ "@workerdeck/protocol": "3.2.0",
44
+ "@workerdeck/react": "3.2.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "monaco-editor": "^0.56.0",
@@ -0,0 +1,87 @@
1
+ import type { ComponentType, ReactNode } from 'react'
2
+ import type { PermissionRequest } from '@workerdeck/protocol'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { PermissionPrompt } from './PermissionPrompt.tsx'
5
+ import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
6
+ import { TerminalPermissionPrompt } from '../terminal/PermissionPrompt.tsx'
7
+ import { TerminalQuestionPrompt } from '../terminal/QuestionPrompt.tsx'
8
+ import { TerminalSurface } from '../terminal/surface.tsx'
9
+ import type { TerminalAffordances } from '../terminal/affordances.tsx'
10
+
11
+ // Deliberately the built-in prompts' own callback shapes, so `PermissionPrompt` and
12
+ // `TerminalPermissionPrompt` stay drop-in fallbacks for an input a host declines to draw.
13
+ export interface ApprovalPromptProps {
14
+ request: PermissionRequest
15
+ onApprove: (requestId: string, updatedInput?: Record<string, unknown>) => void
16
+ onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
17
+ }
18
+
19
+ type ApprovalPromptsProps = {
20
+ requests: readonly PermissionRequest[]
21
+ terminal: boolean
22
+ fontSize?: number
23
+ lineHeight?: number
24
+ affordances?: TerminalAffordances | boolean
25
+ hostPrompts?: Record<string, ComponentType<ApprovalPromptProps>>
26
+ onApprove: ApprovalPromptProps['onApprove']
27
+ onDeny: ApprovalPromptProps['onDeny']
28
+ }
29
+
30
+ export function ApprovalPrompts({
31
+ requests,
32
+ terminal,
33
+ fontSize,
34
+ lineHeight,
35
+ affordances,
36
+ hostPrompts,
37
+ onApprove,
38
+ onDeny,
39
+ }: ApprovalPromptsProps) {
40
+ const dismissQuestion = (id: string) => onDeny(id, 'Question dismissed by user')
41
+ return (
42
+ <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
43
+ <PromptSurface terminal={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
44
+ {requests.map((request) => {
45
+ // Before the variant split: a host's entry is the renderer for its tool in
46
+ // both themes, and it overrides the built-in entries rather than racing them.
47
+ const HostPrompt = hostPrompts?.[request.toolName]
48
+ if (HostPrompt) {
49
+ return <HostPrompt key={request.id} request={request} onApprove={onApprove} onDeny={onDeny} />
50
+ }
51
+ const isQuestion = request.toolName === 'AskUserQuestion' && parseUserQuestions(request.input).length > 0
52
+ if (terminal) {
53
+ return isQuestion ? (
54
+ <TerminalQuestionPrompt key={request.id} request={request} onAnswer={onApprove} onDismiss={dismissQuestion} />
55
+ ) : (
56
+ <TerminalPermissionPrompt key={request.id} request={request} onApprove={onApprove} onDeny={onDeny} />
57
+ )
58
+ }
59
+ return isQuestion ? (
60
+ <QuestionPrompt key={request.id} request={request} onAnswer={onApprove} onDismiss={dismissQuestion} />
61
+ ) : (
62
+ <PermissionPrompt key={request.id} request={request} onApprove={onApprove} onDeny={onDeny} />
63
+ )
64
+ })}
65
+ </PromptSurface>
66
+ </div>
67
+ )
68
+ }
69
+
70
+ type PromptSurfaceProps = {
71
+ terminal: boolean
72
+ fontSize?: number
73
+ lineHeight?: number
74
+ affordances?: TerminalAffordances | boolean
75
+ children: ReactNode
76
+ }
77
+
78
+ function PromptSurface({ terminal, fontSize, lineHeight, affordances, children }: PromptSurfaceProps) {
79
+ if (!terminal) {
80
+ return <div className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-2">{children}</div>
81
+ }
82
+ return (
83
+ <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} affordances={affordances} bleed="1ch" className="term-transcript">
84
+ {children}
85
+ </TerminalSurface>
86
+ )
87
+ }
@@ -394,6 +394,26 @@ export function Composer({
394
394
  </div>
395
395
  ) : null
396
396
 
397
+ const stagedStrip = staged.length > 0 && attachments ? <AttachmentStrip attachments={attachments} /> : null
398
+
399
+ const field = (minHeight: number, maxHeight: number, fieldClass: string) => (
400
+ <PromptArea
401
+ {...bind}
402
+ triggers={triggers}
403
+ markdown={false}
404
+ normalizeBullets={false}
405
+ onSubmit={submit}
406
+ disabled={disabled}
407
+ placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
408
+ minHeight={minHeight}
409
+ maxHeight={maxHeight}
410
+ aria-label={shellMode ? 'Run a shell command' : 'Message the agent'}
411
+ className={fieldClass}
412
+ onImagePaste={(file) => attachments?.add([file])}
413
+ {...shellKeys}
414
+ />
415
+ )
416
+
397
417
  if (terminal) {
398
418
  const line = lineHeight ?? 18
399
419
  return (
@@ -408,26 +428,12 @@ export function Composer({
408
428
  className={cn('term-composer', shellMode && 'term-composer-shell', disabled && 'opacity-60')}
409
429
  >
410
430
  <div className="term-composer-body">
411
- {staged.length > 0 && attachments ? <AttachmentStrip attachments={attachments} /> : null}
431
+ {stagedStrip}
412
432
  <div className="term-row">
413
433
  {canAttach && !shellMode ? fileField : null}
414
434
  {gutter}
415
435
  <div className="flex min-w-0 items-start">
416
- <PromptArea
417
- {...bind}
418
- triggers={triggers}
419
- markdown={false}
420
- normalizeBullets={false}
421
- onSubmit={submit}
422
- disabled={disabled}
423
- placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
424
- minHeight={line}
425
- maxHeight={line * 10}
426
- aria-label={shellMode ? 'Run a shell command' : 'Message the agent'}
427
- className="term-composer-field min-w-0 flex-1"
428
- onImagePaste={(file) => attachments?.add([file])}
429
- {...shellKeys}
430
- />
436
+ {field(line, line * 10, 'term-composer-field min-w-0 flex-1')}
431
437
  {submitButton}
432
438
  </div>
433
439
  </div>
@@ -470,44 +476,16 @@ export function Composer({
470
476
  disabled && 'opacity-60',
471
477
  )}
472
478
  >
473
- {staged.length > 0 && attachments ? <AttachmentStrip attachments={attachments} /> : null}
479
+ {stagedStrip}
474
480
  {inline ? (
475
481
  <div className="flex items-end gap-1 p-1">
476
482
  {shellMode ? <ShellBadge onLeave={() => leaveShellMode(false)} /> : attach}
477
- <PromptArea
478
- {...bind}
479
- triggers={triggers}
480
- markdown={false}
481
- normalizeBullets={false}
482
- onSubmit={submit}
483
- disabled={disabled}
484
- placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
485
- minHeight={20}
486
- maxHeight={192}
487
- aria-label={shellMode ? 'Run a shell command' : 'Message the agent'}
488
- className="min-w-0 flex-1 py-1 text-body-sm text-text"
489
- onImagePaste={(file) => attachments?.add([file])}
490
- {...shellKeys}
491
- />
483
+ {field(20, 192, 'min-w-0 flex-1 py-1 text-body-sm text-text')}
492
484
  {submitButton}
493
485
  </div>
494
486
  ) : (
495
487
  <>
496
- <PromptArea
497
- {...bind}
498
- triggers={triggers}
499
- markdown={false}
500
- normalizeBullets={false}
501
- onSubmit={submit}
502
- disabled={disabled}
503
- placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
504
- minHeight={28}
505
- maxHeight={192}
506
- aria-label={shellMode ? 'Run a shell command' : 'Message the agent'}
507
- className="px-3 pt-2.5 pb-0 text-body-sm text-text"
508
- onImagePaste={(file) => attachments?.add([file])}
509
- {...shellKeys}
510
- />
488
+ {field(28, 192, 'px-3 pt-2.5 pb-0 text-body-sm text-text')}
511
489
  <div className="flex items-center justify-between gap-2 px-2 pb-2">
512
490
  <div className="flex min-w-0 items-center gap-1">
513
491
  {shellMode ? <ShellBadge onLeave={() => leaveShellMode(false)} /> : attach}
@@ -14,81 +14,89 @@ export interface ContextDialogProps {
14
14
  className?: string
15
15
  }
16
16
 
17
+ export interface ContextPanelProps {
18
+ usage?: ContextUsage
19
+ engine?: ProfileEngine
20
+ }
21
+
17
22
  export function ContextDialog({ usage, engine, open, onOpenChange, className }: ContextDialogProps) {
18
- const note = contextNote(engine)
19
23
  return (
20
24
  <Dialog open={open} onOpenChange={onOpenChange}>
21
25
  <DialogContent className={className}>
22
26
  <DialogHeader title="Context" description={usage?.model} />
23
27
  <DialogBody>
24
- {!usage ? (
25
- <p className="py-6 text-center text-body-sm text-fg-4">
26
- No reading yet - the context window is measured after a turn completes.
27
- </p>
28
- ) : (
29
- <>
30
- <div className="flex items-baseline justify-between gap-4">
31
- <span className="text-label text-fg-3">Used</span>
32
- <span className="font-mono text-body-sm text-fg-1">
33
- {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} · {usage.percentage.toFixed(0)}%
34
- </span>
35
- </div>
36
- <div className="mt-2 h-2 overflow-hidden rounded-full bg-border">
37
- <div
38
- className={cn('h-full rounded-full', meterTintClass(usage.percentage))}
39
- style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
40
- />
41
- </div>
42
- {usage.categories.length > 0 ? (
43
- <div className="mt-5">
44
- <h3 className="text-label font-medium text-fg-3">Breakdown</h3>
45
- <div className="mt-2 flex flex-col gap-3">
46
- {usage.categories.map((category) => {
47
- const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
48
- const color = cssColor(category.color)
49
- return (
50
- <div key={category.name}>
51
- <div className="flex items-baseline justify-between gap-3">
52
- <span className="flex min-w-0 items-center gap-2">
53
- <span
54
- className="size-2 shrink-0 rounded-full bg-fg-4"
55
- style={color ? { backgroundColor: color } : undefined}
56
- />
57
- <span className="truncate text-body-sm text-fg-1">{category.name}</span>
58
- </span>
59
- <span className="shrink-0 font-mono text-label text-fg-3">{formatTokens(category.tokens)}</span>
60
- </div>
61
- <div className="mt-1 h-1 overflow-hidden rounded-full bg-border">
62
- <div
63
- className="h-full rounded-full bg-fg-4"
64
- style={{
65
- width: `${Math.min(100, share)}%`,
66
- backgroundColor: color,
67
- }}
68
- />
69
- </div>
70
- </div>
71
- )
72
- })}
73
- </div>
74
- </div>
75
- ) : null}
76
- </>
77
- )}
78
- {note ? (
79
- <div className="mt-5 border-t border-border pt-4">
80
- <h3 className="text-label font-medium text-fg-3">Why this window is 272K</h3>
81
- <p className="mt-2 text-body-sm text-fg-2">{note.summary}</p>
82
- {note.detail.map((line) => (
83
- <p key={line} className="mt-2 text-body-sm text-fg-3">
84
- {line}
85
- </p>
86
- ))}
87
- <p className="mt-2 text-body-sm text-warning">{note.caveat}</p>
88
- </div>
89
- ) : null}
28
+ <ContextPanel usage={usage} engine={engine} />
90
29
  </DialogBody>
91
30
  </DialogContent>
92
31
  </Dialog>
93
32
  )
94
33
  }
34
+
35
+ export function ContextPanel({ usage, engine }: ContextPanelProps) {
36
+ const note = contextNote(engine)
37
+ return (
38
+ <>
39
+ {!usage ? (
40
+ <p className="py-6 text-center text-body-sm text-fg-4">No reading yet - the context window is measured after a turn completes.</p>
41
+ ) : (
42
+ <>
43
+ <div className="flex items-baseline justify-between gap-4">
44
+ <span className="text-label text-fg-3">Used</span>
45
+ <span className="font-mono text-body-sm text-fg-1">
46
+ {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} · {usage.percentage.toFixed(0)}%
47
+ </span>
48
+ </div>
49
+ <div className="mt-2 h-2 overflow-hidden rounded-full bg-border">
50
+ <div
51
+ className={cn('h-full rounded-full', meterTintClass(usage.percentage))}
52
+ style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
53
+ />
54
+ </div>
55
+ {usage.categories.length > 0 ? (
56
+ <div className="mt-5">
57
+ <h3 className="text-label font-medium text-fg-3">Breakdown</h3>
58
+ <div className="mt-2 flex flex-col gap-3">
59
+ {usage.categories.map((category) => {
60
+ const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
61
+ const color = cssColor(category.color)
62
+ return (
63
+ <div key={category.name}>
64
+ <div className="flex items-baseline justify-between gap-3">
65
+ <span className="flex min-w-0 items-center gap-2">
66
+ <span className="size-2 shrink-0 rounded-full bg-fg-4" style={color ? { backgroundColor: color } : undefined} />
67
+ <span className="truncate text-body-sm text-fg-1">{category.name}</span>
68
+ </span>
69
+ <span className="shrink-0 font-mono text-label text-fg-3">{formatTokens(category.tokens)}</span>
70
+ </div>
71
+ <div className="mt-1 h-1 overflow-hidden rounded-full bg-border">
72
+ <div
73
+ className="h-full rounded-full bg-fg-4"
74
+ style={{
75
+ width: `${Math.min(100, share)}%`,
76
+ backgroundColor: color,
77
+ }}
78
+ />
79
+ </div>
80
+ </div>
81
+ )
82
+ })}
83
+ </div>
84
+ </div>
85
+ ) : null}
86
+ </>
87
+ )}
88
+ {note ? (
89
+ <div className="mt-5 border-t border-border pt-4">
90
+ <h3 className="text-label font-medium text-fg-3">Why this window is 272K</h3>
91
+ <p className="mt-2 text-body-sm text-fg-2">{note.summary}</p>
92
+ {note.detail.map((line) => (
93
+ <p key={line} className="mt-2 text-body-sm text-fg-3">
94
+ {line}
95
+ </p>
96
+ ))}
97
+ <p className="mt-2 text-body-sm text-warning">{note.caveat}</p>
98
+ </div>
99
+ ) : null}
100
+ </>
101
+ )
102
+ }
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from 'react'
2
+ import { ArrowLeft } from 'lucide-react'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { Button } from '../ui/Button.tsx'
5
+ import { Ink, Row } from '../terminal/row.tsx'
6
+ import { TerminalSurface } from '../terminal/surface.tsx'
7
+ import { WithActions } from '../terminal/affordances.tsx'
8
+
9
+ export type FrameKind = 'shell' | 'subagent'
10
+
11
+ export interface FrameStripProps {
12
+ kind: FrameKind
13
+ name: string
14
+ status?: string
15
+ detail?: string
16
+ failed: boolean
17
+ live: boolean
18
+ onBack: () => void
19
+ // Drawn at the strip's trailing edge; a terminal strip then hangs them inline, which is why only the name stays the Back target.
20
+ actions?: ReactNode
21
+ terminal: boolean
22
+ fontSize?: number
23
+ lineHeight?: number
24
+ }
25
+
26
+ const TERMINAL_TONE = { shell: { name: 'magenta', live: 'magenta' }, subagent: { name: 'green', live: 'mark' } } as const
27
+ const CARDS_LIVE_CLASS: Record<FrameKind, string> = { shell: 'text-[var(--wd-shell-accent)]', subagent: 'text-accent' }
28
+
29
+ export function FrameStrip({ kind, name, status, detail, failed, live, onBack, actions, terminal, fontSize, lineHeight }: FrameStripProps) {
30
+ if (terminal) {
31
+ const tones = TERMINAL_TONE[kind]
32
+ const line = (label: ReactNode) => (
33
+ <Row glyph="←" glyphTone="dim" indent={1} tone={failed ? 'red' : tones.name}>
34
+ {label}
35
+ {status ? <Ink tone={failed ? 'red' : live ? tones.live : 'dim'}> · {status}</Ink> : null}
36
+ {detail ? <Ink tone="faint"> · {detail}</Ink> : null}
37
+ </Row>
38
+ )
39
+ return (
40
+ <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className="shrink-0">
41
+ {actions === undefined ? (
42
+ <button type="button" onClick={onBack} aria-label="Back to the session" className="block w-full cursor-pointer text-left">
43
+ {line(name)}
44
+ </button>
45
+ ) : (
46
+ <WithActions placement="inline" actions={actions}>
47
+ {line(
48
+ <button type="button" onClick={onBack} aria-label="Back to the session" className="cursor-pointer text-left">
49
+ {name}
50
+ </button>,
51
+ )}
52
+ </WithActions>
53
+ )}
54
+ </TerminalSurface>
55
+ )
56
+ }
57
+
58
+ return (
59
+ <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
60
+ <Button variant="ghost" size="sm" onClick={onBack} className="h-6 gap-1 px-1.5">
61
+ <ArrowLeft className="size-3.5" />
62
+ Back
63
+ </Button>
64
+ <span className={cn('min-w-0 flex-1 truncate text-body-sm', kind === 'shell' && 'font-mono', failed ? 'text-danger' : 'text-fg-2')}>
65
+ {name}
66
+ </span>
67
+ {status ? (
68
+ <span className={cn('shrink-0 text-label', failed ? 'text-danger' : live ? CARDS_LIVE_CLASS[kind] : 'text-fg-3')}>{status}</span>
69
+ ) : null}
70
+ {detail ? <span className="shrink-0 text-label text-fg-4">{detail}</span> : null}
71
+ {actions}
72
+ </div>
73
+ )
74
+ }
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from '@workerdeck/protocol'
1
2
  import { useCallback, useEffect, useState } from 'react'
2
3
  import type { WorkerDeckClient } from '@workerdeck/client'
3
4
  import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
@@ -37,7 +38,7 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
37
38
  setEntries(response.entries)
38
39
  setTruncated(response.truncated ?? false)
39
40
  } catch (e) {
40
- setError(e instanceof Error ? e.message : 'Could not read that directory')
41
+ setError(errorMessage(e, 'Could not read that directory'))
41
42
  } finally {
42
43
  setLoading(false)
43
44
  }
@@ -87,7 +88,7 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
87
88
  content: response.encoding === 'utf8' ? response.content : '(binary file - not shown)',
88
89
  })
89
90
  } catch (e) {
90
- setError(e instanceof Error ? e.message : 'Could not read that file')
91
+ setError(errorMessage(e, 'Could not read that file'))
91
92
  } finally {
92
93
  setLoading(false)
93
94
  }