@workerdeck/ui 0.6.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
@@ -0,0 +1,94 @@
1
+ import { useState } from 'react'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import { ChevronDown, Clock, Wrench } from 'lucide-react'
4
+ import { Badge } from '../ui/Badge.tsx'
5
+ import { CodeBlock } from '../ui/CodeBlock.tsx'
6
+ import { Spinner } from '../ui/Spinner.tsx'
7
+ import { cn } from '../../lib/utils.ts'
8
+ import { toolInputPreview } from '../../lib/format.ts'
9
+
10
+ export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
11
+
12
+ const RESULT_PREVIEW_CHARS = 2000
13
+
14
+ export interface ToolCallCardProps {
15
+ item: ToolCallItem
16
+ className?: string
17
+ }
18
+
19
+ /** Badge per execution state. `pending`/`deferred` mean the work is happening
20
+ * somewhere else (this tab's sandbox, a queue) — distinct from the model still
21
+ * running the call itself. */
22
+ const STATE_BADGE = {
23
+ running: { label: 'Running', variant: 'info', busy: true },
24
+ pending: { label: 'Executing', variant: 'info', busy: true },
25
+ deferred: { label: 'Deferred', variant: 'accent', busy: false },
26
+ settled: { label: 'Done', variant: 'success', busy: false },
27
+ failed: { label: 'Error', variant: 'danger', busy: false },
28
+ } as const
29
+
30
+ export function ToolCallCard({ item, className }: ToolCallCardProps) {
31
+ const [open, setOpen] = useState(false)
32
+ const [fullResult, setFullResult] = useState(false)
33
+ const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
34
+ const badge = STATE_BADGE[status]
35
+ const isError = status === 'failed' || item.result?.isError === true
36
+
37
+ const resultText = item.result?.text ?? ''
38
+ const truncated = !fullResult && resultText.length > RESULT_PREVIEW_CHARS
39
+ const shownResult = truncated ? resultText.slice(0, RESULT_PREVIEW_CHARS) : resultText
40
+
41
+ return (
42
+ <div
43
+ data-slot='tool-call'
44
+ data-state={status}
45
+ className={cn('w-full overflow-hidden rounded-lg border border-border bg-surface', className)}>
46
+ <button
47
+ type='button'
48
+ onClick={() => setOpen((v) => !v)}
49
+ className={cn(
50
+ 'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors outline-none',
51
+ 'hover:bg-surface-hover focus-visible:bg-surface-hover',
52
+ )}>
53
+ <Wrench className='size-3.5 shrink-0 text-fg-3' />
54
+ <span className='shrink-0 font-mono text-body-sm font-medium text-fg-1'>{item.name}</span>
55
+ <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-4'>
56
+ {toolInputPreview(item.input)}
57
+ </span>
58
+ <Badge variant={badge.variant} dot={!badge.busy} className='shrink-0 gap-1'>
59
+ {badge.busy ? <Spinner className='size-3 text-current' /> : null}
60
+ {status === 'deferred' ? <Clock className='size-3 text-current' /> : null}
61
+ {badge.label}
62
+ </Badge>
63
+ <ChevronDown
64
+ className={cn('size-3.5 shrink-0 text-fg-4 transition-transform', open && 'rotate-180')}
65
+ />
66
+ </button>
67
+ {open ? (
68
+ <div className='flex flex-col gap-2 border-t border-border p-2.5'>
69
+ <CodeBlock code={JSON.stringify(item.input, null, 2)} label='Parameters' />
70
+ {item.logs?.length ? (
71
+ <CodeBlock code={item.logs.join('\n')} label='Logs' />
72
+ ) : null}
73
+ {item.result !== undefined ? (
74
+ <div>
75
+ <CodeBlock
76
+ code={shownResult || '(empty result)'}
77
+ label={isError ? 'Error' : 'Result'}
78
+ className={cn(isError && 'border-danger/40 [&_pre]:text-danger')}
79
+ />
80
+ {truncated ? (
81
+ <button
82
+ type='button'
83
+ className='mt-1 text-label text-fg-3 underline-offset-2 hover:underline'
84
+ onClick={() => setFullResult(true)}>
85
+ Show all {resultText.length.toLocaleString()} chars
86
+ </button>
87
+ ) : null}
88
+ </div>
89
+ ) : null}
90
+ </div>
91
+ ) : null}
92
+ </div>
93
+ )
94
+ }
@@ -0,0 +1,114 @@
1
+ import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
2
+ import { cn } from '../../lib/utils.ts'
3
+ import { formatCost, formatDuration } from '../../lib/format.ts'
4
+ import { Conversation, ConversationContent, ConversationScrollButton } from './Conversation.tsx'
5
+ import { FileCard } from './FileCard.tsx'
6
+ import { Loader } from './Loader.tsx'
7
+ import { Message, MessageContent } from './Message.tsx'
8
+ import { Reasoning } from './Reasoning.tsx'
9
+ import { Response } from './Response.tsx'
10
+ import { ToolCallCard } from './ToolCallCard.tsx'
11
+
12
+ function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
13
+ return (
14
+ <div data-slot='turn-result' className='flex items-center gap-2 py-1'>
15
+ <div className='h-px flex-1 bg-border' />
16
+ <span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
17
+ {item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} ·{' '}
18
+ {formatCost(item.totalCostUsd)}
19
+ </span>
20
+ <div className='h-px flex-1 bg-border' />
21
+ </div>
22
+ )
23
+ }
24
+
25
+ function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
26
+ return (
27
+ <div
28
+ data-slot='notice'
29
+ className={cn(
30
+ 'rounded-md border px-3 py-2 text-body-sm',
31
+ item.level === 'error'
32
+ ? 'border-transparent bg-danger-bg text-danger'
33
+ : 'border-border bg-surface text-fg-3',
34
+ )}>
35
+ {item.text}
36
+ </div>
37
+ )
38
+ }
39
+
40
+ function TranscriptItemView({
41
+ item,
42
+ fileUrl,
43
+ }: {
44
+ item: TranscriptItem
45
+ fileUrl?: (path: string) => string
46
+ }) {
47
+ switch (item.kind) {
48
+ case 'user':
49
+ return (
50
+ <Message from='user'>
51
+ <MessageContent>{item.text}</MessageContent>
52
+ </Message>
53
+ )
54
+ case 'assistant_text':
55
+ return (
56
+ <Message from='assistant'>
57
+ <MessageContent>
58
+ <Response streaming={item.streaming}>{item.text}</Response>
59
+ </MessageContent>
60
+ </Message>
61
+ )
62
+ case 'thinking':
63
+ return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
64
+ case 'tool_call':
65
+ return <ToolCallCard item={item} />
66
+ case 'turn_result':
67
+ return <TurnResultRow item={item} />
68
+ case 'notice':
69
+ return <NoticeRow item={item} />
70
+ case 'file_delivered':
71
+ return <FileCard item={item} href={fileUrl?.(item.path)} />
72
+ default:
73
+ return null
74
+ }
75
+ }
76
+
77
+ /** Should the "waiting for output" loader show? Only while running with no in-flight
78
+ * streamed content at the tail of the transcript. */
79
+ function showLoader(state: TranscriptState): boolean {
80
+ if (state.status !== 'running' && state.status !== 'starting') return false
81
+ const last = state.items.at(-1)
82
+ if (!last) return true
83
+ if (last.kind === 'assistant_text' && last.streaming) return false
84
+ if (last.kind === 'thinking' && last.id === 'streaming-thinking') return false
85
+ return last.kind !== 'turn_result' || state.status === 'running'
86
+ }
87
+
88
+ export interface TranscriptProps {
89
+ state: TranscriptState
90
+ /** Builds the download URL for a delivered file (see FileCard). Typically
91
+ * `(path) => client.sessionFileUrl(sessionId, path)`. */
92
+ fileUrl?: (path: string) => string
93
+ className?: string
94
+ }
95
+
96
+ export function Transcript({ state, fileUrl, className }: TranscriptProps) {
97
+ return (
98
+ <Conversation className={className}>
99
+ <ConversationContent>
100
+ {state.items.length === 0 && state.status !== 'starting' ? (
101
+ <div className='py-12 text-center text-body-sm text-fg-4'>No messages yet.</div>
102
+ ) : (
103
+ state.items.map((item) => (
104
+ <TranscriptItemView key={`${item.kind}:${item.id}`} item={item} fileUrl={fileUrl} />
105
+ ))
106
+ )}
107
+ {showLoader(state) ? (
108
+ <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
109
+ ) : null}
110
+ </ConversationContent>
111
+ <ConversationScrollButton />
112
+ </Conversation>
113
+ )
114
+ }
@@ -0,0 +1,16 @@
1
+ import type { SessionStatus } from '@workerdeck/protocol'
2
+ import type { BadgeProps } from '../ui/Badge.tsx'
3
+
4
+ export const STATUS_META: Record<
5
+ SessionStatus,
6
+ { label: string; variant: NonNullable<BadgeProps['variant']>; busy: boolean }
7
+ > = {
8
+ starting: { label: 'Starting', variant: 'info', busy: true },
9
+ running: { label: 'Running', variant: 'info', busy: true },
10
+ awaiting_approval: { label: 'Needs approval', variant: 'warning', busy: true },
11
+ idle: { label: 'Idle', variant: 'success', busy: false },
12
+ // Waiting on a deferred execution: the run is alive but nothing is burning.
13
+ parked: { label: 'Parked', variant: 'accent', busy: false },
14
+ failed: { label: 'Failed', variant: 'danger', busy: false },
15
+ closed: { label: 'Closed', variant: 'neutral', busy: false },
16
+ }
@@ -0,0 +1,42 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ type AnimatedPlaceholderProps = {
6
+ texts: string[]
7
+ interval?: number
8
+ }
9
+
10
+ /**
11
+ * Cross-fading placeholder that rotates through `texts`.
12
+ *
13
+ * Each text is keyed so React remounts it on change, replaying the
14
+ * `tw-animate-css` enter animation (slide down + fade in). No animation
15
+ * library is required.
16
+ */
17
+ export function AnimatedPlaceholder({ texts, interval = 3000 }: AnimatedPlaceholderProps) {
18
+ const [index, setIndex] = useState(0)
19
+
20
+ useEffect(() => {
21
+ if (texts.length <= 1) return
22
+
23
+ const id = setInterval(() => {
24
+ setIndex((prev) => (prev + 1) % texts.length)
25
+ }, interval)
26
+
27
+ return () => clearInterval(id)
28
+ }, [texts.length, interval])
29
+
30
+ return (
31
+ <div
32
+ className="pointer-events-none absolute top-0 left-0 overflow-hidden select-none"
33
+ style={{ color: 'var(--prompt-area-placeholder, var(--muted-foreground))' }}
34
+ aria-hidden="true">
35
+ <div
36
+ key={index}
37
+ className="animate-in fade-in-0 slide-in-from-top-4 duration-300 ease-in-out">
38
+ {texts[index]}
39
+ </div>
40
+ </div>
41
+ )
42
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Clipboard-related DOM utilities for the PromptArea component.
3
+ * Handles serializing selections and inserting pasted segments at the cursor.
4
+ *
5
+ * Not merged into dom-helpers.ts to avoid overcrowding that file with
6
+ * clipboard I/O concerns alongside its DOM traversal and chip-accessor helpers.
7
+ */
8
+ import type { Segment, ChipSegment } from './types.ts'
9
+ import {
10
+ chipNodeToSegment,
11
+ getChipDisplay,
12
+ getChipTrigger,
13
+ getSelectionRange,
14
+ isChipElement,
15
+ isHTMLElement,
16
+ } from './dom-helpers.ts'
17
+ import { mergeAdjacentTextSegments } from './prompt-area-engine.ts'
18
+ import { getTextLengthInRange } from './cursor-helpers.ts'
19
+
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
22
+ }
23
+
24
+ /**
25
+ * Visitor callbacks for {@link walkFragmentNodes}. Each fires for the
26
+ * corresponding node kind encountered during a depth-first walk.
27
+ */
28
+ type FragmentVisitor = {
29
+ /** A text node — receives its text content (may be empty). */
30
+ onText: (text: string) => void
31
+ /** A chip element (has `data-chip-trigger`). */
32
+ onChip: (node: HTMLElement) => void
33
+ /** A `<br>` line break. */
34
+ onBreak: () => void
35
+ }
36
+
37
+ /**
38
+ * Depth-first walk of a selection fragment, classifying each node as text,
39
+ * chip, or line break and recursing into any other element (decoration spans,
40
+ * anchors, browser-inserted wrappers).
41
+ *
42
+ * Both fragment serializers share this single traversal so they cannot drift
43
+ * on which nodes count as chips/breaks or how nested decorations are unwrapped.
44
+ */
45
+ function walkFragmentNodes(fragment: DocumentFragment, visitor: FragmentVisitor): void {
46
+ const walk = (node: Node): void => {
47
+ if (node.nodeType === Node.TEXT_NODE) {
48
+ visitor.onText(node.textContent ?? '')
49
+ } else if (isChipElement(node)) {
50
+ visitor.onChip(node)
51
+ } else if (isHTMLElement(node) && node.tagName === 'BR') {
52
+ visitor.onBreak()
53
+ } else {
54
+ node.childNodes.forEach(walk)
55
+ }
56
+ }
57
+
58
+ fragment.childNodes.forEach(walk)
59
+ }
60
+
61
+ /**
62
+ * Serializes a DocumentFragment (from selection) to plain text,
63
+ * converting chip elements to their `trigger + displayText` form.
64
+ */
65
+ export function serializeFragmentToPlainText(fragment: DocumentFragment): string {
66
+ let text = ''
67
+
68
+ walkFragmentNodes(fragment, {
69
+ onText: (value) => {
70
+ text += value
71
+ },
72
+ onChip: (node) => {
73
+ text += (getChipTrigger(node) ?? '') + (getChipDisplay(node) ?? '')
74
+ },
75
+ onBreak: () => {
76
+ text += '\n'
77
+ },
78
+ })
79
+
80
+ return text
81
+ }
82
+
83
+ /**
84
+ * Serializes a DocumentFragment to an array of Segment objects,
85
+ * preserving chip data for internal copy/paste.
86
+ */
87
+ export function serializeFragmentToSegments(fragment: DocumentFragment): Segment[] {
88
+ const segments: Segment[] = []
89
+
90
+ walkFragmentNodes(fragment, {
91
+ onText: (value) => {
92
+ if (value) segments.push({ type: 'text', text: value })
93
+ },
94
+ onChip: (node) => {
95
+ const chip = chipNodeToSegment(node)
96
+ if (chip) segments.push(chip)
97
+ },
98
+ onBreak: () => {
99
+ segments.push({ type: 'text', text: '\n' })
100
+ },
101
+ })
102
+
103
+ return segments
104
+ }
105
+
106
+ /**
107
+ * Parses segment JSON from the clipboard. Returns null if invalid.
108
+ */
109
+ export function parseSegmentsFromClipboard(json: string): Segment[] | null {
110
+ try {
111
+ const parsed: unknown = JSON.parse(json)
112
+ if (!Array.isArray(parsed)) return null
113
+
114
+ const segments: Segment[] = []
115
+ for (const item of parsed) {
116
+ if (!isRecord(item)) return null
117
+
118
+ if (item.type === 'text' && typeof item.text === 'string') {
119
+ segments.push({ type: 'text', text: item.text })
120
+ } else if (
121
+ item.type === 'chip' &&
122
+ typeof item.trigger === 'string' &&
123
+ typeof item.value === 'string' &&
124
+ typeof item.displayText === 'string'
125
+ ) {
126
+ const chip: ChipSegment = {
127
+ type: 'chip',
128
+ trigger: item.trigger,
129
+ value: item.value,
130
+ displayText: item.displayText,
131
+ ...(item.data !== undefined ? { data: item.data } : {}),
132
+ ...(item.autoResolved ? { autoResolved: true } : {}),
133
+ }
134
+ segments.push(chip)
135
+ } else {
136
+ return null
137
+ }
138
+ }
139
+
140
+ return segments
141
+ } catch {
142
+ return null
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Inserts pasted segments at the current cursor position within existing segments.
148
+ * Splits any text segment that straddles the cursor so the pasted content lands
149
+ * exactly at the cursor and nothing before or after is lost.
150
+ */
151
+ export function insertSegmentsAtCursor(
152
+ currentSegments: Segment[],
153
+ pastedSegments: Segment[],
154
+ editor: HTMLElement,
155
+ ): Segment[] {
156
+ const range = getSelectionRange()
157
+ if (!range) return [...currentSegments, ...pastedSegments]
158
+
159
+ const preRange = document.createRange()
160
+ preRange.selectNodeContents(editor)
161
+ preRange.setEnd(range.startContainer, range.startOffset)
162
+ const cursorOffset = getTextLengthInRange(preRange)
163
+
164
+ const result: Segment[] = []
165
+ let offset = 0
166
+ let inserted = false
167
+
168
+ const insertOnce = (): void => {
169
+ if (!inserted) {
170
+ result.push(...pastedSegments)
171
+ inserted = true
172
+ }
173
+ }
174
+
175
+ for (const seg of currentSegments) {
176
+ if (seg.type === 'chip') {
177
+ const chipLen = seg.trigger.length + seg.displayText.length
178
+ if (offset >= cursorOffset) insertOnce()
179
+ result.push(seg)
180
+ offset += chipLen
181
+ continue
182
+ }
183
+
184
+ const segEnd = offset + seg.text.length
185
+ if (segEnd <= cursorOffset) {
186
+ // Entirely before the cursor
187
+ result.push(seg)
188
+ } else if (offset >= cursorOffset) {
189
+ // Entirely after the cursor
190
+ insertOnce()
191
+ result.push(seg)
192
+ } else {
193
+ // Cursor falls inside this text segment — split it.
194
+ const splitAt = cursorOffset - offset
195
+ const before = seg.text.slice(0, splitAt)
196
+ const after = seg.text.slice(splitAt)
197
+ if (before) result.push({ type: 'text', text: before })
198
+ insertOnce()
199
+ if (after) result.push({ type: 'text', text: after })
200
+ }
201
+ offset = segEnd
202
+ }
203
+
204
+ insertOnce()
205
+ return mergeAdjacentTextSegments(result)
206
+ }