@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
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.
|
|
3
|
+
"version": "0.30.0",
|
|
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",
|
package/src/components/sheet.tsx
CHANGED
|
@@ -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=
|
|
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
|
+
}
|