@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.
@@ -0,0 +1,220 @@
1
+ // Structural shapes for everything task-related — delegated work (docs/tasks.md), its session
2
+ // (RFC 0020) and its files (RFC 0022).
3
+ //
4
+ // Same rule as `../workers/types.ts`: the package never imports an app's OpenAPI types. These
5
+ // describe the minimum the components read. Where the dashboard's API is the only producer today
6
+ // the shape follows it closely, but every component tolerates a value it doesn't know — an
7
+ // unknown state falls back to a neutral style, an unknown event type to a boundary line — rather
8
+ // than crashing on a payload a newer API sends.
9
+
10
+ /** The five states a task goes through. Derived at read time by the API, never polled into place. */
11
+ export type TaskState = "queued" | "running" | "in-review" | "done" | "failed"
12
+
13
+ /**
14
+ * What the platform is doing to a task *right now*, on a human's behalf.
15
+ *
16
+ * Accepting, dropping and answering are `202`s that finish in a durable job minutes later, so a
17
+ * task can be `in-review` and *being accepted* at the same time — and the second fact is the one
18
+ * the reader who just clicked is looking for.
19
+ */
20
+ export type TaskPendingAction = "accepting" | "dropping" | "replying"
21
+
22
+ /** A task as the list rows, the cockpit and its sidebar draw it. */
23
+ export interface TaskItem {
24
+ id: string
25
+ title: string
26
+ prompt?: string | null
27
+ state: TaskState
28
+ branch?: string | null
29
+ baseBranch?: string | null
30
+ workerId?: string | null
31
+ /** Where the work landed — a PR, an MR, a preview deployment. Not forge-specific. */
32
+ outcomeUrl?: string | null
33
+ outcomeLabel?: string | null
34
+ /** Legacy: what a removed status command once reported. Drawn as JSON when present. */
35
+ meta?: Record<string, unknown> | null
36
+ error?: string | null
37
+ createdAt: string
38
+ /** When the task last moved — a turn, a state change. What lists sort and date rows on. */
39
+ lastActivityAt?: string | null
40
+ startedAt?: string | null
41
+ completedAt?: string | null
42
+ /** Whether a follow-up can resume the session — a captured `session.started` gave an id. */
43
+ canResume?: boolean
44
+ pendingAction?: TaskPendingAction | null
45
+ /** When that was asked for. What the spinner counts from — across reloads and readers. */
46
+ pendingSince?: string | null
47
+ }
48
+
49
+ /** A row of the org-wide conversation list: a task plus the project it belongs to. */
50
+ export interface TaskConversation extends TaskItem {
51
+ projectId: string
52
+ projectName?: string | null
53
+ }
54
+
55
+ /** One normalized event of a session (`task_events`). `payload` is read laxly by type. */
56
+ export interface TaskEvent {
57
+ seq: number
58
+ ts: string
59
+ /** `message` | `thinking` | `plan` | `tool.call` | `tool.result` | `session.started` | `session.ended` | `usage` | `session.title` | `raw` … */
60
+ type: string
61
+ payload: Record<string, unknown>
62
+ }
63
+
64
+ /** A command the platform ran for a task (branch setup, the session, accept…), with its captured tail. */
65
+ export interface TaskCommand {
66
+ id: string
67
+ command: string
68
+ cwd?: string | null
69
+ mode?: "sync" | "background" | string
70
+ /** `running` | `succeeded` | `failed` | `timeout` | `canceled` | `lost`. */
71
+ status: string
72
+ exitCode?: number | null
73
+ stdout?: string | null
74
+ stderr?: string | null
75
+ truncated?: boolean
76
+ createdAt: string
77
+ finishedAt?: string | null
78
+ label?: string | null
79
+ }
80
+
81
+ /** What a session has consumed — task-wide, derived by the API from the whole event log. */
82
+ export interface TaskSessionUsageData {
83
+ model?: string | null
84
+ contextTokens?: number | null
85
+ contextWindow?: number | null
86
+ inputTokens?: number | null
87
+ outputTokens?: number | null
88
+ cacheReadTokens?: number | null
89
+ cacheCreationTokens?: number | null
90
+ costUsd?: number | null
91
+ turns?: number | null
92
+ durationMs?: number | null
93
+ }
94
+
95
+ /** A file already in the conversation, as an event's payload carries it. */
96
+ export interface TaskFileRef {
97
+ id: string
98
+ filename: string | null
99
+ mediaType: string
100
+ bytes: number
101
+ }
102
+
103
+ /** Where one of a task's stored files is served from. Built by the app, which knows the route. */
104
+ export type AttachmentUrl = (attachmentId: string) => string
105
+
106
+ /** A file prepared in the browser for a turn — see `prepareFile`. */
107
+ export interface DraftFile {
108
+ /** Local only — React keys and the remove button. */
109
+ key: string
110
+ name: string
111
+ mediaType: string
112
+ /** Base64, no data-url prefix. */
113
+ data: string
114
+ bytes: number
115
+ /** Object URL for the thumbnail — images only. Revoked by whoever drops the draft. */
116
+ previewUrl: string | null
117
+ }
118
+
119
+ /**
120
+ * A message that has been sent but has not come back yet.
121
+ *
122
+ * `sinceSeq` is the timeline's high-water mark at the moment it was sent: matching on text alone
123
+ * would make a repeated message ("continue", twice) match the *previous* one's event.
124
+ */
125
+ export interface PendingMessage {
126
+ text: string
127
+ sinceSeq: number
128
+ /** Object URLs of the images going with it, drawn until the stored ones come back. */
129
+ previews?: string[]
130
+ }
131
+
132
+ // ── Diff ─────────────────────────────────────────────────────────────────────────────────────
133
+
134
+ export interface TaskDiffFile {
135
+ path: string
136
+ oldPath?: string | null
137
+ /** `added` | `modified` | `deleted` | `renamed` | `copied` | `type-changed` | `untracked`. */
138
+ status: string
139
+ additions: number | null
140
+ deletions: number | null
141
+ binary?: boolean
142
+ }
143
+
144
+ export interface TaskRepoDiff {
145
+ cwd?: string
146
+ name: string
147
+ base?: string | null
148
+ branch?: string | null
149
+ files: TaskDiffFile[]
150
+ additions: number
151
+ deletions: number
152
+ error?: string | null
153
+ }
154
+
155
+ /** What a branch changes, read with git in the task's own machine. */
156
+ export interface TaskDiff {
157
+ available: boolean
158
+ /** Why it could not be read: `no-machine` | `machine-stopped` | `node-unreachable` | `unsupported`. */
159
+ reason?: string | null
160
+ branch: string
161
+ baseBranch?: string | null
162
+ repos: TaskRepoDiff[]
163
+ additions: number
164
+ deletions: number
165
+ files: number
166
+ }
167
+
168
+ export interface TaskPatchLine {
169
+ kind: "context" | "add" | "del"
170
+ old: number | null
171
+ new: number | null
172
+ text: string
173
+ }
174
+
175
+ export interface TaskPatchHunk {
176
+ header: string
177
+ context?: string | null
178
+ lines: TaskPatchLine[]
179
+ }
180
+
181
+ export interface TaskFilePatch {
182
+ binary?: boolean
183
+ truncated?: boolean
184
+ hunks: TaskPatchHunk[]
185
+ }
186
+
187
+ // ── Pull request ─────────────────────────────────────────────────────────────────────────────
188
+
189
+ /** A proposal at the forge for the task's branch — reported, never observed. */
190
+ export interface TaskPullRequestData {
191
+ /** Short reference, e.g. `#289` or `!42`. */
192
+ label: string
193
+ url: string
194
+ title: string
195
+ state: "draft" | "open" | "merged" | "closed"
196
+ checks?: "pending" | "passing" | "failing" | null
197
+ review?: "approved" | "changes-requested" | "pending" | null
198
+ comments?: number | null
199
+ updatedAt?: string | null
200
+ }
201
+
202
+ export interface TaskPullRequests {
203
+ /** `false` → nothing connected can answer; the section is absent, not empty. */
204
+ supported: boolean
205
+ repos: { repo: string; pullRequest: TaskPullRequestData | null; error?: string | null }[]
206
+ }
207
+
208
+ // ── Machine ──────────────────────────────────────────────────────────────────────────────────
209
+
210
+ /** The node a task's worker lives on. */
211
+ export interface TaskMachineNode {
212
+ name: string
213
+ online?: boolean
214
+ }
215
+
216
+ /** A port detected listening inside the task's container. */
217
+ export interface TaskMachinePort {
218
+ port: number
219
+ process?: string | null
220
+ }
@@ -0,0 +1,126 @@
1
+ "use client"
2
+
3
+ // The pairing a project page is made of, drawn from both ends.
4
+ //
5
+ // A task has a machine and a machine is sometimes held by a task — the same fact, and neither list
6
+ // said it. Each row now names its counterpart and links to it, so the two tabs of a project page
7
+ // (workspaces, tasks) are one story told twice rather than two lists to correlate by hand. Both
8
+ // directions are resolved from the two lists the page already holds: no extra round trip.
9
+
10
+ import { ServerIcon } from "lucide-react"
11
+
12
+ import { cn } from "../../utils"
13
+ import type { WorkerCardWorker } from "../workers/types"
14
+ import type { LinkRender } from "../workers/worker-card"
15
+ import { WorkerStatusDot, resolveWorkerStatus } from "../workers/worker-status"
16
+ import { isLiveTaskState, taskStateStyle } from "./task-state"
17
+ import { workerLabel } from "./machine"
18
+ import type { TaskItem } from "./types"
19
+
20
+ const defaultLink: LinkRender = ({ href, className, children }) => (
21
+ <a href={href} className={className}>
22
+ {children}
23
+ </a>
24
+ )
25
+
26
+ /** The task to show for a worker, and whether it still holds the machine. */
27
+ export type WorkerTask<T extends TaskItem = TaskItem> = { task: T; live: boolean }
28
+
29
+ /**
30
+ * Worker id → the task to name in its row.
31
+ *
32
+ * A pooled worker is reused across tasks, so "its task" is only unambiguous while one is live: a
33
+ * task holds its machine until it reaches a terminal state, so a live task wins. Once none is,
34
+ * the most recent one that ran there is still worth showing — as history — because it is what the
35
+ * branch checked out in that workspace belongs to.
36
+ */
37
+ export function indexTasksByWorker<T extends TaskItem>(tasks: T[]): Map<string, WorkerTask<T>> {
38
+ const byWorker = new Map<string, WorkerTask<T>>()
39
+ const at = (t: TaskItem) => new Date(t.lastActivityAt ?? t.createdAt).getTime()
40
+ // Most recently active first, so the first task seen for a worker is already the one to keep
41
+ // unless a live one turns up behind it.
42
+ const ordered = [...tasks].sort((a, b) => at(b) - at(a))
43
+ for (const task of ordered) {
44
+ if (!task.workerId) continue
45
+ const live = isLiveTaskState(task.state)
46
+ const current = byWorker.get(task.workerId)
47
+ if (!current || (live && !current.live)) byWorker.set(task.workerId, { task, live })
48
+ }
49
+ return byWorker
50
+ }
51
+
52
+ /**
53
+ * What a workspace row says about the task on it: state as a dot, title as the link. Muted when
54
+ * the task is over — the machine is free, and the row should not read as busy.
55
+ */
56
+ export function WorkerTaskChip({
57
+ entry,
58
+ href,
59
+ render,
60
+ className,
61
+ }: {
62
+ entry: WorkerTask
63
+ href: string
64
+ render?: { link?: LinkRender }
65
+ className?: string
66
+ }) {
67
+ const { task, live } = entry
68
+ const Link = render?.link ?? defaultLink
69
+ const label = taskStateStyle(task.state).label
70
+ return (
71
+ <span title={live ? `${label} — ${task.title}` : `Last task that ran here (${label}) — ${task.title}`} className="inline-flex min-w-0">
72
+ <Link
73
+ href={href}
74
+ className={cn(
75
+ "inline-flex min-w-0 items-center gap-1.5 text-xs underline-offset-2 transition-colors hover:text-foreground hover:underline",
76
+ live ? "text-foreground/80" : "text-muted-foreground/50",
77
+ className,
78
+ )}
79
+ >
80
+ <span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", taskStateStyle(task.state).dotClass, !live && "opacity-50")} />
81
+ <span className="truncate">{task.title}</span>
82
+ </Link>
83
+ </span>
84
+ )
85
+ }
86
+
87
+ /**
88
+ * What a task row says about its machine: the workspace's name and its live status, so a task
89
+ * sitting in review on a stopped worker says so without being opened.
90
+ *
91
+ * `worker` is undefined when the row is gone from the project's list — a deleted machine leaves
92
+ * `workerId` behind on purpose (the record of the work outlives it), so the link still points
93
+ * somewhere and just stops claiming a status it can't observe.
94
+ */
95
+ export function TaskWorkerChip({
96
+ worker,
97
+ href,
98
+ render,
99
+ className,
100
+ }: {
101
+ worker?: WorkerCardWorker | null
102
+ href?: string
103
+ render?: { link?: LinkRender }
104
+ className?: string
105
+ }) {
106
+ const Link = render?.link ?? defaultLink
107
+ const status = worker ? resolveWorkerStatus(worker) : null
108
+ const body = (
109
+ <>
110
+ {status ? <WorkerStatusDot status={status} /> : <ServerIcon className="h-3 w-3 shrink-0" />}
111
+ <span className="max-w-[160px] truncate">{worker ? workerLabel(worker) : "workspace"}</span>
112
+ </>
113
+ )
114
+ const cls = cn("flex min-w-0 items-center gap-1.5 transition-colors hover:text-foreground", className)
115
+ return (
116
+ <span title={status ? `Workspace — ${status.label}` : "The workspace this task ran on"} className="inline-flex min-w-0">
117
+ {href ? (
118
+ <Link href={href} className={cls}>
119
+ {body}
120
+ </Link>
121
+ ) : (
122
+ <span className={cls}>{body}</span>
123
+ )}
124
+ </span>
125
+ )
126
+ }