@spunto/design-system 0.28.0 → 0.30.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/README.md +35 -1
- package/package.json +6 -1
- package/src/components/sheet.tsx +7 -1
- package/src/components/tasks/agent-markdown.tsx +322 -0
- package/src/components/tasks/attachments.ts +152 -0
- package/src/components/tasks/command-log.tsx +102 -0
- package/src/components/tasks/composer.tsx +252 -0
- package/src/components/tasks/conversation-list.tsx +238 -0
- package/src/components/tasks/diff-panel.tsx +534 -0
- package/src/components/tasks/event-stream.tsx +860 -0
- package/src/components/tasks/index.ts +111 -0
- package/src/components/tasks/machine.tsx +455 -0
- package/src/components/tasks/new-task-dialog.tsx +301 -0
- package/src/components/tasks/pull-request.tsx +179 -0
- package/src/components/tasks/session-usage.tsx +165 -0
- package/src/components/tasks/task-attachments.tsx +475 -0
- package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
- package/src/components/tasks/task-cockpit.tsx +229 -0
- package/src/components/tasks/task-details.tsx +239 -0
- package/src/components/tasks/task-list.tsx +272 -0
- package/src/components/tasks/task-state.tsx +180 -0
- package/src/components/tasks/tasks.test.tsx +205 -0
- package/src/components/tasks/tool-stack.tsx +692 -0
- package/src/components/tasks/types.ts +220 -0
- package/src/components/tasks/worker-link.tsx +126 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// One visual language for a task's five states, shared by every surface that names a task — the
|
|
4
|
+
// project's list, the org-wide conversation list, the cockpit's top bar, a workspace row. Two
|
|
5
|
+
// copies of a state colour map is how a product ends up with `in-review` amber in one place and
|
|
6
|
+
// flame in another.
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState } from "react"
|
|
9
|
+
import { CheckCircle2Icon, CircleAlertIcon, ClockIcon, GitPullRequestIcon, Loader2Icon } from "lucide-react"
|
|
10
|
+
|
|
11
|
+
import { cn } from "../../utils"
|
|
12
|
+
import { Badge } from "../badge"
|
|
13
|
+
import type { TaskPendingAction, TaskState } from "./types"
|
|
14
|
+
|
|
15
|
+
export interface TaskStateStyle {
|
|
16
|
+
label: string
|
|
17
|
+
/** Background + text + border, for a badge or a chip. */
|
|
18
|
+
className: string
|
|
19
|
+
/** The text colour alone — for an icon or a line of text in that state's colour. */
|
|
20
|
+
textClass: string
|
|
21
|
+
/** The 8 px dot, for places a badge would be too loud. */
|
|
22
|
+
dotClass: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The lifecycle order a task travels — queued → running → in review — then history. Lists group
|
|
27
|
+
* in this order so a column reads as a pipeline; `done` and `failed` sink because they are over.
|
|
28
|
+
*/
|
|
29
|
+
export const TASK_STATE_ORDER: TaskState[] = ["queued", "running", "in-review", "failed", "done"]
|
|
30
|
+
|
|
31
|
+
// `in-review` is the one that wants the eye — the only state waiting on a human — so it gets the
|
|
32
|
+
// brand flame; running stays calm.
|
|
33
|
+
export const taskStateConfig: Record<TaskState, TaskStateStyle> = {
|
|
34
|
+
queued: {
|
|
35
|
+
label: "Queued",
|
|
36
|
+
className: "bg-muted text-muted-foreground border-border",
|
|
37
|
+
textClass: "text-muted-foreground",
|
|
38
|
+
dotClass: "bg-muted-foreground/40",
|
|
39
|
+
},
|
|
40
|
+
running: {
|
|
41
|
+
label: "Running",
|
|
42
|
+
className: "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30",
|
|
43
|
+
textClass: "text-amber-700 dark:text-amber-400",
|
|
44
|
+
dotClass: "bg-amber-500",
|
|
45
|
+
},
|
|
46
|
+
"in-review": {
|
|
47
|
+
label: "In review",
|
|
48
|
+
className: "bg-primary/15 text-primary border-primary/30",
|
|
49
|
+
textClass: "text-primary",
|
|
50
|
+
dotClass: "bg-primary",
|
|
51
|
+
},
|
|
52
|
+
done: {
|
|
53
|
+
label: "Done",
|
|
54
|
+
className: "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30",
|
|
55
|
+
textClass: "text-green-700 dark:text-green-400",
|
|
56
|
+
dotClass: "bg-green-500",
|
|
57
|
+
},
|
|
58
|
+
failed: {
|
|
59
|
+
label: "Failed",
|
|
60
|
+
className: "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/30",
|
|
61
|
+
textClass: "text-red-700 dark:text-red-400",
|
|
62
|
+
dotClass: "bg-red-500",
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const taskPendingActionConfig: Record<TaskPendingAction, { label: string; className: string; textClass: string }> = {
|
|
67
|
+
accepting: { label: "Accepting", className: "bg-primary/10 text-primary border-primary/30", textClass: "text-primary" },
|
|
68
|
+
dropping: {
|
|
69
|
+
label: "Dropping",
|
|
70
|
+
className: "bg-destructive/10 text-destructive border-destructive/30",
|
|
71
|
+
textClass: "text-destructive",
|
|
72
|
+
},
|
|
73
|
+
replying: { label: "Sending", className: "bg-primary/10 text-primary border-primary/30", textClass: "text-primary" },
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lax lookup: an unknown state reads as `queued` (neutral) instead of throwing. */
|
|
77
|
+
export function taskStateStyle(state: string): TaskStateStyle {
|
|
78
|
+
return taskStateConfig[state as TaskState] ?? taskStateConfig.queued
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Queued, running or in review — the task still holds its machine and can still move. */
|
|
82
|
+
export function isLiveTaskState(state: string): boolean {
|
|
83
|
+
return state === "queued" || state === "running" || state === "in-review"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function TaskStateIcon({ state, className }: { state: string; className?: string }) {
|
|
87
|
+
const c = cn("h-3 w-3", className)
|
|
88
|
+
if (state === "queued") return <ClockIcon className={c} />
|
|
89
|
+
if (state === "running") return <Loader2Icon className={cn(c, "animate-spin")} />
|
|
90
|
+
if (state === "in-review") return <GitPullRequestIcon className={c} />
|
|
91
|
+
if (state === "done") return <CheckCircle2Icon className={c} />
|
|
92
|
+
return <CircleAlertIcon className={c} />
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface TaskStateBadgeProps {
|
|
96
|
+
state: string
|
|
97
|
+
pendingAction?: TaskPendingAction | null
|
|
98
|
+
pendingSince?: string | null
|
|
99
|
+
className?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Where the task stands — unless something is happening to it, in which case *that*.
|
|
104
|
+
*
|
|
105
|
+
* The two are never shown side by side: while an accept runs, "In review · Accepting…" is two
|
|
106
|
+
* badges saying one thing, and the state is the half that is about to stop being true. A dashed
|
|
107
|
+
* border is the whole of the extra vocabulary, and the clock answers "is this stuck?".
|
|
108
|
+
*/
|
|
109
|
+
export function TaskStateBadge({ state, pendingAction = null, pendingSince = null, className }: TaskStateBadgeProps) {
|
|
110
|
+
if (pendingAction) return <TaskPendingBadge action={pendingAction} since={pendingSince} className={className} />
|
|
111
|
+
const style = taskStateStyle(state)
|
|
112
|
+
return (
|
|
113
|
+
<Badge variant="outline" className={cn("h-5 shrink-0 gap-1 px-1.5 text-[10px]", style.className, className)}>
|
|
114
|
+
<TaskStateIcon state={state} />
|
|
115
|
+
{style.label}
|
|
116
|
+
</Badge>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function TaskPendingBadge({
|
|
121
|
+
action,
|
|
122
|
+
since = null,
|
|
123
|
+
className,
|
|
124
|
+
}: {
|
|
125
|
+
action: TaskPendingAction
|
|
126
|
+
since?: string | null
|
|
127
|
+
className?: string
|
|
128
|
+
}) {
|
|
129
|
+
const style = taskPendingActionConfig[action]
|
|
130
|
+
const elapsed = useElapsed(since)
|
|
131
|
+
return (
|
|
132
|
+
<Badge variant="outline" className={cn("h-5 shrink-0 gap-1 border-dashed px-1.5 text-[10px]", style.className, className)}>
|
|
133
|
+
<Loader2Icon className="h-3 w-3 animate-spin" />
|
|
134
|
+
{style.label}
|
|
135
|
+
{elapsed && <span className="tabular-nums opacity-70">{elapsed}</span>}
|
|
136
|
+
</Badge>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The same five states as an 8 px dot — context rather than subject (a task named in a workspace row). */
|
|
141
|
+
export function TaskStateDot({ state, className }: { state: string; className?: string }) {
|
|
142
|
+
return <span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", taskStateStyle(state).dotClass, className)} />
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A ticking "for how long", for something still going: a tool call in flight, a machine being
|
|
147
|
+
* prepared, an accept in progress. `null` stops the clock.
|
|
148
|
+
*
|
|
149
|
+
* The one live clock of the package: `formatRelativeTime` deliberately doesn't tick, but a spinner
|
|
150
|
+
* with a frozen number next to it reads as stuck — which is exactly the question it is answering.
|
|
151
|
+
*/
|
|
152
|
+
export function useElapsed(since: string | null | undefined): string | null {
|
|
153
|
+
const [now, setNow] = useState(() => Date.now())
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!since) return
|
|
156
|
+
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
157
|
+
return () => clearInterval(id)
|
|
158
|
+
}, [since])
|
|
159
|
+
if (!since) return null
|
|
160
|
+
const seconds = Math.max(0, Math.round((now - new Date(since).getTime()) / 1000))
|
|
161
|
+
if (!Number.isFinite(seconds)) return null
|
|
162
|
+
if (seconds < 60) return `${seconds}s`
|
|
163
|
+
const minutes = Math.floor(seconds / 60)
|
|
164
|
+
// An hours tier: a tool call going since yesterday must not read "1850m 20s".
|
|
165
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
|
|
166
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** How long something took, between two ISO dates, in the coarsest unit that still says something. */
|
|
170
|
+
export function taskSpan(startIso: string | null | undefined, endIso: string | null | undefined): string | null {
|
|
171
|
+
if (!startIso || !endIso) return null
|
|
172
|
+
const ms = new Date(endIso).getTime() - new Date(startIso).getTime()
|
|
173
|
+
if (!Number.isFinite(ms) || ms < 0) return null
|
|
174
|
+
if (ms < 1000) return `${ms}ms`
|
|
175
|
+
const s = Math.round(ms / 1000)
|
|
176
|
+
if (s < 60) return `${s}s`
|
|
177
|
+
const m = Math.floor(s / 60)
|
|
178
|
+
if (m < 60) return `${m}m ${s % 60}s`
|
|
179
|
+
return `${Math.floor(m / 60)}h ${m % 60}m`
|
|
180
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The derivations `/tasks` makes on its own — what a folded run says, what the boot panel claims,
|
|
3
|
+
* which task a workspace row names — and the rows that have to show up (or not) in jsdom.
|
|
4
|
+
*
|
|
5
|
+
* The timeline itself is virtualized and measures real layout, which jsdom does not have; what it
|
|
6
|
+
* draws is covered by the pure functions it folds events with.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi } from "vitest"
|
|
9
|
+
import { render, screen, fireEvent } from "@testing-library/react"
|
|
10
|
+
|
|
11
|
+
import { renderInlineMarkdown } from "./agent-markdown"
|
|
12
|
+
import { TaskComposer } from "./composer"
|
|
13
|
+
import { bootSteps } from "./machine"
|
|
14
|
+
import { formatCost, formatTokens } from "./session-usage"
|
|
15
|
+
import { TaskList } from "./task-list"
|
|
16
|
+
import { taskSpan } from "./task-state"
|
|
17
|
+
import { runStats, toolSubject, toolVerb, type ToolRun } from "./tool-stack"
|
|
18
|
+
import type { TaskCommand, TaskEvent, TaskItem } from "./types"
|
|
19
|
+
import { indexTasksByWorker } from "./worker-link"
|
|
20
|
+
|
|
21
|
+
const ev = (seq: number, type: string, payload: Record<string, unknown>, ts = `2026-09-24T10:00:${String(seq).padStart(2, "0")}Z`): TaskEvent => ({
|
|
22
|
+
seq,
|
|
23
|
+
ts,
|
|
24
|
+
type,
|
|
25
|
+
payload,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const task = (over: Partial<TaskItem> = {}): TaskItem => ({
|
|
29
|
+
id: "t1",
|
|
30
|
+
title: "Fix the login on Safari",
|
|
31
|
+
prompt: "The login button does nothing on Safari 18.",
|
|
32
|
+
state: "running",
|
|
33
|
+
branch: "task/fix-login-a1b2c3",
|
|
34
|
+
createdAt: "2026-09-24T10:00:00Z",
|
|
35
|
+
lastActivityAt: "2026-09-24T10:05:00Z",
|
|
36
|
+
...over,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("tool runs", () => {
|
|
40
|
+
it("reads a tool name as a verb, by prefix, with a fallback", () => {
|
|
41
|
+
expect(toolVerb("Read")).toBe("read")
|
|
42
|
+
expect(toolVerb("MultiEdit")).toBe("edit")
|
|
43
|
+
expect(toolVerb("Bash")).toBe("run")
|
|
44
|
+
expect(toolVerb("mcp__weird__thing")).toBe("other")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("pulls the subject out of JSON arguments, and keeps raw text otherwise", () => {
|
|
48
|
+
expect(toolSubject(JSON.stringify({ file_path: "/src/app.ts", limit: 40 }))).toBe("/src/app.ts")
|
|
49
|
+
expect(toolSubject(JSON.stringify({ command: "npm test\n -- --run" }))).toBe("npm test -- --run")
|
|
50
|
+
expect(toolSubject("{clipped json")).toBe("{clipped json")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("summarises a run in verbs, keeps failures counted, and shows only the last call as running", () => {
|
|
54
|
+
const run: ToolRun = {
|
|
55
|
+
items: [
|
|
56
|
+
{ kind: "tool", entry: { call: ev(1, "tool.call", { name: "Read" }), result: ev(2, "tool.result", {}) } },
|
|
57
|
+
{ kind: "tool", entry: { call: ev(3, "tool.call", { name: "Read" }), result: ev(4, "tool.result", {}) } },
|
|
58
|
+
// An earlier call with no result is a capture gap, not a tool running for hours.
|
|
59
|
+
{ kind: "tool", entry: { call: ev(5, "tool.call", { name: "Grep" }) } },
|
|
60
|
+
{ kind: "thinking", event: ev(6, "thinking", { text: "hm" }) },
|
|
61
|
+
{ kind: "tool", entry: { call: ev(7, "tool.call", { name: "Bash" }), result: ev(8, "tool.result", { isError: true }) } },
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
const stats = runStats(run)
|
|
65
|
+
expect(stats.label).toBe("read 2 files · 1 search · ran 1 command · 1 thought")
|
|
66
|
+
expect(stats.failed).toBe(1)
|
|
67
|
+
expect(stats.running).toBeNull()
|
|
68
|
+
|
|
69
|
+
const live: ToolRun = { items: [...run.items, { kind: "tool", entry: { call: ev(9, "tool.call", { name: "Edit" }) } }] }
|
|
70
|
+
expect(runStats(live).running?.call.seq).toBe(9)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe("boot steps", () => {
|
|
75
|
+
const branchSetup = (status: string): TaskCommand => ({
|
|
76
|
+
id: "c1",
|
|
77
|
+
command: "git checkout -B …",
|
|
78
|
+
status,
|
|
79
|
+
createdAt: "2026-09-24T10:00:10Z",
|
|
80
|
+
label: "Branch setup (spunto)",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("is looking for a machine while queued without a worker", () => {
|
|
84
|
+
const steps = bootSteps({ task: task({ state: "queued" }), worker: null, commands: [] })
|
|
85
|
+
expect(steps.map((s) => s.key)).toEqual(["machine", "container", "setup", "branch", "session"])
|
|
86
|
+
expect(steps[0].state).toBe("running")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("says the machine was never allocated on a task that died before one", () => {
|
|
90
|
+
const steps = bootSteps({ task: task({ state: "failed" }), worker: null, commands: [] })
|
|
91
|
+
// A post-mortem drops the container/setup rows: the machine is no longer the task's.
|
|
92
|
+
expect(steps.map((s) => s.key)).toEqual(["machine", "branch", "session"])
|
|
93
|
+
expect(steps[0].state).toBe("error")
|
|
94
|
+
expect(steps[2].detail).toBe("never started")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("starts the session step once the branch is ready on a ready worker", () => {
|
|
98
|
+
const worker = { id: "w1", name: "pool-1", state: "ready", dockerState: "running", createdAt: "2026-09-20T00:00:00Z" }
|
|
99
|
+
const steps = bootSteps({ task: task({ state: "queued" }), worker, commands: [branchSetup("succeeded")] })
|
|
100
|
+
expect(steps.find((s) => s.key === "machine")?.detail).toBe("recycled from the pool · pool-1")
|
|
101
|
+
expect(steps.find((s) => s.key === "branch")?.state).toBe("done")
|
|
102
|
+
expect(steps.find((s) => s.key === "session")?.state).toBe("running")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("worker ↔ task", () => {
|
|
107
|
+
it("names the live task on a worker, else the most recent one", () => {
|
|
108
|
+
const tasks = [
|
|
109
|
+
task({ id: "old", state: "done", workerId: "w1", lastActivityAt: "2026-09-24T12:00:00Z" }),
|
|
110
|
+
task({ id: "live", state: "in-review", workerId: "w1", lastActivityAt: "2026-09-24T09:00:00Z" }),
|
|
111
|
+
task({ id: "other", state: "failed", workerId: "w2" }),
|
|
112
|
+
]
|
|
113
|
+
const index = indexTasksByWorker(tasks)
|
|
114
|
+
expect(index.get("w1")).toMatchObject({ task: { id: "live" }, live: true })
|
|
115
|
+
expect(index.get("w2")).toMatchObject({ task: { id: "other" }, live: false })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("formatters", () => {
|
|
120
|
+
it("reads token counts as orders of magnitude", () => {
|
|
121
|
+
expect(formatTokens(812)).toBe("812")
|
|
122
|
+
expect(formatTokens(41_234)).toBe("41.2k")
|
|
123
|
+
expect(formatTokens(1_000_000)).toBe("1M")
|
|
124
|
+
expect(formatCost(0.0042)).toBe("$0.0042")
|
|
125
|
+
expect(formatCost(1.5)).toBe("$1.50")
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("spans two dates, and refuses a clock going backwards", () => {
|
|
129
|
+
expect(taskSpan("2026-09-24T10:00:00Z", "2026-09-24T10:02:05Z")).toBe("2m 5s")
|
|
130
|
+
expect(taskSpan("2026-09-24T10:00:00Z", "2026-09-24T09:00:00Z")).toBeNull()
|
|
131
|
+
expect(taskSpan("2026-09-24T10:00:00Z", null)).toBeNull()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe("agent markdown", () => {
|
|
136
|
+
it("never turns a non-http link into an anchor", () => {
|
|
137
|
+
render(<p>{renderInlineMarkdown("[click](javascript:alert(1)) and https://spunto.net/x.", "t")}</p>)
|
|
138
|
+
expect(screen.queryByText("click")?.closest("a")).toBeNull()
|
|
139
|
+
// Trailing sentence punctuation is not part of the URL.
|
|
140
|
+
expect(screen.getByText("https://spunto.net/x").closest("a")?.getAttribute("href")).toBe("https://spunto.net/x")
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe("TaskList", () => {
|
|
145
|
+
it("offers Accept only in review, and hands the row to the callback", () => {
|
|
146
|
+
const onAccept = vi.fn()
|
|
147
|
+
render(
|
|
148
|
+
<TaskList
|
|
149
|
+
tasks={[task({ id: "a", state: "in-review", title: "Review me" }), task({ id: "b", state: "running", title: "Still going" })]}
|
|
150
|
+
taskHref={(t) => `/tasks/${t.id}`}
|
|
151
|
+
onAccept={onAccept}
|
|
152
|
+
onDrop={() => {}}
|
|
153
|
+
/>,
|
|
154
|
+
)
|
|
155
|
+
const accepts = screen.getAllByRole("button", { name: /accept/i })
|
|
156
|
+
expect(accepts).toHaveLength(1)
|
|
157
|
+
fireEvent.click(accepts[0])
|
|
158
|
+
expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }))
|
|
159
|
+
expect(screen.getByText("Review me").closest("a")?.getAttribute("href")).toBe("/tasks/a")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("holds both buttons while an action is in flight, and says which", () => {
|
|
163
|
+
render(
|
|
164
|
+
<TaskList
|
|
165
|
+
tasks={[task({ state: "in-review", pendingAction: "accepting", pendingSince: new Date().toISOString() })]}
|
|
166
|
+
taskHref={() => "#"}
|
|
167
|
+
onAccept={() => {}}
|
|
168
|
+
onDrop={() => {}}
|
|
169
|
+
/>,
|
|
170
|
+
)
|
|
171
|
+
expect(screen.getByRole("button", { name: /accepting…/i })).toHaveProperty("disabled", true)
|
|
172
|
+
expect(screen.getByRole("button", { name: /drop/i })).toHaveProperty("disabled", true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("starts history folded", () => {
|
|
176
|
+
render(<TaskList tasks={[task({ state: "done", title: "Old work" })]} taskHref={() => "#"} />)
|
|
177
|
+
expect(screen.queryByText("Old work")).toBeNull()
|
|
178
|
+
fireEvent.click(screen.getByText("show 1 more"))
|
|
179
|
+
expect(screen.getByText("Old work")).toBeTruthy()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe("TaskComposer", () => {
|
|
184
|
+
it("says why it is closed on a one-shot harness that is still working", () => {
|
|
185
|
+
render(<TaskComposer state="running" protocol="claude-json" onSubmit={() => {}} />)
|
|
186
|
+
expect((screen.getByRole("textbox") as HTMLTextAreaElement).disabled).toBe(true)
|
|
187
|
+
expect(screen.getByRole("textbox").getAttribute("placeholder")).toMatch(/reads its prompt once/)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("sends on Enter in an interactive session, and clears the box", () => {
|
|
191
|
+
const onSubmit = vi.fn()
|
|
192
|
+
render(<TaskComposer state="running" protocol="claude-stream" canResume onSubmit={onSubmit} onInterrupt={() => {}} />)
|
|
193
|
+
const box = screen.getByRole("textbox") as HTMLTextAreaElement
|
|
194
|
+
fireEvent.change(box, { target: { value: " do X instead " } })
|
|
195
|
+
fireEvent.keyDown(box, { key: "Enter" })
|
|
196
|
+
expect(onSubmit).toHaveBeenCalledWith("do X instead", [])
|
|
197
|
+
expect(box.value).toBe("")
|
|
198
|
+
expect(screen.getByRole("button", { name: /stop/i })).toBeTruthy()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("renders nothing once the task is over", () => {
|
|
202
|
+
const { container } = render(<TaskComposer state="done" protocol="claude-stream" onSubmit={() => {}} />)
|
|
203
|
+
expect(container.innerHTML).toBe("")
|
|
204
|
+
})
|
|
205
|
+
})
|