@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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/build/index.d.mts +927 -0
- package/build/index.mjs +5236 -0
- package/build/index.mjs.map +1 -0
- package/package.json +73 -0
- package/src/components/agent/Composer.tsx +139 -0
- package/src/components/agent/Conversation.tsx +59 -0
- package/src/components/agent/FileCard.tsx +50 -0
- package/src/components/agent/Loader.tsx +21 -0
- package/src/components/agent/Message.tsx +44 -0
- package/src/components/agent/ModelSelect.tsx +87 -0
- package/src/components/agent/PermissionModeSelect.tsx +94 -0
- package/src/components/agent/PermissionPrompt.tsx +52 -0
- package/src/components/agent/QuestionPrompt.tsx +193 -0
- package/src/components/agent/Reasoning.tsx +58 -0
- package/src/components/agent/Response.tsx +31 -0
- package/src/components/agent/SessionList.tsx +93 -0
- package/src/components/agent/SessionPanel.tsx +149 -0
- package/src/components/agent/StatusBar.tsx +140 -0
- package/src/components/agent/ToolCallCard.tsx +94 -0
- package/src/components/agent/Transcript.tsx +114 -0
- package/src/components/agent/status.ts +16 -0
- package/src/components/prompt-area/animated-placeholder.tsx +42 -0
- package/src/components/prompt-area/clipboard-helpers.ts +206 -0
- package/src/components/prompt-area/cursor-helpers.ts +244 -0
- package/src/components/prompt-area/dom-helpers.ts +721 -0
- package/src/components/prompt-area/file-strip.tsx +250 -0
- package/src/components/prompt-area/html-to-markdown.ts +278 -0
- package/src/components/prompt-area/image-strip.tsx +49 -0
- package/src/components/prompt-area/index.ts +23 -0
- package/src/components/prompt-area/prompt-area-engine.ts +705 -0
- package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
- package/src/components/prompt-area/prompt-area.tsx +375 -0
- package/src/components/prompt-area/remove-button.tsx +37 -0
- package/src/components/prompt-area/segment-helpers.ts +62 -0
- package/src/components/prompt-area/trigger-popover.tsx +139 -0
- package/src/components/prompt-area/trigger-presets.ts +143 -0
- package/src/components/prompt-area/types.ts +360 -0
- package/src/components/prompt-area/use-markdown-mode.ts +113 -0
- package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
- package/src/components/prompt-area/use-prompt-area.ts +1507 -0
- package/src/components/prompt-area/use-trigger-search.ts +115 -0
- package/src/components/ui/AlertDialog.tsx +56 -0
- package/src/components/ui/Badge.tsx +42 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Card.tsx +29 -0
- package/src/components/ui/CodeBlock.tsx +31 -0
- package/src/components/ui/CopyButton.tsx +28 -0
- package/src/components/ui/Input.tsx +20 -0
- package/src/components/ui/ProgressRing.tsx +49 -0
- package/src/components/ui/Select.tsx +80 -0
- package/src/components/ui/Sonner.tsx +22 -0
- package/src/components/ui/Spinner.tsx +6 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +34 -0
- package/src/index.ts +99 -0
- package/src/lib/format.ts +67 -0
- package/src/lib/utils.ts +33 -0
- package/src/styles/theme.css +413 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type {
|
|
3
|
+
PermissionRequest,
|
|
4
|
+
QuestionBehavior,
|
|
5
|
+
UserQuestion,
|
|
6
|
+
UserQuestionOption,
|
|
7
|
+
} from '@workerdeck/protocol'
|
|
8
|
+
import { MessageCircleQuestion, X } from 'lucide-react'
|
|
9
|
+
import { Badge } from '../ui/Badge.tsx'
|
|
10
|
+
import { Button } from '../ui/Button.tsx'
|
|
11
|
+
import { Input } from '../ui/Input.tsx'
|
|
12
|
+
import { cn } from '../../lib/utils.ts'
|
|
13
|
+
|
|
14
|
+
export type QuestionBehaviorMeta = {
|
|
15
|
+
value: QuestionBehavior
|
|
16
|
+
label: string
|
|
17
|
+
description: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The AskUserQuestion policies surfaced on job/session creation forms. */
|
|
21
|
+
export const QUESTION_BEHAVIORS: QuestionBehaviorMeta[] = [
|
|
22
|
+
{ value: 'auto', label: 'Auto-answer', description: 'pick each question’s recommended option' },
|
|
23
|
+
{ value: 'ask', label: 'Ask', description: 'wait for a watcher or webhook controller to answer' },
|
|
24
|
+
{ value: 'deny', label: 'Disabled', description: 'the agent is told to decide on its own' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** Extract well-formed questions from an AskUserQuestion permission request's input. */
|
|
28
|
+
export function parseUserQuestions(input: Record<string, unknown>): UserQuestion[] {
|
|
29
|
+
const raw = Array.isArray(input.questions) ? input.questions : []
|
|
30
|
+
return raw.flatMap((entry): UserQuestion[] => {
|
|
31
|
+
const q = entry as Partial<UserQuestion>
|
|
32
|
+
if (typeof q.question !== 'string' || !Array.isArray(q.options)) return []
|
|
33
|
+
const options = q.options.filter(
|
|
34
|
+
(o): o is UserQuestionOption => typeof (o as UserQuestionOption | undefined)?.label === 'string',
|
|
35
|
+
)
|
|
36
|
+
if (options.length === 0) return []
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
question: q.question,
|
|
40
|
+
header: typeof q.header === 'string' ? q.header : '',
|
|
41
|
+
options,
|
|
42
|
+
multiSelect: q.multiSelect === true,
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Selection = { labels: string[]; other: string; otherActive: boolean }
|
|
49
|
+
|
|
50
|
+
const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
|
|
51
|
+
|
|
52
|
+
/** A question's answer string: chosen label(s) (multi-select comma-joined), with any
|
|
53
|
+
* free-text "Other" appended — the value the CLI expects in `updatedInput.answers`. */
|
|
54
|
+
function answerFor(selection: Selection): string {
|
|
55
|
+
const parts = [...selection.labels]
|
|
56
|
+
if (selection.otherActive && selection.other.trim()) parts.push(selection.other.trim())
|
|
57
|
+
return parts.join(', ')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface QuestionPromptProps {
|
|
61
|
+
/** A pending permission whose toolName is 'AskUserQuestion'. */
|
|
62
|
+
request: PermissionRequest
|
|
63
|
+
/** Allow the tool with `updatedInput` (the original input plus `answers`). */
|
|
64
|
+
onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
|
|
65
|
+
/** Deny the tool — the model proceeds without an answer. */
|
|
66
|
+
onDismiss: (requestId: string, message?: string) => void
|
|
67
|
+
className?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Interactive form for the AskUserQuestion tool: option buttons per question
|
|
71
|
+
* (multi-select where the question allows it), a free-text "Other" escape hatch,
|
|
72
|
+
* and the focused option's preview. Falls back to nothing renderable → the caller
|
|
73
|
+
* should show a generic PermissionPrompt if `parseUserQuestions` finds no questions. */
|
|
74
|
+
export function QuestionPrompt({ request, onAnswer, onDismiss, className }: QuestionPromptProps) {
|
|
75
|
+
const questions = parseUserQuestions(request.input)
|
|
76
|
+
const [selections, setSelections] = useState<Selection[]>(() =>
|
|
77
|
+
questions.map(() => EMPTY_SELECTION),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const update = (index: number, patch: Partial<Selection>) => {
|
|
81
|
+
setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const toggle = (index: number, label: string, multiSelect: boolean) => {
|
|
85
|
+
const current = selections[index] ?? EMPTY_SELECTION
|
|
86
|
+
if (multiSelect) {
|
|
87
|
+
update(index, {
|
|
88
|
+
labels: current.labels.includes(label)
|
|
89
|
+
? current.labels.filter((l) => l !== label)
|
|
90
|
+
: [...current.labels, label],
|
|
91
|
+
})
|
|
92
|
+
} else {
|
|
93
|
+
update(index, { labels: current.labels[0] === label ? [] : [label], otherActive: false })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const complete = questions.every((_, i) => answerFor(selections[i] ?? EMPTY_SELECTION) !== '')
|
|
98
|
+
|
|
99
|
+
const submit = () => {
|
|
100
|
+
const answers: Record<string, string> = {}
|
|
101
|
+
questions.forEach((q, i) => {
|
|
102
|
+
answers[q.question] = answerFor(selections[i] ?? EMPTY_SELECTION)
|
|
103
|
+
})
|
|
104
|
+
onAnswer(request.id, { ...request.input, answers })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
data-slot='question-prompt'
|
|
110
|
+
className={cn('rounded-lg border border-info/40 bg-info-bg p-3', className)}>
|
|
111
|
+
<div className='flex items-start gap-2.5'>
|
|
112
|
+
<MessageCircleQuestion className='mt-0.5 size-4 shrink-0 text-info' />
|
|
113
|
+
<div className='flex min-w-0 flex-1 flex-col gap-3'>
|
|
114
|
+
{questions.map((q, index) => {
|
|
115
|
+
const selection = selections[index] ?? EMPTY_SELECTION
|
|
116
|
+
return (
|
|
117
|
+
<div key={index} className='flex flex-col gap-1.5'>
|
|
118
|
+
<div className='flex items-center gap-2'>
|
|
119
|
+
{q.header ? <Badge variant='info'>{q.header}</Badge> : null}
|
|
120
|
+
<span className='text-body-sm font-medium text-fg-1'>{q.question}</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div className='flex flex-col gap-1'>
|
|
123
|
+
{q.options.map((option) => {
|
|
124
|
+
const selected = selection.labels.includes(option.label)
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
key={option.label}
|
|
128
|
+
type='button'
|
|
129
|
+
onClick={() => toggle(index, option.label, q.multiSelect === true)}
|
|
130
|
+
className={cn(
|
|
131
|
+
'rounded-md border px-2.5 py-1.5 text-left transition-colors',
|
|
132
|
+
selected
|
|
133
|
+
? 'border-info bg-bg'
|
|
134
|
+
: 'border-border bg-bg/50 hover:border-border-strong hover:bg-bg',
|
|
135
|
+
)}>
|
|
136
|
+
<span className='block text-body-sm font-medium text-fg-1'>
|
|
137
|
+
{option.label}
|
|
138
|
+
</span>
|
|
139
|
+
{option.description ? (
|
|
140
|
+
<span className='block text-label text-fg-4'>{option.description}</span>
|
|
141
|
+
) : null}
|
|
142
|
+
{selected && option.preview ? (
|
|
143
|
+
<pre className='mt-1.5 max-h-40 overflow-auto rounded-md bg-code-bg px-2.5 py-1.5 font-mono text-label whitespace-pre-wrap text-fg-2'>
|
|
144
|
+
{option.preview}
|
|
145
|
+
</pre>
|
|
146
|
+
) : null}
|
|
147
|
+
</button>
|
|
148
|
+
)
|
|
149
|
+
})}
|
|
150
|
+
<div className='flex items-center gap-2'>
|
|
151
|
+
<button
|
|
152
|
+
type='button'
|
|
153
|
+
onClick={() => update(index, { otherActive: !selection.otherActive })}
|
|
154
|
+
className={cn(
|
|
155
|
+
'shrink-0 rounded-md border px-2.5 py-1.5 text-body-sm font-medium transition-colors',
|
|
156
|
+
selection.otherActive
|
|
157
|
+
? 'border-info bg-bg text-fg-1'
|
|
158
|
+
: 'border-border bg-bg/50 text-fg-3 hover:border-border-strong hover:bg-bg',
|
|
159
|
+
)}>
|
|
160
|
+
Other…
|
|
161
|
+
</button>
|
|
162
|
+
{selection.otherActive ? (
|
|
163
|
+
<Input
|
|
164
|
+
autoFocus
|
|
165
|
+
value={selection.other}
|
|
166
|
+
onChange={(e) => update(index, { other: e.target.value })}
|
|
167
|
+
placeholder='Type your own answer'
|
|
168
|
+
className='flex-1'
|
|
169
|
+
/>
|
|
170
|
+
) : null}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
})}
|
|
176
|
+
<div>
|
|
177
|
+
<Button size='sm' onClick={submit} disabled={!complete}>
|
|
178
|
+
Answer
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
<Button
|
|
183
|
+
variant='ghost'
|
|
184
|
+
size='icon-sm'
|
|
185
|
+
aria-label='Dismiss question'
|
|
186
|
+
className='shrink-0'
|
|
187
|
+
onClick={() => onDismiss(request.id, 'Question dismissed by user')}>
|
|
188
|
+
<X className='size-3.5' />
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Brain, ChevronDown } from 'lucide-react'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { Response } from './Response.tsx'
|
|
5
|
+
|
|
6
|
+
export interface ReasoningProps {
|
|
7
|
+
children: string
|
|
8
|
+
/** Auto-opens while true, auto-closes shortly after it flips false. */
|
|
9
|
+
isStreaming?: boolean
|
|
10
|
+
defaultOpen?: boolean
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Collapsible extended-thinking block: open while the model is thinking, tucks itself
|
|
15
|
+
* away once the thought is finished (unless the user toggled it manually). */
|
|
16
|
+
export function Reasoning({ children, isStreaming = false, defaultOpen, className }: ReasoningProps) {
|
|
17
|
+
const [open, setOpen] = useState(defaultOpen ?? isStreaming)
|
|
18
|
+
const userToggled = useRef(false)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (userToggled.current) return
|
|
22
|
+
if (isStreaming) {
|
|
23
|
+
setOpen(true)
|
|
24
|
+
} else {
|
|
25
|
+
const timer = setTimeout(() => setOpen(false), 600)
|
|
26
|
+
return () => clearTimeout(timer)
|
|
27
|
+
}
|
|
28
|
+
}, [isStreaming])
|
|
29
|
+
|
|
30
|
+
// Models with encrypted thinking emit the blocks but never the summary text. Then there is
|
|
31
|
+
// nothing to expand: show a bare "Thinking…" marker live, and nothing at all once it's done.
|
|
32
|
+
const hasText = children.trim() !== ''
|
|
33
|
+
if (!hasText && !isStreaming) return null
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div data-slot='reasoning' data-streaming={isStreaming || undefined} className={cn('w-full', className)}>
|
|
37
|
+
<button
|
|
38
|
+
type='button'
|
|
39
|
+
disabled={!hasText}
|
|
40
|
+
onClick={() => {
|
|
41
|
+
userToggled.current = true
|
|
42
|
+
setOpen((v) => !v)
|
|
43
|
+
}}
|
|
44
|
+
className='flex items-center gap-1.5 text-label text-fg-3 transition-colors outline-none disabled:cursor-default hover:text-fg-1 disabled:hover:text-fg-3'>
|
|
45
|
+
<Brain className='size-3.5' />
|
|
46
|
+
<span>{isStreaming ? 'Thinking…' : 'Thought process'}</span>
|
|
47
|
+
{hasText ? (
|
|
48
|
+
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
|
|
49
|
+
) : null}
|
|
50
|
+
</button>
|
|
51
|
+
{open && hasText ? (
|
|
52
|
+
<div className='mt-2 border-l-2 border-border pl-3 text-body-sm text-fg-3 [&_*]:text-fg-3'>
|
|
53
|
+
<Response streaming={isStreaming}>{children}</Response>
|
|
54
|
+
</div>
|
|
55
|
+
) : null}
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { memo } from 'react'
|
|
2
|
+
import { Streamdown } from 'streamdown'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
|
|
5
|
+
export interface ResponseProps {
|
|
6
|
+
children: string
|
|
7
|
+
/** Streaming text: tolerate incomplete markdown (unclosed fences, half links). */
|
|
8
|
+
streaming?: boolean
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Markdown renderer for assistant output — streaming-safe via streamdown, code
|
|
13
|
+
* highlighted with shiki (dual theme follows [data-theme] through the dark: variant). */
|
|
14
|
+
export const Response = memo(
|
|
15
|
+
function Response({ children, streaming, className }: ResponseProps) {
|
|
16
|
+
return (
|
|
17
|
+
<Streamdown
|
|
18
|
+
mode={streaming ? 'streaming' : 'static'}
|
|
19
|
+
parseIncompleteMarkdown={streaming}
|
|
20
|
+
shikiTheme={['github-light', 'github-dark']}
|
|
21
|
+
className={cn(
|
|
22
|
+
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
|
23
|
+
className,
|
|
24
|
+
)}>
|
|
25
|
+
{children}
|
|
26
|
+
</Streamdown>
|
|
27
|
+
)
|
|
28
|
+
},
|
|
29
|
+
(prev, next) =>
|
|
30
|
+
prev.children === next.children && prev.streaming === next.streaming && prev.className === next.className,
|
|
31
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { SessionInfo } from '@workerdeck/protocol'
|
|
2
|
+
import { Trash2 } from 'lucide-react'
|
|
3
|
+
import { Badge } from '../ui/Badge.tsx'
|
|
4
|
+
import { Button } from '../ui/Button.tsx'
|
|
5
|
+
import { cn } from '../../lib/utils.ts'
|
|
6
|
+
import { formatCost, formatRelativeTime } from '../../lib/format.ts'
|
|
7
|
+
import { STATUS_META } from './status.ts'
|
|
8
|
+
|
|
9
|
+
export interface SessionListItemProps {
|
|
10
|
+
session: SessionInfo
|
|
11
|
+
active?: boolean
|
|
12
|
+
onSelect?: (id: string) => void
|
|
13
|
+
onDelete?: (id: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SessionListItem({ session, active, onSelect, onDelete }: SessionListItemProps) {
|
|
17
|
+
const meta = STATUS_META[session.status]
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
data-slot='session-list-item'
|
|
21
|
+
data-active={active || undefined}
|
|
22
|
+
className={cn(
|
|
23
|
+
'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
|
|
24
|
+
active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
|
|
25
|
+
)}>
|
|
26
|
+
<button
|
|
27
|
+
type='button'
|
|
28
|
+
onClick={() => onSelect?.(session.id)}
|
|
29
|
+
className='min-w-0 flex-1 text-left outline-none'>
|
|
30
|
+
<div className='flex items-center gap-2'>
|
|
31
|
+
<span className='truncate text-body-sm font-medium text-fg-1'>
|
|
32
|
+
{session.title ?? session.id.slice(0, 8)}
|
|
33
|
+
</span>
|
|
34
|
+
<Badge variant={meta.variant} dot className='shrink-0'>
|
|
35
|
+
{meta.label}
|
|
36
|
+
</Badge>
|
|
37
|
+
</div>
|
|
38
|
+
<div className='mt-0.5 flex items-center gap-2 font-mono text-label text-fg-4'>
|
|
39
|
+
<span className='truncate'>{session.cwd}</span>
|
|
40
|
+
{session.profile ? <span className='shrink-0'>@{session.profile}</span> : null}
|
|
41
|
+
<span className='shrink-0'>{formatCost(session.totalCostUsd)}</span>
|
|
42
|
+
<span className='shrink-0'>{formatRelativeTime(session.lastActivityAt ?? session.createdAt)}</span>
|
|
43
|
+
</div>
|
|
44
|
+
</button>
|
|
45
|
+
{onDelete ? (
|
|
46
|
+
<Button
|
|
47
|
+
variant='ghost'
|
|
48
|
+
size='icon-sm'
|
|
49
|
+
aria-label='Close session'
|
|
50
|
+
className='opacity-0 transition-opacity group-hover:opacity-100'
|
|
51
|
+
onClick={() => onDelete(session.id)}>
|
|
52
|
+
<Trash2 className='size-3.5 text-fg-3' />
|
|
53
|
+
</Button>
|
|
54
|
+
) : null}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SessionListProps {
|
|
60
|
+
sessions: SessionInfo[]
|
|
61
|
+
activeId?: string
|
|
62
|
+
onSelect?: (id: string) => void
|
|
63
|
+
onDelete?: (id: string) => void
|
|
64
|
+
emptyText?: string
|
|
65
|
+
className?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function SessionList({
|
|
69
|
+
sessions,
|
|
70
|
+
activeId,
|
|
71
|
+
onSelect,
|
|
72
|
+
onDelete,
|
|
73
|
+
emptyText = 'No sessions yet.',
|
|
74
|
+
className,
|
|
75
|
+
}: SessionListProps) {
|
|
76
|
+
return (
|
|
77
|
+
<div data-slot='session-list' className={cn('flex flex-col gap-1', className)}>
|
|
78
|
+
{sessions.length === 0 ? (
|
|
79
|
+
<div className='px-2.5 py-6 text-center text-body-sm text-fg-4'>{emptyText}</div>
|
|
80
|
+
) : (
|
|
81
|
+
sessions.map((session) => (
|
|
82
|
+
<SessionListItem
|
|
83
|
+
key={session.id}
|
|
84
|
+
session={session}
|
|
85
|
+
active={session.id === activeId}
|
|
86
|
+
onSelect={onSelect}
|
|
87
|
+
onDelete={onDelete}
|
|
88
|
+
/>
|
|
89
|
+
))
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
2
|
+
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
3
|
+
import { PROVIDER_PERMISSION_MODES } from '@workerdeck/protocol'
|
|
4
|
+
import { useClaudeSession, useToolCallHost } from '@workerdeck/react'
|
|
5
|
+
import { cn } from '../../lib/utils.ts'
|
|
6
|
+
import { Composer } from './Composer.tsx'
|
|
7
|
+
import { ModelSelect } from './ModelSelect.tsx'
|
|
8
|
+
import { PermissionModeSelect } from './PermissionModeSelect.tsx'
|
|
9
|
+
import { PermissionPrompt } from './PermissionPrompt.tsx'
|
|
10
|
+
import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
|
|
11
|
+
import { StatusBar } from './StatusBar.tsx'
|
|
12
|
+
import { Transcript } from './Transcript.tsx'
|
|
13
|
+
|
|
14
|
+
export interface SessionPanelProps {
|
|
15
|
+
client: WorkerDeckClient
|
|
16
|
+
sessionId: string | undefined
|
|
17
|
+
/** Optional slot rendered at the top, above the status bar. */
|
|
18
|
+
header?: ReactNode
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The all-in-one embeddable session surface: status bar, streaming transcript,
|
|
24
|
+
* permission prompts, composer. Attaches via useClaudeSession; remount (key) to switch
|
|
25
|
+
* sessions.
|
|
26
|
+
*/
|
|
27
|
+
export function SessionPanel({ client, sessionId, header, className }: SessionPanelProps) {
|
|
28
|
+
// Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
|
|
29
|
+
// the panel rather than through `toast`. The panel does not mount a `Toaster`, and
|
|
30
|
+
// an embedder that doesn't either would drop the only signal that a command failed
|
|
31
|
+
// — the select would just "not stick". An error channel a host can lose by omission
|
|
32
|
+
// is not an error channel.
|
|
33
|
+
const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
|
|
34
|
+
const { state, connected, handle, send, approve, deny, interrupt, setModel, setPermissionMode } =
|
|
35
|
+
useClaudeSession(client, sessionId, { onProtocolError: setProtocolError })
|
|
36
|
+
// Callers are told to remount on a session switch, but a changed prop must not leave
|
|
37
|
+
// the previous session's failure on screen.
|
|
38
|
+
useEffect(() => setProtocolError(undefined), [sessionId])
|
|
39
|
+
// Host server-bridged tool calls (provider-engine sessions) in this tab, on the
|
|
40
|
+
// SAME handle the panel attached with — the bridge asks the first attached
|
|
41
|
+
// client. Free for Claude sessions: the guest loads lazily on the first call,
|
|
42
|
+
// which for them never comes.
|
|
43
|
+
useToolCallHost(handle)
|
|
44
|
+
const busy = state.status === 'running' || state.status === 'awaiting_approval'
|
|
45
|
+
const ended = state.status === 'failed' || state.status === 'closed'
|
|
46
|
+
|
|
47
|
+
// "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
|
|
48
|
+
// even though the CLI's command list doesn't include it.
|
|
49
|
+
const commands = useMemo(() => {
|
|
50
|
+
if (!state.commands) return undefined
|
|
51
|
+
if (state.commands.some((c) => c.name === 'model')) return state.commands
|
|
52
|
+
return [
|
|
53
|
+
{ name: 'model', description: 'Switch the model for this session', argumentHint: '<model>' },
|
|
54
|
+
...state.commands,
|
|
55
|
+
]
|
|
56
|
+
}, [state.commands])
|
|
57
|
+
|
|
58
|
+
// "/model <id>" switches the model directly instead of going to the CLI.
|
|
59
|
+
const handleSend = (text: string) => {
|
|
60
|
+
const modelCommand = /^\/model\s+(\S+)$/.exec(text)
|
|
61
|
+
if (modelCommand) {
|
|
62
|
+
setModel(modelCommand[1])
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
send(text)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
data-slot='session-panel'
|
|
71
|
+
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
|
|
72
|
+
{header}
|
|
73
|
+
<StatusBar state={state} connected={connected} />
|
|
74
|
+
{protocolError ? (
|
|
75
|
+
<div className='px-3 pt-2'>
|
|
76
|
+
<div
|
|
77
|
+
role='alert'
|
|
78
|
+
className='mx-auto flex w-full max-w-3xl items-start gap-2 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-body-sm text-danger'>
|
|
79
|
+
<span className='min-w-0 flex-1 break-words'>{protocolError}</span>
|
|
80
|
+
<button
|
|
81
|
+
type='button'
|
|
82
|
+
onClick={() => setProtocolError(undefined)}
|
|
83
|
+
aria-label='Dismiss error'
|
|
84
|
+
className='shrink-0 opacity-70 transition-opacity hover:opacity-100'>
|
|
85
|
+
✕
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
<Transcript
|
|
91
|
+
state={state}
|
|
92
|
+
fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
|
|
93
|
+
/>
|
|
94
|
+
{state.pendingApprovals.length > 0 ? (
|
|
95
|
+
<div className='px-3 pb-2'>
|
|
96
|
+
<div className='mx-auto flex w-full max-w-3xl flex-col gap-2'>
|
|
97
|
+
{state.pendingApprovals.map((request) =>
|
|
98
|
+
request.toolName === 'AskUserQuestion' &&
|
|
99
|
+
parseUserQuestions(request.input).length > 0 ? (
|
|
100
|
+
<QuestionPrompt
|
|
101
|
+
key={request.id}
|
|
102
|
+
request={request}
|
|
103
|
+
onAnswer={approve}
|
|
104
|
+
onDismiss={deny}
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<PermissionPrompt
|
|
108
|
+
key={request.id}
|
|
109
|
+
request={request}
|
|
110
|
+
onApprove={approve}
|
|
111
|
+
onDeny={deny}
|
|
112
|
+
/>
|
|
113
|
+
),
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
) : null}
|
|
118
|
+
<Composer
|
|
119
|
+
onSend={handleSend}
|
|
120
|
+
onInterrupt={interrupt}
|
|
121
|
+
busy={busy}
|
|
122
|
+
disabled={ended || !sessionId}
|
|
123
|
+
commands={commands}
|
|
124
|
+
toolbar={
|
|
125
|
+
<>
|
|
126
|
+
{state.models?.length ? (
|
|
127
|
+
<ModelSelect
|
|
128
|
+
models={state.models}
|
|
129
|
+
model={state.model}
|
|
130
|
+
onModelChange={setModel}
|
|
131
|
+
disabled={ended}
|
|
132
|
+
/>
|
|
133
|
+
) : null}
|
|
134
|
+
{state.permissionMode ? (
|
|
135
|
+
<PermissionModeSelect
|
|
136
|
+
mode={state.permissionMode}
|
|
137
|
+
onModeChange={setPermissionMode}
|
|
138
|
+
// A provider session rejects the CLI-only modes with a
|
|
139
|
+
// protocol_error — don't offer what can only fail.
|
|
140
|
+
modes={state.engine === 'provider' ? PROVIDER_PERMISSION_MODES : undefined}
|
|
141
|
+
disabled={ended}
|
|
142
|
+
/>
|
|
143
|
+
) : null}
|
|
144
|
+
</>
|
|
145
|
+
}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { TranscriptState } from '@workerdeck/react'
|
|
3
|
+
import type { ContextUsage, RateLimitInfo } from '@workerdeck/protocol'
|
|
4
|
+
import { WifiOff } from 'lucide-react'
|
|
5
|
+
import { Badge } from '../ui/Badge.tsx'
|
|
6
|
+
import { ProgressRing } from '../ui/ProgressRing.tsx'
|
|
7
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
8
|
+
import { Tip } from '../ui/Tooltip.tsx'
|
|
9
|
+
import { cn } from '../../lib/utils.ts'
|
|
10
|
+
import { formatCost, formatCountdown, formatTokens } from '../../lib/format.ts'
|
|
11
|
+
import { STATUS_META } from './status.ts'
|
|
12
|
+
|
|
13
|
+
export interface StatusBarProps {
|
|
14
|
+
state: TranscriptState
|
|
15
|
+
connected: boolean
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Ticking clock for reset countdowns — rate_limit events are sparse, so tick locally. */
|
|
20
|
+
function useNow(intervalMs = 30_000): number {
|
|
21
|
+
const [now, setNow] = useState(() => Date.now())
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const timer = setInterval(() => setNow(Date.now()), intervalMs)
|
|
24
|
+
return () => clearInterval(timer)
|
|
25
|
+
}, [intervalMs])
|
|
26
|
+
return now
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const utilizationColor = (pct: number) =>
|
|
30
|
+
pct >= 95 ? 'text-danger' : pct >= 80 ? 'text-warning' : 'text-fg-3'
|
|
31
|
+
|
|
32
|
+
/** The CLI reports category colors as its own theme token names ('inactive',
|
|
33
|
+
* 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
|
|
34
|
+
const cssColor = (color: string): string | undefined =>
|
|
35
|
+
typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
|
|
36
|
+
|
|
37
|
+
function ContextMeter({ usage }: { usage: ContextUsage }) {
|
|
38
|
+
return (
|
|
39
|
+
<Tip
|
|
40
|
+
content={
|
|
41
|
+
<div className='flex min-w-44 flex-col gap-1 py-0.5'>
|
|
42
|
+
{usage.categories.map((c) => (
|
|
43
|
+
<div key={c.name} className='flex items-center gap-2'>
|
|
44
|
+
<span
|
|
45
|
+
className='size-2 shrink-0 rounded-full bg-fg-4'
|
|
46
|
+
style={cssColor(c.color) ? { backgroundColor: c.color } : undefined}
|
|
47
|
+
/>
|
|
48
|
+
<span className='flex-1'>{c.name}</span>
|
|
49
|
+
<span className='font-mono text-fg-3'>{formatTokens(c.tokens)}</span>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
<div className='mt-0.5 flex items-center justify-between gap-2 border-t border-border pt-1'>
|
|
53
|
+
<span>Total</span>
|
|
54
|
+
<span className='font-mono text-fg-3'>
|
|
55
|
+
{formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} (
|
|
56
|
+
{usage.percentage.toFixed(0)}%)
|
|
57
|
+
</span>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
}>
|
|
61
|
+
<span
|
|
62
|
+
className={cn(
|
|
63
|
+
'inline-flex cursor-default items-center gap-1 font-mono text-label',
|
|
64
|
+
utilizationColor(usage.percentage),
|
|
65
|
+
)}>
|
|
66
|
+
Ctx {formatTokens(usage.totalTokens)}
|
|
67
|
+
</span>
|
|
68
|
+
</Tip>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function RateLimitMeter({ label, info, now }: { label: string; info: RateLimitInfo; now: number }) {
|
|
73
|
+
// The CLI omits utilization on some updates — show the window without a made-up 0%.
|
|
74
|
+
const pct = info.utilization
|
|
75
|
+
const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
|
|
76
|
+
return (
|
|
77
|
+
<Tip
|
|
78
|
+
content={
|
|
79
|
+
<div className='flex min-w-36 flex-col gap-1 py-0.5'>
|
|
80
|
+
<div className='flex items-center justify-between gap-2'>
|
|
81
|
+
<span>{label} usage</span>
|
|
82
|
+
<span className='font-mono text-fg-3'>
|
|
83
|
+
{pct !== undefined ? `${pct.toFixed(1)}%` : '—'}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
{resetsAtMs !== undefined ? (
|
|
87
|
+
<div className='flex items-center justify-between gap-2'>
|
|
88
|
+
<span>Resets in</span>
|
|
89
|
+
<span className='font-mono text-fg-3'>{formatCountdown(resetsAtMs, now)}</span>
|
|
90
|
+
</div>
|
|
91
|
+
) : null}
|
|
92
|
+
{info.isUsingOverage ? <div className='text-warning'>Using overage</div> : null}
|
|
93
|
+
{info.status === 'rejected' ? <div className='text-danger'>Limit reached</div> : null}
|
|
94
|
+
</div>
|
|
95
|
+
}>
|
|
96
|
+
<span
|
|
97
|
+
className={cn(
|
|
98
|
+
'inline-flex cursor-default items-center gap-1 font-mono text-label',
|
|
99
|
+
info.status === 'rejected' ? 'text-danger' : utilizationColor(pct ?? 0),
|
|
100
|
+
)}>
|
|
101
|
+
<ProgressRing value={pct ?? 0} />
|
|
102
|
+
{label}
|
|
103
|
+
{pct !== undefined ? ` ${pct.toFixed(0)}%` : ''}
|
|
104
|
+
{resetsAtMs !== undefined ? (
|
|
105
|
+
<span className='text-fg-4'>· {formatCountdown(resetsAtMs, now)}</span>
|
|
106
|
+
) : null}
|
|
107
|
+
</span>
|
|
108
|
+
</Tip>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function StatusBar({ state, connected, className }: StatusBarProps) {
|
|
113
|
+
const meta = STATUS_META[state.status]
|
|
114
|
+
const now = useNow()
|
|
115
|
+
const session = state.rateLimits?.five_hour
|
|
116
|
+
const weekly = state.rateLimits?.seven_day
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
data-slot='status-bar'
|
|
120
|
+
className={cn(
|
|
121
|
+
'flex items-center gap-3 border-b border-border bg-surface px-3 py-2',
|
|
122
|
+
className,
|
|
123
|
+
)}>
|
|
124
|
+
<Badge variant={meta.variant} dot={!meta.busy}>
|
|
125
|
+
{meta.busy ? <Spinner className='size-3 text-current' /> : null}
|
|
126
|
+
{meta.label}
|
|
127
|
+
</Badge>
|
|
128
|
+
{state.contextUsage ? <ContextMeter usage={state.contextUsage} /> : null}
|
|
129
|
+
{session ? <RateLimitMeter label='Session' info={session} now={now} /> : null}
|
|
130
|
+
{weekly ? <RateLimitMeter label='Weekly' info={weekly} now={now} /> : null}
|
|
131
|
+
<span className='flex-1' />
|
|
132
|
+
{!connected ? (
|
|
133
|
+
<span className='inline-flex items-center gap-1 text-label text-warning'>
|
|
134
|
+
<WifiOff className='size-3' /> reconnecting…
|
|
135
|
+
</span>
|
|
136
|
+
) : null}
|
|
137
|
+
<span className='font-mono text-label text-fg-3'>{formatCost(state.totalCostUsd)}</span>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|