@spunto/design-system 0.32.1 → 0.34.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
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.32.1",
3
+ "version": "0.34.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",
@@ -381,10 +381,17 @@ function useTimeline(events: TaskEvent[]) {
381
381
  let lastMessage = ""
382
382
 
383
383
  for (const event of events) {
384
- if (event.type === "raw" || event.type === "usage" || event.type === "session.title") {
384
+ if (
385
+ event.type === "raw" ||
386
+ event.type === "usage" ||
387
+ event.type === "session.title" ||
388
+ event.type === "session.background"
389
+ ) {
385
390
  // `usage` is a fact about the whole session, not a moment in it — the sidebar shows it.
386
391
  // `session.title` is the same kind of fact: the name the session gave itself, already
387
- // read at the top of the page as the task's own title.
392
+ // read at the top of the page as the task's own title. `session.background` too: what
393
+ // the harness still runs behind the turn, which the task's state already accounts for —
394
+ // the call that started it is on the timeline as a tool call.
388
395
  if (event.type === "raw") raw.push(event)
389
396
  continue
390
397
  }
@@ -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
+ }
@@ -56,7 +56,7 @@ export interface TaskConversation extends TaskItem {
56
56
  export interface TaskEvent {
57
57
  seq: number
58
58
  ts: string
59
- /** `message` | `thinking` | `plan` | `tool.call` | `tool.result` | `session.started` | `session.ended` | `usage` | `session.title` | `raw` … */
59
+ /** `message` | `thinking` | `plan` | `tool.call` | `tool.result` | `session.started` | `session.ended` | `usage` | `session.title` | `session.background` | `raw` … */
60
60
  type: string
61
61
  payload: Record<string, unknown>
62
62
  }