@spunto/design-system 0.28.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.
@@ -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&apos; 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&apos;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
+ }