@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 +1 -1
- package/src/components/tasks/event-stream.tsx +13 -5
- package/src/components/tasks/index.ts +2 -0
- package/src/components/tasks/task-cockpit-mobile.tsx +10 -1
- package/src/components/tasks/task-cockpit.tsx +13 -1
- package/src/components/tasks/task-title.tsx +115 -0
- package/src/components/tasks/tasks.test.tsx +61 -0
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
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" &&
|
|
85
|
-
|
|
86
|
-
tone === "
|
|
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
|
-
|
|
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="
|
|
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
|
+
})
|