@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,239 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// Everything that is a *fact about* a task rather than a moment in it — branch, cost, the pull
|
|
4
|
+
// request, the machine, the prompt, what ran — in one component with two shapes:
|
|
5
|
+
//
|
|
6
|
+
// - `sidebar`: the cockpit's right column, each block a fold, glanced at without pushing the
|
|
7
|
+
// work off screen;
|
|
8
|
+
// - `sheet`: the phone's details sheet, flat blocks, and Drop at the bottom — a destructive
|
|
9
|
+
// action a thumb can reach by accident one pixel from Accept is a bug waiting for a bad week.
|
|
10
|
+
//
|
|
11
|
+
// Before, those were two hand-kept copies of the same list (a sidebar and a mobile sheet) that
|
|
12
|
+
// had to agree on order and wording. The order is an argument, so it lives once: what the session
|
|
13
|
+
// is spending (asked *while* it runs), then did it land (the PR), then can I still poke at it (the
|
|
14
|
+
// machine), then how it was set up.
|
|
15
|
+
|
|
16
|
+
import { createContext, useContext, useState, type ReactNode } from "react"
|
|
17
|
+
import { ChevronRightIcon, ExternalLinkIcon, GitBranchIcon, Loader2Icon, XCircleIcon } from "lucide-react"
|
|
18
|
+
|
|
19
|
+
import { cn, formatRelativeTime } from "../../utils"
|
|
20
|
+
import { TaskCommandLog } from "./command-log"
|
|
21
|
+
import { TaskPullRequest } from "./pull-request"
|
|
22
|
+
import { TaskSessionUsage } from "./session-usage"
|
|
23
|
+
import { isLiveTaskState, taskSpan } from "./task-state"
|
|
24
|
+
import type { TaskCommand, TaskItem, TaskPendingAction, TaskPullRequests, TaskSessionUsageData } from "./types"
|
|
25
|
+
|
|
26
|
+
/** Set by `TaskCockpitMobile` around its sheet, so Drop can close it. @internal */
|
|
27
|
+
export const TaskDetailsSheetContext = createContext<{ close: () => void } | null>(null)
|
|
28
|
+
|
|
29
|
+
export interface TaskDetailsProps {
|
|
30
|
+
task: TaskItem
|
|
31
|
+
/** @default "sidebar" */
|
|
32
|
+
variant?: "sidebar" | "sheet"
|
|
33
|
+
/** What the session consumed. Absent → no Session block (nothing measured is not "free"). */
|
|
34
|
+
usage?: TaskSessionUsageData | null
|
|
35
|
+
/** What the forge says about the branch. Absent or `supported: false` → no block at all. */
|
|
36
|
+
pullRequests?: TaskPullRequests | null
|
|
37
|
+
/** The Machine block's content — typically a `TaskMachine`. */
|
|
38
|
+
machine?: ReactNode
|
|
39
|
+
/** Every command the platform ran for the task, oldest first. */
|
|
40
|
+
commands?: TaskCommand[]
|
|
41
|
+
/** The project's `taskAgentProtocol` — only for a footnote. */
|
|
42
|
+
protocol?: string
|
|
43
|
+
/** `sheet` only: the Drop button at the bottom, on a live task. */
|
|
44
|
+
onDrop?: () => void
|
|
45
|
+
pendingAction?: TaskPendingAction | null
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Fact({ label, children }: { label: string; children: ReactNode }) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="min-w-0">
|
|
52
|
+
<p className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">{label}</p>
|
|
53
|
+
<div className="mt-0.5 text-xs">{children}</div>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Block({
|
|
59
|
+
variant,
|
|
60
|
+
title,
|
|
61
|
+
hint,
|
|
62
|
+
defaultOpen = true,
|
|
63
|
+
children,
|
|
64
|
+
}: {
|
|
65
|
+
variant: "sidebar" | "sheet"
|
|
66
|
+
title: string
|
|
67
|
+
hint?: string
|
|
68
|
+
defaultOpen?: boolean
|
|
69
|
+
children: ReactNode
|
|
70
|
+
}) {
|
|
71
|
+
const [open, setOpen] = useState(defaultOpen)
|
|
72
|
+
if (variant === "sheet") {
|
|
73
|
+
return (
|
|
74
|
+
<div className="border-b px-4 py-3">
|
|
75
|
+
<p className="mb-2 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
76
|
+
{title}
|
|
77
|
+
{hint && ` · ${hint}`}
|
|
78
|
+
</p>
|
|
79
|
+
{children}
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
<div className="border-b border-border/60 last:border-b-0">
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={() => setOpen((v) => !v)}
|
|
88
|
+
aria-expanded={open}
|
|
89
|
+
className="flex w-full items-center gap-1.5 px-4 py-2.5 text-left transition-colors hover:bg-muted/40"
|
|
90
|
+
>
|
|
91
|
+
<ChevronRightIcon className={cn("h-3 w-3 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")} />
|
|
92
|
+
<span className="text-xs font-medium">{title}</span>
|
|
93
|
+
{hint && <span className="ml-auto shrink-0 text-[10px] text-muted-foreground">{hint}</span>}
|
|
94
|
+
</button>
|
|
95
|
+
{open && <div className="px-4 pb-4">{children}</div>}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function TaskDetails({
|
|
101
|
+
task,
|
|
102
|
+
variant = "sidebar",
|
|
103
|
+
usage,
|
|
104
|
+
pullRequests,
|
|
105
|
+
machine,
|
|
106
|
+
commands = [],
|
|
107
|
+
protocol = "none",
|
|
108
|
+
onDrop,
|
|
109
|
+
pendingAction = task.pendingAction ?? null,
|
|
110
|
+
className,
|
|
111
|
+
}: TaskDetailsProps) {
|
|
112
|
+
const sheet = useContext(TaskDetailsSheetContext)
|
|
113
|
+
const meta = task.meta && Object.keys(task.meta).length > 0 ? task.meta : null
|
|
114
|
+
const ran = task.completedAt ? taskSpan(task.startedAt ?? task.createdAt, task.completedAt) : null
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className={className}>
|
|
118
|
+
<div className={cn("border-b", variant === "sidebar" ? "border-border/60 px-4 py-3" : "space-y-3 px-4 py-3")}>
|
|
119
|
+
<p className="text-xs text-muted-foreground">
|
|
120
|
+
Delegated {formatRelativeTime(task.createdAt)}
|
|
121
|
+
{/* From `startedAt`, not `createdAt`: the wait for a worker is not run time. */}
|
|
122
|
+
{ran && ` · ran ${ran}`}
|
|
123
|
+
</p>
|
|
124
|
+
|
|
125
|
+
{task.error && (
|
|
126
|
+
<p
|
|
127
|
+
className={cn(
|
|
128
|
+
"rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-2 whitespace-pre-wrap break-words text-destructive",
|
|
129
|
+
variant === "sidebar" ? "mt-2.5 text-[11px]" : "text-xs",
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
{task.error}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
<div className={cn("grid grid-cols-2 gap-3", variant === "sidebar" && "mt-3")}>
|
|
137
|
+
{/* Not truncated: a branch name ends in the part that identifies the task. */}
|
|
138
|
+
{task.branch && (
|
|
139
|
+
<div className="col-span-2">
|
|
140
|
+
<Fact label="Branch">
|
|
141
|
+
<span className="flex items-start gap-1 font-mono break-all">
|
|
142
|
+
<GitBranchIcon className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
|
143
|
+
{task.branch}
|
|
144
|
+
</span>
|
|
145
|
+
</Fact>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
<Fact label="Base">
|
|
149
|
+
<span className="font-mono text-muted-foreground">{task.baseBranch ?? "project default"}</span>
|
|
150
|
+
</Fact>
|
|
151
|
+
{task.outcomeUrl && (
|
|
152
|
+
<Fact label="Result">
|
|
153
|
+
<a href={task.outcomeUrl} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-primary hover:underline">
|
|
154
|
+
{task.outcomeLabel ?? "link"}
|
|
155
|
+
<ExternalLinkIcon className="h-3 w-3 shrink-0" />
|
|
156
|
+
</a>
|
|
157
|
+
</Fact>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
{usage && (
|
|
163
|
+
<Block variant={variant} title="Session">
|
|
164
|
+
<TaskSessionUsage usage={usage} />
|
|
165
|
+
</Block>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{pullRequests?.supported && (
|
|
169
|
+
<Block variant={variant} title="Pull request">
|
|
170
|
+
<TaskPullRequest data={pullRequests} />
|
|
171
|
+
</Block>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{machine && (
|
|
175
|
+
<Block variant={variant} title="Machine">
|
|
176
|
+
{machine}
|
|
177
|
+
</Block>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{task.prompt && (
|
|
181
|
+
<Block variant={variant} title="Prompt">
|
|
182
|
+
<p className="text-xs leading-relaxed whitespace-pre-wrap break-words">{task.prompt}</p>
|
|
183
|
+
</Block>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<Block
|
|
187
|
+
variant={variant}
|
|
188
|
+
title="What ran"
|
|
189
|
+
hint={`${commands.length} command${commands.length === 1 ? "" : "s"}`}
|
|
190
|
+
defaultOpen={false}
|
|
191
|
+
>
|
|
192
|
+
{commands.length > 0 ? (
|
|
193
|
+
<div className="-mx-4 overflow-hidden border-y border-border/60">
|
|
194
|
+
{commands.map((command) => (
|
|
195
|
+
<TaskCommandLog key={command.id} command={command} />
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
) : (
|
|
199
|
+
<p className="text-[11px] text-muted-foreground">
|
|
200
|
+
{task.state === "queued" ? "Nothing yet — the task is still waiting for a worker." : "No command was recorded for this task."}
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
203
|
+
{variant === "sidebar" && protocol !== "none" && (
|
|
204
|
+
<p className="mt-2.5 text-[10px] leading-relaxed text-muted-foreground">
|
|
205
|
+
The agent session's raw stdout is the stream on the left, one JSON event per line.
|
|
206
|
+
</p>
|
|
207
|
+
)}
|
|
208
|
+
</Block>
|
|
209
|
+
|
|
210
|
+
{/* Only ever filled by a status command that no longer exists — kept for the tasks that ran under it. */}
|
|
211
|
+
{meta && (
|
|
212
|
+
<Block variant={variant} title="Reported" hint="from a removed setting" defaultOpen={false}>
|
|
213
|
+
<pre className="overflow-x-auto font-mono text-[11px] whitespace-pre-wrap break-words">{JSON.stringify(meta, null, 2)}</pre>
|
|
214
|
+
</Block>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{variant === "sheet" && onDrop && isLiveTaskState(task.state) && (
|
|
218
|
+
<div className="px-4 py-4">
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
disabled={pendingAction !== null}
|
|
222
|
+
onClick={() => {
|
|
223
|
+
onDrop()
|
|
224
|
+
sheet?.close()
|
|
225
|
+
}}
|
|
226
|
+
className="flex min-h-11 w-full touch-manipulation items-center justify-center gap-2 rounded-xl border border-destructive/30 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors active:bg-destructive/15 disabled:opacity-50"
|
|
227
|
+
>
|
|
228
|
+
{pendingAction === "dropping" ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <XCircleIcon className="h-4 w-4" />}
|
|
229
|
+
{pendingAction === "dropping" ? "Dropping this task…" : "Drop this task"}
|
|
230
|
+
</button>
|
|
231
|
+
<p className="mt-2 text-center text-[11px] leading-relaxed text-muted-foreground">
|
|
232
|
+
Kills the agent session{protocol !== "none" && " and stops the stream"}, then runs the project's cleanup command if it
|
|
233
|
+
has one.
|
|
234
|
+
</p>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// The delegated work of one project, grouped by state — the "Tasks" tab of a project page.
|
|
4
|
+
//
|
|
5
|
+
// Grouped, not merely sorted, and in the order a task travels — queued, running, in review — so
|
|
6
|
+
// the panel reads as a pipeline; `done` and `failed` sink and start folded, because they are
|
|
7
|
+
// history. Past a handful of rows per group the panel stops growing: that is what the org-wide
|
|
8
|
+
// conversation list (`TaskConversationList`) is for, and `cockpitHref` points there.
|
|
9
|
+
//
|
|
10
|
+
// Presentational: the rows come in as `tasks`, and the two actions go out as callbacks. The wait
|
|
11
|
+
// after a click is read off the task itself (`pendingAction`), which the app predicts into its
|
|
12
|
+
// cache on click — so a row accepted from another screen shows it here too.
|
|
13
|
+
|
|
14
|
+
import { useState, type ReactNode } from "react"
|
|
15
|
+
import { CheckCircle2Icon, GitBranchIcon, GitPullRequestIcon, Loader2Icon, SparklesIcon, XCircleIcon } from "lucide-react"
|
|
16
|
+
|
|
17
|
+
import { cn, formatRelativeTime } from "../../utils"
|
|
18
|
+
import type { LinkRender } from "../workers/worker-card"
|
|
19
|
+
import { TASK_STATE_ORDER, TaskStateBadge, isLiveTaskState, taskStateStyle } from "./task-state"
|
|
20
|
+
import type { TaskItem } from "./types"
|
|
21
|
+
|
|
22
|
+
const defaultLink: LinkRender = ({ href, className, children }) => (
|
|
23
|
+
<a href={href} className={className}>
|
|
24
|
+
{children}
|
|
25
|
+
</a>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
export interface TaskListProps<T extends TaskItem = TaskItem> {
|
|
29
|
+
tasks: T[]
|
|
30
|
+
/** A task's own page — the title is the way in. */
|
|
31
|
+
taskHref: (task: T) => string
|
|
32
|
+
/** Accept, on a task in review. Omitted → no button. */
|
|
33
|
+
onAccept?: (task: T) => void
|
|
34
|
+
/** Drop, on any live task. Omitted → no button. */
|
|
35
|
+
onDrop?: (task: T) => void
|
|
36
|
+
/** The machine chip on the row's last line — typically a `TaskWorkerChip`. */
|
|
37
|
+
renderWorker?: (task: T) => ReactNode
|
|
38
|
+
/** "Open agent cockpit →", shown once the project has more work than a panel holds. */
|
|
39
|
+
cockpitHref?: string
|
|
40
|
+
/** Under the empty state's sentence — typically the "New task" button. */
|
|
41
|
+
emptyAction?: ReactNode
|
|
42
|
+
/** Rows per live group before "show N more". Done and failed always start folded. @default 5 */
|
|
43
|
+
visiblePerGroup?: number
|
|
44
|
+
render?: { link?: LinkRender }
|
|
45
|
+
className?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A task's failure, readable. Clamped to two lines so a long stack doesn't take over the list,
|
|
50
|
+
* expandable because the useful part of "could not create branch: …" is often at the end.
|
|
51
|
+
*/
|
|
52
|
+
function TaskError({ text }: { text: string }) {
|
|
53
|
+
const [open, setOpen] = useState(false)
|
|
54
|
+
const long = text.length > 140
|
|
55
|
+
return (
|
|
56
|
+
<div className="mt-1.5 rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1.5">
|
|
57
|
+
<p className={cn("text-[11px] whitespace-pre-wrap break-words text-destructive", !open && "line-clamp-2")}>{text}</p>
|
|
58
|
+
{long && (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={() => setOpen((v) => !v)}
|
|
62
|
+
className="mt-1 text-[10px] font-medium text-destructive/80 transition-colors hover:text-destructive"
|
|
63
|
+
>
|
|
64
|
+
{open ? "Show less" : "Show full error"}
|
|
65
|
+
</button>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function TaskRow<T extends TaskItem>({
|
|
72
|
+
task,
|
|
73
|
+
href,
|
|
74
|
+
onAccept,
|
|
75
|
+
onDrop,
|
|
76
|
+
worker,
|
|
77
|
+
Link,
|
|
78
|
+
}: {
|
|
79
|
+
task: T
|
|
80
|
+
href: string
|
|
81
|
+
onAccept?: (task: T) => void
|
|
82
|
+
onDrop?: (task: T) => void
|
|
83
|
+
worker?: ReactNode
|
|
84
|
+
Link: LinkRender
|
|
85
|
+
}) {
|
|
86
|
+
const pendingAction = task.pendingAction ?? null
|
|
87
|
+
const busy = pendingAction !== null
|
|
88
|
+
const live = isLiveTaskState(task.state)
|
|
89
|
+
// Always offered on a task in review: Accept and Drop are the *only* way a task leaves review.
|
|
90
|
+
const showAccept = task.state === "in-review" && !!onAccept
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div data-task-row className="group/task border-b border-border/40 px-4 py-2 transition-colors last:border-b-0 hover:bg-muted/30">
|
|
94
|
+
<div className="flex items-center gap-2">
|
|
95
|
+
<Link href={href} className="truncate text-sm font-medium underline-offset-2 hover:underline">
|
|
96
|
+
{task.title}
|
|
97
|
+
</Link>
|
|
98
|
+
<TaskStateBadge state={task.state} pendingAction={pendingAction} pendingSince={task.pendingSince} />
|
|
99
|
+
|
|
100
|
+
{/* Actions live on the row, not behind a menu: reviewing is the moment the panel exists
|
|
101
|
+
for. On a task in review they are always visible; a merely live task only offers Drop,
|
|
102
|
+
revealed on hover — except on touch, where there is no hover, and while something is in
|
|
103
|
+
flight, so the only sign the click did anything doesn't vanish with the cursor. */}
|
|
104
|
+
{live && (showAccept || onDrop) && (
|
|
105
|
+
<div
|
|
106
|
+
className={cn(
|
|
107
|
+
"ml-auto flex shrink-0 items-center gap-1 transition-opacity",
|
|
108
|
+
showAccept || busy
|
|
109
|
+
? "opacity-100"
|
|
110
|
+
: "opacity-100 focus-within:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/task:opacity-100",
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
{showAccept && (
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
disabled={busy}
|
|
117
|
+
onClick={() => onAccept?.(task)}
|
|
118
|
+
className="flex touch-manipulation items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium text-green-700 transition-colors hover:bg-green-500/10 active:bg-green-500/15 disabled:opacity-50 dark:text-green-400"
|
|
119
|
+
title="Mark this task done, running the project's accept command if it has one"
|
|
120
|
+
>
|
|
121
|
+
{pendingAction === "accepting" ? <Loader2Icon className="h-3 w-3 animate-spin" /> : <CheckCircle2Icon className="h-3 w-3" />}
|
|
122
|
+
{pendingAction === "accepting" ? "Accepting…" : "Accept"}
|
|
123
|
+
</button>
|
|
124
|
+
)}
|
|
125
|
+
{onDrop && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
disabled={busy}
|
|
129
|
+
onClick={() => onDrop(task)}
|
|
130
|
+
className="flex touch-manipulation items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive active:bg-destructive/15 disabled:opacity-50"
|
|
131
|
+
title="Kill the agent session and drop this task"
|
|
132
|
+
>
|
|
133
|
+
{pendingAction === "dropping" ? <Loader2Icon className="h-3 w-3 animate-spin" /> : <XCircleIcon className="h-3 w-3" />}
|
|
134
|
+
{pendingAction === "dropping" ? "Dropping…" : "Drop"}
|
|
135
|
+
</button>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{task.prompt && <p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground/80">{task.prompt}</p>}
|
|
142
|
+
|
|
143
|
+
{task.error && <TaskError text={task.error} />}
|
|
144
|
+
|
|
145
|
+
{/* Branch, result, workspace — the task's coordinates. */}
|
|
146
|
+
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
|
|
147
|
+
{task.branch && (
|
|
148
|
+
<span className="flex items-center gap-1 font-mono">
|
|
149
|
+
<GitBranchIcon className="h-3 w-3 shrink-0" />
|
|
150
|
+
{task.branch}
|
|
151
|
+
</span>
|
|
152
|
+
)}
|
|
153
|
+
{task.outcomeUrl && (
|
|
154
|
+
<a
|
|
155
|
+
href={task.outcomeUrl}
|
|
156
|
+
target="_blank"
|
|
157
|
+
rel="noreferrer"
|
|
158
|
+
className="flex items-center gap-1 text-primary hover:underline"
|
|
159
|
+
title={task.outcomeUrl}
|
|
160
|
+
>
|
|
161
|
+
<GitPullRequestIcon className="h-3 w-3 shrink-0" />
|
|
162
|
+
{task.outcomeLabel ?? "result"}
|
|
163
|
+
</a>
|
|
164
|
+
)}
|
|
165
|
+
{worker}
|
|
166
|
+
{/* When it last moved, which is also what the list is ordered by. */}
|
|
167
|
+
<span className="ml-auto whitespace-nowrap">{formatRelativeTime(task.lastActivityAt ?? task.createdAt)}</span>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function TaskGroup<T extends TaskItem>({
|
|
174
|
+
state,
|
|
175
|
+
rows,
|
|
176
|
+
visible,
|
|
177
|
+
children,
|
|
178
|
+
}: {
|
|
179
|
+
state: string
|
|
180
|
+
rows: T[]
|
|
181
|
+
visible: number
|
|
182
|
+
children: (row: T) => ReactNode
|
|
183
|
+
}) {
|
|
184
|
+
const [expanded, setExpanded] = useState(false)
|
|
185
|
+
// Live groups open, history folded: what is happening is what the panel is for.
|
|
186
|
+
const foldedByDefault = state === "done" || state === "failed"
|
|
187
|
+
const shown = expanded ? rows : rows.slice(0, foldedByDefault ? 0 : visible)
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div className="border-b border-border/60 last:border-b-0">
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onClick={() => setExpanded((v) => !v)}
|
|
194
|
+
className="flex w-full items-center gap-2 bg-muted/25 px-4 py-1.5 text-left transition-colors hover:bg-muted/50"
|
|
195
|
+
>
|
|
196
|
+
<span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">{taskStateStyle(state).label}</span>
|
|
197
|
+
<span className="text-[10px] tabular-nums text-muted-foreground/70">{rows.length}</span>
|
|
198
|
+
{rows.length > shown.length && (
|
|
199
|
+
<span className="ml-auto text-[10px] text-muted-foreground">show {rows.length - shown.length} more</span>
|
|
200
|
+
)}
|
|
201
|
+
{expanded && rows.length > visible && <span className="ml-auto text-[10px] text-muted-foreground">collapse</span>}
|
|
202
|
+
</button>
|
|
203
|
+
{shown.map(children)}
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function TaskList<T extends TaskItem>({
|
|
209
|
+
tasks,
|
|
210
|
+
taskHref,
|
|
211
|
+
onAccept,
|
|
212
|
+
onDrop,
|
|
213
|
+
renderWorker,
|
|
214
|
+
cockpitHref,
|
|
215
|
+
emptyAction,
|
|
216
|
+
visiblePerGroup = 5,
|
|
217
|
+
render,
|
|
218
|
+
className,
|
|
219
|
+
}: TaskListProps<T>) {
|
|
220
|
+
const Link = render?.link ?? defaultLink
|
|
221
|
+
const groups = TASK_STATE_ORDER.map((state) => ({ state, rows: tasks.filter((t) => t.state === state) })).filter(
|
|
222
|
+
(g) => g.rows.length > 0,
|
|
223
|
+
)
|
|
224
|
+
// A state the package doesn't know still gets its rows drawn, in a group of its own at the end.
|
|
225
|
+
const unknown = tasks.filter((t) => !TASK_STATE_ORDER.includes(t.state))
|
|
226
|
+
if (unknown.length > 0) groups.push({ state: "queued", rows: unknown })
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div className={cn("space-y-3", className)}>
|
|
230
|
+
{cockpitHref && tasks.length > visiblePerGroup && (
|
|
231
|
+
<div className="flex items-center justify-end">
|
|
232
|
+
<Link href={cockpitHref} className="text-[11px] text-muted-foreground transition-colors hover:text-foreground">
|
|
233
|
+
Open agent cockpit →
|
|
234
|
+
</Link>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{tasks.length > 0 ? (
|
|
239
|
+
<div className="overflow-hidden rounded-xl border bg-card shadow-sm">
|
|
240
|
+
{groups.map((group, i) => (
|
|
241
|
+
<TaskGroup key={`${group.state}-${i}`} state={group.state} rows={group.rows} visible={visiblePerGroup}>
|
|
242
|
+
{(task) => (
|
|
243
|
+
<TaskRow
|
|
244
|
+
key={task.id}
|
|
245
|
+
task={task}
|
|
246
|
+
href={taskHref(task)}
|
|
247
|
+
onAccept={onAccept}
|
|
248
|
+
onDrop={onDrop}
|
|
249
|
+
worker={renderWorker?.(task)}
|
|
250
|
+
Link={Link}
|
|
251
|
+
/>
|
|
252
|
+
)}
|
|
253
|
+
</TaskGroup>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
) : (
|
|
257
|
+
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed bg-card py-12 text-center">
|
|
258
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
|
259
|
+
<SparklesIcon className="h-5 w-5 text-muted-foreground" />
|
|
260
|
+
</div>
|
|
261
|
+
<div>
|
|
262
|
+
<p className="text-sm font-medium">No tasks yet</p>
|
|
263
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
264
|
+
Delegate a unit of work — it gets its own worker, branch and agent session
|
|
265
|
+
</p>
|
|
266
|
+
</div>
|
|
267
|
+
{emptyAction}
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|