@spunto/design-system 0.32.0 → 0.33.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 CHANGED
@@ -264,7 +264,7 @@ sides — exactly the duplication this package is for.
264
264
  dozen entries, where picking is a *logo* task and full cards become a wall.
265
265
  They flow and wrap like tags; the pickers pair them with the full card of
266
266
  what's selected.
267
- - **`ExtensionCard`** (+ `ExtensionCardSkeleton`) — a VS Code extension: its real
267
+ - **`ExtensionCard`** (+ `ExtensionCardSkeleton`) — an IDE extension: its real
268
268
  Open VSX icon, display name, publisher + verified badge, version, formatted
269
269
  install count, rating.
270
270
 
@@ -304,9 +304,10 @@ review. Every surface that shows one:
304
304
  an organization as a messaging app's list: search, state chips whose counts ignore
305
305
  the filter, grouped rows. Fully controlled. Sized by `pointer-coarse`, not width.
306
306
  - **`TaskCockpit`** / **`TaskCockpitMobile`** — one conversation, as a **layout**: top
307
- bar + Accept/Drop, the Session/Diff switch, and where each slot goes. The panels are
308
- slots filled with the package's own components (`session`/`diff` are render
309
- functions receiving the switch to draw in their header). The phone one is its own
307
+ bar + Accept/Drop, the Session/Diff/Terminal switch, and where each slot goes. The
308
+ panels are slots filled with the package's own components (`session`/`diff`/`terminal`
309
+ are render functions receiving the switch to draw in their header; an omitted slot is
310
+ no tab). The phone one is its own
310
311
  screen — a `visualViewport`-sized layer, details in a sheet, Drop at the bottom.
311
312
  - **`TaskEventStream`** — the agent session (RFC 0020): prose first, tool calls folded
312
313
  into runs ("read 8 files · ran 3 commands"), the session-wide tool stack in a sheet,
@@ -315,6 +316,9 @@ review. Every surface that shows one:
315
316
  - **`TaskDiffPanel`** — the branch's diff: summary as a prop, each file's patch through
316
317
  `loadPatch` (a promise; the component owns per-file loading state), intra-line
317
318
  highlighting, and a sentence for every reason it could not be read.
319
+ - **`TaskTerminalPanel`** — the frame of the Terminal tab: the header with the switch,
320
+ optional `actions`, and a dark surface for the app's own shell (the socket is the
321
+ app's), or a sentence saying why there is none. No composer under it.
318
322
  - **`TaskDetails`** (`sidebar` | `sheet`) with **`TaskMachine`**, **`TaskBoot`**,
319
323
  `TaskSessionUsage`, `TaskPullRequest`, `TaskCommandLog` — the facts about a task, in
320
324
  one order, instead of two hand-kept copies.
@@ -418,7 +422,7 @@ And the read-only twin of that form:
418
422
  And the build that configuration triggers, while it runs:
419
423
 
420
424
  - **`BuildSteps`** — an image build as the **list of blocks it is made of**
