@spunto/design-system 0.27.0 → 0.30.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 +35 -1
- package/package.json +6 -1
- package/src/components/sheet.tsx +7 -1
- package/src/components/tasks/agent-markdown.tsx +322 -0
- package/src/components/tasks/attachments.ts +152 -0
- package/src/components/tasks/command-log.tsx +102 -0
- package/src/components/tasks/composer.tsx +252 -0
- package/src/components/tasks/conversation-list.tsx +238 -0
- package/src/components/tasks/diff-panel.tsx +534 -0
- package/src/components/tasks/event-stream.tsx +860 -0
- package/src/components/tasks/index.ts +111 -0
- package/src/components/tasks/machine.tsx +455 -0
- package/src/components/tasks/new-task-dialog.tsx +301 -0
- package/src/components/tasks/pull-request.tsx +179 -0
- package/src/components/tasks/session-usage.tsx +165 -0
- package/src/components/tasks/task-attachments.tsx +475 -0
- package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
- package/src/components/tasks/task-cockpit.tsx +225 -0
- package/src/components/tasks/task-details.tsx +239 -0
- package/src/components/tasks/task-list.tsx +272 -0
- package/src/components/tasks/task-state.tsx +180 -0
- package/src/components/tasks/tasks.test.tsx +205 -0
- package/src/components/tasks/tool-stack.tsx +692 -0
- package/src/components/tasks/types.ts +220 -0
- package/src/components/tasks/worker-link.tsx +126 -0
- package/src/components/workers/index.ts +5 -2
- package/src/components/workers/types.ts +22 -0
- package/src/components/workers/worker-cockpit.tsx +17 -2
- package/src/components/workers/worker-status-history.test.ts +85 -0
- package/src/components/workers/worker-status-history.tsx +166 -0
- package/src/index.ts +1 -1
- package/src/utils.ts +23 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// Delegating work, as a form: what to do and where to start. No machine to pick — the task
|
|
4
|
+
// allocates its own worker (a recycled one when the pool has a free machine), which is the whole
|
|
5
|
+
// point of the object.
|
|
6
|
+
//
|
|
7
|
+
// Presentational like the rest of `/tasks`: the lists it offers (projects, branches, models) come
|
|
8
|
+
// in as props, the value goes out through `onSubmit`, and `submitting`/`error` come back down. The
|
|
9
|
+
// form owns only its draft — the fields and the files — and resets it every time it opens.
|
|
10
|
+
|
|
11
|
+
import { useState, type FormEvent, type ReactElement, type ReactNode } from "react"
|
|
12
|
+
import { GitBranchIcon, PackageIcon, SparklesIcon } from "lucide-react"
|
|
13
|
+
|
|
14
|
+
import { Button } from "../button"
|
|
15
|
+
import {
|
|
16
|
+
Combobox,
|
|
17
|
+
ComboboxContent,
|
|
18
|
+
ComboboxEmpty,
|
|
19
|
+
ComboboxGroup,
|
|
20
|
+
ComboboxInput,
|
|
21
|
+
ComboboxItem,
|
|
22
|
+
ComboboxList,
|
|
23
|
+
} from "../combobox"
|
|
24
|
+
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../dialog"
|
|
25
|
+
import { Input } from "../input"
|
|
26
|
+
import { Label } from "../label"
|
|
27
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../select"
|
|
28
|
+
import { Textarea } from "../textarea"
|
|
29
|
+
import { AttachFileButton, AttachmentDraftStrip, FileDropOverlay, useAttachmentDraft } from "./task-attachments"
|
|
30
|
+
import type { DraftFile } from "./types"
|
|
31
|
+
|
|
32
|
+
export interface NewTaskValue {
|
|
33
|
+
/** Only when `projects` was given — the page otherwise already knows. */
|
|
34
|
+
projectId?: string
|
|
35
|
+
prompt: string
|
|
36
|
+
/** Omitted when blank: the task then takes the name the agent gives the conversation. */
|
|
37
|
+
title?: string
|
|
38
|
+
/** Omitted when blank: the repositories' default branch. */
|
|
39
|
+
baseBranch?: string
|
|
40
|
+
/** Omitted when blank: the project's default model. */
|
|
41
|
+
model?: string
|
|
42
|
+
/** Prepared in the browser — `data` is base64, ready for a JSON body. */
|
|
43
|
+
files: DraftFile[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A harness and the models it knows — the suggestions under "Model". */
|
|
47
|
+
export interface NewTaskModelGroup {
|
|
48
|
+
id: string
|
|
49
|
+
label: string
|
|
50
|
+
models: { value: string; label: string }[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface NewTaskDialogProps {
|
|
54
|
+
open?: boolean
|
|
55
|
+
onOpenChange?: (open: boolean) => void
|
|
56
|
+
/** The button that opens it. Omitted → "New task". `null` → no trigger (open it yourself). */
|
|
57
|
+
trigger?: ReactNode | null
|
|
58
|
+
/** Label of the default trigger. @default "New task" */
|
|
59
|
+
label?: string
|
|
60
|
+
/** Variant of the default trigger. @default "default" */
|
|
61
|
+
variant?: "default" | "outline"
|
|
62
|
+
|
|
63
|
+
/** Pass the org's projects when no project is in scope: the form then asks for one first. */
|
|
64
|
+
projects?: { id: string; name: string }[]
|
|
65
|
+
/** Called when a project is picked, so the app can load that project's branches and default model. */
|
|
66
|
+
onProjectChange?: (projectId: string) => void
|
|
67
|
+
/** Branches common to the project's repositories. Empty → a free-text field. */
|
|
68
|
+
branches?: string[]
|
|
69
|
+
defaultBranch?: string | null
|
|
70
|
+
modelGroups?: NewTaskModelGroup[]
|
|
71
|
+
/** The project's default model, shown as the placeholder so a blank field says what it means. */
|
|
72
|
+
defaultModel?: string | null
|
|
73
|
+
|
|
74
|
+
onSubmit: (value: NewTaskValue) => void
|
|
75
|
+
submitting?: boolean
|
|
76
|
+
error?: string | null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function NewTaskDialog({
|
|
80
|
+
open: openProp,
|
|
81
|
+
onOpenChange,
|
|
82
|
+
trigger,
|
|
83
|
+
label = "New task",
|
|
84
|
+
variant = "default",
|
|
85
|
+
projects,
|
|
86
|
+
onProjectChange,
|
|
87
|
+
branches = [],
|
|
88
|
+
defaultBranch = null,
|
|
89
|
+
modelGroups = [],
|
|
90
|
+
defaultModel = null,
|
|
91
|
+
onSubmit,
|
|
92
|
+
submitting = false,
|
|
93
|
+
error = null,
|
|
94
|
+
}: NewTaskDialogProps) {
|
|
95
|
+
const [localOpen, setLocalOpen] = useState(false)
|
|
96
|
+
const open = openProp ?? localOpen
|
|
97
|
+
const [title, setTitle] = useState("")
|
|
98
|
+
const [prompt, setPrompt] = useState("")
|
|
99
|
+
const [baseBranch, setBaseBranch] = useState("")
|
|
100
|
+
const [model, setModel] = useState("")
|
|
101
|
+
const [projectId, setProjectId] = useState("")
|
|
102
|
+
// What the work starts from — a screenshot of the bug, a mockup, a spec (RFC 0022).
|
|
103
|
+
const draft = useAttachmentDraft()
|
|
104
|
+
|
|
105
|
+
const asksProject = projects !== undefined
|
|
106
|
+
|
|
107
|
+
function handleOpen(next: boolean) {
|
|
108
|
+
if (next) {
|
|
109
|
+
setTitle("")
|
|
110
|
+
setPrompt("")
|
|
111
|
+
setBaseBranch("")
|
|
112
|
+
setModel("")
|
|
113
|
+
setProjectId("")
|
|
114
|
+
draft.clear()
|
|
115
|
+
}
|
|
116
|
+
if (openProp === undefined) setLocalOpen(next)
|
|
117
|
+
onOpenChange?.(next)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleSubmit(e: FormEvent) {
|
|
121
|
+
e.preventDefault()
|
|
122
|
+
if (asksProject && !projectId) return
|
|
123
|
+
if (!prompt.trim()) return
|
|
124
|
+
onSubmit({
|
|
125
|
+
...(asksProject ? { projectId } : {}),
|
|
126
|
+
prompt: prompt.trim(),
|
|
127
|
+
...(title.trim() ? { title: title.trim() } : {}),
|
|
128
|
+
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
|
129
|
+
...(model.trim() ? { model: model.trim() } : {}),
|
|
130
|
+
files: draft.files,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const branchPlaceholder = defaultBranch ? `${defaultBranch} (default)` : "Default branch"
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Dialog open={open} onOpenChange={handleOpen}>
|
|
138
|
+
{trigger !== null &&
|
|
139
|
+
(trigger !== undefined ? (
|
|
140
|
+
<DialogTrigger render={trigger as ReactElement} />
|
|
141
|
+
) : (
|
|
142
|
+
<DialogTrigger render={<Button type="button" size="sm" variant={variant} className="shrink-0 gap-1.5" />}>
|
|
143
|
+
<SparklesIcon className="h-3.5 w-3.5" />
|
|
144
|
+
{label}
|
|
145
|
+
</DialogTrigger>
|
|
146
|
+
))}
|
|
147
|
+
|
|
148
|
+
<DialogContent>
|
|
149
|
+
<DialogHeader>
|
|
150
|
+
<DialogTitle>New task</DialogTitle>
|
|
151
|
+
</DialogHeader>
|
|
152
|
+
|
|
153
|
+
<form onSubmit={handleSubmit} className="mt-1 flex flex-col gap-4">
|
|
154
|
+
{asksProject && (
|
|
155
|
+
<div className="flex flex-col gap-1.5">
|
|
156
|
+
<Label>Project</Label>
|
|
157
|
+
<Select
|
|
158
|
+
value={projectId || null}
|
|
159
|
+
onValueChange={(value) => {
|
|
160
|
+
const next = (value as string | null) ?? ""
|
|
161
|
+
setProjectId(next)
|
|
162
|
+
// The branch list belongs to the previous project; keeping a pick would offer a
|
|
163
|
+
// ref that does not exist on the new one.
|
|
164
|
+
setBaseBranch("")
|
|
165
|
+
if (next) onProjectChange?.(next)
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
<SelectTrigger>
|
|
169
|
+
<SelectValue placeholder="Pick a project…">
|
|
170
|
+
{(value: unknown) => projects.find((p) => p.id === value)?.name ?? "Pick a project…"}
|
|
171
|
+
</SelectValue>
|
|
172
|
+
</SelectTrigger>
|
|
173
|
+
<SelectContent>
|
|
174
|
+
{projects.map((p) => (
|
|
175
|
+
<SelectItem key={p.id} value={p.id}>
|
|
176
|
+
{p.name}
|
|
177
|
+
</SelectItem>
|
|
178
|
+
))}
|
|
179
|
+
</SelectContent>
|
|
180
|
+
</Select>
|
|
181
|
+
<p className="text-[11px] text-muted-foreground">Where the work happens: its repositories, its harness, its settings.</p>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
<div className="flex flex-col gap-1.5">
|
|
186
|
+
<div className="flex items-center gap-2">
|
|
187
|
+
<Label>Prompt</Label>
|
|
188
|
+
{/* Spelled out here, unlike the composer's icon: a form has the room, and this is
|
|
189
|
+
where someone describing a visual bug most wants to hand over the screenshot. */}
|
|
190
|
+
<AttachFileButton className="ml-auto" label="Attach files" onFiles={(files) => void draft.add(files)} />
|
|
191
|
+
</div>
|
|
192
|
+
<div {...draft.dropZone} className="relative flex flex-col gap-1.5">
|
|
193
|
+
{draft.draggingOver && <FileDropOverlay />}
|
|
194
|
+
<Textarea
|
|
195
|
+
value={prompt}
|
|
196
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
197
|
+
onPaste={draft.onPaste}
|
|
198
|
+
rows={6}
|
|
199
|
+
className="resize-y"
|
|
200
|
+
placeholder="What the agent should do, in as much detail as you'd give a teammate. Paste or drop a screenshot, a PDF, a CSV…"
|
|
201
|
+
required
|
|
202
|
+
autoFocus
|
|
203
|
+
/>
|
|
204
|
+
<AttachmentDraftStrip draft={draft} />
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div className="flex flex-col gap-1.5">
|
|
209
|
+
<Label>
|
|
210
|
+
Title <span className="font-normal text-muted-foreground/70">— optional</span>
|
|
211
|
+
</Label>
|
|
212
|
+
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Named by the agent" maxLength={200} />
|
|
213
|
+
<p className="text-[11px] text-muted-foreground">
|
|
214
|
+
Leave it empty and the task takes the name the agent gives the conversation. The branch keeps the name it was created
|
|
215
|
+
with.
|
|
216
|
+
</p>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div className="flex flex-col gap-1.5">
|
|
220
|
+
<Label>Base branch</Label>
|
|
221
|
+
{branches.length > 0 ? (
|
|
222
|
+
<Combobox
|
|
223
|
+
value={baseBranch || null}
|
|
224
|
+
onValueChange={(value) => setBaseBranch((value as string | null) ?? "")}
|
|
225
|
+
inputValue={baseBranch}
|
|
226
|
+
onInputValueChange={(text, details) => {
|
|
227
|
+
if (details.reason === "input-change") setBaseBranch(text)
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
<ComboboxInput placeholder={branchPlaceholder} icon={<GitBranchIcon />} />
|
|
231
|
+
<ComboboxContent>
|
|
232
|
+
<ComboboxList>
|
|
233
|
+
<ComboboxEmpty>No such branch — it will be used as typed</ComboboxEmpty>
|
|
234
|
+
{branches.map((b) => (
|
|
235
|
+
<ComboboxItem key={b} value={b} icon={<GitBranchIcon />}>
|
|
236
|
+
{b}
|
|
237
|
+
</ComboboxItem>
|
|
238
|
+
))}
|
|
239
|
+
</ComboboxList>
|
|
240
|
+
</ComboboxContent>
|
|
241
|
+
</Combobox>
|
|
242
|
+
) : (
|
|
243
|
+
<div className="relative">
|
|
244
|
+
<GitBranchIcon className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
245
|
+
<Input value={baseBranch} onChange={(e) => setBaseBranch(e.target.value)} className="pl-8" placeholder={branchPlaceholder} />
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
<p className="text-[11px] text-muted-foreground">Leave empty to start from the repositories' default branch.</p>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div className="flex flex-col gap-1.5">
|
|
252
|
+
<Label>Model</Label>
|
|
253
|
+
<Combobox
|
|
254
|
+
value={model || null}
|
|
255
|
+
onValueChange={(value) => setModel((value as string | null) ?? "")}
|
|
256
|
+
inputValue={model}
|
|
257
|
+
onInputValueChange={(text, details) => {
|
|
258
|
+
if (details.reason === "input-change") setModel(text)
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
<ComboboxInput
|
|
262
|
+
placeholder={defaultModel ? `${defaultModel} (project default)` : "Harness default"}
|
|
263
|
+
icon={<PackageIcon />}
|
|
264
|
+
className="font-mono"
|
|
265
|
+
/>
|
|
266
|
+
<ComboboxContent>
|
|
267
|
+
<ComboboxList>
|
|
268
|
+
<ComboboxEmpty>Any model id your harness understands</ComboboxEmpty>
|
|
269
|
+
{modelGroups.map((group) => (
|
|
270
|
+
<ComboboxGroup key={group.id} heading={group.label}>
|
|
271
|
+
{group.models.map((m) => (
|
|
272
|
+
<ComboboxItem key={m.value} value={m.value} keywords={[m.label]} meta={m.label}>
|
|
273
|
+
{m.value}
|
|
274
|
+
</ComboboxItem>
|
|
275
|
+
))}
|
|
276
|
+
</ComboboxGroup>
|
|
277
|
+
))}
|
|
278
|
+
</ComboboxList>
|
|
279
|
+
</ComboboxContent>
|
|
280
|
+
</Combobox>
|
|
281
|
+
<p className="text-[11px] text-muted-foreground">
|
|
282
|
+
Pick one, or type any model id your harness understands. Leave empty to use the project's default.
|
|
283
|
+
</p>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
287
|
+
|
|
288
|
+
<DialogFooter>
|
|
289
|
+
<Button type="button" variant="outline" size="sm" onClick={() => handleOpen(false)}>
|
|
290
|
+
Cancel
|
|
291
|
+
</Button>
|
|
292
|
+
<Button type="submit" size="sm" className="gap-1.5" disabled={submitting || (asksProject && !projectId) || !prompt.trim()}>
|
|
293
|
+
<SparklesIcon className="h-3.5 w-3.5" />
|
|
294
|
+
{submitting ? "Delegating…" : "Delegate"}
|
|
295
|
+
</Button>
|
|
296
|
+
</DialogFooter>
|
|
297
|
+
</form>
|
|
298
|
+
</DialogContent>
|
|
299
|
+
</Dialog>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import {
|
|
5
|
+
CheckCircle2Icon,
|
|
6
|
+
CircleDotIcon,
|
|
7
|
+
CircleSlashIcon,
|
|
8
|
+
ExternalLinkIcon,
|
|
9
|
+
GitMergeIcon,
|
|
10
|
+
GitPullRequestDraftIcon,
|
|
11
|
+
GitPullRequestIcon,
|
|
12
|
+
Loader2Icon,
|
|
13
|
+
MessageSquareIcon,
|
|
14
|
+
TriangleAlertIcon,
|
|
15
|
+
XCircleIcon,
|
|
16
|
+
} from "lucide-react"
|
|
17
|
+
|
|
18
|
+
import { cn, formatRelativeTime } from "../../utils"
|
|
19
|
+
import { Badge } from "../badge"
|
|
20
|
+
import type { TaskPullRequestData, TaskPullRequests } from "./types"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What the forge says about the task's branch.
|
|
24
|
+
*
|
|
25
|
+
* The one thing on this screen the platform cannot read off the machine it started — a proposal
|
|
26
|
+
* lives at the forge, with the discussion, the CI and the approvals around it. Everything else
|
|
27
|
+
* here (the diff, the session, the machine) is observed; this is *reported*, by whichever app
|
|
28
|
+
* implements `git.pullRequestForBranch`.
|
|
29
|
+
*
|
|
30
|
+
* So the whole block is conditional on the answer, not on a provider id: `supported: false` means
|
|
31
|
+
* nothing connected can answer, and the section is **absent** rather than empty. That is the rule
|
|
32
|
+
* RFC 0024 asks for — read `operations`, never `if (provider === "github")` — and it is why this
|
|
33
|
+
* component names no forge anywhere in its markup.
|
|
34
|
+
*
|
|
35
|
+
* **It never moves the task.** A merged proposal does not mark the task `done`, and this
|
|
36
|
+
* deliberately does not offer an Accept button of its own: accepting is the project's command,
|
|
37
|
+
* run by the human who read the diff.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
type PullRequest = TaskPullRequestData
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Colours borrowed from the task states they mean the same thing as: merged is a `done`, closed
|
|
44
|
+
* a `failed`, open the flame that says "a human is expected here". A draft is muted on purpose —
|
|
45
|
+
* it is work someone has not yet asked to be looked at.
|
|
46
|
+
*/
|
|
47
|
+
const STATE_STYLES: Record<PullRequest["state"], { label: string; className: string }> = {
|
|
48
|
+
draft: { label: "Draft", className: "bg-muted text-muted-foreground border-border" },
|
|
49
|
+
open: { label: "Open", className: "bg-primary/15 text-primary border-primary/30" },
|
|
50
|
+
merged: { label: "Merged", className: "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30" },
|
|
51
|
+
closed: { label: "Closed", className: "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/30" },
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function StateIcon({ state }: { state: PullRequest["state"] }) {
|
|
55
|
+
if (state === "merged") return <GitMergeIcon className="h-3 w-3" />
|
|
56
|
+
if (state === "closed") return <XCircleIcon className="h-3 w-3" />
|
|
57
|
+
if (state === "draft") return <GitPullRequestDraftIcon className="h-3 w-3" />
|
|
58
|
+
return <GitPullRequestIcon className="h-3 w-3" />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A verdict and its colour — the same three-way vocabulary for checks and for review. */
|
|
62
|
+
function Signal({
|
|
63
|
+
icon,
|
|
64
|
+
label,
|
|
65
|
+
tone,
|
|
66
|
+
}: {
|
|
67
|
+
icon: ReactNode
|
|
68
|
+
label: string
|
|
69
|
+
tone: "good" | "bad" | "waiting"
|
|
70
|
+
}) {
|
|
71
|
+
return (
|
|
72
|
+
<span
|
|
73
|
+
className={cn(
|
|
74
|
+
"inline-flex items-center gap-1 text-[10px]",
|
|
75
|
+
tone === "good" && "text-green-700 dark:text-green-400",
|
|
76
|
+
tone === "bad" && "text-red-700 dark:text-red-400",
|
|
77
|
+
tone === "waiting" && "text-muted-foreground",
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{icon}
|
|
81
|
+
{label}
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Checks({ checks = null }: { checks?: PullRequest["checks"] }) {
|
|
87
|
+
// Null is not "passing": a repository with no CI has not passed anything, and drawing a green
|
|
88
|
+
// tick for it would be the panel's one outright lie.
|
|
89
|
+
if (checks === null) return null
|
|
90
|
+
if (checks === "passing") return <Signal icon={<CheckCircle2Icon className="h-3 w-3" />} label="Checks passing" tone="good" />
|
|
91
|
+
if (checks === "failing") return <Signal icon={<TriangleAlertIcon className="h-3 w-3" />} label="Checks failing" tone="bad" />
|
|
92
|
+
return <Signal icon={<Loader2Icon className="h-3 w-3 animate-spin" />} label="Checks running" tone="waiting" />
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function Review({ review = null }: { review?: PullRequest["review"] }) {
|
|
96
|
+
if (review === null) return null
|
|
97
|
+
if (review === "approved") return <Signal icon={<CheckCircle2Icon className="h-3 w-3" />} label="Approved" tone="good" />
|
|
98
|
+
if (review === "changes-requested")
|
|
99
|
+
return <Signal icon={<CircleSlashIcon className="h-3 w-3" />} label="Changes requested" tone="bad" />
|
|
100
|
+
return <Signal icon={<CircleDotIcon className="h-3 w-3" />} label="Review requested" tone="waiting" />
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function PullRequestCard({ repo, pr, showRepo }: { repo: string; pr: PullRequest; showRepo: boolean }) {
|
|
104
|
+
const style = STATE_STYLES[pr.state] ?? STATE_STYLES.open
|
|
105
|
+
return (
|
|
106
|
+
<a
|
|
107
|
+
href={pr.url}
|
|
108
|
+
target="_blank"
|
|
109
|
+
rel="noreferrer"
|
|
110
|
+
className="group block rounded-md border border-border/60 bg-background/60 px-2.5 py-2 transition-colors hover:border-border hover:bg-muted/40"
|
|
111
|
+
>
|
|
112
|
+
<div className="flex items-center gap-1.5">
|
|
113
|
+
<Badge variant="outline" className={cn("h-5 shrink-0 gap-1 px-1.5 text-[10px]", style.className)}>
|
|
114
|
+
<StateIcon state={pr.state} />
|
|
115
|
+
{style.label}
|
|
116
|
+
</Badge>
|
|
117
|
+
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">{pr.label}</span>
|
|
118
|
+
<ExternalLinkIcon className="ml-auto h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
|
119
|
+
</div>
|
|
120
|
+
{/* The title is the proposal's own, not the task's: an agent that renamed its work on the
|
|
121
|
+
way out is telling the reviewer something, and echoing the task title would hide it. */}
|
|
122
|
+
<p className="mt-1.5 line-clamp-2 text-xs leading-snug">{pr.title}</p>
|
|
123
|
+
{showRepo && <p className="mt-1 truncate font-mono text-[10px] text-muted-foreground">{repo}</p>}
|
|
124
|
+
{(pr.checks || pr.review || pr.comments) && (
|
|
125
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
|
126
|
+
<Checks checks={pr.checks} />
|
|
127
|
+
<Review review={pr.review} />
|
|
128
|
+
{!!pr.comments && (
|
|
129
|
+
<Signal icon={<MessageSquareIcon className="h-3 w-3" />} label={String(pr.comments)} tone="waiting" />
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
{pr.updatedAt && (
|
|
134
|
+
<p className="mt-1.5 text-[10px] text-muted-foreground">Updated {formatRelativeTime(pr.updatedAt)}</p>
|
|
135
|
+
)}
|
|
136
|
+
</a>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The proposals open for the task's branch, one card per repository that has one. Render it only
|
|
142
|
+
* when `data.supported` — the cockpit's details panel does exactly that.
|
|
143
|
+
*/
|
|
144
|
+
export function TaskPullRequest({ data }: { data: TaskPullRequests }) {
|
|
145
|
+
const withProposal = data.repos.filter((r) => r.pullRequest)
|
|
146
|
+
const failed = data.repos.filter((r) => r.error)
|
|
147
|
+
|
|
148
|
+
if (withProposal.length === 0) {
|
|
149
|
+
return (
|
|
150
|
+
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
|
151
|
+
{failed.length > 0
|
|
152
|
+
? failed.map((r) => r.error).join(" · ")
|
|
153
|
+
: // Said plainly, because it is the normal state of a task that has not pushed yet —
|
|
154
|
+
// not a failure, and not something the reader has to do anything about.
|
|
155
|
+
"No pull request for this branch yet."}
|
|
156
|
+
</p>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="space-y-1.5">
|
|
162
|
+
{withProposal.map((row) => (
|
|
163
|
+
<PullRequestCard
|
|
164
|
+
key={row.repo}
|
|
165
|
+
repo={row.repo}
|
|
166
|
+
pr={row.pullRequest!}
|
|
167
|
+
// Named only when there is more than one — on the single-repository project that most
|
|
168
|
+
// are, the repo name is the project's name repeated under every card.
|
|
169
|
+
showRepo={data.repos.length > 1}
|
|
170
|
+
/>
|
|
171
|
+
))}
|
|
172
|
+
{failed.map((row) => (
|
|
173
|
+
<p key={row.repo} className="text-[10px] text-muted-foreground">
|
|
174
|
+
{row.repo}: {row.error}
|
|
175
|
+
</p>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils"
|
|
6
|
+
import type { TaskSessionUsageData } from "./types"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a session has consumed — the accounting panel of the task cockpit.
|
|
10
|
+
*
|
|
11
|
+
* The timeline answers "what is it doing?"; this answers the three questions that come right
|
|
12
|
+
* after, and that a running session is otherwise silent about: **how full is its context**, how
|
|
13
|
+
* many tokens it has burned, and what that has cost. They are facts *about* the session rather
|
|
14
|
+
* than moments in it, which is why they live in the sidebar and not on the rail (see
|
|
15
|
+
* `event-stream.tsx`).
|
|
16
|
+
*
|
|
17
|
+
* Context gets the meter and everything else gets a line of text, because context is the only
|
|
18
|
+
* number here with a ceiling — it is the one that predicts something (a compaction, a session
|
|
19
|
+
* running out of room), where tokens and dollars only ever count up.
|
|
20
|
+
*
|
|
21
|
+
* Every value is the API's (Spunto: `GET .../tasks/{id}/events` → `usage`), derived from the whole event
|
|
22
|
+
* log rather than from the page the timeline happens to hold. Nothing is computed here beyond
|
|
23
|
+
* formatting — in particular not cost, which is the harness's own number.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
/** `41.2k`, `1.4M`, `1M` — a token count is read as an order of magnitude, never digit by digit. */
|
|
28
|
+
export function formatTokens(value: number): string {
|
|
29
|
+
if (value < 1_000) return String(Math.round(value))
|
|
30
|
+
if (value < 1_000_000) {
|
|
31
|
+
const k = value / 1_000
|
|
32
|
+
return `${trim(k >= 100 ? Math.round(k) : k.toFixed(1))}k`
|
|
33
|
+
}
|
|
34
|
+
return `${trim((value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1))}M`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `1.0M` is a window, `1M` is the window — the decimal only earns its place when it says something. */
|
|
38
|
+
function trim(value: string | number): string {
|
|
39
|
+
return String(value).replace(/\.0$/, "")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cheap sessions are the common case and `$0.00` would say nothing, so the small end keeps four
|
|
44
|
+
* decimals; past a cent, two are what anyone reads.
|
|
45
|
+
*/
|
|
46
|
+
export function formatCost(value: number): string {
|
|
47
|
+
return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `21s`, `8m 30s`, `49m`, `1h 12m` — the smaller unit is dropped when it is zero. */
|
|
51
|
+
function formatSessionDuration(ms: number): string {
|
|
52
|
+
const s = Math.round(ms / 1000)
|
|
53
|
+
if (s < 60) return `${s}s`
|
|
54
|
+
const m = Math.floor(s / 60)
|
|
55
|
+
if (m < 60) return s % 60 === 0 ? `${m}m` : `${m}m ${s % 60}s`
|
|
56
|
+
return m % 60 === 0 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 60)}h ${m % 60}m`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The model, without the harness's own decorations — `claude-opus-5[1m]` reads as the model. */
|
|
60
|
+
function shortModel(model: string): string {
|
|
61
|
+
return model.replace(/^claude-/, "").replace(/-\d{8}$/, "")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Line({ label, children }: { label: string; children: ReactNode }) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
67
|
+
<span className="text-[11px] text-muted-foreground">{label}</span>
|
|
68
|
+
<span className="font-mono text-[11px] tabular-nums">{children}</span>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The context meter. Drawn only when the window is known — see `contextWindowOf` in the API. */
|
|
74
|
+
function ContextMeter({ used, window: size }: { used: number; window: number | null }) {
|
|
75
|
+
const pct = size ? Math.min(100, Math.round((used / size) * 100)) : null
|
|
76
|
+
// Same thresholds and the same warm ramp as the node gauges (the dashboard's node gauges): a
|
|
77
|
+
// meter filling up means the same thing everywhere in the product.
|
|
78
|
+
const tone = pct === null ? null : pct > 90 ? "critical" : pct > 75 ? "warning" : null
|
|
79
|
+
const barColor =
|
|
80
|
+
tone === "critical"
|
|
81
|
+
? "var(--destructive)"
|
|
82
|
+
: tone === "warning"
|
|
83
|
+
? "var(--color-amber-500, #f59e0b)"
|
|
84
|
+
: "color-mix(in oklch, var(--ship) 80%, var(--build))"
|
|
85
|
+
return (
|
|
86
|
+
<div>
|
|
87
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
88
|
+
<span className="text-[11px] text-muted-foreground">Context</span>
|
|
89
|
+
<span
|
|
90
|
+
className={cn(
|
|
91
|
+
"font-mono text-[11px] tabular-nums",
|
|
92
|
+
tone === "critical" && "font-semibold text-destructive",
|
|
93
|
+
tone === "warning" && "font-semibold text-amber-600 dark:text-amber-400",
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
{formatTokens(used)}
|
|
97
|
+
{size ? ` / ${formatTokens(size)}` : ""}
|
|
98
|
+
{pct !== null ? ` · ${pct}%` : ""}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
{pct !== null && (
|
|
102
|
+
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
|
103
|
+
<div
|
|
104
|
+
className="h-full rounded-full transition-[width] duration-500"
|
|
105
|
+
style={{ width: `${pct}%`, background: barColor }}
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Never rounds up to 100 while a single token was still paid for: a session whose cache ratio
|
|
115
|
+
* reads "100% cached" is claiming it cost nothing to send, and on a long session the fresh part
|
|
116
|
+
* is always a rounding error away from disappearing.
|
|
117
|
+
*/
|
|
118
|
+
function cachedShare(cached: number, sent: number): number {
|
|
119
|
+
if (cached >= sent) return 100
|
|
120
|
+
return Math.min(99, Math.round((cached / sent) * 100))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function TaskSessionUsage({ usage, className }: { usage: TaskSessionUsageData; className?: string }) {
|
|
124
|
+
const { contextTokens = null, contextWindow = null, inputTokens = null, outputTokens = null, cacheReadTokens = null, cacheCreationTokens = null } = usage
|
|
125
|
+
const costUsd = usage.costUsd ?? null
|
|
126
|
+
const turns = usage.turns ?? null
|
|
127
|
+
const durationMs = usage.durationMs ?? null
|
|
128
|
+
const cached = (cacheReadTokens ?? 0) + (cacheCreationTokens ?? 0)
|
|
129
|
+
// What the session sent, all of it — the split between fresh and cached input is the next line
|
|
130
|
+
// down, because "how much did it read" and "how much did it pay full price for" are different
|
|
131
|
+
// questions and only the first one is asked first.
|
|
132
|
+
const sent = (inputTokens ?? 0) + cached
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className={cn("space-y-2.5", className)}>
|
|
136
|
+
{contextTokens !== null && <ContextMeter used={contextTokens} window={contextWindow} />}
|
|
137
|
+
|
|
138
|
+
<div className="space-y-1">
|
|
139
|
+
{sent > 0 && (
|
|
140
|
+
<Line label="Sent">
|
|
141
|
+
{formatTokens(sent)}
|
|
142
|
+
{cached > 0 && <span className="text-muted-foreground"> · {cachedShare(cached, sent)}% cached</span>}
|
|
143
|
+
</Line>
|
|
144
|
+
)}
|
|
145
|
+
{outputTokens !== null && outputTokens > 0 && <Line label="Generated">{formatTokens(outputTokens)}</Line>}
|
|
146
|
+
{costUsd !== null && <Line label="Cost">{formatCost(costUsd)}</Line>}
|
|
147
|
+
{(turns !== null || durationMs !== null) && (
|
|
148
|
+
<Line label="Turns">
|
|
149
|
+
{[
|
|
150
|
+
turns !== null ? String(turns) : null,
|
|
151
|
+
durationMs !== null ? formatSessionDuration(durationMs) : null,
|
|
152
|
+
]
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.join(" · ")}
|
|
155
|
+
</Line>
|
|
156
|
+
)}
|
|
157
|
+
{usage.model && (
|
|
158
|
+
<Line label="Model">
|
|
159
|
+
<span className="text-muted-foreground">{shortModel(usage.model)}</span>
|
|
160
|
+
</Line>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|