@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.
- 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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @spunto/design-system/tasks — delegated work (docs/tasks.md): the lists that name a task, the
|
|
2
|
+
// cockpit that follows one, and every panel inside it.
|
|
3
|
+
//
|
|
4
|
+
// A domain sub-export like `/workers`: nothing here fetches. Lists take their rows, panels take
|
|
5
|
+
// their data, actions go out as callbacks, and whatever reads an API is a slot or a
|
|
6
|
+
// promise-returning callback (`TaskDiffPanel.loadPatch`). The cockpit is a layout whose panels are
|
|
7
|
+
// the package's own components, assembled by the app.
|
|
8
|
+
|
|
9
|
+
// ── Vocabulary ──────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
export {
|
|
11
|
+
TASK_STATE_ORDER,
|
|
12
|
+
TaskPendingBadge,
|
|
13
|
+
TaskStateBadge,
|
|
14
|
+
TaskStateDot,
|
|
15
|
+
TaskStateIcon,
|
|
16
|
+
isLiveTaskState,
|
|
17
|
+
taskPendingActionConfig,
|
|
18
|
+
taskSpan,
|
|
19
|
+
taskStateConfig,
|
|
20
|
+
taskStateStyle,
|
|
21
|
+
useElapsed,
|
|
22
|
+
} from "./task-state"
|
|
23
|
+
export type { TaskStateBadgeProps, TaskStateStyle } from "./task-state"
|
|
24
|
+
|
|
25
|
+
// ── Lists ───────────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
export { TaskList } from "./task-list"
|
|
27
|
+
export type { TaskListProps } from "./task-list"
|
|
28
|
+
export { TaskConversationList, TaskConversationPlaceholder } from "./conversation-list"
|
|
29
|
+
export type { TaskConversationListProps } from "./conversation-list"
|
|
30
|
+
export { TaskWorkerChip, WorkerTaskChip, indexTasksByWorker } from "./worker-link"
|
|
31
|
+
export type { WorkerTask } from "./worker-link"
|
|
32
|
+
export { NewTaskDialog } from "./new-task-dialog"
|
|
33
|
+
export type { NewTaskDialogProps, NewTaskModelGroup, NewTaskValue } from "./new-task-dialog"
|
|
34
|
+
|
|
35
|
+
// ── The cockpit ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
export { TaskCockpit, TaskPanelTabs } from "./task-cockpit"
|
|
37
|
+
export type { TaskCockpitProps, TaskCockpitView } from "./task-cockpit"
|
|
38
|
+
export { TaskCockpitMobile, useVisualViewport } from "./task-cockpit-mobile"
|
|
39
|
+
export type { TaskCockpitMobileProps, VisualViewportState } from "./task-cockpit-mobile"
|
|
40
|
+
export { TaskDetails } from "./task-details"
|
|
41
|
+
export type { TaskDetailsProps } from "./task-details"
|
|
42
|
+
|
|
43
|
+
// ── The conversation ────────────────────────────────────────────────────────────────────────
|
|
44
|
+
export { TaskEventStream } from "./event-stream"
|
|
45
|
+
export type { TaskEventStreamProps } from "./event-stream"
|
|
46
|
+
export { TaskComposer } from "./composer"
|
|
47
|
+
export type { TaskComposerProps } from "./composer"
|
|
48
|
+
export { AgentMarkdown, renderInlineMarkdown } from "./agent-markdown"
|
|
49
|
+
export {
|
|
50
|
+
TaskToolStackButton,
|
|
51
|
+
TaskToolStackSheet,
|
|
52
|
+
ToolLine,
|
|
53
|
+
ToolRunBody,
|
|
54
|
+
runCalls,
|
|
55
|
+
runDuration,
|
|
56
|
+
runFiles,
|
|
57
|
+
runStats,
|
|
58
|
+
toolSubject,
|
|
59
|
+
toolVerb,
|
|
60
|
+
} from "./tool-stack"
|
|
61
|
+
export type { RunItem, RunStats, StackedCall, ToolCall, ToolRun, ToolVerb } from "./tool-stack"
|
|
62
|
+
|
|
63
|
+
// ── Files (RFC 0022) ────────────────────────────────────────────────────────────────────────
|
|
64
|
+
export { AttachFileButton, AttachmentDraftStrip, FileDropOverlay, TaskFiles, fileRefs, useAttachmentDraft } from "./task-attachments"
|
|
65
|
+
export type { AttachmentDraft } from "./task-attachments"
|
|
66
|
+
export {
|
|
67
|
+
MAX_FILES_PER_TURN,
|
|
68
|
+
MAX_FILE_BYTES,
|
|
69
|
+
MAX_TURN_BYTES,
|
|
70
|
+
fileKind,
|
|
71
|
+
filesFrom,
|
|
72
|
+
formatBytes,
|
|
73
|
+
isPreviewable,
|
|
74
|
+
prepareFile,
|
|
75
|
+
} from "./attachments"
|
|
76
|
+
|
|
77
|
+
// ── Side panels ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
export { TaskDiffPanel } from "./diff-panel"
|
|
79
|
+
export type { TaskDiffPanelProps } from "./diff-panel"
|
|
80
|
+
export { TaskPullRequest } from "./pull-request"
|
|
81
|
+
export { TaskSessionUsage, formatCost, formatTokens } from "./session-usage"
|
|
82
|
+
export { TaskCommandLog } from "./command-log"
|
|
83
|
+
export { TaskBoot, TaskMachine, bootSteps, workerLabel } from "./machine"
|
|
84
|
+
export type { TaskBootStep, TaskMachineProps, TaskMachineTask } from "./machine"
|
|
85
|
+
|
|
86
|
+
export type {
|
|
87
|
+
AttachmentUrl,
|
|
88
|
+
DraftFile,
|
|
89
|
+
PendingMessage,
|
|
90
|
+
TaskCommand,
|
|
91
|
+
TaskConversation,
|
|
92
|
+
TaskDiff,
|
|
93
|
+
TaskDiffFile,
|
|
94
|
+
TaskEvent,
|
|
95
|
+
TaskFilePatch,
|
|
96
|
+
TaskFileRef,
|
|
97
|
+
TaskItem,
|
|
98
|
+
TaskMachineNode,
|
|
99
|
+
TaskMachinePort,
|
|
100
|
+
TaskPatchHunk,
|
|
101
|
+
TaskPatchLine,
|
|
102
|
+
TaskPendingAction,
|
|
103
|
+
TaskPullRequestData,
|
|
104
|
+
TaskPullRequests,
|
|
105
|
+
TaskRepoDiff,
|
|
106
|
+
TaskSessionUsageData,
|
|
107
|
+
TaskState,
|
|
108
|
+
} from "./types"
|
|
109
|
+
|
|
110
|
+
export type { LinkRender } from "../workers/worker-card"
|
|
111
|
+
export { cn, formatRelativeTime } from "../../utils"
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import {
|
|
5
|
+
Code2Icon,
|
|
6
|
+
ExternalLinkIcon,
|
|
7
|
+
GitBranchIcon,
|
|
8
|
+
MoonIcon,
|
|
9
|
+
NetworkIcon,
|
|
10
|
+
ServerIcon,
|
|
11
|
+
SparklesIcon,
|
|
12
|
+
WifiOffIcon,
|
|
13
|
+
} from "lucide-react"
|
|
14
|
+
|
|
15
|
+
import { cn, formatDuration } from "../../utils"
|
|
16
|
+
import { ResourceBars, type WorkerStats } from "../workers/resource-bars"
|
|
17
|
+
import { SetupWaterfall } from "../workers/setup-waterfall"
|
|
18
|
+
import { StepIndicator, phaseLabel, setupTotalMs, type StepState } from "../workers/setup-progress"
|
|
19
|
+
import type { WorkerCardWorker, WorkerSetupStatus } from "../workers/types"
|
|
20
|
+
import type { LinkRender } from "../workers/worker-card"
|
|
21
|
+
import { WorkerSetupTimeline } from "../workers/worker-setup-timeline"
|
|
22
|
+
import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "../workers/worker-status"
|
|
23
|
+
import { useElapsed } from "./task-state"
|
|
24
|
+
import type { TaskCommand, TaskItem, TaskMachineNode, TaskMachinePort } from "./types"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The machine behind a task.
|
|
28
|
+
*
|
|
29
|
+
* A task is a conversation, but it happens *somewhere* — a container, on a node, that had to be
|
|
30
|
+
* found or built before a single token was generated. The cockpit used to say so in three words
|
|
31
|
+
* ("Worker · open"), which is fine right up until the interesting thing is the machine itself:
|
|
32
|
+
*
|
|
33
|
+
* - a `queued` task is **entirely** a machine being prepared. "Waiting for the session's first
|
|
34
|
+
* event…" described none of it, while the platform knew the phase, the repositories being
|
|
35
|
+
* cloned and the lifecycle command running.
|
|
36
|
+
* - a task in review holds a **parked** container (`taskReviewMode: "stop"`) — stopped, disk
|
|
37
|
+
* intact, because releasing it would wipe what you opened the review to look at.
|
|
38
|
+
* - a running session is a container burning CPU and memory, which is the difference between an
|
|
39
|
+
* agent thinking and an agent stuck in a test suite.
|
|
40
|
+
*
|
|
41
|
+
* Everything here is observed, never guessed: the worker row, its container-side setup status,
|
|
42
|
+
* its live stats, and the task's own command history. Where the platform doesn't know (has the
|
|
43
|
+
* pool recycled this machine since the task ended?) the component says so rather than implying.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** The task fields the machine block and the boot panel read. */
|
|
47
|
+
export type TaskMachineTask = Pick<TaskItem, "state" | "createdAt"> &
|
|
48
|
+
Partial<Pick<TaskItem, "startedAt" | "branch" | "baseBranch" | "error">>
|
|
49
|
+
|
|
50
|
+
/** A worker's display name — `name` when it has one, its index otherwise (same rule as the card). */
|
|
51
|
+
export function workerLabel(worker: WorkerCardWorker): string {
|
|
52
|
+
return worker.name?.trim() || (worker.index != null ? `Workspace #${worker.index}` : "Workspace")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Did this task get a machine that already existed?
|
|
57
|
+
*
|
|
58
|
+
* The pool is the whole point — the first task of a project pays for an image build and a clone,
|
|
59
|
+
* the tenth should not — and "recycled" vs "fresh" is exactly the difference between a boot of
|
|
60
|
+
* seconds and one of minutes. Read from the timestamps rather than recorded: a worker older than
|
|
61
|
+
* the task that took it was not created for it.
|
|
62
|
+
*/
|
|
63
|
+
function recycled(task: TaskMachineTask, worker: WorkerCardWorker): boolean {
|
|
64
|
+
if (!worker.createdAt) return false
|
|
65
|
+
return new Date(worker.createdAt).getTime() < new Date(task.createdAt).getTime()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── The sidebar block ────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export interface TaskMachineProps {
|
|
71
|
+
task: TaskMachineTask
|
|
72
|
+
/** The worker row, as the app reads it. `null` → the task has no machine (yet). */
|
|
73
|
+
worker: WorkerCardWorker | null | undefined
|
|
74
|
+
/** The node it lives on. `online: false` draws "offline". */
|
|
75
|
+
node?: TaskMachineNode | null
|
|
76
|
+
/** Live container stats — pass them only while the task is live and the container up. */
|
|
77
|
+
stats?: WorkerStats | null
|
|
78
|
+
/** Ports detected listening in the container (editor and SSH are filtered out). */
|
|
79
|
+
ports?: TaskMachinePort[]
|
|
80
|
+
/** The worker's own page (logs, terminal, files). */
|
|
81
|
+
workerHref?: string
|
|
82
|
+
/** The editor (code-server). Shown only while the container runs. */
|
|
83
|
+
editorHref?: string
|
|
84
|
+
portHref?: (port: number) => string | undefined
|
|
85
|
+
render?: { link?: LinkRender }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function TaskMachine({ task, worker, node = null, stats = null, ports = [], workerHref, editorHref, portHref, render }: TaskMachineProps) {
|
|
89
|
+
const [showBreakdown, setShowBreakdown] = useState(false)
|
|
90
|
+
const Link: LinkRender =
|
|
91
|
+
render?.link ?? (({ href, className, children }) => <a href={href} className={className}>{children}</a>)
|
|
92
|
+
|
|
93
|
+
if (!worker) {
|
|
94
|
+
return (
|
|
95
|
+
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
|
96
|
+
{task.state === "queued"
|
|
97
|
+
? "Looking for a machine — a free worker from the project's pool, or a fresh one."
|
|
98
|
+
: "This task never got a machine."}
|
|
99
|
+
</p>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const status = resolveWorkerStatus(worker)
|
|
104
|
+
const setup = worker.setupStatus ?? null
|
|
105
|
+
const running = worker.dockerState === "running"
|
|
106
|
+
// 8080 is the editor, already its own link; 22 is SSH, not a browser tab.
|
|
107
|
+
const detectedPorts = ports.filter((p) => p.port !== 8080 && p.port !== 22)
|
|
108
|
+
const startup = setupTotalMs(setup)
|
|
109
|
+
// A terminal task no longer holds its worker: the next task may already be using it. Saying
|
|
110
|
+
// "Running" without saying whose is how a cockpit lies quietly.
|
|
111
|
+
const released = task.state === "done" || task.state === "failed"
|
|
112
|
+
const parked = task.state === "in-review" && !running
|
|
113
|
+
const label = workerLabel(worker)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="space-y-2.5">
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<WorkerStatusDot status={status} />
|
|
119
|
+
{workerHref ? (
|
|
120
|
+
<Link href={workerHref} className="min-w-0 flex-1 truncate text-xs font-medium hover:underline">
|
|
121
|
+
{label}
|
|
122
|
+
</Link>
|
|
123
|
+
) : (
|
|
124
|
+
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
|
|
125
|
+
)}
|
|
126
|
+
<WorkerStatusPill status={status} label={status.settingUp ? phaseLabel(setup) : undefined} />
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
|
130
|
+
<span className="flex items-center gap-1">
|
|
131
|
+
<ServerIcon className="h-3 w-3 shrink-0" />
|
|
132
|
+
{node ? node.name : "node unknown"}
|
|
133
|
+
</span>
|
|
134
|
+
{node && !node.online && (
|
|
135
|
+
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400">
|
|
136
|
+
<WifiOffIcon className="h-3 w-3 shrink-0" />
|
|
137
|
+
offline
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
<span aria-hidden>·</span>
|
|
141
|
+
<span className="flex items-center gap-1">
|
|
142
|
+
<SparklesIcon className="h-3 w-3 shrink-0" />
|
|
143
|
+
{recycled(task, worker) ? "recycled from the pool" : "spawned for this task"}
|
|
144
|
+
</span>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* Not while the task is `queued`: the boot panel in the centre column is showing this very
|
|
148
|
+
timeline, in full, and two copies of a cloning repository on one screen is one too many.
|
|
149
|
+
It stays here for the rest — a parked machine restarting for a follow-up, say. */}
|
|
150
|
+
{status.settingUp && task.state !== "queued" && <WorkerSetupTimeline status={setup} settingUp />}
|
|
151
|
+
|
|
152
|
+
{/* `@container`, deliberately: ResourceBars decides to go two-up from its *nearest container
|
|
153
|
+
ancestor*'s width, and outside a WorkerCard there is none — the bars would stay stacked
|
|
154
|
+
for ever (docs/design-system.md § "une brique réutilisée hors de sa carte"). */}
|
|
155
|
+
{stats && (
|
|
156
|
+
<div className="@container">
|
|
157
|
+
<ResourceBars stats={stats} />
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{parked && (
|
|
162
|
+
<p className="flex items-start gap-1.5 rounded-md border border-border/60 bg-muted/30 px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground">
|
|
163
|
+
<MoonIcon className="mt-0.5 h-3 w-3 shrink-0" />
|
|
164
|
+
<span>
|
|
165
|
+
Parked for review — stopped, disk kept as it was. Accepting or dropping the task
|
|
166
|
+
releases it to the pool.
|
|
167
|
+
</span>
|
|
168
|
+
</p>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{released && (
|
|
172
|
+
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
|
173
|
+
This task is over, so the machine is back in the pool — what it runs now may belong to
|
|
174
|
+
another task.
|
|
175
|
+
</p>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
|
|
179
|
+
{editorHref && running && (
|
|
180
|
+
<a
|
|
181
|
+
href={editorHref}
|
|
182
|
+
target="_blank"
|
|
183
|
+
rel="noreferrer"
|
|
184
|
+
className="flex items-center gap-1 text-primary hover:underline"
|
|
185
|
+
>
|
|
186
|
+
<Code2Icon className="h-3 w-3 shrink-0" />
|
|
187
|
+
Open the editor
|
|
188
|
+
<ExternalLinkIcon className="h-3 w-3 shrink-0" />
|
|
189
|
+
</a>
|
|
190
|
+
)}
|
|
191
|
+
{workerHref && (
|
|
192
|
+
<Link href={workerHref} className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground">
|
|
193
|
+
Logs, terminal, files →
|
|
194
|
+
</Link>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{running && portHref && detectedPorts.length > 0 && (
|
|
199
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
200
|
+
{detectedPorts.map((p) => (
|
|
201
|
+
<a
|
|
202
|
+
key={p.port}
|
|
203
|
+
href={portHref(p.port)}
|
|
204
|
+
target="_blank"
|
|
205
|
+
rel="noreferrer"
|
|
206
|
+
title={p.process ? `${p.process} · :${p.port}` : `Port ${p.port}`}
|
|
207
|
+
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
|
|
208
|
+
>
|
|
209
|
+
<NetworkIcon className="h-3 w-3 shrink-0" />:{p.port}
|
|
210
|
+
</a>
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{startup !== null && (
|
|
216
|
+
<div>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={() => setShowBreakdown((v) => !v)}
|
|
220
|
+
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground"
|
|
221
|
+
>
|
|
222
|
+
Booted in <span className="font-mono tabular-nums">{formatDuration(startup)}</span> ·{" "}
|
|
223
|
+
{showBreakdown ? "hide" : "breakdown"}
|
|
224
|
+
</button>
|
|
225
|
+
{showBreakdown && <SetupWaterfall timings={setup?.timings} className="mt-2" />}
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── The boot panel, centre column ────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
export type TaskBootStep = { key: string; label: string; state: StepState; detail?: string }
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* What the platform is doing before the first token — as steps, each one read from something it
|
|
238
|
+
* actually observed.
|
|
239
|
+
*
|
|
240
|
+
* Exported for the same reason it is a pure function: it is the part worth arguing with.
|
|
241
|
+
*/
|
|
242
|
+
export function bootSteps({
|
|
243
|
+
task,
|
|
244
|
+
worker,
|
|
245
|
+
setup,
|
|
246
|
+
commands,
|
|
247
|
+
}: {
|
|
248
|
+
task: TaskMachineTask
|
|
249
|
+
worker: WorkerCardWorker | null | undefined
|
|
250
|
+
setup?: WorkerSetupStatus | null
|
|
251
|
+
commands: TaskCommand[]
|
|
252
|
+
}): TaskBootStep[] {
|
|
253
|
+
const machine: TaskBootStep = worker
|
|
254
|
+
? {
|
|
255
|
+
key: "machine",
|
|
256
|
+
label: "Machine",
|
|
257
|
+
state: "done",
|
|
258
|
+
detail: recycled(task, worker)
|
|
259
|
+
? `recycled from the pool · ${workerLabel(worker)}`
|
|
260
|
+
: `spawned for this task · ${workerLabel(worker)}`,
|
|
261
|
+
}
|
|
262
|
+
: {
|
|
263
|
+
key: "machine",
|
|
264
|
+
label: "Machine",
|
|
265
|
+
// A queued task with no worker is being allocated one; a task that stopped here never got
|
|
266
|
+
// one at all, and pretending it is still looking would be a lie with a spinner on it.
|
|
267
|
+
state: task.state === "queued" ? "running" : "error",
|
|
268
|
+
detail: task.state === "queued" ? "looking for a free worker, or spawning one…" : "never allocated",
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// `ready` and nothing else: it is the platform's own definition of a usable machine (what
|
|
272
|
+
// `waitForWorkerReady` blocks on). A restarting pool worker reports `dockerState: running`
|
|
273
|
+
// seconds before its setup has caught up, and calling that "up" ticks the step while the box
|
|
274
|
+
// right underneath is still cloning.
|
|
275
|
+
const containerState: StepState = !worker
|
|
276
|
+
? "pending"
|
|
277
|
+
: worker.state === "error"
|
|
278
|
+
? "error"
|
|
279
|
+
: worker.state === "ready"
|
|
280
|
+
? "done"
|
|
281
|
+
: "running"
|
|
282
|
+
const container: TaskBootStep = {
|
|
283
|
+
key: "container",
|
|
284
|
+
label: "Container",
|
|
285
|
+
state: containerState,
|
|
286
|
+
detail: worker
|
|
287
|
+
? containerState === "done"
|
|
288
|
+
? "up"
|
|
289
|
+
: resolveWorkerStatus(worker).label.toLowerCase().replace(/…$/, "…")
|
|
290
|
+
: undefined,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A `ready` worker counts as set up even with no status to show for it: old workers predate the
|
|
294
|
+
// container-side reporting, and a step spinning for ever on a machine that is demonstrably up
|
|
295
|
+
// would be the component contradicting the platform.
|
|
296
|
+
const setupState: StepState = !worker
|
|
297
|
+
? "pending"
|
|
298
|
+
: setup?.phase === "error"
|
|
299
|
+
? "error"
|
|
300
|
+
: worker.state === "ready" || setup?.phase === "ready"
|
|
301
|
+
? "done"
|
|
302
|
+
: "running"
|
|
303
|
+
const setupStep: TaskBootStep = {
|
|
304
|
+
key: "setup",
|
|
305
|
+
label: "Setup",
|
|
306
|
+
state: setupState,
|
|
307
|
+
detail: setup ? phaseLabel(setup) : worker ? "waiting for the container's first report…" : undefined,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The platform creates the task's branch in every repository of the project before the session
|
|
311
|
+
// starts — one recorded command per repo, which is where this reads from.
|
|
312
|
+
const branchCommands = commands.filter((c) => (c.label ?? "").startsWith("Branch setup"))
|
|
313
|
+
const branchState: StepState =
|
|
314
|
+
branchCommands.length === 0
|
|
315
|
+
? "pending"
|
|
316
|
+
: branchCommands.some((c) => c.status === "running")
|
|
317
|
+
? "running"
|
|
318
|
+
: branchCommands.some((c) => c.status !== "succeeded")
|
|
319
|
+
? "error"
|
|
320
|
+
: "done"
|
|
321
|
+
const branch: TaskBootStep = {
|
|
322
|
+
key: "branch",
|
|
323
|
+
label: "Branch",
|
|
324
|
+
state: branchState,
|
|
325
|
+
detail: `${task.branch} from ${task.baseBranch ?? "the project's default"}`,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Only a recycled machine runs the project's reset command, so the step exists if and only if
|
|
329
|
+
// that command has a row — the same rule as everything else here: nothing ran, nothing to show.
|
|
330
|
+
const resetCommand = commands.find((c) => (c.label ?? "") === "Reset")
|
|
331
|
+
const reset: TaskBootStep | null = resetCommand
|
|
332
|
+
? {
|
|
333
|
+
key: "reset",
|
|
334
|
+
label: "Reset",
|
|
335
|
+
state:
|
|
336
|
+
resetCommand.status === "running" ? "running" : resetCommand.status === "succeeded" ? "done" : "error",
|
|
337
|
+
detail: "putting the reused machine back in shape",
|
|
338
|
+
}
|
|
339
|
+
: null
|
|
340
|
+
|
|
341
|
+
const queued = task.state === "queued"
|
|
342
|
+
const ready = branchState === "done" && (!reset || reset.state === "done")
|
|
343
|
+
const session: TaskBootStep = {
|
|
344
|
+
key: "session",
|
|
345
|
+
label: "Agent session",
|
|
346
|
+
state: task.startedAt ? "done" : queued && ready ? "running" : "pending",
|
|
347
|
+
detail: task.startedAt
|
|
348
|
+
? "started"
|
|
349
|
+
: queued
|
|
350
|
+
? reset && reset.state !== "done"
|
|
351
|
+
? "starts once the machine is back in shape"
|
|
352
|
+
: "starts as soon as the branch is ready"
|
|
353
|
+
: "never started",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// A post-mortem drops the container and setup rows on purpose. Their only source is the
|
|
357
|
+
// machine's state *now* — and a task that is over no longer owns its machine, so those two rows
|
|
358
|
+
// would describe someone else's boot. What is left is recorded: which worker it got, whether
|
|
359
|
+
// its branch commands ran, whether a session ever started.
|
|
360
|
+
const rest = reset ? [branch, reset, session] : [branch, session]
|
|
361
|
+
return queued ? [machine, container, setupStep, ...rest] : [machine, ...rest]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The centre column of a task that has no session yet — which is every `queued` task, and any
|
|
366
|
+
* task that died before one started.
|
|
367
|
+
*
|
|
368
|
+
* The steps are the ones the platform actually goes through, so a failed boot shows *where* it
|
|
369
|
+
* stopped instead of an empty transcript — including the reset, on a machine that needed one.
|
|
370
|
+
*/
|
|
371
|
+
export function TaskBoot({
|
|
372
|
+
task,
|
|
373
|
+
worker,
|
|
374
|
+
commands,
|
|
375
|
+
}: {
|
|
376
|
+
task: TaskMachineTask
|
|
377
|
+
worker: WorkerCardWorker | null | undefined
|
|
378
|
+
commands: TaskCommand[]
|
|
379
|
+
}) {
|
|
380
|
+
const setup = worker?.setupStatus ?? null
|
|
381
|
+
const steps = bootSteps({ task, worker, setup, commands })
|
|
382
|
+
const elapsed = useElapsed(task.state === "queued" ? task.createdAt : null)
|
|
383
|
+
const queued = task.state === "queued"
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
// `min-h-full` + `my-auto`, not `h-full items-center`: centred while it fits, and growing (so
|
|
387
|
+
// the parent's scroll takes over) once the setup timeline unfolds — a box that is centred
|
|
388
|
+
// *and* capped overflows at both ends, cutting off the title and the footnote.
|
|
389
|
+
<div className="flex min-h-full flex-col p-4">
|
|
390
|
+
<div className="mx-auto my-auto w-full max-w-md">
|
|
391
|
+
<div className="mb-3 flex items-center gap-2">
|
|
392
|
+
<span className="relative flex h-2 w-2 shrink-0">
|
|
393
|
+
<span className={cn("h-2 w-2 rounded-full", queued ? "bg-yellow-400" : "bg-destructive")} />
|
|
394
|
+
{queued && (
|
|
395
|
+
<span className="absolute inset-0 h-2 w-2 animate-ping rounded-full bg-yellow-400 opacity-60 motion-reduce:animate-none" />
|
|
396
|
+
)}
|
|
397
|
+
</span>
|
|
398
|
+
<h3 className="text-sm font-medium">{queued ? "Preparing the machine" : "The session never started"}</h3>
|
|
399
|
+
{elapsed && <span className="ml-auto font-mono text-[11px] tabular-nums text-muted-foreground">{elapsed}</span>}
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<div className="overflow-hidden rounded-lg border bg-card/60">
|
|
403
|
+
{steps.map((step) => (
|
|
404
|
+
<div key={step.key} className="flex items-start gap-2.5 border-b border-border/50 px-3 py-2.5 last:border-b-0">
|
|
405
|
+
<span className="mt-0.5">
|
|
406
|
+
<StepIndicator state={step.state} />
|
|
407
|
+
</span>
|
|
408
|
+
<div className="min-w-0 flex-1">
|
|
409
|
+
<p
|
|
410
|
+
className={cn(
|
|
411
|
+
"text-xs",
|
|
412
|
+
step.state === "pending" ? "text-muted-foreground/50" : "font-medium",
|
|
413
|
+
step.state === "error" && "text-destructive",
|
|
414
|
+
)}
|
|
415
|
+
>
|
|
416
|
+
{step.label}
|
|
417
|
+
</p>
|
|
418
|
+
{step.detail && (
|
|
419
|
+
<p className="mt-0.5 break-words font-mono text-[11px] leading-relaxed text-muted-foreground">
|
|
420
|
+
{step.detail}
|
|
421
|
+
</p>
|
|
422
|
+
)}
|
|
423
|
+
{/* The setup is the long one, and the only step with sub-steps worth watching:
|
|
424
|
+
which repository is cloning, which lifecycle command is running. Tied to the
|
|
425
|
+
step's own state rather than the worker's: a recycled machine carries the
|
|
426
|
+
setup status of its previous boot — which is the whole point of recycling —
|
|
427
|
+
and unfolding a finished timeline under a ticked step is just noise. */}
|
|
428
|
+
{step.key === "setup" && step.state === "running" && (
|
|
429
|
+
<WorkerSetupTimeline status={setup} settingUp className="mt-2" />
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
{/* The reason, where the eye already is. It is in the sidebar too, but a post-mortem whose
|
|
437
|
+
centre column says only "never started" makes you hunt for the one line that matters. */}
|
|
438
|
+
{!queued && task.error && (
|
|
439
|
+
<p className="mt-3 whitespace-pre-wrap break-words rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-2 text-[11px] leading-relaxed text-destructive">
|
|
440
|
+
{task.error}
|
|
441
|
+
</p>
|
|
442
|
+
)}
|
|
443
|
+
|
|
444
|
+
<p className="mt-3 flex items-start gap-1.5 text-[11px] leading-relaxed text-muted-foreground">
|
|
445
|
+
<GitBranchIcon className="mt-0.5 h-3 w-3 shrink-0" />
|
|
446
|
+
<span>
|
|
447
|
+
{queued
|
|
448
|
+
? "The session appears here on its own as soon as the agent starts. Tasks of the same project share this pool of machines — the first one pays for the image build, the next ones reuse it."
|
|
449
|
+
: "Nothing was streamed for this task. The sidebar has every command the platform ran."}
|
|
450
|
+
</span>
|
|
451
|
+
</p>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
)
|
|
455
|
+
}
|