@spunto/design-system 0.27.0 → 0.30.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 +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 +225 -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
- package/src/components/workers/index.ts +5 -2
- package/src/components/workers/types.ts +22 -0
- package/src/components/workers/worker-cockpit.tsx +17 -2
- package/src/components/workers/worker-status-history.test.ts +85 -0
- package/src/components/workers/worker-status-history.tsx +166 -0
- package/src/index.ts +1 -1
- package/src/utils.ts +23 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { ChevronRightIcon, TerminalIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn, formatRelativeTime } from "../../utils"
|
|
7
|
+
import { taskSpan } from "./task-state"
|
|
8
|
+
import type { TaskCommand } from "./types"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
const STATUS_STYLES: Record<string, string> = {
|
|
12
|
+
running: "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30",
|
|
13
|
+
succeeded: "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30",
|
|
14
|
+
failed: "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/30",
|
|
15
|
+
timeout: "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/30",
|
|
16
|
+
canceled: "bg-muted text-muted-foreground border-border",
|
|
17
|
+
lost: "bg-muted text-muted-foreground border-border",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function Stream({ name, text }: { name: string; text: string }) {
|
|
21
|
+
if (!text.trim()) return null
|
|
22
|
+
return (
|
|
23
|
+
<div>
|
|
24
|
+
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground mb-1">{name}</p>
|
|
25
|
+
{/* Left as one scrollable block rather than a terminal emulator: this is a finished
|
|
26
|
+
command's captured tail, not a live TTY — there is nothing to interact with. */}
|
|
27
|
+
<pre className="max-h-96 overflow-auto rounded-md bg-muted/50 border px-3 py-2 text-[11px] leading-relaxed font-mono whitespace-pre-wrap break-words">
|
|
28
|
+
{text}
|
|
29
|
+
</pre>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One command Spunto ran for a task, with its output.
|
|
36
|
+
*
|
|
37
|
+
* Open by default when it failed, is still running, or is the **latest** one — that last case
|
|
38
|
+
* matters more than it looks: the newest command is almost always the agent session, which is
|
|
39
|
+
* the thing the page exists to show. A green branch setup above it is context, not news, so it
|
|
40
|
+
* stays folded.
|
|
41
|
+
*/
|
|
42
|
+
export function TaskCommandLog({ command, defaultOpen = false }: { command: TaskCommand; defaultOpen?: boolean }) {
|
|
43
|
+
const interesting = command.status !== "succeeded"
|
|
44
|
+
const [open, setOpen] = useState(interesting || defaultOpen)
|
|
45
|
+
const took = taskSpan(command.createdAt, command.finishedAt)
|
|
46
|
+
const stdout = command.stdout ?? ""
|
|
47
|
+
const stderr = command.stderr ?? ""
|
|
48
|
+
const hasOutput = Boolean(stdout.trim() || stderr.trim())
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="border-b border-border/60 last:border-b-0">
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={() => setOpen((v) => !v)}
|
|
55
|
+
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-muted/30 transition-colors"
|
|
56
|
+
>
|
|
57
|
+
<ChevronRightIcon className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")} />
|
|
58
|
+
<TerminalIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
59
|
+
<span className="text-xs font-medium truncate">{command.label ?? "Command"}</span>
|
|
60
|
+
<span className={cn("rounded-full border px-1.5 py-px text-[10px] shrink-0", STATUS_STYLES[command.status] ?? STATUS_STYLES.lost)}>
|
|
61
|
+
{command.status}
|
|
62
|
+
{command.exitCode !== null && command.exitCode !== 0 ? ` · ${command.exitCode}` : ""}
|
|
63
|
+
</span>
|
|
64
|
+
{command.mode === "background" && (
|
|
65
|
+
<span className="text-[10px] text-muted-foreground shrink-0">background</span>
|
|
66
|
+
)}
|
|
67
|
+
<span className="ml-auto flex items-center gap-2 text-[10px] text-muted-foreground shrink-0">
|
|
68
|
+
{took && <span>{took}</span>}
|
|
69
|
+
<span>{formatRelativeTime(command.createdAt)}</span>
|
|
70
|
+
</span>
|
|
71
|
+
</button>
|
|
72
|
+
|
|
73
|
+
{open && (
|
|
74
|
+
<div className="px-4 pb-4 space-y-3">
|
|
75
|
+
<div>
|
|
76
|
+
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground mb-1">
|
|
77
|
+
Command{command.cwd && <> · <span className="font-mono normal-case tracking-normal">{command.cwd}</span></>}
|
|
78
|
+
</p>
|
|
79
|
+
<pre className="max-h-64 overflow-auto rounded-md bg-muted/50 border px-3 py-2 text-[11px] leading-relaxed font-mono whitespace-pre-wrap break-words">
|
|
80
|
+
{command.command}
|
|
81
|
+
</pre>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<Stream name="stdout" text={stdout} />
|
|
85
|
+
<Stream name="stderr" text={stderr} />
|
|
86
|
+
|
|
87
|
+
{!hasOutput && (
|
|
88
|
+
<p className="text-[11px] text-muted-foreground">
|
|
89
|
+
{command.status === "running" ? "Still running — no output captured yet." : "No output."}
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{command.truncated && (
|
|
94
|
+
<p className="text-[10px] text-muted-foreground">
|
|
95
|
+
Output was longer than the 64 KiB kept per stream — this is the tail.
|
|
96
|
+
</p>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { ArrowUpIcon, CornerDownLeftIcon, Loader2Icon, SquareIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { Button } from "../button"
|
|
8
|
+
import { AttachFileButton, AttachmentDraftStrip, FileDropOverlay, useAttachmentDraft } from "./task-attachments"
|
|
9
|
+
import type { DraftFile, TaskPendingAction } from "./types"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Answering a task — the bottom of the cockpit's centre column.
|
|
13
|
+
*
|
|
14
|
+
* What it does is a **new turn, not an interruption**: the harness is resumed with what you wrote,
|
|
15
|
+
* on the same branch, in the same worker, and its events continue the timeline above. Which is
|
|
16
|
+
* why it only appears while the task is in review — a session that is still talking has no open
|
|
17
|
+
* stdin to interrupt, and a box that pretended otherwise would be the worst kind of lie: one that
|
|
18
|
+
* looks like it worked.
|
|
19
|
+
*
|
|
20
|
+
* So the disabled states are not decoration, they are the honest half of the feature. Each says
|
|
21
|
+
* which of the three reasons applies, because "the box is greyed out" is not an answer.
|
|
22
|
+
*
|
|
23
|
+
* It also takes **files** — any file (RFC 0022) — and all three ways in are meant to be findable:
|
|
24
|
+
*
|
|
25
|
+
* - **paste** is the one that carries the feature — a screenshot of the thing that is wrong lives
|
|
26
|
+
* on the clipboard, and any path that asks someone to save it to a file first is a path they
|
|
27
|
+
* will not take;
|
|
28
|
+
* - **drop** anywhere on the box, which lights up to say so;
|
|
29
|
+
* - **the paperclip**, at the left edge of the writing area, where a messaging app puts it.
|
|
30
|
+
*
|
|
31
|
+
* That last one is placed rather than merely present: it started life as a small icon in the hint
|
|
32
|
+
* bar under the textarea and the first person who looked for it did not find it. A control for one
|
|
33
|
+
* of the two things you do with a message does not belong in the footnotes.
|
|
34
|
+
*
|
|
35
|
+
* What is picked rides along as tiles above the box, the way an attachment does in a mail client,
|
|
36
|
+
* so the words keep the room. Images show a thumbnail; everything else shows what it is called and
|
|
37
|
+
* how big it is, which is all there is to say about a tarball.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export interface TaskComposerProps {
|
|
41
|
+
state: string
|
|
42
|
+
/** Did the stream give us a session id to resume? Without one there is nothing to continue. */
|
|
43
|
+
canResume?: boolean
|
|
44
|
+
/** The project's `taskAgentProtocol`. `claude-stream` is interactive: answer while it works, and Stop. */
|
|
45
|
+
protocol: string
|
|
46
|
+
/**
|
|
47
|
+
* The turn: the words, and the files prepared in the browser (`DraftFile.data` is base64). The
|
|
48
|
+
* composer hands the thumbnails' object URLs over with them — the app draws the pending bubble
|
|
49
|
+
* from them and revokes them once it is done with them.
|
|
50
|
+
*/
|
|
51
|
+
onSubmit: (prompt: string, files: DraftFile[]) => void
|
|
52
|
+
onInterrupt?: () => void
|
|
53
|
+
/** A turn of yours is on its way — the send button spins and the box holds. */
|
|
54
|
+
pending?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Whatever else the platform is doing to this task. Accepting and dropping both borrow the
|
|
57
|
+
* machine the session lives in, so the box closes for their duration and says which one — a
|
|
58
|
+
* fourth reason, in a component whose whole argument is that a greyed-out box owes an answer.
|
|
59
|
+
*/
|
|
60
|
+
pendingAction?: TaskPendingAction | null
|
|
61
|
+
error?: string | null
|
|
62
|
+
/**
|
|
63
|
+
* `compact` is the phone layout: one line, a round send button on the right, the paperclip on
|
|
64
|
+
* the left. Chosen by the caller rather than by a media query — it is the cockpit's layout that
|
|
65
|
+
* decides, and `TaskCockpitMobile` passes it.
|
|
66
|
+
*/
|
|
67
|
+
layout?: "default" | "compact"
|
|
68
|
+
className?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function TaskComposer({
|
|
72
|
+
state,
|
|
73
|
+
canResume = false,
|
|
74
|
+
protocol,
|
|
75
|
+
onSubmit,
|
|
76
|
+
onInterrupt,
|
|
77
|
+
pending = false,
|
|
78
|
+
pendingAction = null,
|
|
79
|
+
error = null,
|
|
80
|
+
layout = "default",
|
|
81
|
+
className,
|
|
82
|
+
}: TaskComposerProps) {
|
|
83
|
+
const [prompt, setPrompt] = useState("")
|
|
84
|
+
const draft = useAttachmentDraft()
|
|
85
|
+
const isMobile = layout === "compact"
|
|
86
|
+
|
|
87
|
+
if (state === "done" || state === "failed") return null
|
|
88
|
+
|
|
89
|
+
// Interactive: the harness is alive with an open stdin, so it can be talked to *while it
|
|
90
|
+
// works*, and asked to stop. One-shot: its stdin closed with the prompt, so the only moment
|
|
91
|
+
// there is anywhere to put words is once it has handed back.
|
|
92
|
+
const interactive = protocol === "claude-stream"
|
|
93
|
+
|
|
94
|
+
const reason =
|
|
95
|
+
pendingAction === "accepting"
|
|
96
|
+
? "Accepting this task — the project's accept command is running in its machine."
|
|
97
|
+
: pendingAction === "dropping"
|
|
98
|
+
? "Dropping this task."
|
|
99
|
+
: state === "queued"
|
|
100
|
+
? "The task is still waiting for a worker."
|
|
101
|
+
: state === "running" && !interactive
|
|
102
|
+
? "The session is working, and this harness reads its prompt once. You can answer as soon as it hands back."
|
|
103
|
+
: protocol === "none"
|
|
104
|
+
? "This project doesn't capture its agent session, so there is no session to resume. Set Session stream in its delegated-work settings."
|
|
105
|
+
: !canResume && state !== "running"
|
|
106
|
+
? "No session id was captured for this task, so there is nothing to resume."
|
|
107
|
+
: null
|
|
108
|
+
|
|
109
|
+
const disabled = reason !== null || pending
|
|
110
|
+
|
|
111
|
+
function submit() {
|
|
112
|
+
const text = prompt.trim()
|
|
113
|
+
// Words are still required with an attachment: "look at this" is a turn, a bare file is a
|
|
114
|
+
// riddle — and the harness is handed a path, so something has to say what to do with it.
|
|
115
|
+
if (!text || disabled) return
|
|
116
|
+
onSubmit(text, draft.files)
|
|
117
|
+
setPrompt("")
|
|
118
|
+
// `release`, not `clear`: the cockpit is now drawing these very thumbnails under the pending
|
|
119
|
+
// message, and revoking them here would blank it the instant it appeared. It owns them now.
|
|
120
|
+
draft.release()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const textarea = (
|
|
124
|
+
<textarea
|
|
125
|
+
value={prompt}
|
|
126
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
127
|
+
// Enter sends, Shift+Enter breaks the line: this is a reply box, and the common case
|
|
128
|
+
// by far is one or two sentences.
|
|
129
|
+
onKeyDown={(e) => {
|
|
130
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
131
|
+
e.preventDefault()
|
|
132
|
+
submit()
|
|
133
|
+
}
|
|
134
|
+
}}
|
|
135
|
+
disabled={disabled}
|
|
136
|
+
// Paste stays on the textarea (it is a keyboard event, it needs the focused element);
|
|
137
|
+
// dropping is handled by the whole composer below.
|
|
138
|
+
onPaste={draft.onPaste}
|
|
139
|
+
rows={isMobile ? 1 : 2}
|
|
140
|
+
// Two lines of placeholder cost a third of a phone screen before a word is typed.
|
|
141
|
+
placeholder={reason ?? (isMobile ? "Answer the agent…" : "Answer the agent — “in fact, do X instead”. Enter to send.")}
|
|
142
|
+
// 16px in the compact layout: below that, iOS zooms the whole page in the moment the field
|
|
143
|
+
// takes focus — and a reply box that rearranges the screen around it is not a reply box.
|
|
144
|
+
className={cn(
|
|
145
|
+
"w-full resize-none bg-transparent px-3 py-2 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
|
|
146
|
+
isMobile ? "text-base" : "text-sm",
|
|
147
|
+
)}
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const errorLine = error && (
|
|
152
|
+
<p className="mb-2 rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1.5 text-[11px] text-destructive">
|
|
153
|
+
{error}
|
|
154
|
+
</p>
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// ── Phone: the field and its send button on one line ──────────────────────────────────
|
|
158
|
+
//
|
|
159
|
+
// The desktop layout stacks a footer under the textarea — the hint, Stop, Send. On a phone
|
|
160
|
+
// that is a second row of chrome above the keyboard for one button, so the button moves into
|
|
161
|
+
// the line, round, on the right, where every messaging app has trained a thumb to find it.
|
|
162
|
+
if (isMobile) {
|
|
163
|
+
return (
|
|
164
|
+
<div className={cn("shrink-0 border-t border-border bg-card/40 px-2.5 py-2", className)}>
|
|
165
|
+
{errorLine}
|
|
166
|
+
{reason && <p className="mb-1.5 px-1 text-[11px] leading-snug text-muted-foreground">{reason}</p>}
|
|
167
|
+
<div className="flex items-end gap-2">
|
|
168
|
+
{/* Left of the field, like every phone messaging app — a thumb reaches for it there. */}
|
|
169
|
+
<AttachFileButton size="lg" disabled={disabled} onFiles={(files: File[]) => void draft.add(files)} />
|
|
170
|
+
<div
|
|
171
|
+
className={cn(
|
|
172
|
+
"min-w-0 flex-1 rounded-2xl border bg-background transition-colors focus-within:border-primary/40",
|
|
173
|
+
disabled && "opacity-60",
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
<AttachmentDraftStrip draft={draft} />
|
|
177
|
+
{textarea}
|
|
178
|
+
</div>
|
|
179
|
+
{interactive && state === "running" && onInterrupt && (
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={() => onInterrupt?.()}
|
|
183
|
+
aria-label="Stop the current turn"
|
|
184
|
+
className="flex h-10 w-10 shrink-0 touch-manipulation items-center justify-center rounded-full border border-border text-muted-foreground transition-transform active:scale-95"
|
|
185
|
+
>
|
|
186
|
+
<SquareIcon className="h-4 w-4" />
|
|
187
|
+
</button>
|
|
188
|
+
)}
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
onClick={submit}
|
|
192
|
+
disabled={disabled || !prompt.trim()}
|
|
193
|
+
aria-label="Send"
|
|
194
|
+
className="flex h-10 w-10 shrink-0 touch-manipulation items-center justify-center rounded-full bg-primary text-primary-foreground transition-transform active:scale-95 disabled:opacity-40"
|
|
195
|
+
>
|
|
196
|
+
{pending ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ArrowUpIcon className="h-5 w-5" />}
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div className={cn("shrink-0 border-t border-border bg-card/40 px-8 py-3", className)}>
|
|
205
|
+
<div className="mx-auto max-w-3xl">
|
|
206
|
+
{errorLine}
|
|
207
|
+
|
|
208
|
+
<div
|
|
209
|
+
{...draft.dropZone}
|
|
210
|
+
className={cn(
|
|
211
|
+
"relative rounded-lg border bg-background transition-colors focus-within:border-primary/40",
|
|
212
|
+
disabled && "opacity-60",
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
{draft.draggingOver && <FileDropOverlay />}
|
|
216
|
+
<AttachmentDraftStrip draft={draft} />
|
|
217
|
+
{/* The paperclip sits in the writing row, at its left edge, not in the hint bar below:
|
|
218
|
+
it is a control of the message, not a note about it. */}
|
|
219
|
+
<div className="flex items-start gap-1.5 pl-2 pt-2">
|
|
220
|
+
<AttachFileButton disabled={disabled} onFiles={(files: File[]) => void draft.add(files)} />
|
|
221
|
+
<div className="-ml-1 min-w-0 flex-1">{textarea}</div>
|
|
222
|
+
</div>
|
|
223
|
+
<div className="flex items-center gap-2 border-t border-border/60 px-2 py-1.5">
|
|
224
|
+
<span className="mr-auto truncate text-[10px] text-muted-foreground">
|
|
225
|
+
{reason ??
|
|
226
|
+
(interactive
|
|
227
|
+
? // Both paths are real and the composer cannot know which one it will take:
|
|
228
|
+
// the session is usually still listening, and is resumed if it has exited.
|
|
229
|
+
"Goes to the live session — or resumes it if it has exited."
|
|
230
|
+
: "Resumes the session with your message — same branch, same worker.")}
|
|
231
|
+
</span>
|
|
232
|
+
{interactive && state === "running" && onInterrupt && (
|
|
233
|
+
<Button
|
|
234
|
+
variant="ghost"
|
|
235
|
+
size="sm"
|
|
236
|
+
onClick={() => onInterrupt?.()}
|
|
237
|
+
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
|
238
|
+
>
|
|
239
|
+
<SquareIcon className="h-3 w-3" />
|
|
240
|
+
Stop
|
|
241
|
+
</Button>
|
|
242
|
+
)}
|
|
243
|
+
<Button size="sm" className="h-7 gap-1.5 px-2.5 text-xs" disabled={disabled || !prompt.trim()} onClick={submit}>
|
|
244
|
+
{pending ? <Loader2Icon className="h-3 w-3 animate-spin" /> : <CornerDownLeftIcon className="h-3 w-3" />}
|
|
245
|
+
Send
|
|
246
|
+
</Button>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// Every conversation of an organization, as a messaging app's list — the left column of the agent
|
|
4
|
+
// cockpit (`/agents`), answering "what are my agents doing, and which one needs me?".
|
|
5
|
+
//
|
|
6
|
+
// Grouped by state in lifecycle order (queued → running → in review, then failed and done), and
|
|
7
|
+
// inside a group most recently active first — the order the API already returns — so the task
|
|
8
|
+
// that just handed back is the top row of the review group, however long ago it was delegated.
|
|
9
|
+
//
|
|
10
|
+
// Controlled: search, state filter, selection and paging are the app's (the selection belongs in
|
|
11
|
+
// the URL so a conversation stays linkable). The component owns only what is drawn.
|
|
12
|
+
//
|
|
13
|
+
// Sized by input, not by width: a phone's full-width list and a desktop's 320 px sidebar are the
|
|
14
|
+
// same width, so what changes is `pointer-coarse` — bigger rows and a drawn chevron for a thumb.
|
|
15
|
+
|
|
16
|
+
import type { ReactNode } from "react"
|
|
17
|
+
import { ChevronRightIcon, Loader2Icon, MessagesSquareIcon, SearchIcon } from "lucide-react"
|
|
18
|
+
|
|
19
|
+
import { cn, formatRelativeTime } from "../../utils"
|
|
20
|
+
import { Button } from "../button"
|
|
21
|
+
import { TASK_STATE_ORDER, TaskStateIcon, taskPendingActionConfig, taskStateStyle, useElapsed } from "./task-state"
|
|
22
|
+
import type { TaskConversation, TaskState } from "./types"
|
|
23
|
+
|
|
24
|
+
export interface TaskConversationListProps<T extends TaskConversation = TaskConversation> {
|
|
25
|
+
/** The loaded page, most recently active first. */
|
|
26
|
+
tasks: T[]
|
|
27
|
+
/** How many match the current filter in total — drives "N of M" and "Load more". */
|
|
28
|
+
total?: number
|
|
29
|
+
/**
|
|
30
|
+
* Per-state counts, **ignoring** the state filter: a filter you cannot see out of is a trap,
|
|
31
|
+
* and "3 failed" is how you find your way back.
|
|
32
|
+
*/
|
|
33
|
+
counts?: Partial<Record<TaskState, number>>
|
|
34
|
+
loading?: boolean
|
|
35
|
+
selectedId?: string | null
|
|
36
|
+
onSelect: (task: T) => void
|
|
37
|
+
|
|
38
|
+
query: string
|
|
39
|
+
onQueryChange: (query: string) => void
|
|
40
|
+
/** Active state chips. Empty → every state. */
|
|
41
|
+
states: TaskState[]
|
|
42
|
+
onStatesChange: (states: TaskState[]) => void
|
|
43
|
+
|
|
44
|
+
/** "Load N more" under the list, while `tasks.length < total`. */
|
|
45
|
+
onLoadMore?: () => void
|
|
46
|
+
/** How many the next page brings, for the button's label. @default 40 */
|
|
47
|
+
pageSize?: number
|
|
48
|
+
|
|
49
|
+
/** @default "Agents" */
|
|
50
|
+
title?: string
|
|
51
|
+
/** Next to the title — typically the "New" task button. */
|
|
52
|
+
action?: ReactNode
|
|
53
|
+
className?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Chip({
|
|
57
|
+
label,
|
|
58
|
+
count,
|
|
59
|
+
active,
|
|
60
|
+
onClick,
|
|
61
|
+
tint,
|
|
62
|
+
}: {
|
|
63
|
+
label: string
|
|
64
|
+
count: number
|
|
65
|
+
active: boolean
|
|
66
|
+
onClick: () => void
|
|
67
|
+
tint: string
|
|
68
|
+
}) {
|
|
69
|
+
return (
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={onClick}
|
|
73
|
+
aria-pressed={active}
|
|
74
|
+
className={cn(
|
|
75
|
+
"flex shrink-0 touch-manipulation items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors active:bg-muted pointer-coarse:px-3 pointer-coarse:py-1.5 pointer-coarse:text-[12px]",
|
|
76
|
+
active ? tint : "text-muted-foreground hover:bg-muted/60",
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{label}
|
|
80
|
+
<span className={cn("tabular-nums", !active && "text-muted-foreground/70")}>{count}</span>
|
|
81
|
+
</button>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function RowTime({ task }: { task: TaskConversation }) {
|
|
86
|
+
const elapsed = useElapsed(task.pendingAction ? task.pendingSince : null)
|
|
87
|
+
const pending = task.pendingAction ? taskPendingActionConfig[task.pendingAction] : null
|
|
88
|
+
return (
|
|
89
|
+
<span className={cn("shrink-0 text-[10px] tabular-nums pointer-coarse:text-[11px]", pending ? pending.textClass : "text-muted-foreground")}>
|
|
90
|
+
{pending ? `${pending.label}${elapsed ? ` ${elapsed}` : "…"}` : formatRelativeTime(task.lastActivityAt ?? task.createdAt)}
|
|
91
|
+
</span>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ConversationRow({ task, selected, onSelect }: { task: TaskConversation; selected: boolean; onSelect: () => void }) {
|
|
96
|
+
const tone = task.pendingAction ? taskPendingActionConfig[task.pendingAction].textClass : taskStateStyle(task.state).textClass
|
|
97
|
+
return (
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={onSelect}
|
|
101
|
+
aria-current={selected || undefined}
|
|
102
|
+
className={cn(
|
|
103
|
+
"w-full touch-manipulation border-b border-border/50 px-3 py-2.5 text-left transition-colors active:bg-muted pointer-coarse:px-4 pointer-coarse:py-3",
|
|
104
|
+
selected ? "bg-primary/[0.07]" : "hover:bg-muted/50",
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
<div className="flex items-center gap-2">
|
|
108
|
+
<span className={cn("shrink-0", tone)}>
|
|
109
|
+
{task.pendingAction ? <Loader2Icon className="h-3 w-3 animate-spin" /> : <TaskStateIcon state={task.state} />}
|
|
110
|
+
</span>
|
|
111
|
+
<span className={cn("min-w-0 flex-1 truncate text-xs pointer-coarse:text-sm", selected ? "font-semibold" : "font-medium")}>
|
|
112
|
+
{task.title}
|
|
113
|
+
</span>
|
|
114
|
+
{/* When it last moved — replaced, while something is in flight, by how long it has been. */}
|
|
115
|
+
<RowTime task={task} />
|
|
116
|
+
{/* Touch has no hover to say "this leads somewhere", so the affordance is drawn. */}
|
|
117
|
+
<ChevronRightIcon className="hidden h-4 w-4 shrink-0 text-muted-foreground/40 pointer-coarse:block" />
|
|
118
|
+
</div>
|
|
119
|
+
{/* The project is a coordinate, so it is subordinate — but it has to be there: two
|
|
120
|
+
conversations called "fix the login" in two projects are otherwise the same row. */}
|
|
121
|
+
{task.projectName && (
|
|
122
|
+
<p className="mt-0.5 truncate pl-5 text-[10px] text-muted-foreground pointer-coarse:pl-6 pointer-coarse:text-[11px]">
|
|
123
|
+
{task.projectName}
|
|
124
|
+
</p>
|
|
125
|
+
)}
|
|
126
|
+
</button>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function TaskConversationList<T extends TaskConversation>({
|
|
131
|
+
tasks,
|
|
132
|
+
total,
|
|
133
|
+
counts = {},
|
|
134
|
+
loading = false,
|
|
135
|
+
selectedId = null,
|
|
136
|
+
onSelect,
|
|
137
|
+
query,
|
|
138
|
+
onQueryChange,
|
|
139
|
+
states,
|
|
140
|
+
onStatesChange,
|
|
141
|
+
onLoadMore,
|
|
142
|
+
pageSize = 40,
|
|
143
|
+
title = "Agents",
|
|
144
|
+
action,
|
|
145
|
+
className,
|
|
146
|
+
}: TaskConversationListProps<T>) {
|
|
147
|
+
const groups = TASK_STATE_ORDER.map((state) => ({ state, rows: tasks.filter((t) => t.state === state) })).filter(
|
|
148
|
+
(g) => g.rows.length > 0,
|
|
149
|
+
)
|
|
150
|
+
const toggle = (state: TaskState) =>
|
|
151
|
+
onStatesChange(states.includes(state) ? states.filter((s) => s !== state) : [...states, state])
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className={cn("flex min-h-0 flex-col bg-card/40", className)}>
|
|
155
|
+
<div className="shrink-0 space-y-2 border-b border-border px-3 py-2.5 pointer-coarse:space-y-2.5 pointer-coarse:px-4 pointer-coarse:py-3">
|
|
156
|
+
<div className="flex items-center gap-2">
|
|
157
|
+
<MessagesSquareIcon className="h-4 w-4 shrink-0 text-primary" />
|
|
158
|
+
<h1 className="text-sm font-semibold pointer-coarse:text-base">{title}</h1>
|
|
159
|
+
<span className="text-[10px] text-muted-foreground">
|
|
160
|
+
{total !== undefined ? `${tasks.length} of ${total}` : loading ? "…" : null}
|
|
161
|
+
</span>
|
|
162
|
+
{action && <div className="ml-auto">{action}</div>}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div className="relative">
|
|
166
|
+
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-coarse:left-2.5 pointer-coarse:h-4 pointer-coarse:w-4" />
|
|
167
|
+
<input
|
|
168
|
+
value={query}
|
|
169
|
+
onChange={(e) => onQueryChange(e.target.value)}
|
|
170
|
+
placeholder="Search conversations…"
|
|
171
|
+
aria-label="Search conversations"
|
|
172
|
+
// 16px on touch, deliberately: anything smaller and iOS zooms the page in on focus.
|
|
173
|
+
className="w-full rounded-md border bg-background py-1.5 pr-2 pl-7 text-xs outline-none focus-visible:border-primary/40 pointer-coarse:rounded-lg pointer-coarse:py-2 pointer-coarse:pl-8 pointer-coarse:text-base"
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* One scrolling line on touch rather than wrapped rows: five chips would otherwise eat a
|
|
178
|
+
third of a phone screen before a single conversation shows up. */}
|
|
179
|
+
<div className="flex flex-wrap gap-1 pointer-coarse:-mx-4 pointer-coarse:flex-nowrap pointer-coarse:gap-1.5 pointer-coarse:overflow-x-auto pointer-coarse:px-4 pointer-coarse:pb-0.5 pointer-coarse:[scrollbar-width:none]">
|
|
180
|
+
{TASK_STATE_ORDER.map((state) => (
|
|
181
|
+
<Chip
|
|
182
|
+
key={state}
|
|
183
|
+
label={taskStateStyle(state).label}
|
|
184
|
+
count={counts[state] ?? 0}
|
|
185
|
+
active={states.includes(state)}
|
|
186
|
+
tint={taskStateStyle(state).className}
|
|
187
|
+
onClick={() => toggle(state)}
|
|
188
|
+
/>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
194
|
+
{loading && tasks.length === 0 && <p className="px-3 py-4 text-xs text-muted-foreground">Loading…</p>}
|
|
195
|
+
{!loading && tasks.length === 0 && (
|
|
196
|
+
<p className="px-3 py-4 text-xs leading-relaxed text-muted-foreground">
|
|
197
|
+
{query || states.length > 0 ? "No conversation matches that filter." : "No delegated work yet. Open a project and start a task."}
|
|
198
|
+
</p>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{groups.map((group) => (
|
|
202
|
+
<div key={group.state}>
|
|
203
|
+
<p className="sticky top-0 z-10 border-b border-border/60 bg-card/95 px-3 py-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase backdrop-blur pointer-coarse:px-4 pointer-coarse:py-1.5">
|
|
204
|
+
{taskStateStyle(group.state).label}
|
|
205
|
+
<span className="ml-1.5 tabular-nums opacity-60">{group.rows.length}</span>
|
|
206
|
+
</p>
|
|
207
|
+
{group.rows.map((task) => (
|
|
208
|
+
<ConversationRow key={task.id} task={task} selected={task.id === selectedId} onSelect={() => onSelect(task)} />
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
))}
|
|
212
|
+
|
|
213
|
+
{onLoadMore && total !== undefined && tasks.length < total && (
|
|
214
|
+
<div className="p-2">
|
|
215
|
+
<Button variant="outline" size="sm" className="h-7 w-full text-xs" onClick={onLoadMore}>
|
|
216
|
+
Load {Math.min(pageSize, total - tasks.length)} more
|
|
217
|
+
</Button>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The right-hand side of the agent cockpit before a conversation is picked. */
|
|
226
|
+
export function TaskConversationPlaceholder({ children, className }: { children?: ReactNode; className?: string }) {
|
|
227
|
+
return (
|
|
228
|
+
<div className={cn("flex min-h-0 flex-1 items-center justify-center p-6", className)}>
|
|
229
|
+
<div className="max-w-sm text-center">
|
|
230
|
+
<MessagesSquareIcon className="mx-auto h-8 w-8 text-muted-foreground/40" />
|
|
231
|
+
<p className="mt-3 text-sm font-medium">Pick a conversation</p>
|
|
232
|
+
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
233
|
+
{children ?? "Every task of this organization is on the left, whichever project it belongs to — the ones waiting on you first."}
|
|
234
|
+
</p>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
)
|
|
238
|
+
}
|