@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 CHANGED
@@ -188,7 +188,7 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
188
188
  provider is also the umbrella that mounts the tooltip delay group and the overlay
189
189
  portal container the dialogs/selects render into.
190
190
 
191
- ## Domain components — `/workers`, `/devcontainer`, `/projects`
191
+ ## Domain components — `/workers`, `/devcontainer`, `/projects`, `/tasks`
192
192
 
193
193
  Everything above is domain-free: a `Button` knows nothing about Spunto. Components
194
194
  that **do** know a Spunto concept live behind their own entry point, so the root
@@ -201,6 +201,7 @@ import { Button, Card } from "@spunto/design-system"
201
201
  import { WorkerCard } from "@spunto/design-system/workers" // domain
202
202
  import { ImageCard, FeatureCard, ExtensionCard } from "@spunto/design-system/devcontainer" // domain
203
203
  import { ProjectForm, ProjectPanel } from "@spunto/design-system/projects" // domain
204
+ import { TaskList, TaskCockpit, TaskEventStream } from "@spunto/design-system/tasks" // domain
204
205
  import { Section, ProductHeader } from "@spunto/design-system/marketing" // not a domain — see below
205
206
  ```
206
207
 
@@ -292,6 +293,39 @@ hand-typed reference that degrades cleanly.
292
293
  `DevcontainerFeatureEntry` and `VscodeExtensionEntry` are all-optional, and an
293
294
  unknown registry degrades to a monogram instead of an invented author.
294
295
 
296
+ ### `@spunto/design-system/tasks`
297
+
298
+ Delegated work — a task is a worker, a branch, an agent session and something to
299
+ review. Every surface that shows one:
300
+
301
+ - **`TaskList`** — one project's tasks grouped by state in the order a task travels
302
+ (queued → running → in review, then failed and done, folded), Accept/Drop on the row.
303
+ - **`TaskConversationList`** (+ `TaskConversationPlaceholder`) — every conversation of
304
+ an organization as a messaging app's list: search, state chips whose counts ignore
305
+ the filter, grouped rows. Fully controlled. Sized by `pointer-coarse`, not width.
306
+ - **`TaskCockpit`** / **`TaskCockpitMobile`** — one conversation, as a **layout**: top
307
+ bar + Accept/Drop, the Session/Diff switch, and where each slot goes. The panels are
308
+ slots filled with the package's own components (`session`/`diff` are render
309
+ functions receiving the switch to draw in their header). The phone one is its own
310
+ screen — a `visualViewport`-sized layer, details in a sheet, Drop at the bottom.
311
+ - **`TaskEventStream`** — the agent session (RFC 0020): prose first, tool calls folded
312
+ into runs ("read 8 files · ran 3 commands"), the session-wide tool stack in a sheet,
313
+ virtualized (`@tanstack/react-virtual`), opened at its end, paging older history as
314
+ the reader scrolls up. **`TaskComposer`** under it says *why* it is closed when it is.
315
+ - **`TaskDiffPanel`** — the branch's diff: summary as a prop, each file's patch through
316
+ `loadPatch` (a promise; the component owns per-file loading state), intra-line
317
+ highlighting, and a sentence for every reason it could not be read.
318
+ - **`TaskDetails`** (`sidebar` | `sheet`) with **`TaskMachine`**, **`TaskBoot`**,
319
+ `TaskSessionUsage`, `TaskPullRequest`, `TaskCommandLog` — the facts about a task, in
320
+ one order, instead of two hand-kept copies.
321
+ - **`NewTaskDialog`** — delegating: prompt, files, title, base branch, model.
322
+ - Vocabulary and helpers: `TaskStateBadge`, `taskStateConfig`, `useElapsed`,
323
+ `AgentMarkdown`, `useAttachmentDraft`/`prepareFile`/`TaskFiles` (files, RFC 0022),
324
+ `runStats`/`toolVerb`/`toolSubject`, `bootSteps`, `indexTasksByWorker`.
325
+
326
+ Same rules as `/workers`: nothing fetches, types are structural and lax
327
+ (`TaskItem`, `TaskEvent`, `TaskDiff`…), links go through `render.link`.
328
+
295
329
  ### `@spunto/design-system/projects`
296
330
 
297
331
  - **`ProjectForm`** — the whole "compose a dev environment" screen: identity,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.28.0",
3
+ "version": "0.30.1",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,6 +41,10 @@
41
41
  "types": "./src/components/devcontainer/index.ts",
42
42
  "import": "./src/components/devcontainer/index.ts"
43
43
  },
44
+ "./tasks": {
45
+ "types": "./src/components/tasks/index.ts",
46
+ "import": "./src/components/tasks/index.ts"
47
+ },
44
48
  "./projects": {
45
49
  "types": "./src/components/projects/index.ts",
46
50
  "import": "./src/components/projects/index.ts"
@@ -70,6 +74,7 @@
70
74
  "brand:export": "node scripts/export-logo.ts"
71
75
  },
72
76
  "dependencies": {
77
+ "@tanstack/react-virtual": "^3.14.13",
73
78
  "@xterm/addon-fit": "^0.11.0",
74
79
  "@xterm/addon-search": "^0.16.0",
75
80
  "@xterm/addon-web-links": "^0.12.0",
@@ -76,6 +76,8 @@ export interface SheetContentProps extends SheetPrimitive.Popup.Props {
76
76
  size?: SheetSize
77
77
  /** Draws the ✕ in the top corner. @default true */
78
78
  showClose?: boolean
79
+ /** Merged onto the backdrop — e.g. a `z-*` to match a raised `className` on the panel. */
80
+ backdropClassName?: string
79
81
  }
80
82
 
81
83
  /**
@@ -93,6 +95,7 @@ function SheetContent({
93
95
  side = "right",
94
96
  size = "md",
95
97
  showClose = true,
98
+ backdropClassName,
96
99
  ...props
97
100
  }: SheetContentProps) {
98
101
  const container = useOverlayContainer()
@@ -101,7 +104,10 @@ function SheetContent({
101
104
  <SheetPrimitive.Portal container={container ?? undefined}>
102
105
  <SheetPrimitive.Backdrop
103
106
  data-slot="sheet-backdrop"
104
- className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:bg-black/60"
107
+ className={cn(
108
+ "fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:bg-black/60",
109
+ backdropClassName
110
+ )}
105
111
  />
106
112
  <SheetPrimitive.Popup
107
113
  data-slot="sheet-content"
@@ -0,0 +1,322 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { cn } from "../../utils"
4
+
5
+ /**
6
+ * Markdown, for what an agent actually writes.
7
+ *
8
+ * An agent's messages are markdown — headings, `**bold**`, bullet lists, fenced code — and
9
+ * rendering them as raw text turns a summary into `1. **Lecture** — \`README.md\` lu`, which is
10
+ * exactly what was on screen before this existed. It is not a stylistic detail: the message is
11
+ * the one thing on the page written *for* the reader.
12
+ *
13
+ * Hand-written rather than pulled from a library, for two reasons. The subset is small and
14
+ * bounded (this is CLI output, not user-authored documents), and everything here builds React
15
+ * nodes — there is no `dangerouslySetInnerHTML` anywhere, so a message that contains HTML is
16
+ * text, not markup. An MDX pipeline is built for files we wrote ourselves; running arbitrary
17
+ * agent output through one would be a different risk entirely.
18
+ *
19
+ * What it does not do, deliberately: nested lists, images, reference links, `_italic_`. Those
20
+ * degrade to their source text, which is readable — the failure mode is "less pretty", never
21
+ * "loses what the agent said".
22
+ */
23
+
24
+ /**
25
+ * `code`, **bold**, *italic*, [text](url), bare https?:// URLs. Bare URLs matter as much as
26
+ * bracketed ones: a harness pastes a raw link ("PR opened at https://github.com/…") far more
27
+ * often than it writes markdown syntax for one.
28
+ *
29
+ * Emphasis and link text are re-scanned rather than emitted as text: an agent announcing a PR
30
+ * writes `**https://github.com/…/289**` — the URL it most wants you to click is exactly the one
31
+ * it emphasised, and a plain <strong> would have swallowed it. Recursion terminates because the
32
+ * inner text is always strictly shorter than the token it came from.
33
+ */
34
+ const INLINE =
35
+ /(`[^`\n]+`|\*\*[^*\n]+\*\*|\*[^*\n]+\*|\[[^\]\n]+\]\([^)\s]+\)|https?:\/\/[^\s<>()\[\]]+)/g
36
+
37
+ export function renderInlineMarkdown(text: string, keyPrefix: string): ReactNode[] {
38
+ return text.split(INLINE).map((token, i) => {
39
+ const key = `${keyPrefix}-${i}`
40
+ if (!token) return null
41
+ if (token.startsWith("`") && token.endsWith("`")) {
42
+ return (
43
+ <code key={key} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.9em]">
44
+ {token.slice(1, -1)}
45
+ </code>
46
+ )
47
+ }
48
+ if (token.startsWith("**") && token.endsWith("**")) {
49
+ return (
50
+ <strong key={key} className="font-semibold">
51
+ {renderInlineMarkdown(token.slice(2, -2), key)}
52
+ </strong>
53
+ )
54
+ }
55
+ if (token.startsWith("*") && token.endsWith("*") && token.length > 2) {
56
+ return <em key={key}>{renderInlineMarkdown(token.slice(1, -1), key)}</em>
57
+ }
58
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(token)
59
+ if (link) {
60
+ const href = link[2]
61
+ // Only http(s): an agent can write any string, and a `javascript:` href would be a link
62
+ // the reader has no way to tell apart from a real one.
63
+ if (/^https?:\/\//i.test(href)) {
64
+ return (
65
+ <a key={key} href={href} target="_blank" rel="noreferrer" className="text-primary underline underline-offset-2">
66
+ {renderInlineMarkdown(link[1], key)}
67
+ </a>
68
+ )
69
+ }
70
+ return <span key={key}>{renderInlineMarkdown(link[1], key)}</span>
71
+ }
72
+ if (/^https?:\/\//i.test(token)) {
73
+ // Sentence punctuation trailing a bare URL ("… see https://x.dev/y.") is not part of the
74
+ // link — split it back out so the period doesn't 404.
75
+ const trailing = /[.,;:!?]+$/.exec(token)?.[0] ?? ""
76
+ const href = trailing ? token.slice(0, -trailing.length) : token
77
+ return (
78
+ <span key={key}>
79
+ <a href={href} target="_blank" rel="noreferrer" className="text-primary underline underline-offset-2 break-all">
80
+ {href}
81
+ </a>
82
+ {trailing}
83
+ </span>
84
+ )
85
+ }
86
+ return <span key={key}>{token}</span>
87
+ })
88
+ }
89
+
90
+ type Align = "left" | "center" | "right" | null
91
+
92
+ /**
93
+ * Split a table row on its cell separators.
94
+ *
95
+ * GFM cuts a row on pipes *before* any inline parsing, so `\|` is the only way to put a literal
96
+ * pipe in a cell — which also means a pipe inside a code span still splits, there and here.
97
+ * The leading and trailing pipes are optional decoration, not empty cells.
98
+ */
99
+ function splitRow(line: string): string[] {
100
+ const trimmed = line.trim()
101
+ const cells: string[] = []
102
+ let cell = ""
103
+ for (let i = 0; i < trimmed.length; i++) {
104
+ if (trimmed[i] === "\\" && trimmed[i + 1] === "|") {
105
+ cell += "|"
106
+ i++
107
+ continue
108
+ }
109
+ if (trimmed[i] === "|") {
110
+ cells.push(cell)
111
+ cell = ""
112
+ continue
113
+ }
114
+ cell += trimmed[i]
115
+ }
116
+ cells.push(cell)
117
+ if (cells.length > 1 && trimmed.startsWith("|")) cells.shift()
118
+ if (cells.length > 1 && /(^|[^\\])\|$/.test(trimmed)) cells.pop()
119
+ return cells.map((c) => c.trim())
120
+ }
121
+
122
+ /** `| --- | :---: |` → one alignment per column, or null if this isn't a delimiter row. */
123
+ function delimiterRow(line: string | undefined): Align[] | null {
124
+ if (line === undefined || !line.includes("-")) return null
125
+ const cells = splitRow(line)
126
+ if (!cells.length) return null
127
+ const align: Align[] = []
128
+ for (const cell of cells) {
129
+ if (!/^:?-+:?$/.test(cell)) return null
130
+ const left = cell.startsWith(":")
131
+ const right = cell.endsWith(":")
132
+ align.push(left && right ? "center" : right ? "right" : left ? "left" : null)
133
+ }
134
+ return align
135
+ }
136
+
137
+ /**
138
+ * A table starts at a header row only if the very next line is a delimiter row with the same
139
+ * number of columns — the rule that keeps a prose line that happens to contain a pipe from
140
+ * being read as a one-row table.
141
+ */
142
+ function tableAt(lines: string[], i: number): { align: Align[]; head: string[] } | null {
143
+ if (!/(^|[^\\])\|/.test(lines[i] ?? "")) return null
144
+ const align = delimiterRow(lines[i + 1])
145
+ if (!align) return null
146
+ const head = splitRow(lines[i])
147
+ return head.length === align.length ? { align, head } : null
148
+ }
149
+
150
+ type Block =
151
+ | { kind: "p"; lines: string[] }
152
+ | { kind: "heading"; level: number; text: string }
153
+ | { kind: "list"; ordered: boolean; items: string[] }
154
+ | { kind: "code"; lang: string | null; lines: string[] }
155
+ | { kind: "quote"; lines: string[] }
156
+ | { kind: "table"; align: Align[]; head: string[]; rows: string[][] }
157
+
158
+ /** Group lines into blocks. One pass, no lookahead beyond the current block. */
159
+ function parse(source: string): Block[] {
160
+ const blocks: Block[] = []
161
+ const lines = source.replace(/\r\n/g, "\n").split("\n")
162
+ let i = 0
163
+
164
+ while (i < lines.length) {
165
+ const line = lines[i]
166
+
167
+ const fence = /^```(\w*)\s*$/.exec(line)
168
+ if (fence) {
169
+ const body: string[] = []
170
+ i++
171
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) body.push(lines[i++])
172
+ i++ // closing fence, or the end of the message if the agent was cut off mid-block
173
+ blocks.push({ kind: "code", lang: fence[1] || null, lines: body })
174
+ continue
175
+ }
176
+
177
+ const table = tableAt(lines, i)
178
+ if (table) {
179
+ const rows: string[][] = []
180
+ i += 2
181
+ // A row is padded or clipped to the header's width: an agent writing a table by hand
182
+ // miscounts a cell far more often than it means to start a new block.
183
+ while (i < lines.length && lines[i].trim() !== "" && /(^|[^\\])\|/.test(lines[i])) {
184
+ const cells = splitRow(lines[i++])
185
+ rows.push(table.head.map((_, c) => cells[c] ?? ""))
186
+ }
187
+ blocks.push({ kind: "table", align: table.align, head: table.head, rows })
188
+ continue
189
+ }
190
+
191
+ const heading = /^(#{1,4})\s+(.*)$/.exec(line)
192
+ if (heading) {
193
+ blocks.push({ kind: "heading", level: heading[1].length, text: heading[2] })
194
+ i++
195
+ continue
196
+ }
197
+
198
+ if (/^\s*([-*+])\s+/.test(line) || /^\s*\d+[.)]\s+/.test(line)) {
199
+ const ordered = /^\s*\d+[.)]\s+/.test(line)
200
+ const items: string[] = []
201
+ while (i < lines.length && (ordered ? /^\s*\d+[.)]\s+/ : /^\s*([-*+])\s+/).test(lines[i])) {
202
+ items.push(lines[i].replace(ordered ? /^\s*\d+[.)]\s+/ : /^\s*([-*+])\s+/, ""))
203
+ i++
204
+ }
205
+ blocks.push({ kind: "list", ordered, items })
206
+ continue
207
+ }
208
+
209
+ if (/^>\s?/.test(line)) {
210
+ const body: string[] = []
211
+ while (i < lines.length && /^>\s?/.test(lines[i])) body.push(lines[i++].replace(/^>\s?/, ""))
212
+ blocks.push({ kind: "quote", lines: body })
213
+ continue
214
+ }
215
+
216
+ if (line.trim() === "") {
217
+ i++
218
+ continue
219
+ }
220
+
221
+ const body: string[] = []
222
+ while (
223
+ i < lines.length &&
224
+ lines[i].trim() !== "" &&
225
+ !/^```/.test(lines[i]) &&
226
+ !/^#{1,4}\s/.test(lines[i]) &&
227
+ !/^\s*([-*+])\s+/.test(lines[i]) &&
228
+ !/^\s*\d+[.)]\s+/.test(lines[i]) &&
229
+ !/^>\s?/.test(lines[i]) &&
230
+ !tableAt(lines, i)
231
+ ) {
232
+ body.push(lines[i++])
233
+ }
234
+ blocks.push({ kind: "p", lines: body })
235
+ }
236
+
237
+ return blocks
238
+ }
239
+
240
+ const ALIGN_CLASS = { left: "text-left", center: "text-center", right: "text-right" } as const
241
+
242
+ const HEADING_CLASS = ["text-base font-semibold", "text-sm font-semibold", "text-sm font-medium", "text-sm font-medium"]
243
+
244
+ export function AgentMarkdown({ text, className }: { text: string; className?: string }) {
245
+ const blocks = parse(text)
246
+ return (
247
+ <div className={cn("space-y-2 text-sm leading-6 [&>*:first-child]:mt-0", className)}>
248
+ {blocks.map((block, i) => {
249
+ switch (block.kind) {
250
+ case "heading":
251
+ return (
252
+ <p key={i} className={cn("mt-3", HEADING_CLASS[block.level - 1])}>
253
+ {renderInlineMarkdown(block.text, `h${i}`)}
254
+ </p>
255
+ )
256
+ case "code":
257
+ return (
258
+ <pre
259
+ key={i}
260
+ className="overflow-x-auto rounded-md border bg-muted/60 px-3 py-2 font-mono text-[12px] leading-relaxed"
261
+ >
262
+ <code>{block.lines.join("\n")}</code>
263
+ </pre>
264
+ )
265
+ case "list": {
266
+ const Tag = block.ordered ? "ol" : "ul"
267
+ return (
268
+ <Tag key={i} className={cn("space-y-1 pl-5", block.ordered ? "list-decimal" : "list-disc")}>
269
+ {block.items.map((item, j) => (
270
+ <li key={j} className="marker:text-muted-foreground">{renderInlineMarkdown(item, `l${i}-${j}`)}</li>
271
+ ))}
272
+ </Tag>
273
+ )
274
+ }
275
+ case "table":
276
+ // Scrollable rather than wrapped: a comparison table is read across a row, and a
277
+ // column squeezed to two characters of width loses the comparison entirely.
278
+ return (
279
+ <div key={i} className="overflow-x-auto rounded-md border">
280
+ <table className="w-full border-collapse text-[13px]">
281
+ <thead className="bg-muted/50">
282
+ <tr>
283
+ {block.head.map((cell, c) => (
284
+ <th
285
+ key={c}
286
+ className={cn(
287
+ "whitespace-nowrap px-2.5 py-1.5 font-medium",
288
+ ALIGN_CLASS[block.align[c] ?? "left"],
289
+ )}
290
+ >
291
+ {renderInlineMarkdown(cell, `th${i}-${c}`)}
292
+ </th>
293
+ ))}
294
+ </tr>
295
+ </thead>
296
+ <tbody>
297
+ {block.rows.map((row, r) => (
298
+ <tr key={r} className="border-t">
299
+ {row.map((cell, c) => (
300
+ <td key={c} className={cn("px-2.5 py-1.5 align-top", ALIGN_CLASS[block.align[c] ?? "left"])}>
301
+ {renderInlineMarkdown(cell, `td${i}-${r}-${c}`)}
302
+ </td>
303
+ ))}
304
+ </tr>
305
+ ))}
306
+ </tbody>
307
+ </table>
308
+ </div>
309
+ )
310
+ case "quote":
311
+ return (
312
+ <blockquote key={i} className="border-l-2 border-border pl-3 text-muted-foreground">
313
+ {renderInlineMarkdown(block.lines.join(" "), `q${i}`)}
314
+ </blockquote>
315
+ )
316
+ default:
317
+ return <p key={i}>{renderInlineMarkdown(block.lines.join("\n"), `p${i}`)}</p>
318
+ }
319
+ })}
320
+ </div>
321
+ )
322
+ }
@@ -0,0 +1,152 @@
1
+ import type { DraftFile } from "./types"
2
+
3
+ /**
4
+ * Preparing a file for a turn, in the browser.
5
+ *
6
+ * Almost everything passes through untouched — a PDF is a PDF, a CSV is a CSV. The one thing that
7
+ * gets work done to it is an **image**, and for a specific reason: a model downscales anything
8
+ * past 1568 px on its longest edge before it looks at it, so sending a 4K screenshot spends
9
+ * bandwidth, database and (on the way through the worker) a second of file writing to deliver
10
+ * pixels that are thrown away. A pasted retina screenshot routinely goes from 3 MB to under
11
+ * 200 KB this way, and the model sees exactly the same thing.
12
+ *
13
+ * A small image is left alone: re-encoding a crisp PNG of a terminal into a lossy format to save
14
+ * nothing is how screenshots of text become unreadable.
15
+ *
16
+ * The result travels as base64 in the JSON body of the turn, next to the words — one request, so
17
+ * a half-sent message is not a state anyone has to think about.
18
+ */
19
+
20
+ export const MAX_FILES_PER_TURN = 10
21
+
22
+ /** Matches the API's own ceiling — a refusal after the upload is a worse refusal. */
23
+ export const MAX_FILE_BYTES = 10 * 1024 * 1024
24
+
25
+ /** And in total, so ten files at the ceiling is not a 100 MB request. */
26
+ export const MAX_TURN_BYTES = 25 * 1024 * 1024
27
+
28
+ /** The four the API will hand back with their own content type, so the four that get a thumbnail. */
29
+ export const PREVIEWABLE_MEDIA_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]
30
+
31
+ /** What a model downscales to anyway. Past this, pixels cost and buy nothing. */
32
+ const MAX_EDGE = 1568
33
+
34
+ /** Under this, an image is already cheap and keeps its original encoding, untouched. */
35
+ const KEEP_AS_IS_BYTES = 400 * 1024
36
+
37
+
38
+ export function isPreviewable(mediaType: string): boolean {
39
+ return PREVIEWABLE_MEDIA_TYPES.includes(mediaType)
40
+ }
41
+
42
+ /** Every file in a paste or a drop — a clipboard carries its payload as a file among others. */
43
+ export function filesFrom(list: FileList | File[] | DataTransferItemList | null): File[] {
44
+ if (!list) return []
45
+ const files: File[] = []
46
+ for (const entry of Array.from(list as ArrayLike<File | DataTransferItem>)) {
47
+ const file = entry instanceof File ? entry : entry.kind === "file" ? entry.getAsFile() : null
48
+ if (file) files.push(file)
49
+ }
50
+ return files
51
+ }
52
+
53
+ /**
54
+ * One file, ready to send. `null` when it cannot be used — past the ceiling even after shrinking,
55
+ * or bytes the browser could not read at all.
56
+ *
57
+ * A file with no media type (which happens: an extensionless file, or one the OS has no opinion
58
+ * about) is sent as `application/octet-stream` rather than refused. The platform stores bytes; it
59
+ * is not in a position to be fussy about what someone calls them.
60
+ */
61
+ export async function prepareFile(file: File): Promise<DraftFile | null> {
62
+ const shrunk = await shrink(file)
63
+ const usable = shrunk ?? file
64
+ if (usable.size === 0 || usable.size > MAX_FILE_BYTES) return null
65
+ const data = await toBase64(usable)
66
+ if (data === null) return null
67
+ const mediaType = usable.type || "application/octet-stream"
68
+ return {
69
+ key: `${file.name}-${file.lastModified}-${Math.random().toString(36).slice(2, 8)}`,
70
+ // A pasted screenshot has no name of its own; the browser calls it `image.png` and that will
71
+ // do — it is what the agent will see the file called.
72
+ name: file.name || (isPreviewable(mediaType) ? "image.png" : "file"),
73
+ mediaType,
74
+ data,
75
+ bytes: usable.size,
76
+ previewUrl: isPreviewable(mediaType) ? URL.createObjectURL(usable) : null,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Re-encode, but only when it actually buys something — and only for an image.
82
+ *
83
+ * A GIF is left alone whatever its size: it may be animated, and a canvas would silently keep the
84
+ * first frame — a quiet loss is worse than a large file. Anything the browser cannot decode comes
85
+ * back as `null` and the original is sent as it is; the API is the one that says no.
86
+ */
87
+ async function shrink(file: File): Promise<File | null> {
88
+ if (!isPreviewable(file.type) || file.type === "image/gif") return null
89
+ if (file.size <= KEEP_AS_IS_BYTES) return null
90
+ if (typeof createImageBitmap !== "function") return null
91
+
92
+ let bitmap: ImageBitmap
93
+ try {
94
+ bitmap = await createImageBitmap(file)
95
+ } catch {
96
+ return null
97
+ }
98
+ const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height))
99
+ const width = Math.max(1, Math.round(bitmap.width * scale))
100
+ const height = Math.max(1, Math.round(bitmap.height * scale))
101
+
102
+ const canvas = document.createElement("canvas")
103
+ canvas.width = width
104
+ canvas.height = height
105
+ const ctx = canvas.getContext("2d")
106
+ if (!ctx) {
107
+ bitmap.close()
108
+ return null
109
+ }
110
+ ctx.drawImage(bitmap, 0, 0, width, height)
111
+ bitmap.close()
112
+
113
+ // WebP for both: it beats PNG on a screenshot at a quality no reader can tell apart, and beats
114
+ // JPEG on the flat colour and hard edges a screenshot is mostly made of.
115
+ const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/webp", 0.9))
116
+ if (!blob || blob.size >= file.size) return null
117
+ return new File([blob], `${file.name || "image"}.webp`, { type: "image/webp" })
118
+ }
119
+
120
+ function toBase64(file: Blob): Promise<string | null> {
121
+ return new Promise((resolve) => {
122
+ const reader = new FileReader()
123
+ reader.onerror = () => resolve(null)
124
+ reader.onload = () => {
125
+ const result = typeof reader.result === "string" ? reader.result : ""
126
+ const comma = result.indexOf(",")
127
+ resolve(comma === -1 ? null : result.slice(comma + 1))
128
+ }
129
+ reader.readAsDataURL(file)
130
+ })
131
+ }
132
+
133
+ /** Human-readable size — small enough to sit under a thumbnail or next to a file name. */
134
+ export function formatBytes(bytes: number): string {
135
+ if (bytes < 1024) return `${bytes} B`
136
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
137
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
138
+ }
139
+
140
+ /**
141
+ * The short, upper-case tag shown on a non-image file's tile — `PDF`, `CSV`, `ZST`.
142
+ *
143
+ * From the name rather than the media type, because that is what the reader themselves would say
144
+ * the file is, and because a media type is frequently `application/octet-stream` for exactly the
145
+ * files whose extension is most informative.
146
+ */
147
+ export function fileKind(name: string, mediaType: string): string {
148
+ const ext = /\.([A-Za-z0-9]{1,8})$/.exec(name)?.[1]
149
+ if (ext) return ext.toUpperCase()
150
+ const subtype = mediaType.split("/")[1]?.replace(/^x-|\+.*$/g, "")
151
+ return (subtype ?? "file").slice(0, 5).toUpperCase()
152
+ }