@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 +9 -5
- package/package.json +1 -1
- package/src/components/devcontainer/extension-card.tsx +1 -1
- package/src/components/devcontainer/index.ts +1 -1
- package/src/components/devcontainer/types.ts +1 -1
- package/src/components/projects/build-steps.tsx +1 -1
- package/src/components/projects/chip-list.tsx +2 -2
- package/src/components/projects/extension-picker.tsx +1 -1
- package/src/components/projects/index.ts +1 -1
- package/src/components/projects/project-form.tsx +1 -1
- package/src/components/tasks/index.ts +2 -0
- package/src/components/tasks/task-cockpit-mobile.tsx +5 -5
- package/src/components/tasks/task-cockpit.tsx +54 -17
- package/src/components/tasks/tasks.test.tsx +45 -0
- package/src/components/tasks/terminal-panel.tsx +51 -0
- package/src/components/workers/git-branch-chips.tsx +1 -1
- package/src/components/workers/worker-card.tsx +2 -2
- package/src/components/workers/worker-cockpit.tsx +2 -2
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`) —
|
|
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
|
|
308
|
-
slots filled with the package's own components (`session`/`diff`
|
|
309
|
-
functions receiving the switch to draw in their header
|
|
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,
|
|
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
|
@@ -24,7 +24,7 @@ export interface ExtensionCardProps {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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: "
|
|
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 {
|
|
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
|
|
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
|
-
{
|
|
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
|
|
18
|
-
// render functions because the switch is drawn *inside* their header strip (two
|
|
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
|
|
69
|
+
/** The session panel. Receives the switch to draw in its header. */
|
|
67
70
|
session: (tabs: ReactNode) => ReactNode
|
|
68
|
-
/** The diff panel. Omitted → no
|
|
71
|
+
/** The diff panel. Omitted → no Diff tab. */
|
|
69
72
|
diff?: (tabs: ReactNode) => ReactNode
|
|
70
|
-
/**
|
|
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
|
|
85
|
-
*
|
|
86
|
-
* the width would make
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 && (
|