@spunto/design-system 0.30.1 → 0.31.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -68,7 +68,13 @@ type Item =
68
68
  /** A chain of machinery, folded — see `tool-stack.tsx`. */
69
69
  | { kind: "run"; run: ToolRun }
70
70
 
71
- /** A marker on the rail. `tone` is what carries meaning at a glance, so it stays a small set. */
71
+ /**
72
+ * A marker on the rail. `tone` is what carries meaning at a glance, so it stays a small set.
73
+ *
74
+ * The rail runs *behind* every marker, so the disc must stay opaque: the tone's tint is a
75
+ * background-image laid over `bg-background`, never a translucent background-color (a `bg-x/10`
76
+ * would replace the opaque fill and let the line show through the icon).
77
+ */
72
78
  function Marker({
73
79
  icon,
74
80
  tone = "muted",
@@ -80,10 +86,12 @@ function Marker({
80
86
  <span
81
87
  className={cn(
82
88
  "absolute left-0 top-0.5 flex h-6 w-6 -translate-x-1/2 items-center justify-center rounded-full border bg-background",
83
- tone === "primary" && "border-primary/40 bg-primary/10 text-primary",
84
- tone === "success" && "border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-500",
85
- tone === "danger" && "border-destructive/40 bg-destructive/10 text-destructive",
86
- tone === "pending" && "border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-500",
89
+ tone === "primary" && "border-primary/40 bg-linear-to-b from-primary/10 to-primary/10 text-primary",
90
+ tone === "success" &&
91
+ "border-green-500/40 bg-linear-to-b from-green-500/10 to-green-500/10 text-green-600 dark:text-green-500",
92
+ tone === "danger" && "border-destructive/40 bg-linear-to-b from-destructive/10 to-destructive/10 text-destructive",
93
+ tone === "pending" &&
94
+ "border-amber-500/40 bg-linear-to-b from-amber-500/10 to-amber-500/10 text-amber-600 dark:text-amber-500",
87
95
  tone === "muted" && "text-muted-foreground",
88
96
  )}
89
97
  >
@@ -38,6 +38,8 @@ export type { TaskCockpitProps, TaskCockpitView } from "./task-cockpit"
38
38
  export { TaskCockpitMobile, useVisualViewport } from "./task-cockpit-mobile"
39
39
  export type { TaskCockpitMobileProps, VisualViewportState } from "./task-cockpit-mobile"
40
40
  export { TaskDetails } from "./task-details"
41
+ export { TASK_TITLE_MAX_LENGTH, TaskTitle } from "./task-title"
42
+ export type { TaskTitleProps } from "./task-title"
41
43
  export type { TaskDetailsProps } from "./task-details"
42
44
 
43
45
  // ── The conversation ────────────────────────────────────────────────────────────────────────
@@ -26,6 +26,7 @@ import type { LinkRender } from "../workers/worker-card"
26
26
  import { TaskPanelTabs, useCockpitView, type TaskCockpitProps } from "./task-cockpit"
27
27
  import { TaskDetailsSheetContext } from "./task-details"
28
28
  import { TaskStateIcon, taskPendingActionConfig, taskStateStyle, useElapsed } from "./task-state"
29
+ import { TaskTitle } from "./task-title"
29
30
  import type { TaskItem, TaskPendingAction } from "./types"
30
31
 
31
32
  export interface TaskCockpitMobileProps extends Omit<TaskCockpitProps, "className"> {
@@ -163,6 +164,8 @@ export function TaskCockpitMobile({
163
164
  pendingAction,
164
165
  pendingSince,
165
166
  onAccept,
167
+ onTitleChange,
168
+ titleUpdating,
166
169
  backHref,
167
170
  onBack,
168
171
  render,
@@ -219,7 +222,13 @@ export function TaskCockpitMobile({
219
222
  >
220
223
  {back}
221
224
  <div className="min-w-0 flex-1 pl-1">
222
- <p className="truncate text-sm leading-tight font-semibold">{task?.title ?? "Conversation"}</p>
225
+ {task ? (
226
+ <p className="flex min-w-0">
227
+ <TaskTitle title={task.title} onTitleChange={onTitleChange} updating={titleUpdating} className="text-sm leading-tight font-semibold" />
228
+ </p>
229
+ ) : (
230
+ <p className="truncate text-sm leading-tight font-semibold">Conversation</p>
231
+ )}
223
232
  {task && <NavStatus task={task} action={action} since={since} />}
224
233
  </div>
225
234
 
@@ -30,6 +30,7 @@ import { Button } from "../button"
30
30
  import { buttonVariants } from "../button-variants"
31
31
  import type { LinkRender } from "../workers/worker-card"
32
32
  import { TaskStateBadge, isLiveTaskState } from "./task-state"
33
+ import { TaskTitle } from "./task-title"
33
34
  import type { TaskItem, TaskPendingAction } from "./types"
34
35
 
35
36
  export type TaskCockpitView = "session" | "diff"
@@ -46,6 +47,13 @@ export interface TaskCockpitProps {
46
47
  onAccept?: () => void
47
48
  /** Drop, on any live task. Omitted → no button. */
48
49
  onDrop?: () => void
50
+ /**
51
+ * Rename the task, in any state: the title becomes a click-to-edit field. Called with the
52
+ * trimmed new title, only when it changed. Omitted → the title is read-only.
53
+ */
54
+ onTitleChange?: (title: string) => void
55
+ /** The rename is in flight — the submitted title stays drawn, with a spinner, until it is false. */
56
+ titleUpdating?: boolean
49
57
 
50
58
  /** Back arrow in the top bar. Omitted when the cockpit sits next to its own list. */
51
59
  backHref?: string
@@ -125,6 +133,8 @@ export function TaskCockpit({
125
133
  pendingSince,
126
134
  onAccept,
127
135
  onDrop,
136
+ onTitleChange,
137
+ titleUpdating,
128
138
  backHref,
129
139
  render,
130
140
  view: viewProp,
@@ -176,7 +186,9 @@ export function TaskCockpit({
176
186
  <div className="h-4 w-px shrink-0 bg-border" />
177
187
  </>
178
188
  )}
179
- <h1 className="truncate text-sm font-semibold">{task.title}</h1>
189
+ <h1 className="flex min-w-0">
190
+ <TaskTitle title={task.title} onTitleChange={onTitleChange} updating={titleUpdating} className="text-sm font-semibold" />
191
+ </h1>
180
192
  <TaskStateBadge state={task.state} pendingAction={action} pendingSince={since} />
181
193
  </div>
182
194
 
@@ -0,0 +1,115 @@
1
+ "use client"
2
+
3
+ // A conversation's title, renamed in place.
4
+ //
5
+ // Without `onTitleChange` it is just the title — the cockpits mount it either way, so an app that
6
+ // cannot rename (or a reader who may not) sees exactly what it saw before. With it, the title is a
7
+ // button: a click turns it into a field, Enter or leaving the field saves, Escape gives up. An
8
+ // empty or unchanged field saves nothing — a title can be replaced, not removed.
9
+ //
10
+ // Nothing here fetches. The new name goes out through the callback; `updating` says the app is
11
+ // still writing it, and until it stops the field's value stays on screen with a spinner, so the
12
+ // old name never flashes back between Enter and the answer — whether or not the app patches its
13
+ // own cache optimistically.
14
+
15
+ import { useEffect, useRef, useState } from "react"
16
+ import { Loader2Icon, PencilIcon } from "lucide-react"
17
+
18
+ import { cn } from "../../utils"
19
+
20
+ /** The API's bound on a task title (`RenameTaskSchema` / `CreateTaskSchema`). */
21
+ export const TASK_TITLE_MAX_LENGTH = 200
22
+
23
+ export interface TaskTitleProps {
24
+ title: string
25
+ /** Called with the trimmed new title, only when it differs. Omitted → read-only. */
26
+ onTitleChange?: (title: string) => void
27
+ /** The rename is in flight: spinner, no second edit, and the submitted title stays drawn. */
28
+ updating?: boolean
29
+ /** The heading's own type — `text-sm font-semibold` in both cockpits. Also applied to the field. */
30
+ className?: string
31
+ }
32
+
33
+ export function TaskTitle({ title, onTitleChange, updating = false, className }: TaskTitleProps) {
34
+ const [editing, setEditing] = useState(false)
35
+ const [draft, setDraft] = useState(title)
36
+ const [submitted, setSubmitted] = useState<string | null>(null)
37
+ const input = useRef<HTMLInputElement>(null)
38
+
39
+ useEffect(() => {
40
+ if (!editing) return
41
+ input.current?.focus()
42
+ input.current?.select()
43
+ }, [editing])
44
+
45
+ // The submitted title is only a bridge over the wait: once the app says it is done, the prop is
46
+ // the truth again — including when the rename was refused and the old title is still the name.
47
+ useEffect(() => {
48
+ if (!updating) setSubmitted(null)
49
+ }, [updating])
50
+
51
+ const shown = updating && submitted !== null ? submitted : title
52
+
53
+ if (!onTitleChange) return <span className={cn("min-w-0 truncate", className)}>{title}</span>
54
+
55
+ const commit = () => {
56
+ setEditing(false)
57
+ const next = draft.trim()
58
+ if (!next || next === title) return
59
+ setSubmitted(next)
60
+ onTitleChange(next)
61
+ }
62
+
63
+ if (editing) {
64
+ return (
65
+ <input
66
+ ref={input}
67
+ value={draft}
68
+ maxLength={TASK_TITLE_MAX_LENGTH}
69
+ aria-label="Conversation title"
70
+ onChange={(e) => setDraft(e.target.value)}
71
+ onBlur={commit}
72
+ onKeyDown={(e) => {
73
+ if (e.key === "Enter") {
74
+ e.preventDefault()
75
+ commit()
76
+ } else if (e.key === "Escape") {
77
+ e.preventDefault()
78
+ setEditing(false)
79
+ }
80
+ }}
81
+ className={cn(
82
+ // Sized to what is typed (`field-sizing`), from a width worth typing in, up to the room the
83
+ // bar gives it — a default-width input cut a 40-character title in half.
84
+ "-mx-1 min-w-48 rounded-sm bg-transparent pr-2 pl-1 ring-1 ring-ring/60 outline-none [field-sizing:content]",
85
+ className,
86
+ )}
87
+ />
88
+ )
89
+ }
90
+
91
+ return (
92
+ <button
93
+ type="button"
94
+ disabled={updating}
95
+ onClick={() => {
96
+ setDraft(title)
97
+ setEditing(true)
98
+ }}
99
+ title="Rename"
100
+ aria-label={`Rename “${shown}”`}
101
+ className="group flex min-w-0 items-center gap-1.5 rounded-sm text-left focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-default"
102
+ >
103
+ <span className={cn("min-w-0 truncate", className)}>{shown}</span>
104
+ {updating ? (
105
+ <Loader2Icon aria-label="Renaming" className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
106
+ ) : (
107
+ // Shown on hover or focus — and always on a touch screen, which has no hover to reveal it.
108
+ <PencilIcon
109
+ aria-hidden
110
+ className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 [@media(hover:none)]:opacity-60"
111
+ />
112
+ )}
113
+ </button>
114
+ )
115
+ }
@@ -12,8 +12,10 @@ import { renderInlineMarkdown } from "./agent-markdown"
12
12
  import { TaskComposer } from "./composer"
13
13
  import { bootSteps } from "./machine"
14
14
  import { formatCost, formatTokens } from "./session-usage"
15
+ import { TaskCockpit } from "./task-cockpit"
15
16
  import { TaskList } from "./task-list"
16
17
  import { taskSpan } from "./task-state"
18
+ import { TaskTitle } from "./task-title"
17
19
  import { runStats, toolSubject, toolVerb, type ToolRun } from "./tool-stack"
18
20
  import type { TaskCommand, TaskEvent, TaskItem } from "./types"
19
21
  import { indexTasksByWorker } from "./worker-link"
@@ -203,3 +205,62 @@ describe("TaskComposer", () => {
203
205
  expect(container.innerHTML).toBe("")
204
206
  })
205
207
  })
208
+
209
+ describe("TaskTitle", () => {
210
+ const edit = (name = /Rename/) => {
211
+ fireEvent.click(screen.getByRole("button", { name }))
212
+ return screen.getByRole("textbox", { name: "Conversation title" }) as HTMLInputElement
213
+ }
214
+
215
+ it("is plain text without a callback", () => {
216
+ render(<TaskTitle title="Fix the login" />)
217
+ expect(screen.getByText("Fix the login")).toBeTruthy()
218
+ expect(screen.queryByRole("button")).toBeNull()
219
+ })
220
+
221
+ it("saves the trimmed title on Enter, and on leaving the field", () => {
222
+ const onTitleChange = vi.fn()
223
+ const { rerender } = render(<TaskTitle title="Fix the login" onTitleChange={onTitleChange} />)
224
+ fireEvent.change(edit(), { target: { value: " Safari login " } })
225
+ fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" })
226
+ expect(onTitleChange).toHaveBeenLastCalledWith("Safari login")
227
+
228
+ rerender(<TaskTitle title="Safari login" onTitleChange={onTitleChange} />)
229
+ const input = edit()
230
+ fireEvent.change(input, { target: { value: "Safari 18 login" } })
231
+ fireEvent.blur(input)
232
+ expect(onTitleChange).toHaveBeenLastCalledWith("Safari 18 login")
233
+ })
234
+
235
+ it("saves nothing on Escape, on an empty field, or on the same title", () => {
236
+ const onTitleChange = vi.fn()
237
+ render(<TaskTitle title="Fix the login" onTitleChange={onTitleChange} />)
238
+ fireEvent.change(edit(), { target: { value: "Something else" } })
239
+ fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" })
240
+ fireEvent.change(edit(), { target: { value: " " } })
241
+ fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" })
242
+ fireEvent.keyDown(edit(), { key: "Enter" })
243
+ expect(onTitleChange).not.toHaveBeenCalled()
244
+ expect(screen.getByText("Fix the login")).toBeTruthy()
245
+ })
246
+
247
+ it("keeps the submitted title drawn while updating, then defers to the prop", () => {
248
+ const { rerender } = render(<TaskTitle title="Fix the login" onTitleChange={() => {}} />)
249
+ fireEvent.change(edit(), { target: { value: "Safari login" } })
250
+ fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" })
251
+ rerender(<TaskTitle title="Fix the login" onTitleChange={() => {}} updating />)
252
+ expect(screen.getByText("Safari login")).toBeTruthy()
253
+ expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true)
254
+ // Refused: the app stops updating without renaming, and the old name is the name again.
255
+ rerender(<TaskTitle title="Fix the login" onTitleChange={() => {}} />)
256
+ expect(screen.getByText("Fix the login")).toBeTruthy()
257
+ })
258
+
259
+ it("is editable from the cockpit's top bar when the app can rename", () => {
260
+ const onTitleChange = vi.fn()
261
+ render(<TaskCockpit task={task()} session={() => null} onTitleChange={onTitleChange} />)
262
+ fireEvent.change(edit(/Fix the login on Safari/), { target: { value: "Safari 18 login" } })
263
+ fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" })
264
+ expect(onTitleChange).toHaveBeenCalledWith("Safari 18 login")
265
+ })
266
+ })