421
- (base image, bootstrap, one per devcontainer feature, VS Code extensions,
425
+ (base image, bootstrap, one per devcontainer feature, IDE extensions,
422
426
  finalize, DinD seed) rather than a wall of log. Each row lights up in turn and
423
427
  carries its own duration, so "what is it doing, and for how long" reads at a
424
428
  glance and a slow feature is visibly *the* slow one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.32.0",
3
+ "version": "0.33.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",
@@ -24,7 +24,7 @@ export interface ExtensionCardProps {
24
24
  }
25
25
 
26
26
  /**
27
- * A VS Code extension, as a card: its real Open VSX icon, its display name, the
27
+ * An IDE extension, as a card: its real Open VSX icon, its display name, the
28
28
  * publisher (with the marketplace's verified check), version, install count and
29
29
  * rating.
30
30
  *
@@ -1,5 +1,5 @@
1
1
  // @spunto/design-system/devcontainer — the three catalogs you compose an
2
- // environment from: base images, devcontainer features, VS Code extensions.
2
+ // environment from: base images, devcontainer features, IDE extensions.
3
3
  //
4
4
  // A second *domain* sub-export, next to `/workers`: the root entry stays
5
5
  // "primitives with no domain" (a Button knows nothing about an OCI reference).
@@ -59,7 +59,7 @@ export interface DevcontainerFeatureEntry {
59
59
  }
60
60
 
61
61
  /**
62
- * A VS Code extension. Shaped to swallow an Open VSX search result as-is
62
+ * An IDE extension. Shaped to swallow an Open VSX search result as-is
63
63
  * (`namespace` + `name` + `files.icon` + `downloadCount`…) as well as the
64
64
  * flattened form an app might store.
65
65
  */
@@ -163,7 +163,7 @@ function StepRow({ step, last }: { step: BuildStepItem; last: boolean }) {
163
163
  * An image build, as the list of blocks it is actually made of, instead of a wall of log.
164
164
  *
165
165
  * A project image build pulls a base image, creates the user, installs the editor and the terminal
166
- * tooling, every devcontainer feature and every VS Code extension, then bakes the DinD seed — all of
166
+ * tooling, every devcontainer feature and every IDE extension, then bakes the DinD seed — all of
167
167
  * it, until now, indistinguishable from the outside: one terminal scrolling. Here each of those is a
168
168
  * row that lights up in turn, with its own duration, so "what is it doing, and for how long" is
169
169
  * readable at a glance and a slow feature is visibly *the* slow one.
@@ -104,10 +104,10 @@ export function FeatureChips({ features, className }: { features: { id: string }
104
104
  }
105
105
 
106
106
  /**
107
- * VS Code extensions — `publisher.name` shown by its name half, with the
107
+ * IDE extensions — `publisher.name` shown by its name half, with the
108
108
  * publisher kept in the tooltip. Same logo derivation as the features
109
109
  * (`ms-python.python` → Python); anything unrecognised keeps the puzzle piece,
110
- * which is what a VS Code extension *is*.
110
+ * which is what an IDE extension *is*.
111
111
  */
112
112
  export function ExtensionChips({ extensions, className }: { extensions: string[]; className?: string }) {
113
113
  return (
@@ -25,7 +25,7 @@ export interface ExtensionPickerProps {
25
25
  }
26
26
 
27
27
  /**
28
- * Pick the VS Code extensions installed into code-server: a marketplace search
28
+ * Pick the IDE extensions installed into code-server: a marketplace search
29
29
  * rendering `ExtensionCard`s, plus the picked list.
30
30
  *
31
31
  * The payloads of everything picked this session are kept around, so a selected
@@ -95,7 +95,7 @@ export type { BuildCacheListProps, PrebuildButtonProps } from "./build-cache-lis
95
95
  // The build itself, while it runs: the blocks an image is made of, each lighting
96
96
  // up in turn. Same domain as the cache list it sits next to (a project's image),
97
97
  // hence this entry rather than one of its own — and domain it is, since the
98
- // blocks are Spunto's (a devcontainer feature, a VS Code extension).
98
+ // blocks are Spunto's (a devcontainer feature, an IDE extension).
99
99
  export { BuildSteps } from "./build-steps"
100
100
  export type { BuildStepsProps, BuildStepItem, BuildStepKind, BuildStepState } from "./build-steps"
101
101
 
@@ -45,7 +45,7 @@ const SECTION_META: Record<
45
45
  image: { icon: BoxIcon, accent: "ship", title: "Base image", hint: "The devcontainer your workers run on" },
46
46
  repositories: { icon: GitBranchIcon, accent: "run", title: "Repositories", hint: "Cloned into /workspace at startup" },
47
47
  features: { icon: PuzzleIcon, accent: "flame", title: "Features", hint: "Devcontainer features baked into the image" },
48
- extensions: { icon: BlocksIcon, accent: "build", title: "Extensions", hint: "VS Code extensions, installed via code-server" },
48
+ extensions: { icon: BlocksIcon, accent: "build", title: "Extensions", hint: "IDE extensions, installed via code-server" },
49
49
  lifecycle: { icon: TerminalIcon, accent: "ship", title: "Lifecycle", hint: "Commands run on create & on every start" },
50
50
  ports: { icon: NetworkIcon, accent: "run", title: "Ports", hint: "Automatically forwarded for every worker" },
51
51
  prewarm: { icon: LayersIcon, accent: "build", title: "Prewarm images", hint: "Docker images pre-loaded into the worker's docker-in-docker on first start" },
@@ -79,6 +79,8 @@ export {
79
79
  // ── Side panels ─────────────────────────────────────────────────────────────────────────────
80
80
  export { TaskDiffPanel } from "./diff-panel"
81
81
  export type { TaskDiffPanelProps } from "./diff-panel"
82
+ export { TaskTerminalPanel } from "./terminal-panel"
83
+ export type { TaskTerminalPanelProps } from "./terminal-panel"
82
84
  export { TaskPullRequest } from "./pull-request"
83
85
  export { TaskSessionUsage, formatCost, formatTokens } from "./session-usage"
84
86
  export { TaskCommandLog } from "./command-log"
@@ -23,7 +23,7 @@ import { CheckCircle2Icon, ChevronLeftIcon, Loader2Icon, SlidersHorizontalIcon,
23
23
  import { cn, formatRelativeTime } from "../../utils"
24
24
  import { useOverlayContainer } from "../spunto-provider"
25
25
  import type { LinkRender } from "../workers/worker-card"
26
- import { TaskPanelTabs, useCockpitView, type TaskCockpitProps } from "./task-cockpit"
26
+ import { useCockpitPanels, type TaskCockpitProps } from "./task-cockpit"
27
27
  import { TaskDetailsSheetContext } from "./task-details"
28
28
  import { TaskStateIcon, taskPendingActionConfig, taskStateStyle, useElapsed } from "./task-state"
29
29
  import { TaskTitle } from "./task-title"
@@ -173,6 +173,7 @@ export function TaskCockpitMobile({
173
173
  onViewChange,
174
174
  session,
175
175
  diff,
176
+ terminal,
176
177
  composer,
177
178
  details,
178
179
  fullscreen = true,
@@ -181,12 +182,11 @@ export function TaskCockpitMobile({
181
182
  const vp = useVisualViewport(fullscreen)
182
183
  const container = useOverlayContainer()
183
184
  const [detailsOpen, setDetailsOpen] = useState(false)
184
- const [view, setView] = useCockpitView(viewProp, onViewChange)
185
+ const { view, panel } = useCockpitPanels({ view: viewProp, onViewChange, session, diff, terminal })
185
186
  const Link = render?.link ?? defaultLink
186
187
 
187
188
  const action = task ? (pendingAction !== undefined ? pendingAction : (task.pendingAction ?? null)) : null
188
189
  const since = task ? (pendingSince !== undefined ? pendingSince : (task.pendingSince ?? null)) : null
189
- const tabs = diff ? <TaskPanelTabs view={view} onChange={setView} /> : null
190
190
 
191
191
  const back = onBack ? (
192
192
  <button type="button" onClick={onBack} aria-label="Back" className={NAV_BUTTON}>
@@ -260,8 +260,8 @@ export function TaskCockpitMobile({
260
260
  )
261
261
  ) : (
262
262
  <>
263
- {view === "diff" && diff ? diff(tabs) : session(tabs)}
264
- {composer && <div style={{ paddingBottom: fullscreen && vp.keyboard === 0 ? "env(safe-area-inset-bottom)" : 0 }}>{composer}</div>}
263
+ {panel()}
264
+ {composer && view !== "terminal" && <div style={{ paddingBottom: fullscreen && vp.keyboard === 0 ? "env(safe-area-inset-bottom)" : 0 }}>{composer}</div>}
265
265
  </>
266
266
  )}
267
267
 
@@ -12,11 +12,12 @@
12
12
  //
13
13
  // session → `TaskEventStream` diff → `TaskDiffPanel`
14
14
  // composer → `TaskComposer` details → `TaskDetails` (with a `TaskMachine` inside)
15
+ // terminal → `TaskTerminalPanel` (around the app's own shell)
15
16
  //
16
17
  // — and the cockpit owns the chrome between them: the top bar and its two actions, the
17
- // Session/Diff switch, and where each slot goes as the box narrows. `session` and `diff` are
18
- // render functions because the switch is drawn *inside* their header strip (two strips of chrome
19
- // above a transcript is one too many).
18
+ // Session/Diff/Terminal switch, and where each slot goes as the box narrows. `session`, `diff` and
19
+ // `terminal` are render functions because the switch is drawn *inside* their header strip (two
20
+ // strips of chrome above a transcript is one too many).
20
21
  //
21
22
  // It claims no page chrome of its own — it fills its parent — which is what lets the same code
22
23
  // live at its own URL and on the right-hand side of the org-wide conversation list. On a phone,
@@ -33,7 +34,9 @@ import { TaskStateBadge, isLiveTaskState } from "./task-state"
33
34
  import { TaskTitle } from "./task-title"
34
35
  import type { TaskItem, TaskPendingAction } from "./types"
35
36
 
36
- export type TaskCockpitView = "session" | "diff"
37
+ export type TaskCockpitView = "session" | "diff" | "terminal"
38
+
39
+ const VIEW_ORDER: readonly TaskCockpitView[] = ["session", "diff", "terminal"]
37
40
 
38
41
  export interface TaskCockpitProps {
39
42
  /** `undefined`/`null` → the loading line, or the not-found message once `loading` is false. */
@@ -59,15 +62,25 @@ export interface TaskCockpitProps {
59
62
  backHref?: string
60
63
  render?: { link?: LinkRender }
61
64
 
62
- /** Controlled Session/Diff switch. Uncontrolled (starting on `session`) when omitted. */
65
+ /** Controlled Session/Diff/Terminal switch. Uncontrolled (starting on `session`) when omitted. */
63
66
  view?: TaskCockpitView
64
67
  onViewChange?: (view: TaskCockpitView) => void
65
68
 
66
- /** The session panel. Receives the Session/Diff switch to draw in its header. */
69
+ /** The session panel. Receives the switch to draw in its header. */
67
70
  session: (tabs: ReactNode) => ReactNode
68
- /** The diff panel. Omitted → no switch, the session alone. */
71
+ /** The diff panel. Omitted → no Diff tab. */
69
72
  diff?: (tabs: ReactNode) => ReactNode
70
- /** Under both panels: answering the agent is something a reviewer does *because* of the diff. */
73
+ /**
74
+ * A shell on the task's machine — typically a `TaskTerminalPanel` around the app's own terminal,
75
+ * since the socket behind it is the app's. Omitted → no Terminal tab (a task with no machine has
76
+ * nothing to open one on). With neither `diff` nor `terminal`, no switch at all: the session alone.
77
+ */
78
+ terminal?: (tabs: ReactNode) => ReactNode
79
+ /**
80
+ * Under the session and the diff: answering the agent is something a reviewer does *because* of
81
+ * the diff. Not under the terminal — it has a keyboard of its own, and two places to type,
82
+ * stacked, is one too many.
83
+ */
71
84
  composer?: ReactNode
72
85
  /** The side column. */
73
86
  details?: ReactNode
@@ -81,22 +94,25 @@ const defaultLink: LinkRender = ({ href, className, children }) => (
81
94
  )
82
95
 
83
96
  /**
84
- * The two ways of looking at a task, in the strip each panel already owns. A tab rather than a
85
- * second column: "what it did" and "what is different now" are two frames of mind, and splitting
86
- * the width would make both unreadable in a sidebar-sized column.
97
+ * The ways of looking at a task, in the strip each panel already owns. A tab rather than a second
98
+ * column: "what it did", "what is different now" and "let me look myself" are three frames of
99
+ * mind, and splitting the width would make each unreadable in a sidebar-sized column.
87
100
  */
88
101
  export function TaskPanelTabs({
89
102
  view,
90
103
  onChange,
104
+ views = ["session", "diff"],
91
105
  className,
92
106
  }: {
93
107
  view: TaskCockpitView
94
108
  onChange: (view: TaskCockpitView) => void
109
+ /** Which tabs to draw, in this order. Defaults to Session/Diff. */
110
+ views?: readonly TaskCockpitView[]
95
111
  className?: string
96
112
  }) {
97
113
  return (
98
114
  <div role="tablist" className={cn("flex shrink-0 items-center gap-0.5 rounded-md bg-muted/60 p-0.5", className)}>
99
- {(["session", "diff"] as const).map((value) => (
115
+ {views.map((value) => (
100
116
  <button
101
117
  key={value}
102
118
  type="button"
@@ -115,7 +131,28 @@ export function TaskPanelTabs({
115
131
  )
116
132
  }
117
133
 
118
- /** The view state, controlled or not — shared with `TaskCockpitMobile`. @internal */
134
+ /**
135
+ * The panels this cockpit has, the switch between them, and the one to draw — shared with
136
+ * `TaskCockpitMobile`. A view whose slot went away (the machine was released while its terminal
137
+ * was open) falls back to the session rather than to an empty box. @internal
138
+ */
139
+ export function useCockpitPanels({
140
+ view: viewProp,
141
+ onViewChange,
142
+ session,
143
+ diff,
144
+ terminal,
145
+ }: Pick<TaskCockpitProps, "view" | "onViewChange" | "session" | "diff" | "terminal">) {
146
+ const [view, setView] = useCockpitView(viewProp, onViewChange)
147
+ const slots: Record<TaskCockpitView, ((tabs: ReactNode) => ReactNode) | undefined> = { session, diff, terminal }
148
+ const views = VIEW_ORDER.filter((v) => slots[v])
149
+ const current = slots[view] ? view : "session"
150
+ const tabs = views.length > 1 ? <TaskPanelTabs view={current} onChange={setView} views={views} /> : null
151
+ // A thunk, not the element: the cockpit asks for it only once there is a task to draw.
152
+ return { view: current, panel: () => (slots[current] ?? session)(tabs) }
153
+ }
154
+
155
+ /** The view state, controlled or not. @internal */
119
156
  export function useCockpitView(view: TaskCockpitView | undefined, onViewChange: ((view: TaskCockpitView) => void) | undefined) {
120
157
  const [local, setLocal] = useState<TaskCockpitView>("session")
121
158
  const current = view ?? local
@@ -141,11 +178,12 @@ export function TaskCockpit({
141
178
  onViewChange,
142
179
  session,
143
180
  diff,
181
+ terminal,
144
182
  composer,
145
183
  details,
146
184
  className,
147
185
  }: TaskCockpitProps) {
148
- const [view, setView] = useCockpitView(viewProp, onViewChange)
186
+ const { view, panel } = useCockpitPanels({ view: viewProp, onViewChange, session, diff, terminal })
149
187
  const Link = render?.link ?? defaultLink
150
188
 
151
189
  if (!task) {
@@ -171,7 +209,6 @@ export function TaskCockpit({
171
209
  const since = pendingSince !== undefined ? pendingSince : (task.pendingSince ?? null)
172
210
  const busy = action !== null
173
211
  const live = isLiveTaskState(task.state)
174
- const tabs = diff ? <TaskPanelTabs view={view} onChange={setView} /> : null
175
212
 
176
213
  return (
177
214
  <div className={cn("@container flex min-h-0 flex-1 flex-col overflow-hidden", className)}>
@@ -226,8 +263,8 @@ export function TaskCockpit({
226
263
  column holds long mono lines and <pre> blocks. Without it the column refuses to shrink
227
264
  and pushes the details column past the edge, where `overflow-hidden` clips it. */}
228
265
  <div className="flex min-h-[280px] min-w-0 flex-1 flex-col border-border @min-[42rem]:border-r">
229
- {view === "diff" && diff ? diff(tabs) : session(tabs)}
230
- {composer}
266
+ {panel()}
267
+ {view !== "terminal" && composer}
231
268
  </div>
232
269
 
233
270
  {details && (
@@ -5,6 +5,7 @@
5
5
  * The timeline itself is virtualized and measures real layout, which jsdom does not have; what it
6
6
  * draws is covered by the pure functions it folds events with.
7
7
  */
8
+ import type { ReactNode } from "react"
8
9
  import { describe, it, expect, vi } from "vitest"
9
10
  import { render, screen, fireEvent } from "@testing-library/react"
10
11
 
@@ -337,3 +338,47 @@ describe("TaskTitle", () => {
337
338
  expect(onTitleChange).toHaveBeenCalledWith("Safari 18 login")
338
339
  })
339
340
  })
341
+
342
+ describe("TaskCockpit panels", () => {
343
+ const panel = (name: string) => (tabs: ReactNode) => (
344
+ <div>
345
+ {tabs}
346
+ <p>{name} panel</p>
347
+ </div>
348
+ )
349
+
350
+ it("draws no switch with the session alone", () => {
351
+ render(<TaskCockpit task={task()} session={panel("session")} />)
352
+ expect(screen.queryByRole("tablist")).toBeNull()
353
+ })
354
+
355
+ it("offers only the tabs it has a panel for, in a fixed order", () => {
356
+ render(<TaskCockpit task={task()} session={panel("session")} terminal={panel("terminal")} />)
357
+ expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["session", "terminal"])
358
+ })
359
+
360
+ it("switches to the terminal, and leaves the composer behind", () => {
361
+ render(
362
+ <TaskCockpit
363
+ task={task()}
364
+ session={panel("session")}
365
+ diff={panel("diff")}
366
+ terminal={panel("terminal")}
367
+ composer={<p>composer</p>}
368
+ />,
369
+ )
370
+ expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["session", "diff", "terminal"])
371
+ fireEvent.click(screen.getByRole("tab", { name: "terminal" }))
372
+ expect(screen.getByText("terminal panel")).toBeTruthy()
373
+ expect(screen.queryByText("composer")).toBeNull()
374
+ fireEvent.click(screen.getByRole("tab", { name: "diff" }))
375
+ expect(screen.getByText("composer")).toBeTruthy()
376
+ })
377
+
378
+ it("falls back to the session when the open view's panel goes away", () => {
379
+ const { rerender } = render(<TaskCockpit task={task()} view="terminal" session={panel("session")} terminal={panel("terminal")} />)
380
+ expect(screen.getByText("terminal panel")).toBeTruthy()
381
+ rerender(<TaskCockpit task={task()} view="terminal" session={panel("session")} />)
382
+ expect(screen.getByText("session panel")).toBeTruthy()
383
+ })
384
+ })
@@ -0,0 +1,51 @@
1
+ "use client"
2
+
3
+ // A shell on the task's machine, in the cockpit's third tab.
4
+ //
5
+ // The frame only: the header strip every cockpit panel owns (so the Session/Diff/Terminal switch
6
+ // sits where it sits on the other two), and a dark box under it. What goes in the box is the
7
+ // app's — a pty at the other end of a socket, persistent sessions, a clipboard — none of which a
8
+ // design system can open. Without it, the box says why there is nothing to type into.
9
+ //
10
+ // Same surface as `TerminalPanel` (#09090b), so the switch from a light transcript to a shell
11
+ // reads as "a terminal", not as a broken theme.
12
+
13
+ import type { ReactNode } from "react"
14
+
15
+ import { cn } from "../../utils"
16
+
17
+ export interface TaskTerminalPanelProps {
18
+ /** The Session/Diff/Terminal switch, from the cockpit's render function. */
19
+ tabs?: ReactNode
20
+ /** Right of the switch — a way out to the full worker page, a full-screen button on a phone. */
21
+ actions?: ReactNode
22
+ /** The shell. Omitted → `placeholder`, on the same dark surface. */
23
+ children?: ReactNode
24
+ /** Why there is no shell (yet). */
25
+ placeholder?: ReactNode
26
+ className?: string
27
+ }
28
+
29
+ export function TaskTerminalPanel({
30
+ tabs,
31
+ actions,
32
+ children,
33
+ placeholder = "The machine is not running — there is nothing to open a shell on.",
34
+ className,
35
+ }: TaskTerminalPanelProps) {
36
+ return (
37
+ <div className={cn("flex min-h-0 flex-1 flex-col", className)}>
38
+ <div className="flex shrink-0 items-center gap-2 border-b border-border bg-card px-4 py-2">
39
+ {tabs ?? <h2 className="text-xs font-medium">Terminal</h2>}
40
+ {actions && <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div>}
41
+ </div>
42
+ <div className="flex min-h-0 flex-1 flex-col bg-[#09090b]">
43
+ {children ?? (
44
+ <div className="flex flex-1 items-center justify-center px-6 text-center">
45
+ <p className="max-w-sm font-mono text-xs text-[#71717a]">{placeholder}</p>
46
+ </div>
47
+ )}
48
+ </div>
49
+ </div>
50
+ )
51
+ }
@@ -100,7 +100,7 @@ function RepoCounters({ repo }: { repo: GitRepoStatus }) {
100
100
 
101
101
  export interface GitStatusSummaryProps {
102
102
  repos: GitRepoStatus[]
103
- /** Heading above the list — e.g. "Open in VS Code" inside a tooltip. */
103
+ /** Heading above the list — e.g. "Open IDE" inside a tooltip. */
104
104
  title?: string
105
105
  className?: string
106
106
  }
@@ -90,7 +90,7 @@ export interface WorkerCardProps {
90
90
  *
91
91
  * ```tsx
92
92
  * menu={[
93
- * codeUrl && { label: "Open in VS Code", icon: Code2Icon, href: codeUrl, target: "_blank" },
93
+ * codeUrl && { label: "Open IDE", icon: Code2Icon, href: codeUrl, target: "_blank" },
94
94
  * "separator",
95
95
  * isRunning && { label: "Stop", icon: SquareIcon, onClick: () => stop.mutate(), loading: stop.isPending },
96
96
  * "separator",
@@ -104,7 +104,7 @@ export interface WorkerCardProps {
104
104
  * item, a dialog opened from an entry). Takes precedence over `menu`.
105
105
  */
106
106
  actions?: ReactNode
107
- /** Replaces the default "View" footer entirely (extra buttons, a VS Code link…). */
107
+ /** Replaces the default "View" footer entirely (extra buttons, an IDE link…). */
108
108
  footer?: ReactNode
109
109
  /** Label of the default footer button. */
110
110
  viewLabel?: string
@@ -90,7 +90,7 @@ export interface WorkerCockpitProps {
90
90
  onAddTag?: (tag: string, next: string[]) => void
91
91
  onRemoveTag?: (tag: string, next: string[]) => void
92
92
 
93
- /** "Open VS Code", top-right. Omitted → the button doesn't show. */
93
+ /** "Open IDE", top-right. Omitted → the button doesn't show. */
94
94
  editorHref?: string
95
95
 
96
96
  onStop?: () => void
@@ -473,7 +473,7 @@ export function WorkerCockpit({
473
473
  <div className="flex shrink-0 items-center gap-2">
474
474
  {settled && editorHref && (
475
475
  <Link href={editorHref} className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8 gap-1.5 px-3 text-xs")}>
476
- <Code2Icon className="h-3.5 w-3.5" />Open VS Code<ExternalLinkIcon className="h-2.5 w-2.5 opacity-70" />
476
+ <Code2Icon className="h-3.5 w-3.5" />Open IDE<ExternalLinkIcon className="h-2.5 w-2.5 opacity-70" />
477
477
  </Link>
478
478
  )}
479
479
  {settled && onStop && (