@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.
Files changed (32) hide show
  1. package/README.md +35 -1
  2. package/package.json +6 -1
  3. package/src/components/sheet.tsx +7 -1
  4. package/src/components/tasks/agent-markdown.tsx +322 -0
  5. package/src/components/tasks/attachments.ts +152 -0
  6. package/src/components/tasks/command-log.tsx +102 -0
  7. package/src/components/tasks/composer.tsx +252 -0
  8. package/src/components/tasks/conversation-list.tsx +238 -0
  9. package/src/components/tasks/diff-panel.tsx +534 -0
  10. package/src/components/tasks/event-stream.tsx +860 -0
  11. package/src/components/tasks/index.ts +111 -0
  12. package/src/components/tasks/machine.tsx +455 -0
  13. package/src/components/tasks/new-task-dialog.tsx +301 -0
  14. package/src/components/tasks/pull-request.tsx +179 -0
  15. package/src/components/tasks/session-usage.tsx +165 -0
  16. package/src/components/tasks/task-attachments.tsx +475 -0
  17. package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
  18. package/src/components/tasks/task-cockpit.tsx +225 -0
  19. package/src/components/tasks/task-details.tsx +239 -0
  20. package/src/components/tasks/task-list.tsx +272 -0
  21. package/src/components/tasks/task-state.tsx +180 -0
  22. package/src/components/tasks/tasks.test.tsx +205 -0
  23. package/src/components/tasks/tool-stack.tsx +692 -0
  24. package/src/components/tasks/types.ts +220 -0
  25. package/src/components/tasks/worker-link.tsx +126 -0
  26. package/src/components/workers/index.ts +5 -2
  27. package/src/components/workers/types.ts +22 -0
  28. package/src/components/workers/worker-cockpit.tsx +17 -2
  29. package/src/components/workers/worker-status-history.test.ts +85 -0
  30. package/src/components/workers/worker-status-history.tsx +166 -0
  31. package/src/index.ts +1 -1
  32. 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
+ }