@spunto/design-system 0.7.2 → 0.9.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,120 @@
1
+ // "repo · branch +3" chips — what a worker is actually working on.
2
+ //
3
+ // The dashboard's version shows the modified count; Lite's also shows ahead /
4
+ // behind. This is the union: every counter is rendered only when the caller
5
+ // supplies it, so neither app has to send a field it doesn't have.
6
+
7
+ import { ArrowDownIcon, ArrowUpIcon, ExternalLinkIcon, GitBranchIcon } from "lucide-react"
8
+
9
+ import { cn } from "../../utils"
10
+
11
+ export interface GitRepoStatus {
12
+ /** Absolute path of the checkout inside the worker — also the React key. */
13
+ path: string
14
+ branch?: string | null
15
+ /** Display name; defaults to the last segment of `path`. */
16
+ name?: string
17
+ /** Uncommitted files. */
18
+ modified?: number
19
+ /** Commits ahead of / behind the upstream branch. */
20
+ ahead?: number
21
+ behind?: number
22
+ }
23
+
24
+ export interface GitBranchChipsProps {
25
+ repos: GitRepoStatus[]
26
+ /**
27
+ * Chip → URL. A slot, not a link component: the design system knows nothing
28
+ * about code-server URLs, and returning `undefined` renders a plain span.
29
+ */
30
+ repoHref?: (repo: GitRepoStatus) => string | undefined
31
+ className?: string
32
+ }
33
+
34
+ export function repoName(repo: GitRepoStatus): string {
35
+ return repo.name ?? repo.path.split("/").filter(Boolean).pop() ?? repo.path
36
+ }
37
+
38
+ /** One chip per checked-out repository, wrapping onto as many lines as needed. */
39
+ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsProps) {
40
+ if (!repos.length) return null
41
+
42
+ return (
43
+ <div data-slot="git-branch-chips" className={cn("flex flex-wrap gap-1.5", className)}>
44
+ {repos.map((repo) => {
45
+ const href = repoHref?.(repo)
46
+ const Tag = href ? "a" : "span"
47
+ return (
48
+ <Tag
49
+ key={repo.path}
50
+ {...(href ? { href, target: "_blank", rel: "noopener noreferrer" } : {})}
51
+ title={repo.path}
52
+ className={cn(
53
+ "inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground",
54
+ href && "transition-colors hover:border-border/80 hover:bg-muted/70"
55
+ )}
56
+ >
57
+ <span className="font-medium text-foreground/70">{repoName(repo)}</span>
58
+ <span className="mx-0.5 text-muted-foreground/40">·</span>
59
+ <GitBranchIcon className="h-2.5 w-2.5 shrink-0" />
60
+ <span className="max-w-[120px] truncate">{repo.branch || "—"}</span>
61
+ <RepoCounters repo={repo} />
62
+ {href && <ExternalLinkIcon className="ml-0.5 h-2.5 w-2.5 opacity-40" />}
63
+ </Tag>
64
+ )
65
+ })}
66
+ </div>
67
+ )
68
+ }
69
+
70
+ /** Amber for local work not yet committed/pushed, muted for what's merely behind. */
71
+ function RepoCounters({ repo }: { repo: GitRepoStatus }) {
72
+ return (
73
+ <>
74
+ {!!repo.modified && <span className="ml-0.5 text-amber-500 dark:text-amber-400">+{repo.modified}</span>}
75
+ {!!repo.ahead && (
76
+ <span className="ml-0.5 inline-flex items-center text-amber-500 dark:text-amber-400">
77
+ <ArrowUpIcon className="h-2.5 w-2.5" />
78
+ {repo.ahead}
79
+ </span>
80
+ )}
81
+ {!!repo.behind && (
82
+ <span className="ml-0.5 inline-flex items-center text-muted-foreground/70">
83
+ <ArrowDownIcon className="h-2.5 w-2.5" />
84
+ {repo.behind}
85
+ </span>
86
+ )}
87
+ </>
88
+ )
89
+ }
90
+
91
+ export interface GitStatusSummaryProps {
92
+ repos: GitRepoStatus[]
93
+ /** Heading above the list — e.g. "Open in VS Code" inside a tooltip. */
94
+ title?: string
95
+ className?: string
96
+ }
97
+
98
+ /**
99
+ * The same information as the chips, stacked one repo per line. Meant for the
100
+ * inside of a tooltip or a popover, where chips would wrap badly — the tooltip
101
+ * itself stays the app's business (the design system doesn't decide what opens
102
+ * it).
103
+ */
104
+ export function GitStatusSummary({ repos, title, className }: GitStatusSummaryProps) {
105
+ if (!repos.length) return null
106
+
107
+ return (
108
+ <span data-slot="git-status-summary" className={cn("flex flex-col gap-1", className)}>
109
+ {title && <span className="text-[10px] uppercase tracking-wide opacity-60">{title}</span>}
110
+ {repos.map((repo) => (
111
+ <span key={repo.path} className="flex items-center gap-1.5">
112
+ <span className="font-medium">{repoName(repo)}</span>
113
+ <GitBranchIcon className="h-3 w-3 opacity-60" />
114
+ <span className="font-mono">{repo.branch || "—"}</span>
115
+ <RepoCounters repo={repo} />
116
+ </span>
117
+ ))}
118
+ </span>
119
+ )
120
+ }
@@ -0,0 +1,35 @@
1
+ // @spunto/design-system/workers — Spunto's *domain* components.
2
+ //
3
+ // Deliberately a sub-export rather than part of the main entry: the root of the
4
+ // package stays "primitives with no domain" (a Button knows nothing about a
5
+ // worker), and a third party consuming the design system never has to carry the
6
+ // notion of a worker. New business components go here — see the README's
7
+ // "Domain components" section for the rule.
8
+
9
+ export { WorkerCard } from "./worker-card"
10
+ export type { WorkerCardProps, LinkRender } from "./worker-card"
11
+
12
+ export { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus, workerStatusConfig } from "./worker-status"
13
+ export type { WorkerStatus, WorkerStatusConfig, WorkerStatusKey } from "./worker-status"
14
+
15
+ export { SetupProgress, StepIndicator, phaseLabel, setupProgress, setupTotalMs } from "./setup-progress"
16
+ export type { SetupProgressProps, StepState } from "./setup-progress"
17
+
18
+ export { ResourceBars } from "./resource-bars"
19
+ export type { ResourceBarsProps, WorkerStats } from "./resource-bars"
20
+
21
+ export { GitBranchChips, GitStatusSummary, repoName } from "./git-branch-chips"
22
+ export type { GitBranchChipsProps, GitRepoStatus, GitStatusSummaryProps } from "./git-branch-chips"
23
+
24
+ export { TagChips } from "./tag-chips"
25
+ export type { TagChipsProps } from "./tag-chips"
26
+ export { tagColor } from "./tag-color"
27
+ export type { TagPalette } from "./tag-color"
28
+
29
+ export { CreatorAvatar } from "./creator-avatar"
30
+
31
+ export type { WorkerCardWorker, WorkerCreator, WorkerSetupStatus } from "./types"
32
+
33
+ // Re-exported here too so a consumer of the worker surface doesn't have to
34
+ // import the root entry just to format the timestamps it feeds these components.
35
+ export { cn, formatDuration, formatRelativeTime } from "../../utils"
@@ -0,0 +1,100 @@
1
+ // CPU + memory of a running container, as two thin bars.
2
+ //
3
+ // Written identically in the dashboard's card and its table row — the `compact`
4
+ // flag is the only thing that ever differed between the two copies.
5
+
6
+ import type { ReactNode } from "react"
7
+ import { CpuIcon, MemoryStickIcon } from "lucide-react"
8
+
9
+ import { cn } from "../../utils"
10
+
11
+ export interface WorkerStats {
12
+ cpuPercent: number
13
+ memUsageMb: number
14
+ memLimitMb?: number
15
+ memPercent?: number
16
+ }
17
+
18
+ export interface ResourceBarsProps {
19
+ stats: WorkerStats
20
+ /** Drops the framed panel — just the two bars, for a table cell or a tooltip. */
21
+ compact?: boolean
22
+ className?: string
23
+ }
24
+
25
+ /** Past this, the bar turns red: the container is about to be the problem. */
26
+ const DANGER = 80
27
+
28
+ export function ResourceBars({ stats, compact = false, className }: ResourceBarsProps) {
29
+ const cpu = stats.cpuPercent ?? 0
30
+ const mem = stats.memPercent ?? 0
31
+
32
+ return (
33
+ <div
34
+ data-slot="resource-bars"
35
+ className={cn(
36
+ "grid grid-cols-2 gap-3",
37
+ !compact && "rounded-lg border border-border/40 bg-muted/20 px-3 py-2.5",
38
+ className
39
+ )}
40
+ >
41
+ <Metric
42
+ icon={<CpuIcon className="h-3 w-3" />}
43
+ label="CPU"
44
+ value={`${cpu.toFixed(1)}%`}
45
+ valueClass={cpu > DANGER ? "text-orange-500 dark:text-orange-400" : "text-foreground/70"}
46
+ percent={cpu}
47
+ // Flame for CPU, Build amber for memory: two bars side by side need to be
48
+ // told apart at a glance, and both hues already mean "compute" here.
49
+ barClass={cpu > DANGER ? "bg-destructive" : "bg-primary"}
50
+ />
51
+ <Metric
52
+ icon={<MemoryStickIcon className="h-3 w-3" />}
53
+ label="Memory"
54
+ value={
55
+ <>
56
+ {(stats.memUsageMb ?? 0).toFixed(0)}
57
+ <span className="text-muted-foreground/50"> MB</span>
58
+ </>
59
+ }
60
+ valueClass="text-foreground/70"
61
+ percent={mem}
62
+ barClass={mem > DANGER ? "bg-destructive" : "bg-build"}
63
+ />
64
+ </div>
65
+ )
66
+ }
67
+
68
+ function Metric({
69
+ icon,
70
+ label,
71
+ value,
72
+ valueClass,
73
+ percent,
74
+ barClass,
75
+ }: {
76
+ icon: ReactNode
77
+ label: string
78
+ value: ReactNode
79
+ valueClass: string
80
+ percent: number
81
+ barClass: string
82
+ }) {
83
+ return (
84
+ <div>
85
+ <div className="mb-1.5 flex items-center justify-between">
86
+ <span className="flex items-center gap-1 text-[11px] text-muted-foreground">
87
+ {icon}
88
+ {label}
89
+ </span>
90
+ <span className={cn("font-mono text-[11px] tabular-nums", valueClass)}>{value}</span>
91
+ </div>
92
+ <div className="h-1 overflow-hidden rounded-full bg-muted">
93
+ <div
94
+ className={cn("h-full rounded-full transition-all duration-500 motion-reduce:transition-none", barClass)}
95
+ style={{ width: `${Math.min(Math.max(percent, 0), 100)}%` }}
96
+ />
97
+ </div>
98
+ </div>
99
+ )
100
+ }
@@ -0,0 +1,130 @@
1
+ // Worker setup, as a percentage and a sentence.
2
+ //
3
+ // The phase → percentage switch and the phase → label switch were written twice,
4
+ // by hand, in two apps — and are the kind of thing that drifts silently (a phase
5
+ // added on the agent side lands in one app's switch and not the other's). One
6
+ // copy, here, plus the bar itself.
7
+ //
8
+ // Hookless, so it stays callable from a React Server Component.
9
+
10
+ import { CheckIcon, CircleIcon, LoaderIcon } from "lucide-react"
11
+
12
+ import { cn } from "../../utils"
13
+ import type { WorkerSetupStatus } from "./types"
14
+
15
+ /**
16
+ * Setup completion, 0-100.
17
+ *
18
+ * The numbers are deliberately uneven: they're calibrated on how long each phase
19
+ * actually takes, not on how many phases there are. Cloning gets a 30-point
20
+ * span sub-divided by repository, because it's the phase where a user watches
21
+ * the longest and the only one with real sub-progress to report.
22
+ */
23
+ export function setupProgress(status: WorkerSetupStatus | null | undefined): number {
24
+ if (!status) return 5
25
+ switch (status.phase) {
26
+ case "initializing": return 10
27
+ case "credentials": return 25
28
+ case "dotfiles": return 35
29
+ case "cloning": {
30
+ const repos = status.repos ?? []
31
+ const done = repos.filter((r) => r.state === "done").length
32
+ const total = repos.length || 1
33
+ return 40 + Math.round((done / total) * 30)
34
+ }
35
+ case "lifecycle": {
36
+ if (status.postCreate === "running") return 75
37
+ if (status.postCreate === "done" && status.postStart === "running") return 88
38
+ return 80
39
+ }
40
+ case "ready": return 100
41
+ case "error": return 100
42
+ default: return 5
43
+ }
44
+ }
45
+
46
+ /** Human phase label — the line printed above the bar. */
47
+ export function phaseLabel(status: WorkerSetupStatus | null | undefined): string {
48
+ if (!status) return "Starting up…"
49
+ switch (status.phase) {
50
+ case "initializing": return "Starting up…"
51
+ case "credentials": return "Configuring credentials…"
52
+ case "dotfiles": return "Installing dotfiles…"
53
+ case "cloning": return "Cloning repositories…"
54
+ case "lifecycle":
55
+ if (status.postCreate === "running") return "Running post-create command…"
56
+ if (status.postStart === "running") return "Running post-start command…"
57
+ return "Running setup commands…"
58
+ case "ready": return "Ready"
59
+ case "error": return "Setup failed"
60
+ default: return "Setting up…"
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Total startup duration (ms) from the container-side phase timings, or null
66
+ * when we can't bracket it — old or crashed workers have no `ready` stamp, and
67
+ * an invented duration would be worse than no duration.
68
+ */
69
+ export function setupTotalMs(status: WorkerSetupStatus | null | undefined): number | null {
70
+ const t = status?.timings
71
+ if (!t || t.initializing == null || t.ready == null) return null
72
+ const d = t.ready - t.initializing
73
+ return d > 0 ? d : null
74
+ }
75
+
76
+ export interface SetupProgressProps {
77
+ status?: WorkerSetupStatus | null
78
+ /** Hides the phase line + percentage, keeping only the bar (table rows). */
79
+ barOnly?: boolean
80
+ className?: string
81
+ }
82
+
83
+ /** Phase label + percentage + the bar itself. Red as soon as the setup failed. */
84
+ export function SetupProgress({ status, barOnly = false, className }: SetupProgressProps) {
85
+ const pct = setupProgress(status)
86
+ const failed = status?.phase === "error"
87
+
88
+ return (
89
+ <div className={cn("w-full", className)}>
90
+ {!barOnly && (
91
+ <div className="mb-1.5 flex items-center justify-between gap-2">
92
+ <span
93
+ className={cn(
94
+ "min-w-0 truncate font-mono text-[11px]",
95
+ failed ? "text-destructive" : "text-yellow-500 dark:text-yellow-400"
96
+ )}
97
+ >
98
+ {phaseLabel(status)}
99
+ </span>
100
+ <span className="shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground">{pct}%</span>
101
+ </div>
102
+ )}
103
+ <div className="h-1 overflow-hidden rounded-full bg-muted">
104
+ <div
105
+ className={cn(
106
+ "h-full rounded-full transition-all duration-700 motion-reduce:transition-none",
107
+ failed ? "bg-destructive" : "bg-yellow-400"
108
+ )}
109
+ style={{ width: `${pct}%` }}
110
+ />
111
+ </div>
112
+ </div>
113
+ )
114
+ }
115
+
116
+ export type StepState = "pending" | "running" | "done" | "error"
117
+
118
+ /**
119
+ * The per-step bullet of a detailed setup view (one repository being cloned, a
120
+ * lifecycle command running). Same four states, same four colours, everywhere.
121
+ */
122
+ export function StepIndicator({ state, className }: { state: StepState; className?: string }) {
123
+ const common = cn("h-3 w-3 shrink-0", className)
124
+
125
+ if (state === "running")
126
+ return <LoaderIcon className={cn(common, "animate-spin text-yellow-400 motion-reduce:animate-none")} />
127
+ if (state === "done") return <CheckIcon className={cn(common, "text-green-500")} />
128
+ if (state === "error") return <CircleIcon className={cn(common, "fill-destructive text-destructive")} />
129
+ return <CircleIcon className={cn(common, "text-muted-foreground/30")} />
130
+ }
@@ -0,0 +1,132 @@
1
+ "use client"
2
+
3
+ // Free-form worker tags, coloured by hash.
4
+ //
5
+ // Read-only by default (Spunto Lite's case). Pass `onAdd`/`onRemove` and the
6
+ // chips grow a cross and a "+" button — the *persistence* stays with the caller,
7
+ // this component only reports intent.
8
+
9
+ import { useEffect, useRef, useState } from "react"
10
+ import { PlusIcon, XIcon } from "lucide-react"
11
+
12
+ import { cn } from "../../utils"
13
+ import { tagColor } from "./tag-color"
14
+
15
+ export interface TagChipsProps {
16
+ tags: string[]
17
+ /** Adding stays enabled while the caller persists — the chip is already there. */
18
+ onAdd?: (tag: string) => void
19
+ onRemove?: (tag: string) => void
20
+ /** Longest accepted tag; anything past it is truncated on commit. */
21
+ maxLength?: number
22
+ className?: string
23
+ }
24
+
25
+ export function TagChips({ tags, onAdd, onRemove, maxLength = 32, className }: TagChipsProps) {
26
+ const editable = !!(onAdd || onRemove)
27
+ // Local echo so a freshly typed tag appears instantly, resynced whenever the
28
+ // caller's list changes — the server's answer always wins in the end.
29
+ const [local, setLocal] = useState<string[]>(tags)
30
+ const [input, setInput] = useState("")
31
+ const [adding, setAdding] = useState(false)
32
+ const inputRef = useRef<HTMLInputElement>(null)
33
+
34
+ // Compared by content, not by reference: callers routinely pass
35
+ // `worker.tags ?? []`, a fresh array on every poll, and swapping state for an
36
+ // identical list would re-render the whole card every few seconds.
37
+ useEffect(() => {
38
+ setLocal((prev) => (prev.length === tags.length && prev.every((t, i) => t === tags[i]) ? prev : tags))
39
+ }, [tags])
40
+
41
+ function commit() {
42
+ // Spaces and commas are the separators a user reflexively types; swallowing
43
+ // them here keeps "one chip = one word" true without an error message.
44
+ const raw = input.trim().replace(/,/g, "").slice(0, maxLength)
45
+ if (raw && !local.includes(raw)) {
46
+ setLocal([...local, raw])
47
+ onAdd?.(raw)
48
+ }
49
+ setInput("")
50
+ setAdding(false)
51
+ }
52
+
53
+ function remove(tag: string) {
54
+ setLocal(local.filter((t) => t !== tag))
55
+ onRemove?.(tag)
56
+ }
57
+
58
+ const shown = editable ? local : tags
59
+ if (!shown.length && !editable) return null
60
+
61
+ return (
62
+ <>
63
+ {shown.map((tag) => {
64
+ const c = tagColor(tag)
65
+ return (
66
+ <span
67
+ key={tag}
68
+ className={cn(
69
+ "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium leading-none",
70
+ c.bg,
71
+ c.text,
72
+ c.border,
73
+ className
74
+ )}
75
+ >
76
+ {tag}
77
+ {onRemove && (
78
+ <button
79
+ type="button"
80
+ onClick={(e) => {
81
+ e.stopPropagation()
82
+ remove(tag)
83
+ }}
84
+ className="ml-0.5 cursor-pointer opacity-50 transition-opacity hover:opacity-100"
85
+ aria-label={`Remove tag ${tag}`}
86
+ >
87
+ <XIcon className="h-2.5 w-2.5" />
88
+ </button>
89
+ )}
90
+ </span>
91
+ )
92
+ })}
93
+
94
+ {onAdd &&
95
+ (adding ? (
96
+ <input
97
+ ref={inputRef}
98
+ autoFocus
99
+ value={input}
100
+ onChange={(e) => setInput(e.target.value.replace(/[\s,]/g, ""))}
101
+ onKeyDown={(e) => {
102
+ if (e.key === "Enter") {
103
+ e.preventDefault()
104
+ commit()
105
+ }
106
+ if (e.key === "Escape") {
107
+ setInput("")
108
+ setAdding(false)
109
+ }
110
+ }}
111
+ // Blur-commits rather than discards: clicking away after typing a
112
+ // tag reads as "done", never as "cancel".
113
+ onBlur={commit}
114
+ placeholder="tag…"
115
+ className="h-4 w-20 rounded-full border border-primary/40 bg-primary/5 px-2 text-[10px] text-primary outline-none focus:border-primary/60"
116
+ />
117
+ ) : (
118
+ <button
119
+ type="button"
120
+ onClick={(e) => {
121
+ e.stopPropagation()
122
+ setAdding(true)
123
+ }}
124
+ className="inline-flex cursor-pointer items-center rounded-full border border-dashed border-border px-1.5 py-0.5 text-[10px] text-muted-foreground/50 transition-colors hover:border-border/80 hover:text-muted-foreground"
125
+ aria-label="Add tag"
126
+ >
127
+ <PlusIcon className="h-2.5 w-2.5" />
128
+ </button>
129
+ ))}
130
+ </>
131
+ )
132
+ }
@@ -0,0 +1,28 @@
1
+ // Deterministic tag → pastel palette, split out of `tag-chips.tsx` (a "use client"
2
+ // module) so it stays callable from a React Server Component and from plain
3
+ // helpers — same reason `buttonVariants` has its own file.
4
+
5
+ const TAG_PALETTES = [
6
+ { bg: "bg-orange-500/12", text: "text-orange-700 dark:text-orange-300", border: "border-orange-400/30" },
7
+ { bg: "bg-amber-500/12", text: "text-amber-700 dark:text-amber-300", border: "border-amber-400/30" },
8
+ { bg: "bg-lime-500/12", text: "text-lime-700 dark:text-lime-300", border: "border-lime-400/30" },
9
+ { bg: "bg-green-500/12", text: "text-green-700 dark:text-green-300", border: "border-green-400/30" },
10
+ { bg: "bg-teal-500/12", text: "text-teal-700 dark:text-teal-300", border: "border-teal-400/30" },
11
+ { bg: "bg-sky-500/12", text: "text-sky-700 dark:text-sky-300", border: "border-sky-400/30" },
12
+ { bg: "bg-violet-500/12", text: "text-violet-700 dark:text-violet-300", border: "border-violet-400/30" },
13
+ { bg: "bg-pink-500/12", text: "text-pink-700 dark:text-pink-300", border: "border-pink-400/30" },
14
+ ] as const
15
+
16
+ export type TagPalette = (typeof TAG_PALETTES)[number]
17
+
18
+ /**
19
+ * Notion-style pastel picked from the tag's own characters, so the same tag is
20
+ * the same colour on every card, in every app, forever — no palette state, no
21
+ * assignment to persist. The classes are written out in full (never built by
22
+ * string concatenation) so Tailwind's scanner can see them.
23
+ */
24
+ export function tagColor(tag: string): TagPalette {
25
+ let h = 0
26
+ for (let i = 0; i < tag.length; i++) h = (Math.imul(31, h) + tag.charCodeAt(i)) | 0
27
+ return TAG_PALETTES[Math.abs(h) % TAG_PALETTES.length]
28
+ }
@@ -0,0 +1,44 @@
1
+ // Structural, deliberately laxist shapes for everything worker-related.
2
+ //
3
+ // The design system must NOT import the OpenAPI-generated types of the Spunto
4
+ // dashboard (nor Spunto Lite's): the two apps model a worker differently — the
5
+ // dashboard has both a lifecycle `state` and a docker-level `dockerState`, Lite
6
+ // has a single `state`; the dashboard's `name` is nullable and backed by an
7
+ // `index`, Lite's is guaranteed. So the types below describe the *minimum* these
8
+ // components read, every field optional, and the state resolution tolerates an
9
+ // unknown value instead of crashing on it (see `resolveWorkerStatus`).
10
+
11
+ /** Container-side setup progress, as reported by the worker agent. */
12
+ export interface WorkerSetupStatus {
13
+ /** `initializing` | `credentials` | `dotfiles` | `cloning` | `lifecycle` | `ready` | `error`. */
14
+ phase?: string | null
15
+ repos?: { name?: string; state?: string | null }[] | null
16
+ postCreate?: string | null
17
+ postStart?: string | null
18
+ error?: string | null
19
+ /** Per-milestone start timestamps (epoch ms) — `initializing` → `ready` brackets the startup. */
20
+ timings?: Record<string, number> | null
21
+ }
22
+
23
+ /** The snapshot a `WorkerCard` draws. Everything but `id` is optional. */
24
+ export interface WorkerCardWorker {
25
+ id: string
26
+ name?: string | null
27
+ /** Fallback identity when `name` is empty — renders as "Workspace #3". */
28
+ index?: number | null
29
+ /** Lifecycle state: `provisioning` | `pulling` | `starting` | `setup` | `ready` | `stopping` | `stopped` | `deleting` | `error`. */
30
+ state?: string | null
31
+ /** Docker-level state: `running` | `stopped` | `exited` | `created` | `not_found` | `error`. */
32
+ dockerState?: string | null
33
+ createdAt?: string | null
34
+ projectVersion?: number | null
35
+ tags?: string[] | null
36
+ setupStatus?: WorkerSetupStatus | null
37
+ }
38
+
39
+ /** Author of a worker — avatar + name, nothing else. */
40
+ export interface WorkerCreator {
41
+ name: string
42
+ login?: string
43
+ avatarUrl?: string | null
44
+ }