@spunto/design-system 0.18.1 → 0.20.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 +27 -4
- package/package.json +3 -2
- package/src/components/action-menu.tsx +225 -0
- package/src/components/workers/index.ts +13 -1
- package/src/components/workers/setup-waterfall.tsx +68 -0
- package/src/components/workers/types.ts +11 -0
- package/src/components/workers/worker-card.tsx +39 -7
- package/src/components/workers/worker-cockpit.tsx +555 -0
- package/src/components/workers/worker-setup-timeline.tsx +181 -0
- package/src/index.ts +7 -0
package/README.md
CHANGED
|
@@ -66,6 +66,21 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
66
66
|
(a confirmation variant, non-dismissible), `Sheet`, `Tooltip`. Their portals render
|
|
67
67
|
into the provider's overlay container; `Tooltip`'s shared delay group is mounted by
|
|
68
68
|
the provider too.
|
|
69
|
+
- _ActionMenu_ — `ActionMenu` (+`ActionMenuItem`, `ActionMenuEntry`), the `⋯` menu
|
|
70
|
+
**described instead of assembled**: `items` is a list of `{ label, icon?, onClick }`
|
|
71
|
+
(or `href`, for a real `<a>` via Base UI's `Menu.LinkItem`) and the component builds
|
|
72
|
+
the trigger, the portal, the popup, the items and their states — `loading` swaps the
|
|
73
|
+
icon for a spinner and disarms the entry, `destructive` turns it red, `shortcut`
|
|
74
|
+
right-aligns a `Kbd`. A menu is the one place an app brings nothing but data, and
|
|
75
|
+
every app was re-typing the same twenty lines of Base UI markup around it. Two things
|
|
76
|
+
the data form gets right that hand-written menus don't: entries may be
|
|
77
|
+
`false`/`null`/`undefined`, so a conditional menu stays one flat array; and
|
|
78
|
+
`"separator"` is an entry like any other, **normalized** — a rule left leading,
|
|
79
|
+
trailing or doubled by a missing neighbour disappears instead of floating at the top
|
|
80
|
+
of the popup. Empty after that pass → nothing renders, not even the `⋯`. Still
|
|
81
|
+
presentational: it never knows what an entry *does*. For what a list can't say (a
|
|
82
|
+
submenu, a checkbox item), compose Base UI directly — that's what `WorkerCard`'s
|
|
83
|
+
`actions` slot is for.
|
|
69
84
|
- _Sheet_ — `Sheet` (+`SheetTrigger`/`SheetClose`/`SheetContent`/`SheetHeader`/
|
|
70
85
|
`SheetFooter`/`SheetTitle`/`SheetDescription`), the same Base UI dialog entering from
|
|
71
86
|
an edge. `side` (4) decides the edge, `size` (`sm`…`full`) how far it comes in — two
|
|
@@ -167,10 +182,18 @@ opinions a dashboard doesn't want, so it gets an entry too.
|
|
|
167
182
|
- **`WorkerCard`** — a Spunto worker as a card: state, author, node, setup
|
|
168
183
|
progress, git branches, resources, banners, actions. **Purely presentational** —
|
|
169
184
|
hand it a snapshot plus slots and it draws. The rule that keeps it that way:
|
|
170
|
-
*everything that CALLS the API is a slot or a callback; everything that
|
|
171
|
-
in the component.* So the `⋯`
|
|
172
|
-
(`href` + `render.link`, never `next/link`), the rebuild
|
|
173
|
-
tag persistence (`onAddTag`/`onRemoveTag`) all stay in the
|
|
185
|
+
*everything that CALLS the API is data, a slot or a callback; everything that
|
|
186
|
+
DRAWS is in the component.* So what the `⋯` entries **do** (`menu`), the footer
|
|
187
|
+
(`footer`), the link (`href` + `render.link`, never `next/link`), the rebuild
|
|
188
|
+
(`onRebuild`) and the tag persistence (`onAddTag`/`onRemoveTag`) all stay in the
|
|
189
|
+
app.
|
|
190
|
+
- **The `⋯` menu is a prop, not markup.** `menu` takes the `ActionMenuEntry[]` of
|
|
191
|
+
the `ActionMenu` primitive — `{ label, icon?, onClick }`, falsy entries allowed,
|
|
192
|
+
`"separator"` normalized — and the card draws the whole menu. Before, every app
|
|
193
|
+
handed the card a fully-built menu through the `actions` slot and re-typed the
|
|
194
|
+
same Base UI markup to do it. `actions` stays as the escape hatch (a submenu, a
|
|
195
|
+
checkbox item) and wins over `menu` when both are given; `render.menuLink` is
|
|
196
|
+
the link slot for an entry with an `href`.
|
|
174
197
|
- **Its bricks, exported individually** because the table view assembles them
|
|
175
198
|
differently: `WorkerStatusDot`/`WorkerStatusPill` (+`resolveWorkerStatus`,
|
|
176
199
|
`workerStatusConfig`), `SetupProgress` (+`setupProgress`, `phaseLabel`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ComponentType, ReactElement, ReactNode } from "react"
|
|
4
|
+
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
|
5
|
+
import { ExternalLinkIcon, MoreVerticalIcon } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "../utils"
|
|
8
|
+
import { Spinner } from "./spinner"
|
|
9
|
+
import { useOverlayContainer } from "./spunto-provider"
|
|
10
|
+
|
|
11
|
+
/** One entry of an `ActionMenu`, described rather than rendered. */
|
|
12
|
+
export interface ActionMenuItem {
|
|
13
|
+
/** What the entry says. */
|
|
14
|
+
label: ReactNode
|
|
15
|
+
/**
|
|
16
|
+
* React key. Defaults to the label when it's a string — give one explicitly if
|
|
17
|
+
* the label is a node, or if two entries share a label.
|
|
18
|
+
*/
|
|
19
|
+
key?: string
|
|
20
|
+
/** Leading icon — any component taking a `className` (lucide-react, a local SVG…). */
|
|
21
|
+
icon?: ComponentType<{ className?: string }>
|
|
22
|
+
/** What the entry does. Omitted (and no `href`) → the entry is inert. */
|
|
23
|
+
onClick?: () => void
|
|
24
|
+
/** Navigates instead of acting: the entry renders as a real `<a>` (middle-click, ⌘-click…). */
|
|
25
|
+
href?: string
|
|
26
|
+
/** `"_blank"` implies the trailing ↗ unless `external` says otherwise. */
|
|
27
|
+
target?: string
|
|
28
|
+
/** Trailing ↗. Defaults to `target === "_blank"`. */
|
|
29
|
+
external?: boolean
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
/**
|
|
32
|
+
* Action in flight: a spinner takes the icon's place and the entry is
|
|
33
|
+
* disabled. Kept apart from `disabled` so the caller doesn't have to swap the
|
|
34
|
+
* icon itself — the pending flag of a mutation goes straight in here.
|
|
35
|
+
*/
|
|
36
|
+
loading?: boolean
|
|
37
|
+
/** Red, and pushed below a separator by convention (the caller still places it). */
|
|
38
|
+
destructive?: boolean
|
|
39
|
+
/** Trailing hint, right-aligned — a `Kbd`, a shortcut, a count. */
|
|
40
|
+
shortcut?: ReactNode
|
|
41
|
+
/** Keeps the popup open after a click (a toggle, typically). */
|
|
42
|
+
closeOnClick?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An entry, a rule between groups, or nothing.
|
|
47
|
+
*
|
|
48
|
+
* The falsy members are the point: a menu is almost always conditional
|
|
49
|
+
* (`isRunning && { label: "Stop", … }`), and allowing `false`/`null`/`undefined`
|
|
50
|
+
* keeps that written as one flat array instead of a `filter(Boolean)` dance.
|
|
51
|
+
* Separators are normalized afterwards — see `normalizeEntries`.
|
|
52
|
+
*/
|
|
53
|
+
export type ActionMenuEntry = ActionMenuItem | "separator" | false | null | undefined
|
|
54
|
+
|
|
55
|
+
/** Renders an `href` entry with the app's link component (`next/link`, a router link…). */
|
|
56
|
+
export type ActionMenuLinkRender = (props: {
|
|
57
|
+
href: string
|
|
58
|
+
target?: string
|
|
59
|
+
children?: ReactNode
|
|
60
|
+
}) => ReactElement
|
|
61
|
+
|
|
62
|
+
export interface ActionMenuProps {
|
|
63
|
+
/** The menu, as data. Falsy entries are dropped; empty → nothing renders at all. */
|
|
64
|
+
items: ActionMenuEntry[]
|
|
65
|
+
/** Accessible name of the default `⋯` trigger. */
|
|
66
|
+
label?: string
|
|
67
|
+
/** Replaces the `⋯` button. Base UI merges its trigger props into this element. */
|
|
68
|
+
trigger?: ReactElement
|
|
69
|
+
triggerClassName?: string
|
|
70
|
+
/** Classes for the popup (its width, typically). */
|
|
71
|
+
className?: string
|
|
72
|
+
align?: "start" | "center" | "end"
|
|
73
|
+
side?: "top" | "bottom" | "left" | "right"
|
|
74
|
+
sideOffset?: number
|
|
75
|
+
/** Escape hatch for `href` entries; without it they render as a plain `<a>`. */
|
|
76
|
+
render?: { link?: ActionMenuLinkRender }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The `⋯` menu, described instead of assembled: give it a list of
|
|
81
|
+
* `{ label, icon, onClick }` and it builds the trigger, the popup, the items,
|
|
82
|
+
* the separators and their states.
|
|
83
|
+
*
|
|
84
|
+
* Every app was rebuilding the same twenty lines of Base UI markup around the
|
|
85
|
+
* same five shapes of entry (an action, a link, a destructive one, one with a
|
|
86
|
+
* mutation in flight, a rule between groups) — and drifting: different paddings,
|
|
87
|
+
* different red, a spinner here and not there. The data form is what the callers
|
|
88
|
+
* actually have anyway; the markup is ours.
|
|
89
|
+
*
|
|
90
|
+
* Two things it does that hand-written menus keep getting wrong:
|
|
91
|
+
*
|
|
92
|
+
* - **Conditional entries.** `false`/`null`/`undefined` are legal entries, so a
|
|
93
|
+
* menu stays one flat array (`isRunning && { label: "Stop", … }`).
|
|
94
|
+
* - **Separators are normalized.** Leading, trailing and consecutive rules are
|
|
95
|
+
* dropped, so a `"separator"` next to an entry that just disappeared doesn't
|
|
96
|
+
* leave a stray line floating at the top of the popup. That's the bug you get
|
|
97
|
+
* every time a conditional item sits next to a conditional separator.
|
|
98
|
+
*
|
|
99
|
+
* It stays presentational: it never knows what an entry *does*. `onClick` and
|
|
100
|
+
* `href` come from the caller, like everywhere else in this package.
|
|
101
|
+
*/
|
|
102
|
+
export function ActionMenu({
|
|
103
|
+
items,
|
|
104
|
+
label = "Actions",
|
|
105
|
+
trigger,
|
|
106
|
+
triggerClassName,
|
|
107
|
+
className,
|
|
108
|
+
align = "end",
|
|
109
|
+
side = "bottom",
|
|
110
|
+
sideOffset = 4,
|
|
111
|
+
render,
|
|
112
|
+
}: ActionMenuProps) {
|
|
113
|
+
const container = useOverlayContainer()
|
|
114
|
+
const entries = normalizeEntries(items)
|
|
115
|
+
|
|
116
|
+
// No action → no affordance. A `⋯` that opens an empty popup is worse than no
|
|
117
|
+
// `⋯` at all, and callers build these lists conditionally: an empty one is the
|
|
118
|
+
// normal outcome for, say, a worker on an offline node.
|
|
119
|
+
if (entries.length === 0) return null
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<MenuPrimitive.Root>
|
|
123
|
+
{trigger ? (
|
|
124
|
+
<MenuPrimitive.Trigger render={trigger} />
|
|
125
|
+
) : (
|
|
126
|
+
<MenuPrimitive.Trigger aria-label={label} className={cn(triggerButton, triggerClassName)}>
|
|
127
|
+
<MoreVerticalIcon className="h-4 w-4" />
|
|
128
|
+
</MenuPrimitive.Trigger>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<MenuPrimitive.Portal container={container ?? undefined}>
|
|
132
|
+
<MenuPrimitive.Positioner className="isolate z-50 outline-none" align={align} side={side} sideOffset={sideOffset}>
|
|
133
|
+
<MenuPrimitive.Popup
|
|
134
|
+
data-slot="action-menu"
|
|
135
|
+
className={cn(
|
|
136
|
+
// `max-h-(--available-height)` + scroll: a menu of twenty actions
|
|
137
|
+
// opened from a card at the bottom of the page must scroll inside
|
|
138
|
+
// the viewport, never spill past its edge.
|
|
139
|
+
"max-h-(--available-height) min-w-52 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/[0.06] outline-none",
|
|
140
|
+
"transition-[transform,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
|
|
141
|
+
className
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
{entries.map((entry, i) =>
|
|
145
|
+
entry === "separator" ? (
|
|
146
|
+
<div key={`sep-${i}`} role="separator" className="-mx-1 my-1 h-px bg-border" />
|
|
147
|
+
) : (
|
|
148
|
+
<Item key={entry.key ?? (typeof entry.label === "string" ? entry.label : `item-${i}`)} item={entry} render={render} />
|
|
149
|
+
)
|
|
150
|
+
)}
|
|
151
|
+
</MenuPrimitive.Popup>
|
|
152
|
+
</MenuPrimitive.Positioner>
|
|
153
|
+
</MenuPrimitive.Portal>
|
|
154
|
+
</MenuPrimitive.Root>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const triggerButton =
|
|
159
|
+
"flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/50 transition-colors outline-none hover:bg-accent hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 data-popup-open:bg-accent data-popup-open:text-foreground"
|
|
160
|
+
|
|
161
|
+
const itemClass =
|
|
162
|
+
"flex cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-sm no-underline outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0"
|
|
163
|
+
|
|
164
|
+
const destructiveClass =
|
|
165
|
+
"text-destructive data-highlighted:bg-destructive/10 data-highlighted:text-destructive dark:data-highlighted:bg-destructive/20"
|
|
166
|
+
|
|
167
|
+
function Item({ item, render }: { item: ActionMenuItem; render?: { link?: ActionMenuLinkRender } }) {
|
|
168
|
+
const { label, icon: Icon, href, target, external, disabled, loading, destructive, shortcut, closeOnClick, onClick } = item
|
|
169
|
+
const className = cn(itemClass, destructive && destructiveClass)
|
|
170
|
+
const showExternal = external ?? target === "_blank"
|
|
171
|
+
|
|
172
|
+
const content = (
|
|
173
|
+
<>
|
|
174
|
+
{loading ? <Spinner className="size-3.5" /> : Icon ? <Icon /> : null}
|
|
175
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
176
|
+
{shortcut != null && <span className="text-xs text-muted-foreground">{shortcut}</span>}
|
|
177
|
+
{showExternal && <ExternalLinkIcon className="opacity-50" />}
|
|
178
|
+
</>
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// A navigation is an `<a>` — Base UI's `LinkItem` renders one, so ⌘-click and
|
|
182
|
+
// "open in new tab" work like anywhere else. `render.link` swaps in the app's
|
|
183
|
+
// router link; without it, a plain anchor (the package never imports `next/link`).
|
|
184
|
+
if (href) {
|
|
185
|
+
return (
|
|
186
|
+
<MenuPrimitive.LinkItem
|
|
187
|
+
className={className}
|
|
188
|
+
closeOnClick
|
|
189
|
+
render={render?.link?.({ href, target }) ?? <a href={href} target={target} rel={target === "_blank" ? "noopener noreferrer" : undefined} />}
|
|
190
|
+
>
|
|
191
|
+
{content}
|
|
192
|
+
</MenuPrimitive.LinkItem>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<MenuPrimitive.Item className={className} disabled={disabled || loading} closeOnClick={closeOnClick ?? true} onClick={onClick}>
|
|
198
|
+
{content}
|
|
199
|
+
</MenuPrimitive.Item>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Drops falsy entries, then every separator that would end up alone: leading,
|
|
205
|
+
* trailing, or next to another one.
|
|
206
|
+
*
|
|
207
|
+
* This is the whole reason separators are data here. Written by hand, a rule
|
|
208
|
+
* between two conditional groups needs its own condition — the union of the
|
|
209
|
+
* conditions below it — and that condition is what goes stale when an entry is
|
|
210
|
+
* added. Here the rule simply disappears when nothing follows it.
|
|
211
|
+
*/
|
|
212
|
+
export function normalizeEntries(entries: ActionMenuEntry[]): (ActionMenuItem | "separator")[] {
|
|
213
|
+
const out: (ActionMenuItem | "separator")[] = []
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
if (!entry) continue
|
|
216
|
+
if (entry === "separator") {
|
|
217
|
+
if (out.length === 0 || out[out.length - 1] === "separator") continue
|
|
218
|
+
out.push("separator")
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
out.push(entry)
|
|
222
|
+
}
|
|
223
|
+
while (out.length > 0 && out[out.length - 1] === "separator") out.pop()
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
@@ -9,12 +9,24 @@
|
|
|
9
9
|
export { WorkerCard } from "./worker-card"
|
|
10
10
|
export type { WorkerCardProps, LinkRender } from "./worker-card"
|
|
11
11
|
|
|
12
|
+
// The card's `menu` prop is a list of these. Re-exported here (they also ship
|
|
13
|
+
// from the root entry) so an app can type its worker menu without importing the
|
|
14
|
+
// primitives entry just for that.
|
|
15
|
+
export { ActionMenu } from "../action-menu"
|
|
16
|
+
export type { ActionMenuProps, ActionMenuItem, ActionMenuEntry, ActionMenuLinkRender } from "../action-menu"
|
|
17
|
+
|
|
18
|
+
export { WorkerCockpit } from "./worker-cockpit"
|
|
19
|
+
export type { WorkerCockpitProps } from "./worker-cockpit"
|
|
20
|
+
|
|
12
21
|
export { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus, workerStatusConfig } from "./worker-status"
|
|
13
22
|
export type { WorkerStatus, WorkerStatusConfig, WorkerStatusKey } from "./worker-status"
|
|
14
23
|
|
|
15
24
|
export { SetupProgress, StepIndicator, phaseLabel, setupProgress, setupTotalMs } from "./setup-progress"
|
|
16
25
|
export type { SetupProgressProps, StepState } from "./setup-progress"
|
|
17
26
|
|
|
27
|
+
export { WorkerSetupTimeline } from "./worker-setup-timeline"
|
|
28
|
+
export { SetupWaterfall } from "./setup-waterfall"
|
|
29
|
+
|
|
18
30
|
export { ResourceBars } from "./resource-bars"
|
|
19
31
|
export type { ResourceBarsProps, WorkerStats } from "./resource-bars"
|
|
20
32
|
|
|
@@ -28,7 +40,7 @@ export type { TagPalette } from "./tag-color"
|
|
|
28
40
|
|
|
29
41
|
export { CreatorAvatar } from "./creator-avatar"
|
|
30
42
|
|
|
31
|
-
export type { WorkerCardWorker, WorkerCreator, WorkerSetupStatus } from "./types"
|
|
43
|
+
export type { WorkerCardWorker, WorkerCockpitWorker, WorkerCreator, WorkerSetupStatus } from "./types"
|
|
32
44
|
|
|
33
45
|
// Re-exported here too so a consumer of the worker surface doesn't have to
|
|
34
46
|
// 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
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "lucide-react"
|
|
13
13
|
|
|
14
14
|
import { cn, formatDuration, formatRelativeTime } from "../../utils"
|
|
15
|
+
import { ActionMenu, type ActionMenuEntry, type ActionMenuLinkRender } from "../action-menu"
|
|
15
16
|
import { buttonVariants } from "../button-variants"
|
|
16
17
|
import { CreatorAvatar } from "./creator-avatar"
|
|
17
18
|
import { GitBranchChips, type GitRepoStatus } from "./git-branch-chips"
|
|
@@ -33,8 +34,12 @@ export interface WorkerCardProps {
|
|
|
33
34
|
* Escape hatches for app-specific rendering. `link` receives `{ href, className,
|
|
34
35
|
* children }` and must return an anchor-ish node (`next/link`, a router link…);
|
|
35
36
|
* without it the card falls back to a plain `<a>`.
|
|
37
|
+
*
|
|
38
|
+
* `menuLink` does the same for a `menu` entry carrying an `href`, but with the
|
|
39
|
+
* other contract: it returns an *element* that Base UI merges its own props
|
|
40
|
+
* and children into, so it takes no `children` of its own.
|
|
36
41
|
*/
|
|
37
|
-
render?: { link?: LinkRender }
|
|
42
|
+
render?: { link?: LinkRender; menuLink?: ActionMenuLinkRender }
|
|
38
43
|
|
|
39
44
|
creator?: WorkerCreator
|
|
40
45
|
nodeName?: string
|
|
@@ -71,7 +76,33 @@ export interface WorkerCardProps {
|
|
|
71
76
|
error?: string | null
|
|
72
77
|
onDismissError?: () => void
|
|
73
78
|
|
|
74
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* The `⋯` menu, top-right, **as data**: one entry is a `label`, optionally an
|
|
81
|
+
* `icon`, and an `onClick` (or an `href`) — the card builds the trigger, the
|
|
82
|
+
* popup and the items. Entries may be `false`/`null` (a menu is always
|
|
83
|
+
* conditional) and `"separator"` is an entry like any other; rules left alone
|
|
84
|
+
* by a missing neighbour are dropped. Empty or all-falsy → no `⋯` at all.
|
|
85
|
+
*
|
|
86
|
+
* What an entry *does* still belongs to the app: `onClick` is where the API
|
|
87
|
+
* call lives. What it *looks like* is ours — that's the difference with the
|
|
88
|
+
* `actions` slot below, which each app used to fill with its own twenty lines
|
|
89
|
+
* of menu markup.
|
|
90
|
+
*
|
|
91
|
+
* ```tsx
|
|
92
|
+
* menu={[
|
|
93
|
+
* codeUrl && { label: "Open in VS Code", icon: Code2Icon, href: codeUrl, target: "_blank" },
|
|
94
|
+
* "separator",
|
|
95
|
+
* isRunning && { label: "Stop", icon: SquareIcon, onClick: () => stop.mutate(), loading: stop.isPending },
|
|
96
|
+
* "separator",
|
|
97
|
+
* { label: "Delete", icon: Trash2Icon, destructive: true, onClick: () => del.mutate() },
|
|
98
|
+
* ]}
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
menu?: ActionMenuEntry[]
|
|
102
|
+
/**
|
|
103
|
+
* Escape hatch for a menu the data form can't express (a submenu, a checkbox
|
|
104
|
+
* item, a dialog opened from an entry). Takes precedence over `menu`.
|
|
105
|
+
*/
|
|
75
106
|
actions?: ReactNode
|
|
76
107
|
/** Replaces the default "View" footer entirely (extra buttons, a VS Code link…). */
|
|
77
108
|
footer?: ReactNode
|
|
@@ -86,10 +117,10 @@ export interface WorkerCardProps {
|
|
|
86
117
|
* on, and what you can do with it.
|
|
87
118
|
*
|
|
88
119
|
* **Purely presentational.** It receives a snapshot and draws it — no query, no
|
|
89
|
-
* mutation, no router. Everything that would talk to an API is a
|
|
90
|
-
* (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule
|
|
91
|
-
* what makes the same card renderable by the dashboard, by Spunto Lite, and
|
|
92
|
-
* the showcase with hand-written fixtures. The card is the assembly; each brick
|
|
120
|
+
* mutation, no router. Everything that would talk to an API is data (`menu`), a
|
|
121
|
+
* slot (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule
|
|
122
|
+
* is what makes the same card renderable by the dashboard, by Spunto Lite, and
|
|
123
|
+
* by the showcase with hand-written fixtures. The card is the assembly; each brick
|
|
93
124
|
* (`WorkerStatusDot`, `SetupProgress`, `ResourceBars`, `GitBranchChips`,
|
|
94
125
|
* `TagChips`) is exported on its own because the table view needs them without
|
|
95
126
|
* the card.
|
|
@@ -116,6 +147,7 @@ export function WorkerCard({
|
|
|
116
147
|
rebuilding = false,
|
|
117
148
|
error,
|
|
118
149
|
onDismissError,
|
|
150
|
+
menu,
|
|
119
151
|
actions,
|
|
120
152
|
footer,
|
|
121
153
|
viewLabel = "View",
|
|
@@ -215,7 +247,7 @@ export function WorkerCard({
|
|
|
215
247
|
</div>
|
|
216
248
|
</div>
|
|
217
249
|
|
|
218
|
-
{actions}
|
|
250
|
+
{actions ?? (menu && <ActionMenu items={menu} render={{ link: render?.menuLink }} />)}
|
|
219
251
|
</div>
|
|
220
252
|
|
|
221
253
|
{status.settingUp && (
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -208,6 +208,13 @@ export type {
|
|
|
208
208
|
OAuthProvider,
|
|
209
209
|
} from "./components/auth"
|
|
210
210
|
|
|
211
|
+
// ActionMenu — the `⋯` menu described as data (`{ label, icon, onClick }[]`)
|
|
212
|
+
// rather than assembled from parts: the caller owns what an entry does, the
|
|
213
|
+
// package owns the markup, the states, and the separators (which are normalized,
|
|
214
|
+
// so a conditional entry can't leave a stray rule behind).
|
|
215
|
+
export { ActionMenu } from "./components/action-menu"
|
|
216
|
+
export type { ActionMenuProps, ActionMenuItem, ActionMenuEntry, ActionMenuLinkRender } from "./components/action-menu"
|
|
217
|
+
|
|
211
218
|
// Kbd — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K). Used by the
|
|
212
219
|
// palette, exported on its own for anywhere else a shortcut needs drawing.
|
|
213
220
|
export { Kbd } from "./components/kbd"
|