@spunto/design-system 0.32.1 → 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 +7 -3
- package/package.json +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/README.md
CHANGED
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|