@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.
- package/README.md +40 -0
- package/package.json +6 -1
- package/src/components/image-pull.tsx +642 -0
- package/src/components/workers/creator-avatar.tsx +38 -0
- package/src/components/workers/git-branch-chips.tsx +120 -0
- package/src/components/workers/index.ts +35 -0
- package/src/components/workers/resource-bars.tsx +100 -0
- package/src/components/workers/setup-progress.tsx +130 -0
- package/src/components/workers/tag-chips.tsx +132 -0
- package/src/components/workers/tag-color.ts +28 -0
- package/src/components/workers/types.ts +44 -0
- package/src/components/workers/worker-card.tsx +305 -0
- package/src/components/workers/worker-status.tsx +181 -0
- package/src/index.ts +10 -1
- package/src/utils.ts +23 -0
- package/styles.css +20 -0
|
@@ -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
|
+
}
|