@spunto/design-system 0.18.1 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,7 +71,8 @@
71
71
  "class-variance-authority": "^0.7.1",
72
72
  "clsx": "^2.1.1",
73
73
  "lucide-react": "^1.7.0",
74
- "tailwind-merge": "^3.6.0"
74
+ "tailwind-merge": "^3.6.0",
75
+ "vaul": "^1.1.2"
75
76
  },
76
77
  "peerDependencies": {
77
78
  "@base-ui/react": "^1.6.0",
@@ -9,12 +9,18 @@
9
9
  export { WorkerCard } from "./worker-card"
10
10
  export type { WorkerCardProps, LinkRender } from "./worker-card"
11
11
 
12
+ export { WorkerCockpit } from "./worker-cockpit"
13
+ export type { WorkerCockpitProps } from "./worker-cockpit"
14
+
12
15
  export { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus, workerStatusConfig } from "./worker-status"
13
16
  export type { WorkerStatus, WorkerStatusConfig, WorkerStatusKey } from "./worker-status"
14
17
 
15
18
  export { SetupProgress, StepIndicator, phaseLabel, setupProgress, setupTotalMs } from "./setup-progress"
16
19
  export type { SetupProgressProps, StepState } from "./setup-progress"
17
20
 
21
+ export { WorkerSetupTimeline } from "./worker-setup-timeline"
22
+ export { SetupWaterfall } from "./setup-waterfall"
23
+
18
24
  export { ResourceBars } from "./resource-bars"
19
25
  export type { ResourceBarsProps, WorkerStats } from "./resource-bars"
20
26
 
@@ -28,7 +34,7 @@ export type { TagPalette } from "./tag-color"
28
34
 
29
35
  export { CreatorAvatar } from "./creator-avatar"
30
36
 
31
- export type { WorkerCardWorker, WorkerCreator, WorkerSetupStatus } from "./types"
37
+ export type { WorkerCardWorker, WorkerCockpitWorker, WorkerCreator, WorkerSetupStatus } from "./types"
32
38
 
33
39
  // Re-exported here too so a consumer of the worker surface doesn't have to
34
40
  // import the root entry just to format the timestamps it feeds these components.
@@ -0,0 +1,68 @@
1
+ "use client"
2
+
3
+ import { formatDuration } from "../../utils"
4
+
5
+ // ── Startup timing waterfall ──────────────────────────────────────────────
6
+ // Renders the per-phase startup durations captured container-side in a
7
+ // worker's setup status (`timings`: ms-epoch per milestone). Each milestone
8
+ // marks the *start* of its phase, so a phase's duration is the gap to the
9
+ // next present milestone; `ready` is the terminal marker.
10
+ const TIMING_PHASES: { key: string; label: string; color: string }[] = [
11
+ { key: "initializing", label: "Initialize", color: "var(--muted-foreground)" },
12
+ { key: "credentials", label: "Credentials", color: "var(--build)" },
13
+ { key: "shell", label: "Shell", color: "var(--build)" },
14
+ { key: "dotfiles", label: "Dotfiles", color: "var(--build)" },
15
+ { key: "cloning", label: "Clone repos", color: "var(--primary)" },
16
+ { key: "postCreate", label: "postCreate", color: "var(--run)" },
17
+ { key: "postStart", label: "postStart", color: "var(--run)" },
18
+ { key: "ready", label: "Ready", color: "var(--run)" },
19
+ ]
20
+
21
+ export function SetupWaterfall({ timings, className = "" }: { timings?: Record<string, number> | null; className?: string }) {
22
+ // Present milestones, in canonical order, as [key,label,color,ts].
23
+ const points = TIMING_PHASES
24
+ .map((p) => ({ ...p, ts: timings?.[p.key] }))
25
+ .filter((p): p is typeof p & { ts: number } => typeof p.ts === "number")
26
+ if (points.length < 2) return null
27
+ const t0 = points[0].ts
28
+ const tEnd = points[points.length - 1].ts
29
+ const total = Math.max(1, tEnd - t0)
30
+ // A segment spans from a milestone to the next present one; the last milestone is
31
+ // the endpoint, so it has no bar of its own.
32
+ const segments = points.slice(0, -1).map((p, i) => ({
33
+ key: p.key,
34
+ label: p.label,
35
+ color: p.color,
36
+ offset: ((p.ts - t0) / total) * 100,
37
+ width: (((points[i + 1].ts - p.ts) / total) * 100),
38
+ duration: points[i + 1].ts - p.ts,
39
+ }))
40
+ const complete = points[points.length - 1].key === "ready"
41
+ return (
42
+ <div className={`rounded-lg border border-border/40 bg-muted/20 text-xs ${className}`}>
43
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border/40">
44
+ <span className="font-mono text-[11px] font-medium text-muted-foreground">
45
+ {complete ? "Startup breakdown" : "Startup so far"}
46
+ </span>
47
+ <span className="font-mono text-[11px] tabular-nums text-foreground/80">{formatDuration(total)}</span>
48
+ </div>
49
+ <div className="px-3 py-2.5 space-y-1.5">
50
+ {segments.map((s) => (
51
+ <div key={s.key} className="grid items-center gap-2" style={{ gridTemplateColumns: "5.5rem 1fr 3rem" }}>
52
+ <span className="truncate font-mono text-[11px] text-muted-foreground">{s.label}</span>
53
+ <span className="relative h-2.5 rounded-sm bg-muted/60">
54
+ <span
55
+ className="absolute inset-y-0 min-w-[2px] rounded-sm"
56
+ style={{ left: `${s.offset}%`, width: `${Math.max(1.5, s.width)}%`, background: s.color }}
57
+ title={`${s.label}: ${formatDuration(s.duration)}`}
58
+ />
59
+ </span>
60
+ <span className="text-right font-mono text-[11px] tabular-nums text-muted-foreground/80">
61
+ {formatDuration(s.duration)}
62
+ </span>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </div>
67
+ )
68
+ }
@@ -42,3 +42,14 @@ export interface WorkerCreator {
42
42
  login?: string
43
43
  avatarUrl?: string | null
44
44
  }
45
+
46
+ /**
47
+ * The snapshot a `WorkerCockpit` draws — `WorkerCardWorker` plus the fields a
48
+ * full page needs and a card doesn't: the checkout it was asked to start on,
49
+ * and whether a container exists at all (a worker can be `WorkerCardWorker`-
50
+ * complete before it ever gets one).
51
+ */
52
+ export interface WorkerCockpitWorker extends WorkerCardWorker {
53
+ branch?: string | null
54
+ containerId?: string | null
55
+ }
@@ -0,0 +1,555 @@
1
+ "use client"
2
+
3
+ // The full worker page, as one component. Top bar, logs/terminal tabs, and a
4
+ // control panel (setup, resources, repos, ports, tags, actions, info) that
5
+ // lives in a sidebar on desktop and a bottom sheet on mobile — same content,
6
+ // because a phone doesn't need less of it, just a different place to put it.
7
+ //
8
+ // Purely presentational, same rule as `WorkerCard`: everything that would
9
+ // talk to an API is a slot (`logsContent`, `terminalContent`, `statsChart`,
10
+ // `actions`-style render props) or a callback (`onStop`, `onAddTag`…). No
11
+ // `useQuery`, no mutation, no router, no `next/link` — that's what lets the
12
+ // same cockpit be driven by the dashboard's react-query hooks today and by
13
+ // hand-written fixtures in the showcase.
14
+
15
+ import { useState, type ComponentType, type ReactNode } from "react"
16
+ import { Drawer as DrawerPrimitive } from "vaul"
17
+ import {
18
+ AlertTriangleIcon,
19
+ AlignLeftIcon,
20
+ ArrowLeftIcon,
21
+ CheckIcon,
22
+ Code2Icon,
23
+ CopyIcon,
24
+ ExternalLinkIcon,
25
+ GitBranchIcon,
26
+ LoaderIcon,
27
+ Maximize2Icon,
28
+ PanelRightOpenIcon,
29
+ PlayIcon,
30
+ RefreshCwIcon,
31
+ SquareIcon,
32
+ TerminalIcon,
33
+ Trash2Icon,
34
+ WifiOffIcon,
35
+ XIcon,
36
+ } from "lucide-react"
37
+
38
+ import { cn, formatRelativeTime } from "../../utils"
39
+ import { Button } from "../button"
40
+ import { buttonVariants } from "../button-variants"
41
+ import { useOverlayContainer } from "../spunto-provider"
42
+ import { CreatorAvatar } from "./creator-avatar"
43
+ import { type GitRepoStatus, repoName } from "./git-branch-chips"
44
+ import { ResourceBars, type WorkerStats } from "./resource-bars"
45
+ import { SetupWaterfall } from "./setup-waterfall"
46
+ import { TagChips } from "./tag-chips"
47
+ import type { WorkerCockpitWorker, WorkerCreator } from "./types"
48
+ import { type LinkRender } from "./worker-card"
49
+ import { WorkerSetupTimeline } from "./worker-setup-timeline"
50
+ import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "./worker-status"
51
+
52
+ type CockpitTab = "logs" | "terminal"
53
+
54
+ export interface WorkerCockpitProps {
55
+ worker: WorkerCockpitWorker
56
+ /** Above the worker name, e.g. "my-project /". Omitted → no breadcrumb. */
57
+ projectName?: string
58
+ /** Project's current config version — a worker below it gets the "Rebuild" banner. */
59
+ currentProjectVersion?: number
60
+
61
+ /** Target of the back button, top-left. Omitted → no back button. */
62
+ backHref?: string
63
+ render?: { link?: LinkRender }
64
+
65
+ creator?: WorkerCreator
66
+ nodeName?: string
67
+ /** `false` draws the "Node offline" banner. `undefined` = unknown, no banner. */
68
+ nodeOnline?: boolean
69
+
70
+ /**
71
+ * Live container stats. `undefined` → no Resources section at all (the
72
+ * worker isn't running). `null` → the section shows with its loading state
73
+ * (container is up, first sample hasn't arrived). An object → bars + chart.
74
+ */
75
+ stats?: WorkerStats | null
76
+ /** App-drawn chart under the bars (recharts, sparkline…) — kept out of the
77
+ * package so it doesn't drag a charting library into every consumer. */
78
+ statsChart?: ReactNode
79
+
80
+ /** What this worker is working on. Empty/omitted → no Repositories section. */
81
+ repos?: GitRepoStatus[]
82
+ /** Chip → URL (e.g. code-server at that folder). Returning undefined renders a static row. */
83
+ repoHref?: (repo: GitRepoStatus) => string | undefined
84
+
85
+ /** Ports detected inside the container. Only pass these while relevant (running, past setup). */
86
+ ports?: { port: number; process?: string }[]
87
+ portHref?: (port: number) => string | undefined
88
+
89
+ onAddTag?: (tag: string, next: string[]) => void
90
+ onRemoveTag?: (tag: string, next: string[]) => void
91
+
92
+ /** "Open VS Code", top-right. Omitted → the button doesn't show. */
93
+ editorHref?: string
94
+
95
+ onStop?: () => void
96
+ stopping?: boolean
97
+ onStart?: () => void
98
+ starting?: boolean
99
+ onRebuild?: () => void
100
+ rebuilding?: boolean
101
+ onDelete?: () => void
102
+ deleting?: boolean
103
+
104
+ /** Fetches the one-shot SSH command; the cockpit copies it to the clipboard and owns the button's own idle/loading/copied state. */
105
+ onCopySsh?: () => Promise<string>
106
+
107
+ /** A failed mutation, typically. */
108
+ error?: string | null
109
+ onDismissError?: () => void
110
+
111
+ activeTab: CockpitTab
112
+ onTabChange: (tab: CockpitTab) => void
113
+ /** What to draw for each tab — the app decides which panel applies (a log stream, a build in progress, a "no container yet" placeholder…). */
114
+ logsContent: ReactNode
115
+ terminalContent: ReactNode
116
+ /** Shown next to the tabs when on the terminal tab. Omitted → no reconnect button. */
117
+ onReconnectTerminal?: () => void
118
+ /** Opens a fullscreen terminal overlay. Omitted → no fullscreen button. */
119
+ onOpenFullscreen?: () => void
120
+
121
+ className?: string
122
+ }
123
+
124
+ function SectionLabel({ children }: { children: ReactNode }) {
125
+ return <p className="mb-2 text-[10px] font-semibold tracking-widest text-muted-foreground/50 uppercase">{children}</p>
126
+ }
127
+
128
+ const BANNER_TONES = {
129
+ offline: "border-orange-500/25 bg-orange-500/5 text-orange-600 dark:text-orange-400",
130
+ warn: "border-amber-500/25 bg-amber-500/5 text-amber-600 dark:text-amber-400",
131
+ error: "border-destructive/25 bg-destructive/5 text-destructive",
132
+ } as const
133
+
134
+ function Banner({ tone, children }: { tone: keyof typeof BANNER_TONES; children: ReactNode }) {
135
+ return (
136
+ <div className={cn("flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-xs", BANNER_TONES[tone])}>
137
+ {children}
138
+ </div>
139
+ )
140
+ }
141
+
142
+ function InfoRow({ label, children }: { label: string; children: ReactNode }) {
143
+ return (
144
+ <div className="flex items-center justify-between px-3 py-2">
145
+ <span className="text-[11px] text-muted-foreground">{label}</span>
146
+ {children}
147
+ </div>
148
+ )
149
+ }
150
+
151
+ /**
152
+ * The full worker page: top bar, logs/terminal tabs, and a control panel —
153
+ * one component, driven entirely by props. See the file header for the
154
+ * present/callback/slot split.
155
+ */
156
+ export function WorkerCockpit({
157
+ worker,
158
+ projectName,
159
+ currentProjectVersion,
160
+ backHref,
161
+ render,
162
+ creator,
163
+ nodeName,
164
+ nodeOnline,
165
+ stats,
166
+ statsChart,
167
+ repos = [],
168
+ repoHref,
169
+ ports = [],
170
+ portHref,
171
+ onAddTag,
172
+ onRemoveTag,
173
+ editorHref,
174
+ onStop,
175
+ stopping = false,
176
+ onStart,
177
+ starting = false,
178
+ onRebuild,
179
+ rebuilding = false,
180
+ onDelete,
181
+ deleting = false,
182
+ onCopySsh,
183
+ error,
184
+ onDismissError,
185
+ activeTab,
186
+ onTabChange,
187
+ logsContent,
188
+ terminalContent,
189
+ onReconnectTerminal,
190
+ onOpenFullscreen,
191
+ className,
192
+ }: WorkerCockpitProps) {
193
+ const [mobileOpen, setMobileOpen] = useState(false)
194
+ const [sshState, setSshState] = useState<"idle" | "loading" | "copied" | "error">("idle")
195
+ const overlayContainer = useOverlayContainer()
196
+
197
+ const status = resolveWorkerStatus(worker)
198
+ const settled = status.running && !status.settingUp
199
+ const isStopped = worker.dockerState === "stopped" || worker.dockerState === "exited"
200
+ const isDeleting = status.key === "deleting"
201
+ const hasContainer = worker.containerId != null
202
+ const title = worker.name || (worker.index != null ? `Workspace #${worker.index}` : worker.id)
203
+ const outdated =
204
+ !status.settingUp &&
205
+ worker.projectVersion != null &&
206
+ currentProjectVersion != null &&
207
+ worker.projectVersion < currentProjectVersion
208
+ const needsAttention = !!error || nodeOnline === false || outdated
209
+
210
+ const Link: LinkRender = render?.link ?? (({ href: to, className: cls, children }) => <a href={to} className={cls}>{children}</a>)
211
+
212
+ async function handleCopySsh() {
213
+ if (!onCopySsh) return
214
+ setSshState("loading")
215
+ try {
216
+ const command = await onCopySsh()
217
+ await navigator.clipboard.writeText(command)
218
+ setSshState("copied")
219
+ setTimeout(() => setSshState("idle"), 2500)
220
+ } catch {
221
+ setSshState("error")
222
+ setTimeout(() => setSshState("idle"), 2500)
223
+ }
224
+ }
225
+
226
+ const showActions = !!((settled && onCopySsh) || (hasContainer && !status.settingUp && !isDeleting && onRebuild) || ((hasContainer || isDeleting) && nodeOnline !== false && onDelete))
227
+ const showTags = (worker.tags?.length ?? 0) > 0 || !!onAddTag
228
+
229
+ const controlPanel = (
230
+ <>
231
+ {error && (
232
+ <Banner tone="error">
233
+ <span className="line-clamp-2 min-w-0 flex-1 font-mono break-words">{error}</span>
234
+ {onDismissError && (
235
+ <button type="button" onClick={onDismissError} aria-label="Dismiss error" className="shrink-0 cursor-pointer opacity-60 transition-opacity hover:opacity-100">
236
+ <XIcon className="h-3.5 w-3.5" />
237
+ </button>
238
+ )}
239
+ </Banner>
240
+ )}
241
+ {nodeOnline === false && (
242
+ <Banner tone="offline">
243
+ <span className="flex min-w-0 items-center gap-2">
244
+ <WifiOffIcon className="h-3.5 w-3.5 shrink-0" />Node offline — actions unavailable
245
+ </span>
246
+ </Banner>
247
+ )}
248
+ {outdated && (
249
+ <Banner tone="warn">
250
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5">
251
+ <AlertTriangleIcon className="h-3.5 w-3.5 shrink-0" />
252
+ Config <span className="font-mono font-semibold">v{worker.projectVersion}</span> — project at{" "}
253
+ <span className="font-mono font-semibold">v{currentProjectVersion}</span>
254
+ </span>
255
+ {onRebuild && (
256
+ <button type="button" disabled={rebuilding} onClick={onRebuild} className="shrink-0 cursor-pointer text-[11px] font-medium hover:underline disabled:opacity-50">
257
+ {rebuilding ? "Rebuilding…" : "Rebuild now"}
258
+ </button>
259
+ )}
260
+ </Banner>
261
+ )}
262
+
263
+ {(status.settingUp || worker.setupStatus) && (
264
+ <div className="space-y-2">
265
+ <SectionLabel>Setup</SectionLabel>
266
+ <WorkerSetupTimeline status={worker.setupStatus ?? null} settingUp={status.settingUp} />
267
+ <SetupWaterfall timings={worker.setupStatus?.timings} />
268
+ </div>
269
+ )}
270
+
271
+ {stats !== undefined && (
272
+ <div>
273
+ <SectionLabel>Resources</SectionLabel>
274
+ {stats ? (
275
+ // `@container`: `ResourceBars` decides to go two-column via a
276
+ // container query, so it needs a container ancestor — here, this
277
+ // panel. Without it the bars stay stacked (they lose their own
278
+ // card once pulled into a sidebar).
279
+ <div className="@container space-y-3 rounded-lg border border-border/40 bg-muted/20 px-3 py-3">
280
+ <ResourceBars stats={stats} compact />
281
+ {statsChart && <div className="pt-1">{statsChart}</div>}
282
+ </div>
283
+ ) : (
284
+ <div className="flex items-center gap-2 py-2 font-mono text-xs text-muted-foreground">
285
+ <LoaderIcon className="h-3 w-3 animate-spin" /> Loading stats…
286
+ </div>
287
+ )}
288
+ </div>
289
+ )}
290
+
291
+ {repos.length > 0 && (
292
+ <div>
293
+ <SectionLabel>Repositories</SectionLabel>
294
+ <div className="space-y-1.5">
295
+ {repos.map((repo) => {
296
+ const href = repoHref?.(repo)
297
+ return (
298
+ <div key={repo.path} className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2">
299
+ <div className="flex items-center justify-between gap-2">
300
+ <div className="flex min-w-0 items-center gap-1.5">
301
+ <GitBranchIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
302
+ <span className="truncate font-mono text-xs font-medium">{repoName(repo)}</span>
303
+ </div>
304
+ {href && (
305
+ <Link href={href} className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-6 shrink-0 gap-1 px-2 text-[11px] text-muted-foreground")}>
306
+ <Code2Icon className="h-3 w-3" />Open
307
+ </Link>
308
+ )}
309
+ </div>
310
+ {repo.branch && (
311
+ <div className="mt-1.5 flex items-center gap-2 pl-[18px] text-[11px] text-muted-foreground">
312
+ <span className="font-mono">{repo.branch}</span>
313
+ {!!repo.modified && <span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-400">{repo.modified} modified</span>}
314
+ {!!repo.ahead && <span className="text-primary">↑{repo.ahead}</span>}
315
+ {!!repo.behind && <span className="text-orange-400">↓{repo.behind}</span>}
316
+ </div>
317
+ )}
318
+ </div>
319
+ )
320
+ })}
321
+ </div>
322
+ </div>
323
+ )}
324
+
325
+ {ports.length > 0 && (
326
+ <div>
327
+ <SectionLabel>Open ports</SectionLabel>
328
+ <div className="space-y-1.5">
329
+ {ports.map((p) => {
330
+ const href = portHref?.(p.port)
331
+ return (
332
+ <div key={p.port} className="flex items-center justify-between rounded-lg border border-border/50 bg-muted/20 px-3 py-2">
333
+ <div className="flex min-w-0 items-center gap-2">
334
+ <span className="font-mono text-xs text-foreground">:{p.port}</span>
335
+ {p.process && <span className="truncate text-[11px] text-muted-foreground">{p.process}</span>}
336
+ </div>
337
+ {href && (
338
+ <Link href={href} className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-6 shrink-0 gap-1 px-2 text-[11px] text-muted-foreground")}>
339
+ <ExternalLinkIcon className="h-3 w-3" />Open
340
+ </Link>
341
+ )}
342
+ </div>
343
+ )
344
+ })}
345
+ </div>
346
+ </div>
347
+ )}
348
+
349
+ {showTags && (
350
+ <div>
351
+ <SectionLabel>Tags</SectionLabel>
352
+ <TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
353
+ </div>
354
+ )}
355
+
356
+ {showActions && (
357
+ <div>
358
+ <SectionLabel>Actions</SectionLabel>
359
+ <div className="space-y-1.5">
360
+ {settled && onCopySsh && (
361
+ <button
362
+ type="button"
363
+ onClick={handleCopySsh}
364
+ disabled={sshState === "loading"}
365
+ className={cn(
366
+ "flex w-full items-center justify-between gap-3 rounded-lg border border-border/50 bg-muted/20 px-3 py-2.5 text-xs transition-colors hover:bg-muted/40",
367
+ sshState === "copied" ? "border-green-500/30 text-green-600 dark:text-green-400" : "text-foreground"
368
+ )}
369
+ >
370
+ <div className="flex items-center gap-2">
371
+ {sshState === "copied" ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5 text-muted-foreground" />}
372
+ <span className="font-medium">
373
+ {sshState === "copied" ? "SSH command copied!" : sshState === "error" ? "Failed to copy — try again" : "Copy SSH command"}
374
+ </span>
375
+ </div>
376
+ <span className="text-[10px] text-muted-foreground/50">5 min token</span>
377
+ </button>
378
+ )}
379
+ {hasContainer && !status.settingUp && !isDeleting && onRebuild && (
380
+ <button type="button" disabled={rebuilding} onClick={onRebuild} className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-muted/20 px-3 py-2.5 text-xs text-foreground transition-colors hover:bg-muted/40 disabled:opacity-50">
381
+ <RefreshCwIcon className={cn("h-3.5 w-3.5 text-muted-foreground", rebuilding && "animate-spin")} />
382
+ <span className="font-medium">Rebuild container</span>
383
+ <span className="ml-auto text-[10px] text-muted-foreground/50">keeps workspace</span>
384
+ </button>
385
+ )}
386
+ {(hasContainer || isDeleting) && nodeOnline !== false && onDelete && (
387
+ <button type="button" disabled={deleting || isDeleting} onClick={onDelete} className="flex w-full items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-xs text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50">
388
+ {isDeleting ? <LoaderIcon className="h-3.5 w-3.5 animate-spin" /> : <Trash2Icon className="h-3.5 w-3.5" />}
389
+ <span className="font-medium">Delete workspace</span>
390
+ <span className="ml-auto text-[10px] opacity-60">permanent</span>
391
+ </button>
392
+ )}
393
+ </div>
394
+ </div>
395
+ )}
396
+
397
+ <div>
398
+ <SectionLabel>Info</SectionLabel>
399
+ <div className="divide-y divide-border/40 rounded-lg border border-border/50 bg-muted/20">
400
+ {creator && (
401
+ <InfoRow label="Owner">
402
+ <div className="flex items-center gap-1.5"><CreatorAvatar creator={creator} /><span className="text-[11px] font-medium">{creator.name}</span></div>
403
+ </InfoRow>
404
+ )}
405
+ {nodeName && (
406
+ <InfoRow label="Node">
407
+ <div className="flex items-center gap-1.5">
408
+ <div className={cn("h-1.5 w-1.5 rounded-full", nodeOnline === false ? "bg-zinc-400" : "bg-green-500")} />
409
+ <span className="font-mono text-[11px] font-medium">{nodeName}</span>
410
+ </div>
411
+ </InfoRow>
412
+ )}
413
+ {worker.projectVersion != null && (
414
+ <InfoRow label="Config version"><span className="font-mono text-[11px] font-medium">v{worker.projectVersion}</span></InfoRow>
415
+ )}
416
+ {worker.branch && (
417
+ <InfoRow label="Branch">
418
+ <div className="flex min-w-0 items-center gap-1.5">
419
+ <GitBranchIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
420
+ <span className="truncate font-mono text-[11px] font-medium">{worker.branch}</span>
421
+ </div>
422
+ </InfoRow>
423
+ )}
424
+ {worker.createdAt && <InfoRow label="Created"><span className="text-[11px] text-muted-foreground">{formatRelativeTime(worker.createdAt)}</span></InfoRow>}
425
+ {worker.containerId && <InfoRow label="Container"><span className="font-mono text-[11px] text-muted-foreground">{worker.containerId.slice(0, 12)}</span></InfoRow>}
426
+ </div>
427
+ </div>
428
+ </>
429
+ )
430
+
431
+ return (
432
+ // `@container`: every breakpoint below queries THIS element's own width,
433
+ // never the viewport — the same cockpit is a 1150px page in the dashboard
434
+ // and a 380px frame in the showcase, and a viewport media query can't tell
435
+ // those apart (see docs/design-system.md's ResourceBars gotcha).
436
+ <div data-slot="worker-cockpit" className={cn("@container flex flex-col overflow-hidden", className)}>
437
+ {/* ── Top bar ── */}
438
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border bg-card/60 px-5 py-3 backdrop-blur-sm @min-[48rem]:px-6">
439
+ <div className="flex min-w-0 items-center gap-2">
440
+ {backHref && (
441
+ <Link href={backHref} className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-8 w-8 shrink-0")}>
442
+ <ArrowLeftIcon className="h-4 w-4" />
443
+ </Link>
444
+ )}
445
+ <div className="h-4 w-px bg-border" />
446
+ {projectName && (
447
+ <>
448
+ <span className="hidden truncate text-sm text-muted-foreground @min-[40rem]:block">{projectName}</span>
449
+ <span className="hidden text-muted-foreground/40 @min-[40rem]:block">/</span>
450
+ </>
451
+ )}
452
+ <div className="flex min-w-0 items-center gap-2">
453
+ <WorkerStatusDot status={status} />
454
+ <span className="truncate text-sm font-semibold">{title}</span>
455
+ <WorkerStatusPill status={status} />
456
+ </div>
457
+ </div>
458
+ <div className="flex shrink-0 items-center gap-2">
459
+ {settled && editorHref && (
460
+ <Link href={editorHref} className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8 gap-1.5 px-3 text-xs")}>
461
+ <Code2Icon className="h-3.5 w-3.5" />Open VS Code<ExternalLinkIcon className="h-2.5 w-2.5 opacity-70" />
462
+ </Link>
463
+ )}
464
+ {settled && onStop && (
465
+ <Button variant="ghost" size="sm" className="h-8 gap-1.5 px-3 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive" disabled={stopping} onClick={onStop}>
466
+ <SquareIcon className="h-3.5 w-3.5" />Stop
467
+ </Button>
468
+ )}
469
+ {(isStopped || worker.dockerState === "error") && nodeOnline !== false && onStart && (
470
+ <Button variant="outline" size="sm" className="h-8 gap-1.5 px-3 text-xs" disabled={starting} onClick={onStart}>
471
+ <PlayIcon className="h-3.5 w-3.5" />Start
472
+ </Button>
473
+ )}
474
+ </div>
475
+ </div>
476
+
477
+ {/* ── Body ── */}
478
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden @min-[64rem]:flex-row">
479
+ {/* Plain buttons, not the design system's `Tabs` — this bar matches the
480
+ worker page's existing look (bg-accent on the active tab) rather than
481
+ the segmented-control style `Tabs` draws elsewhere, so adopting this
482
+ component doesn't change how the page looks. */}
483
+ <div className="flex min-h-0 flex-1 flex-col border-border @min-[64rem]:border-r" style={{ minHeight: 320 }}>
484
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-border bg-card px-3 py-1.5">
485
+ <div className="flex items-center gap-0.5 overflow-x-auto">
486
+ {(
487
+ [
488
+ { id: "logs" as CockpitTab, label: "Logs", icon: AlignLeftIcon },
489
+ { id: "terminal" as CockpitTab, label: "Terminal", icon: TerminalIcon },
490
+ ] satisfies { id: CockpitTab; label: string; icon: ComponentType<{ className?: string }> }[]
491
+ ).map(({ id, label, icon: Icon }) => (
492
+ <button
493
+ key={id}
494
+ onClick={() => onTabChange(id)}
495
+ className={cn(
496
+ "flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
497
+ activeTab === id ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
498
+ )}
499
+ >
500
+ <Icon className="h-3.5 w-3.5" />{label}
501
+ </button>
502
+ ))}
503
+ </div>
504
+ <div className="flex shrink-0 items-center gap-1.5">
505
+ {settled && onOpenFullscreen && (
506
+ <button onClick={onOpenFullscreen} className="flex shrink-0 items-center gap-1.5 rounded-md border border-primary/30 bg-primary/10 px-2.5 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20" title="Open fullscreen terminal">
507
+ <Maximize2Icon className="h-3.5 w-3.5" />
508
+ <span className="hidden @min-[40rem]:inline">Fullscreen</span>
509
+ </button>
510
+ )}
511
+ <span className="hidden font-mono text-[10px] text-muted-foreground/50 @min-[40rem]:inline">{title}</span>
512
+ {activeTab === "terminal" && onReconnectTerminal && (
513
+ <button onClick={onReconnectTerminal} className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:bg-accent hover:text-muted-foreground" title="Reconnect">
514
+ <RefreshCwIcon className="h-3 w-3" />
515
+ </button>
516
+ )}
517
+ <button onClick={() => setMobileOpen(true)} className="relative flex h-6 w-6 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:bg-accent hover:text-muted-foreground @min-[64rem]:hidden" title="Workspace details">
518
+ <PanelRightOpenIcon className="h-3.5 w-3.5" />
519
+ {needsAttention && <span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-amber-500" />}
520
+ </button>
521
+ </div>
522
+ </div>
523
+ <div className="min-h-0 flex-1">
524
+ {activeTab === "logs" ? logsContent : terminalContent}
525
+ </div>
526
+ </div>
527
+
528
+ {/* Desktop: always-visible sidebar. Mobile gets the same content in a
529
+ swipeable bottom drawer (below) so the terminal can own the full
530
+ screen. */}
531
+ <div className="hidden shrink-0 overflow-y-auto bg-card @min-[64rem]:block @min-[64rem]:w-80 @min-[80rem]:w-96">
532
+ <div className="space-y-5 p-4">{controlPanel}</div>
533
+ </div>
534
+ </div>
535
+
536
+ {/* Mobile: the same `vaul` drawer apps/next uses (drag handle,
537
+ swipe-to-dismiss) — not the design system's `Sheet`, which has
538
+ neither. `Sheet` is the right choice for the rest of the package
539
+ (right/left/top panels with no drag gesture); this one thing a
540
+ worker page needs that `Sheet` doesn't do. */}
541
+ <DrawerPrimitive.Root open={mobileOpen} onOpenChange={setMobileOpen} container={overlayContainer ?? undefined}>
542
+ <DrawerPrimitive.Portal>
543
+ <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]" />
544
+ <DrawerPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 mt-24 flex max-h-[80vh] flex-col rounded-t-xl border-t border-border bg-background @min-[64rem]:hidden">
545
+ <div className="mx-auto mt-4 h-1 w-[100px] shrink-0 rounded-full bg-muted" />
546
+ <DrawerPrimitive.Title className="px-4 pt-3 text-base font-semibold text-foreground">
547
+ Workspace details
548
+ </DrawerPrimitive.Title>
549
+ <div className="min-h-0 space-y-5 overflow-y-auto px-4 pt-3 pb-6">{controlPanel}</div>
550
+ </DrawerPrimitive.Content>
551
+ </DrawerPrimitive.Portal>
552
+ </DrawerPrimitive.Root>
553
+ </div>
554
+ )
555
+ }
@@ -0,0 +1,181 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { CheckIcon, GitBranchIcon, LoaderIcon, TerminalIcon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { StepIndicator, phaseLabel, setupProgress, type StepState } from "./setup-progress"
8
+ import type { WorkerSetupStatus } from "./types"
9
+
10
+ /**
11
+ * A worker's setup, step by step: the phase, the bar, and one line per thing being done — a
12
+ * repository cloned, a lifecycle command running.
13
+ *
14
+ * `SetupProgress` answers "how far along?" in one bar, which is what a card or a table row
15
+ * wants. This answers "what is it doing *right now*?", which is the question anyone staring at
16
+ * a machine that isn't ready yet actually has. Hookless and presentational: give it a status
17
+ * snapshot, it draws it.
18
+ */
19
+
20
+ type Step = { key: string; icon: ReactNode; label: string; state: StepState; suffix?: string }
21
+
22
+ /** `cloning` is the repo-level spelling of `running`; the other states line up already. */
23
+ function stepState(value: string | null | undefined): StepState {
24
+ if (value === "cloning" || value === "running") return "running"
25
+ if (value === "done" || value === "error" || value === "pending") return value
26
+ return "pending"
27
+ }
28
+
29
+ function steps(status: WorkerSetupStatus | null): Step[] {
30
+ if (!status) return []
31
+ const out: Step[] = []
32
+
33
+ for (const repo of status.repos ?? []) {
34
+ const name = repo.name ?? ""
35
+ const state = stepState(repo.state)
36
+ out.push({
37
+ key: `repo:${name}`,
38
+ icon: <GitBranchIcon className="h-3 w-3 shrink-0 opacity-70" />,
39
+ label: name.split("/").pop() || name,
40
+ state,
41
+ suffix: state === "running" ? "cloning…" : undefined,
42
+ })
43
+ }
44
+
45
+ // `null` means the project has no such command — not that it is pending.
46
+ for (const [key, value] of [
47
+ ["postCreate", status.postCreate],
48
+ ["postStart", status.postStart],
49
+ ] as const) {
50
+ if (value == null) continue
51
+ const state = stepState(value)
52
+ out.push({
53
+ key,
54
+ icon: <TerminalIcon className="h-3 w-3 shrink-0 opacity-70" />,
55
+ label: key,
56
+ state,
57
+ suffix: state === "running" ? "running…" : undefined,
58
+ })
59
+ }
60
+
61
+ return out
62
+ }
63
+
64
+ export function WorkerSetupTimeline({
65
+ status,
66
+ settingUp = false,
67
+ className,
68
+ }: {
69
+ status: WorkerSetupStatus | null
70
+ /** The machine is still being provisioned — drives the bar and the amber framing. */
71
+ settingUp?: boolean
72
+ className?: string
73
+ }) {
74
+ if (!status && !settingUp) return null
75
+
76
+ const pct = setupProgress(status)
77
+ const failed = status?.phase === "error"
78
+ const done = status?.phase === "ready"
79
+ const rows = steps(status)
80
+
81
+ return (
82
+ <div
83
+ className={cn(
84
+ "rounded-lg border text-xs",
85
+ failed
86
+ ? "border-destructive/20 bg-destructive/5"
87
+ : settingUp
88
+ ? "border-yellow-500/20 bg-yellow-500/5"
89
+ : "border-border/40 bg-muted/20",
90
+ className,
91
+ )}
92
+ >
93
+ <div className="flex items-center justify-between border-b border-inherit px-3 py-2">
94
+ <span
95
+ className={cn(
96
+ "font-mono text-[11px] font-medium",
97
+ failed
98
+ ? "text-destructive"
99
+ : settingUp
100
+ ? "text-yellow-500 dark:text-yellow-400"
101
+ : done
102
+ ? "text-green-600 dark:text-green-400"
103
+ : "text-muted-foreground",
104
+ )}
105
+ >
106
+ {settingUp ? phaseLabel(status) : done ? "Setup complete" : failed ? "Setup failed" : "Setup"}
107
+ </span>
108
+ {settingUp && <span className="font-mono text-[11px] tabular-nums text-muted-foreground">{pct}%</span>}
109
+ {done && <CheckIcon className="h-3 w-3 text-green-500" />}
110
+ </div>
111
+
112
+ {settingUp && (
113
+ <div className="px-3 pb-1 pt-2.5">
114
+ <div className="h-1 overflow-hidden rounded-full bg-muted">
115
+ <div
116
+ className={cn(
117
+ "h-full rounded-full transition-all duration-700 motion-reduce:transition-none",
118
+ failed ? "bg-destructive" : "bg-yellow-400",
119
+ )}
120
+ style={{ width: `${pct}%` }}
121
+ />
122
+ </div>
123
+ </div>
124
+ )}
125
+
126
+ {(rows.length > 0 || settingUp || failed) && (
127
+ <div className="space-y-2 px-3 py-2.5">
128
+ {rows.length > 0 ? (
129
+ rows.map((step) => (
130
+ <div key={step.key} className="flex items-center gap-2">
131
+ <StepIndicator state={step.state} />
132
+ <span
133
+ className={cn(
134
+ "shrink-0",
135
+ step.state === "done"
136
+ ? "text-muted-foreground/50"
137
+ : step.state === "running"
138
+ ? "text-foreground/80"
139
+ : step.state === "error"
140
+ ? "text-destructive/70"
141
+ : "text-muted-foreground/30",
142
+ )}
143
+ >
144
+ {step.icon}
145
+ </span>
146
+ <span
147
+ className={cn(
148
+ "min-w-0 truncate font-mono leading-none",
149
+ step.state === "done"
150
+ ? "text-muted-foreground/70"
151
+ : step.state === "running"
152
+ ? "font-medium text-foreground"
153
+ : step.state === "error"
154
+ ? "text-destructive"
155
+ : "text-muted-foreground/40",
156
+ )}
157
+ >
158
+ {step.label}
159
+ </span>
160
+ {step.suffix && <span className="font-mono text-muted-foreground/50">{step.suffix}</span>}
161
+ </div>
162
+ ))
163
+ ) : settingUp ? (
164
+ // No step yet: the container exists but its agent hasn't reported a phase — which is
165
+ // most of the wait on a cold start, so it gets a line rather than an empty box.
166
+ <div className="flex items-center gap-2 text-muted-foreground">
167
+ <LoaderIcon className="h-3 w-3 shrink-0 animate-spin motion-reduce:animate-none" />
168
+ <span className="font-mono">Waiting for container…</span>
169
+ </div>
170
+ ) : null}
171
+
172
+ {failed && status?.error && (
173
+ <div className="mt-1 break-all rounded bg-destructive/10 px-2 py-1.5 font-mono text-[11px] leading-4 text-destructive">
174
+ {status.error}
175
+ </div>
176
+ )}
177
+ </div>
178
+ )}
179
+ </div>
180
+ )
181
+ }