@workerdeck/ui 0.9.0 → 0.10.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/README.md +44 -3
- package/build/index.d.mts +762 -21
- package/build/index.mjs +3245 -348
- package/build/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +379 -56
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/EditorTabs.tsx +165 -0
- package/src/components/agent/FileTree.tsx +287 -0
- package/src/components/agent/FileViewer.tsx +148 -0
- package/src/components/agent/HostFilesDialog.tsx +218 -0
- package/src/components/agent/McpDialog.tsx +363 -0
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +99 -22
- package/src/components/agent/PermissionPrompt.tsx +72 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +379 -40
- package/src/components/agent/SessionWorkspace.tsx +282 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +80 -4
- package/src/components/agent/Transcript.tsx +66 -17
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/prompt-area/prompt-area-engine.ts +53 -0
- package/src/components/prompt-area/types.ts +15 -0
- package/src/components/prompt-area/use-prompt-area.ts +20 -0
- package/src/components/ui/CopyButton.tsx +8 -1
- package/src/components/ui/Dialog.tsx +92 -0
- package/src/components/ui/Menu.tsx +55 -0
- package/src/components/ui/Splitter.tsx +133 -0
- package/src/components/ui/Tooltip.tsx +22 -5
- package/src/index.ts +53 -1
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +48 -0
- package/src/lib/tool-icon.ts +74 -0
|
@@ -1,18 +1,39 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
2
|
import type { TranscriptItem } from '@workerdeck/react'
|
|
3
|
-
import { ChevronDown, Clock
|
|
3
|
+
import { ChevronDown, Clock } from 'lucide-react'
|
|
4
4
|
import { Badge } from '../ui/Badge.tsx'
|
|
5
5
|
import { CodeBlock } from '../ui/CodeBlock.tsx'
|
|
6
6
|
import { Spinner } from '../ui/Spinner.tsx'
|
|
7
7
|
import { cn } from '../../lib/utils.ts'
|
|
8
8
|
import { toolInputPreview } from '../../lib/format.ts'
|
|
9
|
+
import { toolIcon } from '../../lib/tool-icon.ts'
|
|
9
10
|
|
|
10
11
|
export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
11
12
|
|
|
12
13
|
const RESULT_PREVIEW_CHARS = 2000
|
|
13
14
|
|
|
15
|
+
/** Codex's built-in image tools name a host path rather than sending bytes —
|
|
16
|
+
* an event log carries references, never base64. Rendering the picture means
|
|
17
|
+
* reading that path back through the gateway's host-file route. */
|
|
18
|
+
const IMAGE_TOOLS = new Set(['CodexImageGeneration', 'CodexImageView'])
|
|
19
|
+
|
|
20
|
+
const imagePathOf = (item: ToolCallItem): string | undefined => {
|
|
21
|
+
if (!IMAGE_TOOLS.has(item.name)) return undefined
|
|
22
|
+
const input = item.input as { savedPath?: unknown; path?: unknown } | null
|
|
23
|
+
const path = input?.savedPath ?? input?.path
|
|
24
|
+
return typeof path === 'string' ? path : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export interface ToolCallCardProps {
|
|
15
28
|
item: ToolCallItem
|
|
29
|
+
/**
|
|
30
|
+
* Reads a host file as a data URL, for tools whose output is a picture on the
|
|
31
|
+
* host. Resolves `undefined` when the gateway won't serve that path — a
|
|
32
|
+
* generated image saved outside the allowed roots (codex's default
|
|
33
|
+
* `$CODEX_HOME/generated_images/`) is one, and the card then names the path
|
|
34
|
+
* instead of showing it.
|
|
35
|
+
*/
|
|
36
|
+
hostImage?: (path: string) => Promise<string | undefined>
|
|
16
37
|
className?: string
|
|
17
38
|
}
|
|
18
39
|
|
|
@@ -27,12 +48,14 @@ const STATE_BADGE = {
|
|
|
27
48
|
failed: { label: 'Error', variant: 'danger', busy: false },
|
|
28
49
|
} as const
|
|
29
50
|
|
|
30
|
-
export function ToolCallCard({ item, className }: ToolCallCardProps) {
|
|
51
|
+
export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps) {
|
|
31
52
|
const [open, setOpen] = useState(false)
|
|
32
53
|
const [fullResult, setFullResult] = useState(false)
|
|
54
|
+
const imagePath = imagePathOf(item)
|
|
33
55
|
const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
|
|
34
56
|
const badge = STATE_BADGE[status]
|
|
35
57
|
const isError = status === 'failed' || item.result?.isError === true
|
|
58
|
+
const Icon = toolIcon(item.name)
|
|
36
59
|
|
|
37
60
|
const resultText = item.result?.text ?? ''
|
|
38
61
|
const truncated = !fullResult && resultText.length > RESULT_PREVIEW_CHARS
|
|
@@ -50,8 +73,13 @@ export function ToolCallCard({ item, className }: ToolCallCardProps) {
|
|
|
50
73
|
'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors outline-none',
|
|
51
74
|
'hover:bg-surface-hover focus-visible:bg-surface-hover',
|
|
52
75
|
)}>
|
|
53
|
-
<
|
|
76
|
+
<Icon className='size-3.5 shrink-0 text-fg-3' />
|
|
54
77
|
<span className='shrink-0 font-mono text-body-sm font-medium text-fg-1'>{item.name}</span>
|
|
78
|
+
{item.backend && item.backend !== 'server' ? (
|
|
79
|
+
<Badge variant='neutral' className='shrink-0'>
|
|
80
|
+
{item.backend}
|
|
81
|
+
</Badge>
|
|
82
|
+
) : null}
|
|
55
83
|
<span className='min-w-0 flex-1 truncate font-mono text-label text-fg-4'>
|
|
56
84
|
{toolInputPreview(item.input)}
|
|
57
85
|
</span>
|
|
@@ -64,6 +92,11 @@ export function ToolCallCard({ item, className }: ToolCallCardProps) {
|
|
|
64
92
|
className={cn('size-3.5 shrink-0 text-fg-4 transition-transform', open && 'rotate-180')}
|
|
65
93
|
/>
|
|
66
94
|
</button>
|
|
95
|
+
{/* The picture is the point of the call — shown without expanding, the
|
|
96
|
+
way the tool's own output would be if the engine had sent bytes. */}
|
|
97
|
+
{imagePath && hostImage ? (
|
|
98
|
+
<HostImage path={imagePath} load={hostImage} />
|
|
99
|
+
) : null}
|
|
67
100
|
{open ? (
|
|
68
101
|
<div className='flex flex-col gap-2 border-t border-border p-2.5'>
|
|
69
102
|
<CodeBlock code={JSON.stringify(item.input, null, 2)} label='Parameters' />
|
|
@@ -92,3 +125,46 @@ export function ToolCallCard({ item, className }: ToolCallCardProps) {
|
|
|
92
125
|
</div>
|
|
93
126
|
)
|
|
94
127
|
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A picture that lives on the host, fetched through the gateway's host-file
|
|
131
|
+
* route and shown inline.
|
|
132
|
+
*
|
|
133
|
+
* Silent on failure by design: a path outside the server's allowed roots is the
|
|
134
|
+
* *expected* case for codex's default save location, and the card's result text
|
|
135
|
+
* already names where the file went. An error banner over that would be noise
|
|
136
|
+
* about a thing the operator can fix in one line of config.
|
|
137
|
+
*/
|
|
138
|
+
function HostImage({
|
|
139
|
+
path,
|
|
140
|
+
load,
|
|
141
|
+
}: {
|
|
142
|
+
path: string
|
|
143
|
+
load: (path: string) => Promise<string | undefined>
|
|
144
|
+
}) {
|
|
145
|
+
const [src, setSrc] = useState<string | undefined>()
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
let cancelled = false
|
|
148
|
+
setSrc(undefined)
|
|
149
|
+
load(path)
|
|
150
|
+
.then((url) => {
|
|
151
|
+
if (!cancelled) setSrc(url)
|
|
152
|
+
})
|
|
153
|
+
.catch(() => {
|
|
154
|
+
// Not readable from here — the path in the result is the answer.
|
|
155
|
+
})
|
|
156
|
+
return () => {
|
|
157
|
+
cancelled = true
|
|
158
|
+
}
|
|
159
|
+
}, [path, load])
|
|
160
|
+
if (!src) return null
|
|
161
|
+
return (
|
|
162
|
+
<div className='border-t border-border p-2.5'>
|
|
163
|
+
<img
|
|
164
|
+
src={src}
|
|
165
|
+
alt={path.split('/').pop() ?? 'Generated image'}
|
|
166
|
+
className='max-h-96 w-auto max-w-full rounded-md border border-border'
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -6,19 +6,34 @@ import { Conversation, ConversationContent, ConversationScrollButton } from './C
|
|
|
6
6
|
import { FileCard } from './FileCard.tsx'
|
|
7
7
|
import { Loader } from './Loader.tsx'
|
|
8
8
|
import { Message, MessageContent } from './Message.tsx'
|
|
9
|
+
import { PromptTokenText } from './PromptTokenText.tsx'
|
|
9
10
|
import { Reasoning } from './Reasoning.tsx'
|
|
10
11
|
import { Response } from './Response.tsx'
|
|
12
|
+
import { SessionEmptyState } from './SessionEmptyState.tsx'
|
|
11
13
|
import { ToolCallCard } from './ToolCallCard.tsx'
|
|
12
14
|
|
|
13
15
|
function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
|
|
14
16
|
return (
|
|
15
|
-
<div data-slot='turn-result' className='
|
|
16
|
-
<div className='
|
|
17
|
-
|
|
18
|
-
{item.isError ?
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
<div data-slot='turn-result' className='py-1'>
|
|
18
|
+
<div className='flex items-center gap-2'>
|
|
19
|
+
<div className='h-px flex-1 bg-border' />
|
|
20
|
+
<span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
|
|
21
|
+
{item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} ·{' '}
|
|
22
|
+
{formatCost(item.totalCostUsd)}
|
|
23
|
+
</span>
|
|
24
|
+
<div className='h-px flex-1 bg-border' />
|
|
25
|
+
</div>
|
|
26
|
+
{/* A failed turn's reasons are the whole point of the row — dropping them
|
|
27
|
+
leaves "error_during_execution" and nothing to act on. */}
|
|
28
|
+
{item.errors?.length ? (
|
|
29
|
+
<ul className='mt-1 flex flex-col gap-0.5 text-center'>
|
|
30
|
+
{item.errors.map((message, index) => (
|
|
31
|
+
<li key={index} className='text-label break-words text-danger'>
|
|
32
|
+
{message}
|
|
33
|
+
</li>
|
|
34
|
+
))}
|
|
35
|
+
</ul>
|
|
36
|
+
) : null}
|
|
22
37
|
</div>
|
|
23
38
|
)
|
|
24
39
|
}
|
|
@@ -42,10 +57,12 @@ function TranscriptItemView({
|
|
|
42
57
|
item,
|
|
43
58
|
fileUrl,
|
|
44
59
|
attachmentUrl,
|
|
60
|
+
hostImage,
|
|
45
61
|
}: {
|
|
46
62
|
item: TranscriptItem
|
|
47
63
|
fileUrl?: (path: string) => string
|
|
48
64
|
attachmentUrl?: (attachmentId: string) => string
|
|
65
|
+
hostImage?: (path: string) => Promise<string | undefined>
|
|
49
66
|
}) {
|
|
50
67
|
switch (item.kind) {
|
|
51
68
|
case 'user':
|
|
@@ -55,7 +72,11 @@ function TranscriptItemView({
|
|
|
55
72
|
<SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} />
|
|
56
73
|
) : null}
|
|
57
74
|
{/* A photo can be the whole message — an empty bubble under it says nothing. */}
|
|
58
|
-
{item.text ?
|
|
75
|
+
{item.text ? (
|
|
76
|
+
<MessageContent>
|
|
77
|
+
<PromptTokenText text={item.text} />
|
|
78
|
+
</MessageContent>
|
|
79
|
+
) : null}
|
|
59
80
|
</Message>
|
|
60
81
|
)
|
|
61
82
|
case 'assistant_text':
|
|
@@ -69,7 +90,7 @@ function TranscriptItemView({
|
|
|
69
90
|
case 'thinking':
|
|
70
91
|
return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
|
|
71
92
|
case 'tool_call':
|
|
72
|
-
return <ToolCallCard item={item} />
|
|
93
|
+
return <ToolCallCard item={item} hostImage={hostImage} />
|
|
73
94
|
case 'turn_result':
|
|
74
95
|
return <TurnResultRow item={item} />
|
|
75
96
|
case 'notice':
|
|
@@ -124,6 +145,14 @@ function SentAttachments({
|
|
|
124
145
|
)
|
|
125
146
|
}
|
|
126
147
|
|
|
148
|
+
/** Rows produced inside a subagent (`parentToolUseId != null`) are stepped in
|
|
149
|
+
* behind a rule, so a Task's own output reads as belonging to the tool call
|
|
150
|
+
* above it rather than as the main thread carrying on. */
|
|
151
|
+
function nestedClass(item: TranscriptItem): string | undefined {
|
|
152
|
+
const nested = 'parentToolUseId' in item && item.parentToolUseId != null
|
|
153
|
+
return nested ? 'border-l-2 border-border pl-3' : undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
export interface TranscriptProps {
|
|
128
157
|
state: TranscriptState
|
|
129
158
|
/** Builds the download URL for a delivered file (see FileCard). Typically
|
|
@@ -133,23 +162,43 @@ export interface TranscriptProps {
|
|
|
133
162
|
* `(id) => client.attachmentUrl(sessionId, id)`. Same-origin and
|
|
134
163
|
* cookie-authenticated, which is what lets an `<img src>` render one. */
|
|
135
164
|
attachmentUrl?: (attachmentId: string) => string
|
|
165
|
+
/** Whether this gateway serves `@file` search here — the empty state must not
|
|
166
|
+
* advertise an affordance the composer doesn't have. */
|
|
167
|
+
canBrowseFiles?: boolean
|
|
168
|
+
/** Reads a host file as a data URL, for tool calls whose output is a picture
|
|
169
|
+
* on the host (codex's `image_gen`). Omit and those cards name the path. */
|
|
170
|
+
hostImage?: (path: string) => Promise<string | undefined>
|
|
136
171
|
className?: string
|
|
137
172
|
}
|
|
138
173
|
|
|
139
|
-
export function Transcript({
|
|
174
|
+
export function Transcript({
|
|
175
|
+
state,
|
|
176
|
+
fileUrl,
|
|
177
|
+
attachmentUrl,
|
|
178
|
+
canBrowseFiles,
|
|
179
|
+
hostImage,
|
|
180
|
+
className,
|
|
181
|
+
}: TranscriptProps) {
|
|
140
182
|
return (
|
|
141
183
|
<Conversation className={className}>
|
|
142
184
|
<ConversationContent>
|
|
143
185
|
{state.items.length === 0 && state.status !== 'starting' ? (
|
|
144
|
-
<
|
|
186
|
+
<SessionEmptyState
|
|
187
|
+
cwd={state.cwd}
|
|
188
|
+
hasCommands={!!state.commands?.length}
|
|
189
|
+
hasSkills={!!state.skills?.some((s) => s.enabled)}
|
|
190
|
+
canBrowseFiles={canBrowseFiles}
|
|
191
|
+
/>
|
|
145
192
|
) : (
|
|
146
193
|
state.items.map((item) => (
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
194
|
+
<div key={`${item.kind}:${item.id}`} className={nestedClass(item)}>
|
|
195
|
+
<TranscriptItemView
|
|
196
|
+
item={item}
|
|
197
|
+
fileUrl={fileUrl}
|
|
198
|
+
attachmentUrl={attachmentUrl}
|
|
199
|
+
hostImage={hostImage}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
153
202
|
))
|
|
154
203
|
)}
|
|
155
204
|
{showLoader(state) ? (
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { ProfileEngine, RateLimitInfo } from '@workerdeck/protocol'
|
|
3
|
+
import { RotateCcw } from 'lucide-react'
|
|
4
|
+
import { Badge } from '../ui/Badge.tsx'
|
|
5
|
+
import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
|
|
6
|
+
import { cn } from '../../lib/utils.ts'
|
|
7
|
+
import {
|
|
8
|
+
formatAgoPrecise,
|
|
9
|
+
formatCost,
|
|
10
|
+
formatCountdown,
|
|
11
|
+
formatRateLimitWindowLong,
|
|
12
|
+
rateLimitWindowSeconds,
|
|
13
|
+
} from '../../lib/format.ts'
|
|
14
|
+
|
|
15
|
+
export interface UsageDialogProps {
|
|
16
|
+
/** Windows in reading order — session, weekly, then per-model weeklies. */
|
|
17
|
+
rateLimits: Array<{ key: string; info: RateLimitInfo }>
|
|
18
|
+
/** claude.ai plan behind the windows ('max', 'pro', …), when there is one. */
|
|
19
|
+
subscriptionType?: string
|
|
20
|
+
engine: ProfileEngine
|
|
21
|
+
totalCostUsd: number
|
|
22
|
+
/** Local receipt time of the last window update. `rate_limit` events are one
|
|
23
|
+
* per turn at best, so a stale reading is normal and worth saying out loud. */
|
|
24
|
+
updatedAt?: number
|
|
25
|
+
open: boolean
|
|
26
|
+
onOpenChange: (open: boolean) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Ticking clock — the countdowns and the pace markers both move with it, and a
|
|
30
|
+
* minute is the finest resolution either of them prints. */
|
|
31
|
+
function useMinuteClock(open: boolean): number {
|
|
32
|
+
const [now, setNow] = useState(() => Date.now())
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return
|
|
35
|
+
setNow(Date.now())
|
|
36
|
+
const timer = setInterval(() => setNow(Date.now()), 60_000)
|
|
37
|
+
return () => clearInterval(timer)
|
|
38
|
+
}, [open])
|
|
39
|
+
return now
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The plan's rate-limit windows, spelled out: how much of each is used, how that
|
|
46
|
+
* compares to the pace that would spend the window exactly, and when it resets.
|
|
47
|
+
*
|
|
48
|
+
* The pace marker is the point. A bar alone says "17% used", which is only
|
|
49
|
+
* alarming or reassuring once you know how far into the week you are — so every
|
|
50
|
+
* window draws a tick at the elapsed share of its duration. Left of the tick is
|
|
51
|
+
* under budget, right of it is ahead of it. The duration comes from the window
|
|
52
|
+
* key (5h, 7d) because the CLI reports a reset time and a percentage and never a
|
|
53
|
+
* duration; a window whose key doesn't say gets no marker rather than a guessed one.
|
|
54
|
+
*/
|
|
55
|
+
export function UsageDialog({
|
|
56
|
+
rateLimits,
|
|
57
|
+
subscriptionType,
|
|
58
|
+
engine,
|
|
59
|
+
totalCostUsd,
|
|
60
|
+
updatedAt,
|
|
61
|
+
open,
|
|
62
|
+
onOpenChange,
|
|
63
|
+
}: UsageDialogProps) {
|
|
64
|
+
const now = useMinuteClock(open)
|
|
65
|
+
return (
|
|
66
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
67
|
+
<DialogContent>
|
|
68
|
+
<DialogHeader
|
|
69
|
+
title='Usage'
|
|
70
|
+
description={engine === 'claude' ? 'Claude Code' : engine}
|
|
71
|
+
actions={
|
|
72
|
+
// The CLI reports a tier ('max'), never the multiplier a
|
|
73
|
+
// subscription page shows — so this says "Max" and stops there.
|
|
74
|
+
subscriptionType ? (
|
|
75
|
+
<Badge variant='accent' className='mt-0.5 shrink-0 capitalize'>
|
|
76
|
+
{subscriptionType}
|
|
77
|
+
</Badge>
|
|
78
|
+
) : null
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
<DialogBody>
|
|
82
|
+
{rateLimits.length === 0 ? (
|
|
83
|
+
<p className='py-6 text-center text-body-sm text-fg-4'>
|
|
84
|
+
{engine === 'claude'
|
|
85
|
+
? 'This session reports no plan windows — API-key sessions have none, and a subscription session reports them once a turn has run.'
|
|
86
|
+
: `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`}
|
|
87
|
+
</p>
|
|
88
|
+
) : (
|
|
89
|
+
<div className='flex flex-col gap-5'>
|
|
90
|
+
{rateLimits.map(({ key, info }) => (
|
|
91
|
+
<UsageWindow key={key} windowKey={key} info={info} now={now} />
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
<div className='mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3'>
|
|
96
|
+
<span className='text-label text-fg-3'>This session has cost</span>
|
|
97
|
+
<span className='font-mono text-body-sm text-fg-1'>{formatCost(totalCostUsd)}</span>
|
|
98
|
+
</div>
|
|
99
|
+
{updatedAt ? (
|
|
100
|
+
<p className='mt-2 text-label text-fg-4'>Updated {formatAgoPrecise(updatedAt, now)}</p>
|
|
101
|
+
) : null}
|
|
102
|
+
</DialogBody>
|
|
103
|
+
</DialogContent>
|
|
104
|
+
</Dialog>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function UsageWindow({
|
|
109
|
+
windowKey,
|
|
110
|
+
info,
|
|
111
|
+
now,
|
|
112
|
+
}: {
|
|
113
|
+
windowKey: string
|
|
114
|
+
info: RateLimitInfo
|
|
115
|
+
now: number
|
|
116
|
+
}) {
|
|
117
|
+
const utilization = info.utilization ?? 0
|
|
118
|
+
const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
|
|
119
|
+
// Share of the window already elapsed — where usage *would* be if it were
|
|
120
|
+
// spent evenly. Needs both a duration (from the key) and a reset time.
|
|
121
|
+
const duration = rateLimitWindowSeconds(windowKey)
|
|
122
|
+
const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
|
|
123
|
+
const pace =
|
|
124
|
+
duration !== undefined && remaining !== undefined && remaining > 0 && remaining < duration
|
|
125
|
+
? (duration - remaining) / duration
|
|
126
|
+
: undefined
|
|
127
|
+
return (
|
|
128
|
+
<div>
|
|
129
|
+
<div className='flex items-baseline justify-between gap-3'>
|
|
130
|
+
<span className='truncate text-body-sm text-fg-1'>
|
|
131
|
+
{formatRateLimitWindowLong(windowKey)}
|
|
132
|
+
</span>
|
|
133
|
+
<span
|
|
134
|
+
className={cn(
|
|
135
|
+
'shrink-0 font-mono text-body-sm font-medium',
|
|
136
|
+
info.status === 'rejected' ? 'text-danger' : 'text-fg-1',
|
|
137
|
+
)}>
|
|
138
|
+
{utilization.toFixed(0)}% used
|
|
139
|
+
</span>
|
|
140
|
+
</div>
|
|
141
|
+
<div className='relative mt-2 h-2 rounded-full bg-border'>
|
|
142
|
+
<div
|
|
143
|
+
className={cn('h-full rounded-full', usageTint(utilization))}
|
|
144
|
+
// A floor, so a barely-touched window still shows a mark instead of
|
|
145
|
+
// reading as missing data.
|
|
146
|
+
style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
|
|
147
|
+
/>
|
|
148
|
+
{pace !== undefined ? (
|
|
149
|
+
<span
|
|
150
|
+
aria-hidden
|
|
151
|
+
title='Spent evenly, usage would be here by now'
|
|
152
|
+
className='absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1'
|
|
153
|
+
style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
|
|
154
|
+
/>
|
|
155
|
+
) : null}
|
|
156
|
+
</div>
|
|
157
|
+
<div className='mt-1.5 flex items-center gap-3 text-label text-fg-4'>
|
|
158
|
+
{resetsAtMs !== undefined ? (
|
|
159
|
+
<span className='inline-flex items-center gap-1'>
|
|
160
|
+
<RotateCcw className='size-3' /> Resets in {formatCountdown(resetsAtMs, now)}
|
|
161
|
+
</span>
|
|
162
|
+
) : null}
|
|
163
|
+
{info.isUsingOverage ? <span className='text-warning'>overage</span> : null}
|
|
164
|
+
{info.status === 'rejected' ? <span className='text-danger'>limit reached</span> : null}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -288,6 +288,59 @@ export function resolveChip(
|
|
|
288
288
|
return { segments: merged, cursorOffset }
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Replaces the active trigger's range with **plain text** instead of a chip.
|
|
293
|
+
*
|
|
294
|
+
* The sibling of {@link resolveChip}, for suggestions that are a typing aid
|
|
295
|
+
* rather than a token: what lands in the document is ordinary editable text the
|
|
296
|
+
* user is expected to finish and change, and it must not look or behave like a
|
|
297
|
+
* resolved chip — no immutability, no trigger character, nothing for a consumer
|
|
298
|
+
* to parse back out. The caret is left at the end of the inserted text so typing
|
|
299
|
+
* continues from there.
|
|
300
|
+
*/
|
|
301
|
+
export function resolveText(
|
|
302
|
+
segments: Segment[],
|
|
303
|
+
activeTrigger: ActiveTrigger,
|
|
304
|
+
text: string,
|
|
305
|
+
): { segments: Segment[]; cursorOffset: number } {
|
|
306
|
+
const triggerStart = activeTrigger.startOffset
|
|
307
|
+
const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char
|
|
308
|
+
|
|
309
|
+
const newSegments: Segment[] = []
|
|
310
|
+
let offset = 0
|
|
311
|
+
let cursorOffset = triggerStart + text.length
|
|
312
|
+
|
|
313
|
+
for (const seg of segments) {
|
|
314
|
+
if (seg.type === 'chip') {
|
|
315
|
+
const chipEnd = offset + `${seg.trigger}${seg.displayText}`.length
|
|
316
|
+
// A trigger range can never overlap a chip — chips are atomic — so a chip
|
|
317
|
+
// is either wholly before or wholly after, and is kept either way.
|
|
318
|
+
if (chipEnd <= triggerStart || offset >= triggerEnd) newSegments.push(seg)
|
|
319
|
+
offset = chipEnd
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
const textStart = offset
|
|
323
|
+
const textEnd = offset + seg.text.length
|
|
324
|
+
if (textEnd <= triggerStart || textStart >= triggerEnd) {
|
|
325
|
+
newSegments.push(seg)
|
|
326
|
+
} else {
|
|
327
|
+
const before = seg.text.slice(0, Math.max(0, triggerStart - textStart))
|
|
328
|
+
const after = seg.text.slice(Math.min(seg.text.length, triggerEnd - textStart))
|
|
329
|
+
if (before) newSegments.push({ type: 'text', text: before })
|
|
330
|
+
newSegments.push({ type: 'text', text })
|
|
331
|
+
if (after) newSegments.push({ type: 'text', text: after })
|
|
332
|
+
}
|
|
333
|
+
offset = textEnd
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const merged = mergeAdjacentTextSegments(newSegments)
|
|
337
|
+
// Clamp: a trigger detected against a stale document could otherwise leave the
|
|
338
|
+
// caret past the end, which renders as no caret at all.
|
|
339
|
+
const total = segmentsToPlainText(merged).length
|
|
340
|
+
if (cursorOffset > total) cursorOffset = total
|
|
341
|
+
return { segments: merged, cursorOffset }
|
|
342
|
+
}
|
|
343
|
+
|
|
291
344
|
// ---------------------------------------------------------------------------
|
|
292
345
|
// Chip removal
|
|
293
346
|
// ---------------------------------------------------------------------------
|
|
@@ -103,6 +103,21 @@ export type TriggerConfig = {
|
|
|
103
103
|
* Return the display text for the chip, or void to use `suggestion.label`.
|
|
104
104
|
*/
|
|
105
105
|
onSelect?: (suggestion: TriggerSuggestion) => string | void
|
|
106
|
+
/**
|
|
107
|
+
* For 'dropdown' mode: opt a suggestion out of becoming a chip.
|
|
108
|
+
*
|
|
109
|
+
* Return a string and the trigger's range is replaced with that **plain,
|
|
110
|
+
* editable text** — the trigger character included — with the caret left at
|
|
111
|
+
* its end. Return undefined and the suggestion resolves to a chip as usual,
|
|
112
|
+
* so one dropdown can mix both kinds.
|
|
113
|
+
*
|
|
114
|
+
* For suggestions that are a typing aid rather than a token: something the
|
|
115
|
+
* user is meant to finish and edit, where a chip would falsely promise the
|
|
116
|
+
* host parses it back out. Takes precedence over `onSelect`, which is not
|
|
117
|
+
* called for a text-resolved suggestion (there is no chip to label), and
|
|
118
|
+
* `onChipAdd` does not fire either.
|
|
119
|
+
*/
|
|
120
|
+
insertAsText?: (suggestion: TriggerSuggestion) => string | undefined
|
|
106
121
|
/**
|
|
107
122
|
* For 'callback' and 'launch' modes: called when the trigger is activated.
|
|
108
123
|
* Receives the full input text and cursor position. For 'launch' it fires on
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
plainTextToSegments,
|
|
18
18
|
segmentsEqual,
|
|
19
19
|
resolveChip,
|
|
20
|
+
resolveText,
|
|
20
21
|
removeChipAtIndex,
|
|
21
22
|
revertChipAtIndex,
|
|
22
23
|
replaceTextRange,
|
|
@@ -929,6 +930,25 @@ export function usePromptArea({
|
|
|
929
930
|
if (!activeTrigger) return
|
|
930
931
|
|
|
931
932
|
const segments = readSegmentsFromDOM()
|
|
933
|
+
|
|
934
|
+
// Text-resolved suggestions leave before any chip machinery runs: there
|
|
935
|
+
// is no chip, so there is nothing for the chip-click editing path or the
|
|
936
|
+
// onChipAdd callback below to be about.
|
|
937
|
+
const asText = activeTrigger.config.insertAsText?.(suggestion)
|
|
938
|
+
if (asText !== undefined) {
|
|
939
|
+
const inserted = resolveText(segments, activeTrigger, asText)
|
|
940
|
+
events.pushUndo(segments)
|
|
941
|
+
onChange(inserted.segments)
|
|
942
|
+
renderSegmentsToDOM(inserted.segments)
|
|
943
|
+
const editor = editorRef.current
|
|
944
|
+
if (editor) setCursorAtOffset(editor, inserted.cursorOffset)
|
|
945
|
+
dismissTrigger()
|
|
946
|
+
setTimeout(() => {
|
|
947
|
+
editorRef.current?.focus()
|
|
948
|
+
}, 0)
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
|
|
932
952
|
const displayText = activeTrigger.config.onSelect?.(suggestion) ?? suggestion.label
|
|
933
953
|
|
|
934
954
|
const chipData = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { Check, Copy } from 'lucide-react'
|
|
3
3
|
import { Button, type ButtonProps } from './Button.tsx'
|
|
4
|
+
import { copyText } from '../../lib/clipboard.ts'
|
|
4
5
|
import { cn } from '../../lib/utils.ts'
|
|
5
6
|
|
|
6
7
|
export interface CopyButtonProps extends Omit<ButtonProps, 'onClick' | 'children'> {
|
|
@@ -16,7 +17,13 @@ export function CopyButton({ value, className, variant = 'ghost', size = 'icon-s
|
|
|
16
17
|
aria-label='Copy'
|
|
17
18
|
className={cn('text-fg-3', className)}
|
|
18
19
|
onClick={() => {
|
|
19
|
-
|
|
20
|
+
// Through `copyText`, which falls back for insecure origins — the
|
|
21
|
+
// dashboard on a LAN address has no `navigator.clipboard` at all, and
|
|
22
|
+
// reaching straight for `.writeText` there throws.
|
|
23
|
+
void copyText(value).then((ok) => {
|
|
24
|
+
// Only tick when it really copied: a check mark over an empty
|
|
25
|
+
// clipboard is worse than no feedback.
|
|
26
|
+
if (!ok) return
|
|
20
27
|
setCopied(true)
|
|
21
28
|
setTimeout(() => setCopied(false), 1500)
|
|
22
29
|
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type FunctionComponent } from 'react'
|
|
2
|
+
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
|
|
3
|
+
import { X } from 'lucide-react'
|
|
4
|
+
import { cn } from '../../lib/utils.ts'
|
|
5
|
+
|
|
6
|
+
export const Dialog = DialogPrimitive.Root
|
|
7
|
+
export const DialogTrigger = DialogPrimitive.Trigger
|
|
8
|
+
export const DialogClose = DialogPrimitive.Close
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A dismissible panel, sized for reading rather than confirming — the web
|
|
12
|
+
* counterpart of the iOS app's detail sheets (context, usage, session info, MCP).
|
|
13
|
+
*
|
|
14
|
+
* Taller than {@link AlertDialogContent} and scrollable inside, because these
|
|
15
|
+
* carry lists whose length is the engine's business, not the layout's.
|
|
16
|
+
*/
|
|
17
|
+
export const DialogContent: FunctionComponent<
|
|
18
|
+
DialogPrimitive.Popup.Props & { size?: 'sm' | 'md' | 'lg' }
|
|
19
|
+
> = ({ className, children, size = 'md', ...props }) => (
|
|
20
|
+
<DialogPrimitive.Portal>
|
|
21
|
+
<DialogPrimitive.Backdrop
|
|
22
|
+
className={cn(
|
|
23
|
+
'fixed inset-0 z-70 bg-black/40 backdrop-blur-[1px]',
|
|
24
|
+
'transition-opacity duration-(--motion-base)',
|
|
25
|
+
'data-starting-style:opacity-0 data-ending-style:opacity-0',
|
|
26
|
+
)}
|
|
27
|
+
/>
|
|
28
|
+
<DialogPrimitive.Popup
|
|
29
|
+
data-slot='dialog-content'
|
|
30
|
+
className={cn(
|
|
31
|
+
'fixed top-1/2 left-1/2 z-70 flex max-h-[min(42rem,calc(100dvh-3rem))] -translate-x-1/2 -translate-y-1/2 flex-col',
|
|
32
|
+
size === 'sm' && 'w-[min(24rem,calc(100vw-2rem))]',
|
|
33
|
+
size === 'md' && 'w-[min(32rem,calc(100vw-2rem))]',
|
|
34
|
+
size === 'lg' && 'w-[min(46rem,calc(100vw-2rem))]',
|
|
35
|
+
'rounded-lg border border-border bg-surface shadow-(--shadow-lg) outline-none',
|
|
36
|
+
'transition-[opacity,transform] duration-(--motion-base)',
|
|
37
|
+
'data-starting-style:scale-95 data-starting-style:opacity-0',
|
|
38
|
+
'data-ending-style:scale-95 data-ending-style:opacity-0',
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
{...props}>
|
|
42
|
+
{children}
|
|
43
|
+
</DialogPrimitive.Popup>
|
|
44
|
+
</DialogPrimitive.Portal>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
/** Title row with the close button, pinned above the scrolling body. */
|
|
48
|
+
export const DialogHeader: FunctionComponent<{
|
|
49
|
+
title: string
|
|
50
|
+
description?: string
|
|
51
|
+
/** Rendered between the title and the close button. */
|
|
52
|
+
actions?: React.ReactNode
|
|
53
|
+
}> = ({ title, description, actions }) => (
|
|
54
|
+
<div className='flex items-start gap-2 border-b border-border px-4 py-3'>
|
|
55
|
+
<div className='min-w-0 flex-1'>
|
|
56
|
+
<DialogPrimitive.Title className='truncate text-body-sm font-semibold text-text'>
|
|
57
|
+
{title}
|
|
58
|
+
</DialogPrimitive.Title>
|
|
59
|
+
{description ? (
|
|
60
|
+
<DialogPrimitive.Description className='mt-0.5 text-label text-fg-4'>
|
|
61
|
+
{description}
|
|
62
|
+
</DialogPrimitive.Description>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
{actions}
|
|
66
|
+
<DialogPrimitive.Close
|
|
67
|
+
aria-label='Close'
|
|
68
|
+
className='-mr-1 flex size-6 shrink-0 items-center justify-center rounded-md text-fg-3 transition-colors outline-none hover:bg-surface-hover hover:text-fg-1'>
|
|
69
|
+
<X className='size-3.5' />
|
|
70
|
+
</DialogPrimitive.Close>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
/** The scrolling region under the header. */
|
|
75
|
+
export const DialogBody: FunctionComponent<React.HTMLAttributes<HTMLDivElement>> = ({
|
|
76
|
+
className,
|
|
77
|
+
...props
|
|
78
|
+
}) => <div className={cn('min-h-0 flex-1 overflow-y-auto p-4', className)} {...props} />
|
|
79
|
+
|
|
80
|
+
/** A label/value row — the shape every one of these panels is mostly made of. */
|
|
81
|
+
export const DialogRow: FunctionComponent<{
|
|
82
|
+
label: string
|
|
83
|
+
children: React.ReactNode
|
|
84
|
+
mono?: boolean
|
|
85
|
+
}> = ({ label, children, mono }) => (
|
|
86
|
+
<div className='flex items-baseline justify-between gap-4 py-1.5'>
|
|
87
|
+
<span className='shrink-0 text-label text-fg-3'>{label}</span>
|
|
88
|
+
<span className={cn('min-w-0 truncate text-right text-body-sm text-fg-1', mono && 'font-mono text-label')}>
|
|
89
|
+
{children}
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
)
|