@workerdeck/ui 0.7.0 → 0.11.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 +81 -3
- package/build/SessionPanel-CyhygZx_.d.mts +277 -0
- package/build/SessionPanel-_U8tjX29.mjs +8409 -0
- package/build/SessionPanel-_U8tjX29.mjs.map +1 -0
- package/build/format-DqR56Y8l.mjs +162 -0
- package/build/format-DqR56Y8l.mjs.map +1 -0
- package/build/format-ljc3lKpA.d.mts +59 -0
- package/build/format.d.mts +2 -0
- package/build/format.mjs +2 -0
- package/build/index.d.mts +615 -87
- package/build/index.mjs +6 -5081
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +199 -0
- package/build/workspace.mjs +849 -0
- package/build/workspace.mjs.map +1 -0
- package/package.json +22 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +522 -87
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/Conversation.tsx +11 -3
- package/src/components/agent/EditorTabs.tsx +165 -0
- package/src/components/agent/FileCard.tsx +26 -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/Loader.tsx +120 -14
- package/src/components/agent/McpDialog.tsx +363 -0
- package/src/components/agent/Message.tsx +51 -17
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +133 -22
- package/src/components/agent/PermissionPrompt.tsx +164 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/QuestionPrompt.tsx +122 -0
- package/src/components/agent/Reasoning.tsx +20 -5
- package/src/components/agent/Response.tsx +128 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +756 -90
- 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 +243 -30
- package/src/components/agent/Transcript.tsx +540 -27
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/agent/line-prompt.tsx +249 -0
- package/src/components/agent/transcript-variant.tsx +61 -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/CodeBlock.tsx +40 -2
- package/src/components/ui/CopyButton.tsx +28 -3
- 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/format.ts +10 -0
- package/src/index.ts +63 -2
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/tool-icon.ts +96 -0
- package/src/workspace.ts +28 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { OpenFile } from '@workerdeck/react'
|
|
3
|
+
import { currentText, isDirty } from '@workerdeck/react'
|
|
4
|
+
import { FileWarning, TriangleAlert } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils.ts'
|
|
6
|
+
import { Button } from '../ui/Button.tsx'
|
|
7
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
8
|
+
import { CodeEditor } from './CodeEditor.tsx'
|
|
9
|
+
|
|
10
|
+
export interface FileViewerProps {
|
|
11
|
+
file: OpenFile | undefined
|
|
12
|
+
/** From `/fs/roots`. False renders the editor read-only rather than letting
|
|
13
|
+
* someone type into a file this gateway will refuse to write. */
|
|
14
|
+
canWrite?: boolean
|
|
15
|
+
onChange?: (path: string, content: string) => void
|
|
16
|
+
onSave?: (path: string) => void
|
|
17
|
+
/** Discard this tab's edits — local only, no re-read. */
|
|
18
|
+
onRevert?: (path: string) => void
|
|
19
|
+
/** Take the version on disk, discarding this tab's edits. */
|
|
20
|
+
onReload?: (path: string) => void
|
|
21
|
+
/** Take this tab's version, over whatever is on disk now. */
|
|
22
|
+
onOverwrite?: (path: string) => void
|
|
23
|
+
onDismissConflict?: (path: string) => void
|
|
24
|
+
className?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The focused file: Monaco, plus the states a file can be in that are not
|
|
29
|
+
* "here is some text".
|
|
30
|
+
*
|
|
31
|
+
* No path row — the tab's tooltip carries the path and the size, and a line of
|
|
32
|
+
* monospace above every file is chrome that never earns its height.
|
|
33
|
+
*/
|
|
34
|
+
export function FileViewer({
|
|
35
|
+
file,
|
|
36
|
+
canWrite,
|
|
37
|
+
onChange,
|
|
38
|
+
onSave,
|
|
39
|
+
onRevert,
|
|
40
|
+
onReload,
|
|
41
|
+
onOverwrite,
|
|
42
|
+
onDismissConflict,
|
|
43
|
+
className,
|
|
44
|
+
}: FileViewerProps) {
|
|
45
|
+
if (!file) return null
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
data-slot='file-viewer'
|
|
50
|
+
className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-bg', className)}>
|
|
51
|
+
{/* The one failure with a choice attached, so it gets a bar rather than a
|
|
52
|
+
toast: the agent rewrote this file while it was open, and which
|
|
53
|
+
version wins is not something to decide on the user's behalf. */}
|
|
54
|
+
{file.conflict ? (
|
|
55
|
+
<div className='flex shrink-0 flex-wrap items-center gap-2 border-b border-warning/40 bg-warning-bg px-3 py-2'>
|
|
56
|
+
<TriangleAlert className='size-3.5 shrink-0 text-warning' />
|
|
57
|
+
<span className='min-w-0 flex-1 text-body-sm text-warning'>
|
|
58
|
+
This file changed on disk since you opened it.
|
|
59
|
+
</span>
|
|
60
|
+
<Button variant='outline' size='xs' onClick={() => onReload?.(file.path)}>
|
|
61
|
+
Use the version on disk
|
|
62
|
+
</Button>
|
|
63
|
+
<Button variant='outline' size='xs' onClick={() => onOverwrite?.(file.path)}>
|
|
64
|
+
Keep mine
|
|
65
|
+
</Button>
|
|
66
|
+
<Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
|
|
67
|
+
Dismiss
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
) : file.saveError ? (
|
|
71
|
+
<div className='flex shrink-0 items-center gap-2 border-b border-danger/40 bg-danger-bg px-3 py-2'>
|
|
72
|
+
<TriangleAlert className='size-3.5 shrink-0 text-danger' />
|
|
73
|
+
<span className='min-w-0 flex-1 text-body-sm text-danger'>{file.saveError}</span>
|
|
74
|
+
<Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
|
|
75
|
+
Dismiss
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
|
|
80
|
+
{file.status === 'loading' ? (
|
|
81
|
+
<Centred>
|
|
82
|
+
<Spinner className='size-4 text-fg-4' />
|
|
83
|
+
</Centred>
|
|
84
|
+
) : file.status === 'error' ? (
|
|
85
|
+
<Centred>
|
|
86
|
+
<TriangleAlert className='size-4 text-danger' />
|
|
87
|
+
<p className='text-body-sm text-danger'>{file.error}</p>
|
|
88
|
+
</Centred>
|
|
89
|
+
) : file.status === 'binary' ? (
|
|
90
|
+
<Centred>
|
|
91
|
+
<FileWarning className='size-4 text-fg-4' />
|
|
92
|
+
<p className='text-body-sm text-fg-4'>This file isn’t text.</p>
|
|
93
|
+
{/* Said out loud, because "can't show it" and "editing it here would
|
|
94
|
+
destroy it" are different reassurances. */}
|
|
95
|
+
<p className='text-label text-fg-4'>It can’t be edited here without corrupting it.</p>
|
|
96
|
+
</Centred>
|
|
97
|
+
) : (
|
|
98
|
+
<CodeEditor
|
|
99
|
+
path={file.path}
|
|
100
|
+
value={currentText(file)}
|
|
101
|
+
readOnly={!canWrite}
|
|
102
|
+
onChange={onChange ? (content) => onChange(file.path, content) : undefined}
|
|
103
|
+
onSave={onSave ? () => onSave(file.path) : undefined}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{/* A status strip only while there is something to say. Saving is fast
|
|
108
|
+
enough that a permanent row would mostly be blank. */}
|
|
109
|
+
{file.status === 'ready' && (file.saving || isDirty(file) || !canWrite) ? (
|
|
110
|
+
<div className='flex shrink-0 items-center gap-2 border-t border-border px-3 py-1'>
|
|
111
|
+
{file.saving ? (
|
|
112
|
+
<>
|
|
113
|
+
<Spinner className='size-3 text-fg-4' />
|
|
114
|
+
<span className='text-label text-fg-4'>Saving…</span>
|
|
115
|
+
</>
|
|
116
|
+
) : !canWrite ? (
|
|
117
|
+
<span className='text-label text-fg-4'>
|
|
118
|
+
Read-only — this gateway doesn’t allow writes.
|
|
119
|
+
</span>
|
|
120
|
+
) : (
|
|
121
|
+
<>
|
|
122
|
+
<span className='text-label text-fg-3'>Unsaved changes</span>
|
|
123
|
+
<span className='flex-1' />
|
|
124
|
+
{/* Revert, not reload: discard *my* edits and go back to what
|
|
125
|
+
this tab read. Re-reading is the conflict bar's job, and it is
|
|
126
|
+
a different question. */}
|
|
127
|
+
<Button variant='ghost' size='xs' onClick={() => onRevert?.(file.path)}>
|
|
128
|
+
Revert
|
|
129
|
+
</Button>
|
|
130
|
+
<Button variant='outline' size='xs' onClick={() => onSave?.(file.path)}>
|
|
131
|
+
Save
|
|
132
|
+
<span className='ml-1 text-fg-4'>⌘S</span>
|
|
133
|
+
</Button>
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
) : null}
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function Centred({ children }: { children: ReactNode }) {
|
|
143
|
+
return (
|
|
144
|
+
<div className='flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-6'>
|
|
145
|
+
{children}
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
3
|
+
import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
|
|
4
|
+
import { ChevronLeft, File, Folder, Link2, Search } from 'lucide-react'
|
|
5
|
+
import { Button } from '../ui/Button.tsx'
|
|
6
|
+
import { CodeBlock } from '../ui/CodeBlock.tsx'
|
|
7
|
+
import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
|
|
8
|
+
import { Input } from '../ui/Input.tsx'
|
|
9
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
10
|
+
import { formatBytes } from '../../lib/format.ts'
|
|
11
|
+
|
|
12
|
+
export interface HostFilesDialogProps {
|
|
13
|
+
client: WorkerDeckClient
|
|
14
|
+
/** The session's working directory — the browser is rooted here. */
|
|
15
|
+
cwd: string | undefined
|
|
16
|
+
open: boolean
|
|
17
|
+
onOpenChange: (open: boolean) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Browse the project the session is working in.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately rooted at the session's cwd rather than at the server's
|
|
24
|
+
* `hostFiles.roots`: the roots are the *security* boundary (the server enforces
|
|
25
|
+
* them on every request), but what someone wants while watching an agent is this
|
|
26
|
+
* project's tree. Read-only — writing is a separate server opt-in and not
|
|
27
|
+
* something a session viewer should be doing behind the agent's back.
|
|
28
|
+
*/
|
|
29
|
+
export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDialogProps) {
|
|
30
|
+
const [path, setPath] = useState<string | undefined>(cwd)
|
|
31
|
+
const [entries, setEntries] = useState<HostDirEntry[]>([])
|
|
32
|
+
const [truncated, setTruncated] = useState(false)
|
|
33
|
+
const [query, setQuery] = useState('')
|
|
34
|
+
const [matches, setMatches] = useState<HostFileMatch[] | undefined>()
|
|
35
|
+
const [file, setFile] = useState<{ path: string; content: string; bytes: number } | undefined>()
|
|
36
|
+
const [loading, setLoading] = useState(false)
|
|
37
|
+
const [error, setError] = useState<string | undefined>()
|
|
38
|
+
|
|
39
|
+
const list = useCallback(
|
|
40
|
+
async (target: string) => {
|
|
41
|
+
setLoading(true)
|
|
42
|
+
setError(undefined)
|
|
43
|
+
try {
|
|
44
|
+
const response = await client.listHostDir(target)
|
|
45
|
+
setPath(response.path)
|
|
46
|
+
setEntries(response.entries)
|
|
47
|
+
setTruncated(response.truncated ?? false)
|
|
48
|
+
} catch (e) {
|
|
49
|
+
setError(e instanceof Error ? e.message : 'Could not read that directory')
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[client],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// Opening resets the browser to the session's directory. Navigation from here
|
|
58
|
+
// is explicit (`list`) rather than an effect on `path`, so walking into a
|
|
59
|
+
// folder is one request and not two.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!open) return
|
|
62
|
+
setFile(undefined)
|
|
63
|
+
setQuery('')
|
|
64
|
+
setMatches(undefined)
|
|
65
|
+
setError(undefined)
|
|
66
|
+
if (cwd) void list(cwd)
|
|
67
|
+
}, [open, cwd, list])
|
|
68
|
+
|
|
69
|
+
// Debounced, and only while there is something to search for; an empty box is
|
|
70
|
+
// "show me the directory again", not "search for everything".
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!open || !cwd) return
|
|
73
|
+
const q = query.trim()
|
|
74
|
+
if (!q) {
|
|
75
|
+
setMatches(undefined)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
client
|
|
80
|
+
.findHostFiles(cwd, q, 40)
|
|
81
|
+
.then((response) => setMatches(response.matches))
|
|
82
|
+
.catch(() => setMatches([]))
|
|
83
|
+
}, 150)
|
|
84
|
+
return () => clearTimeout(timer)
|
|
85
|
+
}, [client, cwd, query, open])
|
|
86
|
+
|
|
87
|
+
const openFile = async (target: string) => {
|
|
88
|
+
setLoading(true)
|
|
89
|
+
setError(undefined)
|
|
90
|
+
try {
|
|
91
|
+
const response = await client.readHostFile(target)
|
|
92
|
+
setFile({
|
|
93
|
+
path: response.path,
|
|
94
|
+
bytes: response.bytes,
|
|
95
|
+
content:
|
|
96
|
+
response.encoding === 'utf8'
|
|
97
|
+
? response.content
|
|
98
|
+
: '(binary file — not shown)',
|
|
99
|
+
})
|
|
100
|
+
} catch (e) {
|
|
101
|
+
setError(e instanceof Error ? e.message : 'Could not read that file')
|
|
102
|
+
} finally {
|
|
103
|
+
setLoading(false)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parent = path && cwd && path !== cwd ? path.slice(0, path.lastIndexOf('/')) || '/' : undefined
|
|
108
|
+
const shown = matches ?? entries
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
112
|
+
<DialogContent size='lg'>
|
|
113
|
+
<DialogHeader
|
|
114
|
+
title={file ? file.path.split('/').pop()! : 'Files'}
|
|
115
|
+
description={file ? file.path : path}
|
|
116
|
+
actions={
|
|
117
|
+
file ? (
|
|
118
|
+
<Button variant='ghost' size='xs' onClick={() => setFile(undefined)}>
|
|
119
|
+
<ChevronLeft className='size-3.5' />
|
|
120
|
+
Back
|
|
121
|
+
</Button>
|
|
122
|
+
) : parent ? (
|
|
123
|
+
<Button variant='ghost' size='xs' onClick={() => void list(parent)}>
|
|
124
|
+
<ChevronLeft className='size-3.5' />
|
|
125
|
+
Up
|
|
126
|
+
</Button>
|
|
127
|
+
) : null
|
|
128
|
+
}
|
|
129
|
+
/>
|
|
130
|
+
<DialogBody className='flex flex-col gap-3'>
|
|
131
|
+
{error ? (
|
|
132
|
+
<div className='rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger'>{error}</div>
|
|
133
|
+
) : null}
|
|
134
|
+
|
|
135
|
+
{file ? (
|
|
136
|
+
<>
|
|
137
|
+
<p className='text-label text-fg-4'>{formatBytes(file.bytes)}</p>
|
|
138
|
+
<CodeBlock code={file.content} label={file.path.split('/').pop()} />
|
|
139
|
+
</>
|
|
140
|
+
) : (
|
|
141
|
+
<>
|
|
142
|
+
<div className='relative'>
|
|
143
|
+
<Search className='absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4' />
|
|
144
|
+
<Input
|
|
145
|
+
value={query}
|
|
146
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
147
|
+
placeholder='Search this project…'
|
|
148
|
+
className='pl-7'
|
|
149
|
+
spellCheck={false}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
{loading && shown.length === 0 ? (
|
|
153
|
+
<div className='py-6 text-center'>
|
|
154
|
+
<Spinner className='size-4 text-fg-4' />
|
|
155
|
+
</div>
|
|
156
|
+
) : shown.length === 0 ? (
|
|
157
|
+
<p className='py-6 text-center text-body-sm text-fg-4'>
|
|
158
|
+
{matches ? 'No matching files.' : 'This directory is empty.'}
|
|
159
|
+
</p>
|
|
160
|
+
) : (
|
|
161
|
+
<ul className='flex flex-col'>
|
|
162
|
+
{matches
|
|
163
|
+
? matches.map((match) => (
|
|
164
|
+
<li key={match.path}>
|
|
165
|
+
<button
|
|
166
|
+
type='button'
|
|
167
|
+
onClick={() => void openFile(match.path)}
|
|
168
|
+
className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
|
|
169
|
+
<File className='size-3.5 shrink-0 text-fg-4' />
|
|
170
|
+
<span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
|
|
171
|
+
{match.relative}
|
|
172
|
+
</span>
|
|
173
|
+
</button>
|
|
174
|
+
</li>
|
|
175
|
+
))
|
|
176
|
+
: entries.map((entry) => (
|
|
177
|
+
<li key={entry.path}>
|
|
178
|
+
<button
|
|
179
|
+
type='button'
|
|
180
|
+
onClick={() =>
|
|
181
|
+
entry.type === 'dir' ? void list(entry.path) : void openFile(entry.path)
|
|
182
|
+
}
|
|
183
|
+
className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
|
|
184
|
+
<EntryIcon type={entry.type} />
|
|
185
|
+
<span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
|
|
186
|
+
{entry.name}
|
|
187
|
+
{entry.type === 'dir' ? '/' : ''}
|
|
188
|
+
</span>
|
|
189
|
+
{entry.bytes !== undefined ? (
|
|
190
|
+
<span className='shrink-0 text-label text-fg-4'>
|
|
191
|
+
{formatBytes(entry.bytes)}
|
|
192
|
+
</span>
|
|
193
|
+
) : null}
|
|
194
|
+
</button>
|
|
195
|
+
</li>
|
|
196
|
+
))}
|
|
197
|
+
</ul>
|
|
198
|
+
)}
|
|
199
|
+
{truncated && !matches ? (
|
|
200
|
+
<p className='text-label text-fg-4'>
|
|
201
|
+
More entries than the server will return — use the search box.
|
|
202
|
+
</p>
|
|
203
|
+
) : null}
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
</DialogBody>
|
|
207
|
+
</DialogContent>
|
|
208
|
+
</Dialog>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A symlink is reported as itself and never silently resolved — following it is
|
|
213
|
+
* the next request's problem, and that request is refused if it escapes the roots. */
|
|
214
|
+
function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
|
|
215
|
+
if (type === 'dir') return <Folder className='size-3.5 shrink-0 text-accent' />
|
|
216
|
+
if (type === 'symlink') return <Link2 className='size-3.5 shrink-0 text-fg-4' />
|
|
217
|
+
return <File className='size-3.5 shrink-0 text-fg-4' />
|
|
218
|
+
}
|
|
@@ -1,21 +1,127 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
1
2
|
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
import { formatDuration, formatTokens } from '../../lib/format.ts'
|
|
4
|
+
import { LineGlyph, useLines } from './transcript-variant.tsx'
|
|
5
|
+
|
|
6
|
+
export interface LoaderProps {
|
|
7
|
+
/** Overrides the cycling verb — for a state that has one true name
|
|
8
|
+
* ("Starting session…"). */
|
|
9
|
+
label?: string
|
|
10
|
+
/** When the current run began, for the elapsed clock. Absent = no clock. */
|
|
11
|
+
startedAt?: number
|
|
12
|
+
/** Context tokens in play, shown beside the clock. */
|
|
13
|
+
tokens?: number
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The frames of the working marker, and the rate they turn over.
|
|
19
|
+
*
|
|
20
|
+
* A four-pointed star growing and shrinking — it reads as *activity* at a glance
|
|
21
|
+
* without any of the pixel-fitting a braille or block spinner needs, and it is
|
|
22
|
+
* the same shape a terminal agent uses because a terminal is where this
|
|
23
|
+
* vocabulary comes from. ~8fps: fast enough to be alive, slow enough not to
|
|
24
|
+
* strobe next to streaming text.
|
|
25
|
+
*/
|
|
26
|
+
const FRAMES = ['✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳']
|
|
27
|
+
const FRAME_MS = 120
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What it says it is doing while it hasn't said anything yet. Cycled on a slow
|
|
31
|
+
* clock so a long turn doesn't sit under one frozen word — a still label reads
|
|
32
|
+
* as a hung process, which is exactly what this is meant to disprove.
|
|
33
|
+
*/
|
|
34
|
+
const VERBS = [
|
|
35
|
+
'Working',
|
|
36
|
+
'Thinking',
|
|
37
|
+
'Churning',
|
|
38
|
+
'Pondering',
|
|
39
|
+
'Whirring',
|
|
40
|
+
'Computing',
|
|
41
|
+
'Percolating',
|
|
42
|
+
'Tinkering',
|
|
43
|
+
'Deliberating',
|
|
44
|
+
'Simmering',
|
|
45
|
+
'Crunching',
|
|
46
|
+
'Noodling',
|
|
47
|
+
]
|
|
48
|
+
const VERB_MS = 4000
|
|
49
|
+
|
|
50
|
+
/** Ticks while mounted, at the spinner's rate. Mounted only while a turn is in
|
|
51
|
+
* flight, so nothing here runs on an idle session. */
|
|
52
|
+
function useFrames(animated: boolean): number {
|
|
53
|
+
const [frame, setFrame] = useState(0)
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!animated) return
|
|
56
|
+
const timer = setInterval(() => setFrame((f) => f + 1), FRAME_MS)
|
|
57
|
+
return () => clearInterval(timer)
|
|
58
|
+
}, [animated])
|
|
59
|
+
return frame
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The OS-level "stop moving things" setting. A spinner is decoration; the word
|
|
63
|
+
* beside it carries the meaning, so honouring this costs nothing. */
|
|
64
|
+
function usePrefersReducedMotion(): boolean {
|
|
65
|
+
const [reduced, setReduced] = useState(false)
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
68
|
+
if (!query) return
|
|
69
|
+
setReduced(query.matches)
|
|
70
|
+
const onChange = () => setReduced(query.matches)
|
|
71
|
+
query.addEventListener('change', onChange)
|
|
72
|
+
return () => query.removeEventListener('change', onChange)
|
|
73
|
+
}, [])
|
|
74
|
+
return reduced
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* "The agent is working and hasn't produced output yet."
|
|
79
|
+
*
|
|
80
|
+
* `lines`: a terminal working line — animated glyph in the gutter, a verb, and
|
|
81
|
+
* the readings that answer "should I still be waiting?" in one parenthesis.
|
|
82
|
+
* `cards`: the three-dot pulse, unchanged.
|
|
83
|
+
*/
|
|
84
|
+
export function Loader({ label, startedAt, tokens, className }: LoaderProps) {
|
|
85
|
+
const lines = useLines()
|
|
86
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
87
|
+
const frame = useFrames(lines && !reducedMotion)
|
|
88
|
+
|
|
89
|
+
if (!lines) {
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
data-slot='loader'
|
|
93
|
+
className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
|
|
94
|
+
<span className='flex items-center gap-1'>
|
|
95
|
+
{[0, 1, 2].map((i) => (
|
|
96
|
+
<span
|
|
97
|
+
key={i}
|
|
98
|
+
className='size-1.5 animate-pulse rounded-full bg-fg-4'
|
|
99
|
+
style={{ animationDelay: `${i * 160}ms` }}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
</span>
|
|
103
|
+
{label ? <span>{label}</span> : null}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Derived from the clock rather than kept in state: the frame tick is already
|
|
109
|
+
// re-rendering, so the seconds and the verb come along for free.
|
|
110
|
+
const elapsed = startedAt === undefined ? undefined : Date.now() - startedAt
|
|
111
|
+
const verb =
|
|
112
|
+
label ?? VERBS[Math.floor((elapsed ?? 0) / VERB_MS) % VERBS.length] ?? VERBS[0]
|
|
113
|
+
const readings = [
|
|
114
|
+
elapsed !== undefined ? formatDuration(elapsed) : undefined,
|
|
115
|
+
tokens !== undefined ? `↓ ${formatTokens(tokens)}` : undefined,
|
|
116
|
+
].filter(Boolean)
|
|
2
117
|
|
|
3
|
-
/** Three-dot pulse shown while the assistant hasn't produced output yet. */
|
|
4
|
-
export function Loader({ label, className }: { label?: string; className?: string }) {
|
|
5
118
|
return (
|
|
6
|
-
<div
|
|
7
|
-
|
|
8
|
-
className=
|
|
9
|
-
|
|
10
|
-
{
|
|
11
|
-
<span
|
|
12
|
-
key={i}
|
|
13
|
-
className='size-1.5 animate-pulse rounded-full bg-fg-4'
|
|
14
|
-
style={{ animationDelay: `${i * 160}ms` }}
|
|
15
|
-
/>
|
|
16
|
-
))}
|
|
119
|
+
<div data-slot='loader' className={cn('flex items-baseline gap-2', className)}>
|
|
120
|
+
<LineGlyph className='text-accent'>{FRAMES[frame % FRAMES.length]}</LineGlyph>
|
|
121
|
+
<span className='min-w-0 flex-1 text-body-sm leading-5 text-fg-3'>
|
|
122
|
+
{verb}…{' '}
|
|
123
|
+
{readings.length ? <span className='text-label text-fg-4'>({readings.join(' · ')})</span> : null}
|
|
17
124
|
</span>
|
|
18
|
-
{label ? <span>{label}</span> : null}
|
|
19
125
|
</div>
|
|
20
126
|
)
|
|
21
127
|
}
|