@hachej/boring-ui-kit 0.4.24 → 0.4.26

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 (76) hide show
  1. package/README.md +26 -2
  2. package/bin/boring-ui-blocks.mjs +50 -13
  3. package/dist/chat-artifacts.d.ts +157 -0
  4. package/dist/chat-artifacts.js +274 -0
  5. package/dist/chat-react-controller.d.ts +25 -0
  6. package/dist/chat-react-controller.js +72 -0
  7. package/dist/chat-react-conversations.d.ts +40 -0
  8. package/dist/chat-react-conversations.js +68 -0
  9. package/dist/chat-react-draft.d.ts +11 -0
  10. package/dist/chat-react-draft.js +91 -0
  11. package/dist/chat-react-remote.d.ts +33 -0
  12. package/dist/chat-react-remote.js +72 -0
  13. package/dist/chat-react-transcript.d.ts +39 -0
  14. package/dist/chat-react-transcript.js +123 -0
  15. package/dist/chat-react.d.ts +60 -0
  16. package/dist/chat-react.js +94 -0
  17. package/dist/chat-text.d.ts +122 -0
  18. package/dist/chat-text.js +216 -0
  19. package/dist/chat-view.d.ts +2 -17
  20. package/dist/chat-view.js +1 -0
  21. package/dist/file-tree.js +5 -0
  22. package/dist/run-notifications.d.ts +42 -0
  23. package/dist/run-notifications.js +67 -0
  24. package/dist/shared-chat/index.d.ts +1 -1
  25. package/dist/shared-chat/index.js +5 -4
  26. package/dist/voice-capture.d.ts +12 -0
  27. package/dist/voice-capture.js +17 -2
  28. package/package.json +10 -2
  29. package/r/agent-target.json +5 -9
  30. package/r/app-shell.json +16 -25
  31. package/r/board-viewer.json +7 -11
  32. package/r/button.json +2 -9
  33. package/r/diagram-viewer.json +0 -5
  34. package/r/dock.json +2 -2
  35. package/r/documents.json +14 -19
  36. package/r/feedback-preview.json +1 -7
  37. package/r/feedback.json +12 -19
  38. package/r/file-tree.json +4 -14
  39. package/r/highlights.json +4 -9
  40. package/r/html-viewer.json +3 -3
  41. package/r/looks-viewer.json +2 -6
  42. package/r/maquette-viewer.json +2 -20
  43. package/r/markdown-editor.json +4 -7
  44. package/r/mockup-viewer.json +2 -5
  45. package/r/pi-ambient.json +11 -19
  46. package/r/pi-app-kinds.json +10 -10
  47. package/r/pi-app.json +16 -26
  48. package/r/pi-artifacts.json +3 -4
  49. package/r/pi-chat-approval.json +3 -5
  50. package/r/pi-chat-attachments.json +2 -4
  51. package/r/pi-chat-markdown.json +3 -5
  52. package/r/pi-chat-mentions.json +2 -3
  53. package/r/pi-chat-pickers.json +2 -4
  54. package/r/pi-chat-plugins.json +11 -11
  55. package/r/pi-chat-question.json +1 -7
  56. package/r/pi-chat-slash.json +3 -6
  57. package/r/pi-chat-subagent.json +3 -8
  58. package/r/pi-chat-voice.json +6 -9
  59. package/r/pi-chat.json +17 -45
  60. package/r/pi-workspace.json +4 -7
  61. package/r/placed-agent.json +4 -5
  62. package/r/provider-setup.json +2 -8
  63. package/r/record-viewer.json +0 -9
  64. package/r/registry.json +163 -395
  65. package/r/requests-board.json +2 -10
  66. package/r/shared-chat.json +9 -23
  67. package/r/task-list-viewer.json +3 -9
  68. package/r/version-history.json +3 -5
  69. package/r/viewer-code.json +2 -3
  70. package/r/viewer-frame.json +7 -15
  71. package/r/viewer-html.json +4 -5
  72. package/r/viewer-image.json +3 -4
  73. package/r/viewer-markdown.json +6 -8
  74. package/r/viewer-pdf.json +5 -7
  75. package/r/viewers.json +6 -6
  76. package/r/windows.json +2 -7
package/README.md CHANGED
@@ -80,9 +80,11 @@ The default schema includes headings, lists, task lists, quotes, code and text m
80
80
 
81
81
  Where the page has Web Audio, the recording is a 16 kHz mono 16-bit WAV (`audio/wav`, 32 kB/s), not MediaRecorder's webm: a webm carries the browser's clock, and one whose timestamps jumped was refused by every OpenAI transcription model. `start()` resolves `recording` once the first sound arrives, so a host showing "recording" never loses the first words; no sound within `firstSoundMs` (5 s) refuses as `busy`. A context the browser keeps suspended (no user activation, e.g. a hold begun on `touchstart`) records that take with MediaRecorder as before; `audioContext: null` always does. `encodeWav` and `downsample` are exported.
82
82
 
83
- `maxMs` caps capture at five minutes by default. Reaching the cap stops the microphone; callers still choose when to consume the recording through `stop()`. Transcription and persistence remain host capabilities. The existing `@hachej/boring-feedback/page` capture API re-exports this implementation unchanged. The headless root imports neither entry.
83
+ `progress()` returns current elapsed milliseconds, PCM RMS level, paused state and pause capability (null outside a capture). `pause()`/`resume()` return false when unavailable/ended. PCM pause keeps the microphone open but stops appending frames: elapsed time and WAV duration exclude paused samples. MediaRecorder fallback exposes no level or pause, never fabricated telemetry.
84
84
 
85
- The [registry voice-message guide](../../registry/README.md#voice-messages-in-pi-chat) describes PiChat's optional hold-to-send composition. Public capture behavior runs in `test/packages/ui.test.mjs`, compatibility in `test/packages/feedback-voice.test.mjs`, and dependency-free installation in `test/scaffold/isolated-ui.test.mjs`.
85
+ `maxMs` caps captured audio at five minutes by default. Reaching the cap stops the microphone; callers still choose when to consume the recording through `stop()`. Transcription and persistence remain host capabilities. The existing `@hachej/boring-feedback/page` capture API re-exports this implementation unchanged. The headless root imports neither entry.
86
+
87
+ The [registry voice-message guide](../../registry/README.md#voice-messages-in-pi-chat) describes PiChat's optional tap-to-record composition. Public capture behavior runs in `test/packages/ui.test.mjs`, compatibility in `test/packages/feedback-voice.test.mjs`, and dependency-free installation in `test/scaffold/isolated-ui.test.mjs`.
86
88
 
87
89
  ## Document marks
88
90
 
@@ -146,6 +148,28 @@ A submit the host refused before admission (status 402 `submission-refused`, for
146
148
 
147
149
  `@hachej/boring-ui-kit/chat-view` holds the chat's presentation rules once, so the registry items (`pi-chat`, `pi-ambient`) keep only their markup. It imports Pi types only: no React, no files or agent code. `derive(view, options)` turns a native `ConversationView` into display rows (cards, activity steps, questions, approvals, artifacts) and the run state; its card content type is generic, and the items fix it to React nodes. `waits(part)`, `waitingPart(rows)` and `waitingForAnswer(rows)` are the one rule for "waiting for the person": a committed question with no result, or an approval with no result and no decision (a decided approval is not waiting). `preparing(...)` is when the natural presentation's single status shows. `pendingNotice` and `finishNotice` give the words of the run toasts and system notifications from one table, `defaultNoticeText`, which a surface can replace to localize them. Evidence: `test/packages/ui-chat-view.test.mjs`.
148
150
 
151
+ `@hachej/boring-ui-kit/chat-text` holds the chat's text grammar once: pure string functions, no React, files or agent code. `mentionToken(value)` writes an `@` mention (quoted when the value has whitespace, a quote or a backslash) and `mentionedPaths(text)`, `MENTION_TOKEN` and `pieces(text, options)` read mentions back, with the same parser as `mentionedPaths` of `@hachej/boring-agent/mentions`; `hasMention` and `removeMention` edit a draft; `slashItems`, `slashGroup` and `slashQuery` drive the `/` menu, `mentionTrigger` the `@` picker. `appendToDraft(draft, text)` adds text at the end of a draft with one space, and `prependToDraft(draft, text)` puts a refused message back on its own line before it. `fileKind`, `isUploadPath`, `isVoicePath`, `displayName`, `visibleParts`, `isLongPaste` and `looksLikeMarkdown` classify attachments and pastes. The shared chat writes and finds member mentions with it (`memberMention`). `kindMentionDraft(text, { kinds })` projects editable composer text plus its hidden kind-token spellings from the full native draft, leaving file tokens visible. `writeKindMentionDraft(text, tokens, skills?)` keeps a leading registered skill first, then prefixes complete hidden tokens before the remaining editable text so unfinished quoted input cannot consume them; `skills` is the registered name list, parsed by the same `pieces` grammar. `mentionKind(value, { kinds })` resolves an app-registered `prefix:path` token without depending on its renderer; `withoutMentions(text)` consumes all tokens for voice sends, while `withoutKindMentions(text, { kinds })` drops only registered app tokens from refused-message restoration. See [App mention kinds](../../registry/README.md#app-mention-kinds) for the React config. Evidence: `test/packages/ui-chat-text.test.mjs`.
152
+
153
+ `@hachej/boring-ui-kit/chat-react` provides `useRunNotifications(controller, store, options)`, `watchConversation(controller, store, options)` and `createNotificationStore()`. Both adapters use one transition over the existing controller's `subscribe`/`getSnapshot`, with no execution lifecycle ownership. `options` supplies `conversationId`, `title`, `question(call)` and optional localized `text` (`NoticeText`); `enabled`, `suppress()` and `onOutcome()` are optional. Questions notify even without a run; approval decisions dismiss input notices; disconnected snapshots neither complete work nor consume transitions. The first connected observation establishes a baseline (and reports pending input), never invents a past completion. Suppression consumes a transition without pushing a toast. Detaching releases only the subscription. The store is transient presentation, not durable delivery. This is host UI policy over native views, not a Pi execution extension. Existing registry notification exports remain available. Evidence: `test/packages/ui-run-notifications.test.mjs`.
154
+
155
+ `chat-view` also owns artifact descriptors and parsing (`parseArtifact`, `detectArtifact`, `recogniseArtifact`), `pendingPresents` and `createPresentAcks`. `artifactVersions(view, detect?)` lists newest conversation results first, even when retried calls reuse an ordinal. `follows(descriptor, versions)` compares the newest revision for that artifact key. `collectArtifacts` remains oldest first for compatibility.
156
+
157
+ ### Chat React hooks
158
+
159
+ `@hachej/boring-ui-kit/chat-react` is opt-in React presentation over the existing `ChatController.subscribe/getSnapshot` seam, never a Pi executor or a second store. `useArtifactFollow` takes the controller, conversation ID, active descriptor/follow selection and host opening/guard callbacks. It returns versions, `open`, `isOpen`, `select`, `recordClosed` and viewer `onShown`/`onFailed` callbacks. Initial artifacts do not auto-open; new ones may when `autoOpen` is enabled (default false), unless the host reports a narrow screen, unsaved work or a close in this turn. Call `recordClosed` only after the host's leave guard accepts. A present is acknowledged with only the revision the viewer reports after it loads and mounts; unmount reports use `hidden: true`, failures refuse, and absent reports stay unconfirmed. Controller/conversation changes fence old reports and retries. The host retains its controller and execution lifetime. Evidence: `test/packages/ui-chat-react.test.mjs`, `test/packages/ui-present-ack.test.mjs`.
160
+
161
+ The same entry exports the shared chat hooks (not from the headless root):
162
+
163
+ - `useChatView(controller, select)` selects the native `view` (possibly undefined) through the existing `subscribe/getSnapshot`; selection is memoized by view and selector identity. It borrows the controller and introduces no store. Keep a selector stable to avoid repeated derivation.
164
+ - `useTranscriptWindow(rows, first = 60, more = 40, history?, automatic = true)` (alias `useTranscript`) returns `hidden`, `visible`, `reveal`, `revealTo`, `reset`, `loaded`, `scrollRef` and `sentinelRef`. Give `scrollRef` the host scroll element. The hook owns only the paging window and top sentinel, not scroll position or follow-bottom. Registry `session.useTranscript` combines it with the app's shadcn MessageScroller, the only actual scroll/anchor owner. `useRowChunks(rows, size = 20)` retains run boundaries and unchanged row arrays for that scroller.
165
+ - `useChatDraft(controller, key)` keeps the existing per-conversation localStorage policy under `pi-chat:draft:<key>`; undefined opts out, blocked storage never blocks typing. The host must scope the key by owner when needed and clear sensitive drafts on logout. `useChatPlacement` / `readChatPlacement` report the last surviving presenter's docked/floating registration; placement is advisory, never authorization.
166
+ - `useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext?, transport?, webSocket? })` returns connecting/offline/ready with the concrete native controller, transport actions and configure. Selection keys include the conversation, resolved URL, identity IDs and transport; new identity objects alone do not reconnect. Current fetch/context callbacks are read after commit; each submit merges current placement alongside the original screen context. Failed acquisition retries every second; transport reconnect remains the existing remote client's responsibility.
167
+ - `useControllerPerConversation({ key, create, connect?, dispose?, retryMs? })` is shared selected-adapter ownership, not a Pi lifecycle. Use a stable key including source/owner/options; undefined acquires nothing. It returns connecting/offline/ready and preserves the factory's concrete controller type. It disposes only what the factory creates (including late results and StrictMode mounts), awaits async teardown on retry, and releases retry timers on selection change. A custom `dispose` releases any additional owned transport; teardown errors are reported to the console. It never aborts native work or closes a borrowed source/Harness. `useSharedChat` uses it without changing its existing connect-on-session-mount behavior.
168
+ - `useConversationsClient({ endpoint, fetch, activeId, onSelect, onChange?, pollMs? })` (alias `useConversations`) returns the headless list and reload/search/create/rename/archive/delete/fork callbacks once the first list arrives. Polling defaults to four seconds; fetch and selection callbacks remain host-owned. Registry `ConversationsConfig` adds presentation-only row actions.
169
+
170
+ Public hook/StrictMode evidence: `test/packages/ui-chat-react-hooks.test.mjs`. Existing `test/contracts/chat-placement*.test.mjs` and `test/ui/pi-chat-draft.test.mjs` also drive the compatibility entry points. Browser scroll and installed consumers are separate gates; session send/upload/model logic remains in the registry pending its own extraction.
171
+
172
+
149
173
  ### Native conversation history
150
174
 
151
175
  `loadEarlier()` reads one native page through `Conversation.entries`. `snapshot.history` distinguishes disabled, idle, loading, ready and error states. Each request contains at most 40 native entries. The controller accumulates pages in display order for the person's current browsing session and retains an opaque native continuation cursor. It clears that cache on history invalidation or `clearHistory()`; loading never restores records to model context. The first request captures the greatest observed active entry ID as an inclusive upper bound; later writes do not move that bound. Parent-owned fork entries remain valid native history.
@@ -4,14 +4,14 @@
4
4
  //
5
5
  // npx boring-ui-blocks check [--design <dir>...] [--manifest <path>] [--cwd <app web dir>] [--json]
6
6
  // npx boring-ui-blocks update [--version <v>] [--registry <url>] [--dry-run] [--manifest <path>] [--cwd <dir>]
7
- // npx boring-ui-blocks add <shadcn add arguments...> [--manifest <path>] [--cwd <dir>]
7
+ // npx boring-ui-blocks add <shadcn add arguments...> [--overwrite] [--manifest <path>] [--cwd <dir>]
8
8
  // npx boring-ui-blocks manifest [--manifest <path>] [--cwd <dir>]
9
9
  // npx boring-ui-blocks help (also --help: the usage and which items to add; exit 0)
10
10
  //
11
11
  // `check` writes nothing. `add`, `update` and `manifest` write the shadcn files they install and the manifest, nothing else.
12
12
  import { createHash } from 'node:crypto';
13
- import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
14
- import { join, relative, resolve, sep } from 'node:path';
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
14
+ import { dirname, join, relative, resolve, sep } from 'node:path';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { fileURLToPath } from 'node:url';
17
17
 
@@ -158,12 +158,24 @@ export function checkBlocks(app, components = appPaths(app).components) {
158
158
  /** The generated shadcn files under the `ui` alias (`.js/.jsx/.ts/.tsx/.css`) and their sha256, by path relative to it. */
159
159
  const uiHashes = ui => new Map(files(ui).filter(path => /\.(?:[jt]sx?|css)$/.test(path)).map(path => [posix(relative(ui, path)), { path, hash: sha256(path) }]));
160
160
 
161
+ /** Records the given generated ui files in the manifest (`add` after a kept file: only what it created). Other entries and keys are kept. */
162
+ export function recordFiles(app, paths, manifest = MANIFEST) {
163
+ const { ui } = appPaths(app);
164
+ const path = resolve(app, manifest);
165
+ const kept = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { generatedBy: 'boring-ui-blocks', dir: posix(relative(app, ui)) };
166
+ const hashes = { ...kept.files };
167
+ for (const file of paths) hashes[posix(relative(ui, file))] = sha256(file);
168
+ mkdirSync(dirname(path), { recursive: true });
169
+ writeFileSync(path, `${JSON.stringify({ ...kept, files: Object.fromEntries(Object.entries(hashes).sort(([a], [b]) => a.localeCompare(b))) }, null, 2)}\n`);
170
+ }
171
+
161
172
  /** (Re)writes the manifest (`manifest`, default SHADCN-GENERATED.json next to components.json): `files` is replaced, other keys are kept. */
162
173
  export function writeManifest(app, manifest = MANIFEST) {
163
174
  const { ui } = appPaths(app);
164
175
  const path = resolve(app, manifest);
165
176
  const hashes = Object.fromEntries([...uiHashes(ui)].map(([name, { hash }]) => [name, hash]));
166
177
  const kept = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { generatedBy: 'boring-ui-blocks', dir: posix(relative(app, ui)) };
178
+ mkdirSync(dirname(path), { recursive: true });
167
179
  writeFileSync(path, `${JSON.stringify({ ...kept, files: hashes }, null, 2)}\n`);
168
180
  return Object.keys(hashes).length;
169
181
  }
@@ -215,18 +227,20 @@ export function check(app, design = [], manifest = MANIFEST) {
215
227
  return { version, blocks: blocks.map(block => block.name), violations };
216
228
  }
217
229
 
218
- const USAGE = `usage: boring-ui-blocks check [--design <dir>...] [--manifest <path>] [--cwd <dir>] [--json] | update [--version <v>] [--registry <url>] [--dry-run] [--manifest <path>] [--cwd <dir>] | add <shadcn add args...> [--manifest <path>] [--cwd <dir>] | manifest [--manifest <path>] [--cwd <dir>] | help\n\n` +
230
+ const USAGE = `usage: boring-ui-blocks check [--design <dir>...] [--manifest <path>] [--cwd <dir>] [--json] | update [--version <v>] [--registry <url>] [--dry-run] [--manifest <path>] [--cwd <dir>] | add <shadcn add args...> [--overwrite] [--manifest <path>] [--cwd <dir>] | manifest [--manifest <path>] [--cwd <dir>] | help\n\n` +
219
231
  'Blocks: pi-app is the whole agent app with every chat feature and viewer. pi-chat is the core chat only: add pi-chat-plugins for every ' +
220
232
  'feature, or one of pi-chat-markdown, pi-chat-slash, pi-chat-mentions, pi-chat-pickers, pi-chat-voice, pi-chat-attachments, ' +
221
233
  'pi-chat-question, pi-chat-approval, pi-chat-subagent and pi-artifacts. Viewers are viewer-frame plus viewer-markdown, viewer-html, ' +
222
234
  'viewer-image, viewer-pdf and viewer-code, loaded by kind. update reinstalls only installed items: ' +
223
- 'add the new ones an app uses once (registry/README.md, "From one item to small items").';
224
- const FLAGS = { check: ['design', 'manifest', 'json'], update: ['version', 'registry', 'dry-run', 'manifest'], manifest: ['manifest'] };
235
+ 'add the new ones an app uses once (registry/README.md, "From one item to small items"). add never prompts: a file that already exists ' +
236
+ 'with other content is kept and listed (exit 1) unless --overwrite replaces it; identical files are left alone.';
237
+ const FLAGS = { check: ['design', 'manifest', 'json'], update: ['version', 'registry', 'dry-run', 'manifest'], manifest: ['manifest'], add: ['manifest', 'overwrite'] };
225
238
 
226
239
  function parse(command, rest) {
227
240
  const flags = { design: [], pass: [] };
228
241
  for (let i = 0; i < rest.length; i++) {
229
242
  const name = rest[i].startsWith('--') ? rest[i].slice(2) : null;
243
+ if (command === 'add' && (name === 'overwrite' || rest[i] === '-o')) { flags.overwrite = true; continue; }
230
244
  if (command === 'add' && name !== 'cwd' && name !== 'manifest') { flags.pass.push(rest[i]); continue; }
231
245
  if (!name || !FLAGS[command]?.includes(name) && name !== 'cwd') throw new Error(`unknown argument ${rest[i]}`);
232
246
  if (name === 'json') flags.json = true;
@@ -267,13 +281,30 @@ function restoreSpecifiers(before, app, log) {
267
281
  if (restored.size) log(`kept the version specifiers of ${[...restored].sort().join(', ')} (the CLI had rewritten them)`);
268
282
  }
269
283
 
270
- function shadcnAdd(app, args, log) {
271
- const argv = ['-y', SHADCN, 'add', ...args];
284
+ /** The files the shadcn CLI may write for a block (the components, ui, lib and hooks aliases) and their bytes, before it runs. */
285
+ function snapshotFiles(app) {
286
+ const { aliases = {} } = readJson(join(app, 'components.json')) ?? {};
287
+ const alias = (name, fallback) => join(app, existsSync(join(app, 'src')) ? 'src' : '', (aliases[name] ?? fallback).replace(/^[@~]\//, ''));
288
+ const { components, ui } = appPaths(app);
289
+ return new Map([...new Set([components, ui, alias('lib', '@/lib'), alias('hooks', '@/hooks')])].flatMap(dir => files(dir)).map(path => [path, readFileSync(path)]));
290
+ }
291
+
292
+ /**
293
+ * Runs the shadcn CLI without a terminal: stdin is closed and the CLI always gets `--overwrite --yes`, so it never asks. With
294
+ * `overwrite` false a file that existed with other content is put back afterwards and returned in `skipped`; identical files are never touched.
295
+ * `created` is the generated ui files the run newly wrote.
296
+ */
297
+ function shadcnAdd(app, args, log, overwrite) {
298
+ const argv = ['-y', SHADCN, 'add', ...args.filter(arg => !['--yes', '-y', '--overwrite'].includes(arg)), '--overwrite', '--yes'];
272
299
  log(`npx ${argv.join(' ')}`);
273
- const before = specifiers(app);
274
- const result = spawnSync('npx', argv, { cwd: app, stdio: 'inherit', shell: process.platform === 'win32' });
300
+ const before = specifiers(app), existing = snapshotFiles(app);
301
+ const result = spawnSync('npx', argv, { cwd: app, stdio: ['ignore', 'inherit', 'inherit'], shell: process.platform === 'win32' });
275
302
  restoreSpecifiers(before, app, log);
276
- return result.status ?? 1;
303
+ const skipped = overwrite ? [] : [...existing].filter(([path, bytes]) => existsSync(path) && !readFileSync(path).equals(bytes));
304
+ for (const [path, bytes] of skipped) writeFileSync(path, bytes);
305
+ const { ui } = appPaths(app);
306
+ const created = files(ui).filter(path => /\.(?:[jt]sx?|css)$/.test(path) && !existing.has(path));
307
+ return { status: result.status ?? 1, skipped: skipped.map(([path]) => path), created };
277
308
  }
278
309
 
279
310
  const line = v => `${v.check} ${v.rule} ${v.file}${v.line ? `:${v.line}:${v.column}` : ''} ${v.detail}`;
@@ -300,7 +331,13 @@ export function main(argv, cwd = process.cwd(), log = console.log, error = conso
300
331
  if (command === 'add') {
301
332
  appPaths(app);
302
333
  if (!flags.pass.length) throw new Error('add needs shadcn add arguments');
303
- const status = shadcnAdd(app, flags.pass, log);
334
+ const { status, skipped, created } = shadcnAdd(app, flags.pass, log, flags.overwrite);
335
+ if (skipped.length) {
336
+ // The files the run created are the app's new generated files: record them, so `check` does not call them unknown. The kept ones stay unrecorded.
337
+ if (created.length) recordFiles(app, created, flags.manifest ?? MANIFEST);
338
+ error(`kept ${skipped.length} existing file(s) that differ from the registry (pass --overwrite to replace them; only the files add created were recorded in the manifest):\n${skipped.map(path => ` ${posix(relative(app, path))}`).join('\n')}`);
339
+ return status || 1;
340
+ }
304
341
  if (status === 0) log(`${flags.manifest ?? MANIFEST}: ${writeManifest(app, flags.manifest)} file(s)`);
305
342
  return status;
306
343
  }
@@ -309,7 +346,7 @@ export function main(argv, cwd = process.cwd(), log = console.log, error = conso
309
346
  const base = (flags.registry ?? itemBase(flags.version ?? installed)).replace(/\/$/, '');
310
347
  const items = blocks.map(block => `${base}/${block.name}.json`);
311
348
  if (flags.dryRun) { log(`npx -y ${SHADCN} add ${items.join(' ')} --overwrite --yes`); return 0; }
312
- const status = shadcnAdd(app, [...items, '--overwrite', '--yes'], log);
349
+ const { status } = shadcnAdd(app, items, log, true);
313
350
  if (status === 0) log(`${flags.manifest ?? MANIFEST}: ${writeManifest(app, flags.manifest)} file(s)`);
314
351
  return status;
315
352
  } catch (cause) {
@@ -0,0 +1,157 @@
1
+ import type { ConversationView } from '@earendil-works/pi-durable';
2
+ import type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';
3
+ /**
4
+ * An artifact a tool call made (or a running `present` call is making), as the chat places it. `descriptor` is the host's own
5
+ * value: the chat hands it back to the host's `open` and `isOpen` and never reads it.
6
+ */
7
+ export interface ChatArtifact {
8
+ readonly state: 'presenting' | 'ready';
9
+ /** One card per id in a reply: the last version, at the place of its last step. */
10
+ readonly id: string | undefined;
11
+ readonly title: string | undefined;
12
+ /** The workspace path, for the conversation's file list. */
13
+ readonly path: string | undefined;
14
+ /** The tool's own result carries it, so its card replaces the step; a host-detected one is shown in addition to the step. */
15
+ readonly own: boolean;
16
+ readonly descriptor: unknown;
17
+ }
18
+ /** Finds an artifact descriptor a finished call's result does not carry itself (the host's `detect`). */
19
+ export type ArtifactDetect = (call: ToolCall, result: ToolResultMessage) => unknown;
20
+ export declare const ARTIFACT_TYPES: readonly ["markdown", "html", "svg", "code", "canvas"];
21
+ export type ArtifactType = typeof ARTIFACT_TYPES[number];
22
+ /** Structural twin of the `ResourceLocator` in `@hachej/boring-files`. */
23
+ export interface ArtifactTarget {
24
+ readonly resource: {
25
+ readonly providerId: string;
26
+ readonly path: string;
27
+ };
28
+ readonly view: {
29
+ readonly kind: 'published';
30
+ } | {
31
+ readonly kind: 'working';
32
+ readonly viewId: string;
33
+ };
34
+ }
35
+ export interface ArtifactDescriptor {
36
+ readonly schema: 'boring.artifact';
37
+ readonly version: 1;
38
+ /** Set by the artifact tools; the descriptor of `present` has none (the file path is its identity). */
39
+ readonly id?: string;
40
+ readonly title: string;
41
+ readonly type: ArtifactType;
42
+ readonly mediaType: string;
43
+ readonly language?: string;
44
+ readonly target: ArtifactTarget;
45
+ /** The revision this message produced. */
46
+ readonly revision: string;
47
+ /** The 1-based version number of this artifact (artifact tools only). */
48
+ readonly ordinal?: number;
49
+ }
50
+ /** What identifies an artifact across its versions: its id, or for a presented file its path. */
51
+ export declare const artifactKey: (artifact: Pick<ArtifactDescriptor, "id" | "target">) => string;
52
+ export declare const PRESENT_TOOL = "present";
53
+ /** A `present` call with no result yet, while its conversation runs: its answer ID (`[assistantEntryId, callId]`) and the path it names. */
54
+ export interface PendingPresent {
55
+ readonly id: string;
56
+ readonly callId: string;
57
+ readonly path: string;
58
+ }
59
+ /** The path a `present` call or a page acknowledgement names, as the server compares it (a leading `./` is the same file). */
60
+ export declare const presentPath: (path: string) => string;
61
+ /**
62
+ * The `present` calls still waiting for the page (the server's view-open record, `@hachej/boring-agent/artifacts`). Mirrors
63
+ * the preview's pending-call scan (the feedback plug-in's `pendingBrowserTasks`): a call with no result, in a conversation that is running.
64
+ */
65
+ export declare function pendingPresents(view: {
66
+ readonly entries: readonly {
67
+ readonly id: number | bigint | string;
68
+ readonly model?: readonly unknown[] | undefined;
69
+ }[];
70
+ readonly docs?: Readonly<Record<string, unknown>>;
71
+ } | undefined): readonly PendingPresent[];
72
+ /** Strict shape check of an untrusted value. Returns a fresh copy or undefined. */
73
+ export declare function parseArtifact(value: unknown): ArtifactDescriptor | undefined;
74
+ /**
75
+ * The descriptor a successful tool result carries: the host's `detect` first, then the `artifact` key of a JSON text result.
76
+ * Failed results never produce one, and a host callback that throws or returns something malformed is ignored. Without
77
+ * the host's `detect`, only a result of `present` counts: any other tool whose text happens
78
+ * to be that JSON (an MCP tool, `cat x.json`) cannot forge a card. A host with renamed tools says so in `detect`.
79
+ */
80
+ export declare function detectArtifact(call: ToolCall, result: ToolResultMessage, detect?: ArtifactDetect | undefined): ArtifactDescriptor | undefined;
81
+ /** The title a still-running `present` call announces in its streamed arguments: the file name. */
82
+ export declare function pendingTitle(call: ToolCall): string | undefined;
83
+ /** What a still-running `present` call will show: the file path. */
84
+ export declare function pendingId(call: ToolCall): string | undefined;
85
+ /**
86
+ * Every artifact version a conversation's tool results produced, oldest first. Hosts use it to list the versions known
87
+ * from the conversation (a version switcher); the chat itself shows only the last version of an artifact per turn.
88
+ */
89
+ export declare function collectArtifacts(view: ConversationView | undefined, detect?: ArtifactDetect | undefined): ArtifactDescriptor[];
90
+ /** What the host's chat answer returns: the kind is all the presents bookkeeping reads. */
91
+ export interface PresentAnswer {
92
+ readonly kind: string;
93
+ }
94
+ export interface PresentAcksHost {
95
+ /** The chat's `answer` (the transport's `?op=answer`); undefined when the host has none. */
96
+ readonly answer: (id: string, answer: string) => Promise<PresentAnswer> | undefined;
97
+ /** Open the file in the panel; false when the panel refuses (unsaved changes the person keeps). */
98
+ readonly open: (path: string) => boolean;
99
+ /** This page's identity in the acknowledgement. */
100
+ readonly instanceId: string;
101
+ /** Test seams: the wait between retries and the clock of the retry bound. Default: real timers. */
102
+ readonly wait?: (ms: number) => Promise<void>;
103
+ readonly now?: () => number;
104
+ }
105
+ /** The page's state of the presents it must answer, as `AgentWorkspace` reads it on each change. */
106
+ export interface PresentContext {
107
+ /** The person closed the panel in this turn. */
108
+ readonly closedThisTurn: boolean;
109
+ /** The open file has unsaved edits. */
110
+ readonly unsaved: boolean;
111
+ /** The screen is a phone (the panel is a sheet and is not opened for an agent). */
112
+ readonly narrow: boolean;
113
+ /** `autoOpen`: when false the panel is not opened for an agent; the call waits for a manual open. */
114
+ readonly autoOpen: boolean;
115
+ /** Whether the panel already shows this file. */
116
+ readonly showing: (path: string) => boolean;
117
+ }
118
+ /**
119
+ * The page's side of the view-open acknowledgement. A `present` call is confirmed only by a viewer's mount report (`shown`) of the
120
+ * revision it names. A call is decided once; an answer the server denies (a revision the page showed before the agent wrote a new one)
121
+ * keeps the call waiting for the next mount report, and an answer is retried briefly while the server has not recorded the call yet.
122
+ */
123
+ export declare function createPresentAcks(host: PresentAcksHost): {
124
+ /** A viewer mounted and shows `path` at `revision`: confirms the calls waiting for that file. */
125
+ shown(view: {
126
+ readonly path: string;
127
+ readonly revision: string;
128
+ }): void;
129
+ /** A viewer could not show `path` (a kind's `onFailed`, or its renderer threw): refuses the calls waiting for that file with `reason`. */
130
+ failed(view: {
131
+ readonly path: string;
132
+ readonly reason: string;
133
+ }): void;
134
+ /** A viewer stopped showing `path` at `revision` (it unmounted, or a custom viewer or the canvas took the file): it no longer confirms. */
135
+ hidden(view: {
136
+ readonly path: string;
137
+ readonly revision: string;
138
+ }): void;
139
+ /** The presents the server waits for: each is decided once, in order. Decided calls and waits that are no longer live are forgotten. */
140
+ consider(calls: readonly PendingPresent[], context: PresentContext): void;
141
+ /** Calls still waiting for a mount (test and debugging view). */
142
+ waiting: () => readonly string[];
143
+ };
144
+ /** What `createPresentAcks` returns. */
145
+ export type PresentAcks = ReturnType<typeof createPresentAcks>;
146
+ /**
147
+ * The artifact a tool call made, as the chat places it: a finished call that carries a descriptor (its own, or one the host's `detect`
148
+ * finds) is ready; a `present` call still running while its conversation runs (`running`) is presenting. Any other call has none.
149
+ */
150
+ export declare function recogniseArtifact(call: ToolCall, result: ToolResultMessage | undefined, running: boolean, detect: ArtifactDetect | undefined): ChatArtifact | undefined;
151
+ /** Every version, newest conversation result first. Ordinals can tie across retries and never determine recency. */
152
+ export declare function artifactVersions(view: ConversationView | undefined, detect?: ArtifactDetect): ArtifactDescriptor[];
153
+ /** Whether this descriptor is the newest known revision of its artifact, never an ordinal comparison. */
154
+ export declare function follows(descriptor: ArtifactDescriptor, versions: readonly ArtifactDescriptor[]): boolean;
155
+ /** The number of user messages in the current native view, used to retain a manual close until the next turn. */
156
+ export declare function artifactTurn(view: ConversationView | undefined): number;
157
+ //# sourceMappingURL=chat-artifacts.d.ts.map
@@ -0,0 +1,274 @@
1
+ /*
2
+ * Artifacts in the chat: substantial content (a document, an HTML page, an SVG, code, a canvas) that the transcript shows as a
3
+ * card and the host opens in its own panel. The descriptor and `parseArtifact` mirror `@hachej/boring-agent/artifacts` on purpose: the UI
4
+ * cannot import that package (it carries Pi runtime code). Keep the two validators in step;
5
+ * `test/contracts/pi-chat-source.test.mjs` checks that they accept and refuse the same values.
6
+ */
7
+ export const ARTIFACT_TYPES = ['markdown', 'html', 'svg', 'code', 'canvas'];
8
+ /** What identifies an artifact across its versions: its id, or for a presented file its path. */
9
+ export const artifactKey = (artifact) => artifact.id ?? artifact.target.resource.path;
10
+ export const PRESENT_TOOL = 'present';
11
+ /** The path a `present` call or a page acknowledgement names, as the server compares it (a leading `./` is the same file). */
12
+ export const presentPath = (path) => path.replace(/^\.\//, '');
13
+ /**
14
+ * The `present` calls still waiting for the page (the server's view-open record, `@hachej/boring-agent/artifacts`). Mirrors
15
+ * the preview's pending-call scan (the feedback plug-in's `pendingBrowserTasks`): a call with no result, in a conversation that is running.
16
+ */
17
+ export function pendingPresents(view) {
18
+ if (!view)
19
+ return [];
20
+ const live = view.docs?.['pi.live'];
21
+ if (!live?.run)
22
+ return [];
23
+ const calls = new Map();
24
+ for (const entry of view.entries)
25
+ for (const message of entry.model ?? []) {
26
+ const item = message;
27
+ if (item.role === 'assistant' && Array.isArray(item.content)) {
28
+ for (const part of item.content) {
29
+ const path = part.arguments?.['path'];
30
+ if (part.type === 'toolCall' && part.name === PRESENT_TOOL && typeof part.id === 'string' && typeof path === 'string') {
31
+ calls.set(part.id, { id: JSON.stringify([Number(entry.id), part.id]), callId: part.id, path: presentPath(path) });
32
+ }
33
+ }
34
+ }
35
+ if (item.role === 'toolResult' && typeof item.toolCallId === 'string')
36
+ calls.delete(item.toolCallId);
37
+ }
38
+ return [...calls.values()];
39
+ }
40
+ const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
41
+ const LANGUAGE = /^[A-Za-z0-9+#._-]{1,32}$/;
42
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
43
+ const hasOnly = (value, keys) => Object.keys(value).every(key => keys.includes(key));
44
+ /** Strict shape check of an untrusted value. Returns a fresh copy or undefined. */
45
+ export function parseArtifact(value) {
46
+ if (!isObject(value) || !hasOnly(value, ['schema', 'version', 'id', 'title', 'type', 'mediaType', 'language', 'target', 'revision', 'ordinal']))
47
+ return undefined;
48
+ const { id, title, type, mediaType, language, target, revision, ordinal } = value;
49
+ if (value['schema'] !== 'boring.artifact' || value['version'] !== 1)
50
+ return undefined;
51
+ if (id !== undefined && (typeof id !== 'string' || !ID.test(id)))
52
+ return undefined;
53
+ if (typeof title !== 'string' || !title.trim() || title.length > 200)
54
+ return undefined;
55
+ if (typeof type !== 'string' || !ARTIFACT_TYPES.includes(type))
56
+ return undefined;
57
+ if (typeof mediaType !== 'string' || !/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i.test(mediaType))
58
+ return undefined;
59
+ if (language !== undefined && (typeof language !== 'string' || !LANGUAGE.test(language)))
60
+ return undefined;
61
+ if (typeof revision !== 'string' || !revision || revision.length > 256)
62
+ return undefined;
63
+ if (ordinal !== undefined && (typeof ordinal !== 'number' || !Number.isSafeInteger(ordinal) || ordinal < 1))
64
+ return undefined;
65
+ if (!isObject(target) || !hasOnly(target, ['resource', 'view']) || !isObject(target['resource']) || !hasOnly(target['resource'], ['providerId', 'path']))
66
+ return undefined;
67
+ const { providerId, path } = target['resource'];
68
+ if (typeof providerId !== 'string' || !providerId || typeof path !== 'string' || !path || path.length > 512)
69
+ return undefined;
70
+ const view = target['view'];
71
+ if (!isObject(view))
72
+ return undefined;
73
+ let parsedView;
74
+ if (view['kind'] === 'published' && hasOnly(view, ['kind']))
75
+ parsedView = { kind: 'published' };
76
+ else if (view['kind'] === 'working' && hasOnly(view, ['kind', 'viewId']) && typeof view['viewId'] === 'string' && view['viewId'])
77
+ parsedView = { kind: 'working', viewId: view['viewId'] };
78
+ else
79
+ return undefined;
80
+ return { schema: 'boring.artifact', version: 1, ...(id === undefined ? {} : { id }), title, type: type, mediaType, ...(language === undefined ? {} : { language: language }),
81
+ target: { resource: { providerId, path }, view: parsedView }, revision, ...(ordinal === undefined ? {} : { ordinal }) };
82
+ }
83
+ const textOf = (result) => result.content.map(part => part.type === 'text' ? part.text : '').join('');
84
+ /**
85
+ * The descriptor a successful tool result carries: the host's `detect` first, then the `artifact` key of a JSON text result.
86
+ * Failed results never produce one, and a host callback that throws or returns something malformed is ignored. Without
87
+ * the host's `detect`, only a result of `present` counts: any other tool whose text happens
88
+ * to be that JSON (an MCP tool, `cat x.json`) cannot forge a card. A host with renamed tools says so in `detect`.
89
+ */
90
+ export function detectArtifact(call, result, detect) {
91
+ if (result.isError)
92
+ return undefined;
93
+ if (detect) {
94
+ try {
95
+ const found = parseArtifact(detect(call, result));
96
+ if (found)
97
+ return found;
98
+ }
99
+ catch { /* a faulty host callback shows no card */ }
100
+ }
101
+ if (call.name !== PRESENT_TOOL)
102
+ return undefined;
103
+ const text = textOf(result);
104
+ if (!text.includes('"boring.artifact"'))
105
+ return undefined;
106
+ try {
107
+ const value = JSON.parse(text);
108
+ return isObject(value) ? parseArtifact(value['artifact']) : undefined;
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ /** The title a still-running `present` call announces in its streamed arguments: the file name. */
115
+ export function pendingTitle(call) {
116
+ const path = call.arguments['path'];
117
+ const title = typeof path === 'string' ? path.slice(path.lastIndexOf('/') + 1) : undefined;
118
+ return title?.trim() ? title.trim().slice(0, 200) : undefined;
119
+ }
120
+ /** What a still-running `present` call will show: the file path. */
121
+ export function pendingId(call) {
122
+ const path = call.arguments['path'];
123
+ return typeof path === 'string' && path ? path.replace(/^(\.\/)+/, '') : undefined;
124
+ }
125
+ /**
126
+ * Every artifact version a conversation's tool results produced, oldest first. Hosts use it to list the versions known
127
+ * from the conversation (a version switcher); the chat itself shows only the last version of an artifact per turn.
128
+ */
129
+ export function collectArtifacts(view, detect) {
130
+ const calls = new Map(), found = [];
131
+ for (const entry of view?.entries ?? [])
132
+ for (const message of entry.model ?? []) {
133
+ if (message.role === 'assistant')
134
+ for (const part of message.content) {
135
+ if (part.type === 'toolCall')
136
+ calls.set(part.id, part);
137
+ }
138
+ else if (message.role === 'toolResult') {
139
+ const call = calls.get(message.toolCallId);
140
+ const descriptor = call && detectArtifact(call, message, detect);
141
+ if (descriptor)
142
+ found.push(descriptor);
143
+ }
144
+ }
145
+ return found;
146
+ }
147
+ /**
148
+ * The page's side of the view-open acknowledgement. A `present` call is confirmed only by a viewer's mount report (`shown`) of the
149
+ * revision it names. A call is decided once; an answer the server denies (a revision the page showed before the agent wrote a new one)
150
+ * keeps the call waiting for the next mount report, and an answer is retried briefly while the server has not recorded the call yet.
151
+ */
152
+ export function createPresentAcks(host) {
153
+ const decided = new Set();
154
+ /** Calls waiting for their file's mount report: answer ID to path. */
155
+ const mounting = new Map();
156
+ const wait = host.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
157
+ const now = host.now ?? (() => Date.now());
158
+ /** The last mount report: the file on screen and its revision. */
159
+ let lastShown;
160
+ /** Sends one answer; returns the server's outcome kind, or undefined when it was not sent. Retries while the record is not written yet. */
161
+ async function send(id, body) {
162
+ const until = now() + 3000;
163
+ for (;;) {
164
+ try {
165
+ const sent = host.answer(id, JSON.stringify(body));
166
+ if (sent === undefined)
167
+ return undefined;
168
+ const outcome = await sent;
169
+ if (outcome.kind !== 'unknown-question' || now() > until)
170
+ return outcome.kind;
171
+ }
172
+ catch {
173
+ return undefined; /* the call ends at its deadline as unconfirmed */
174
+ }
175
+ await wait(20);
176
+ }
177
+ }
178
+ /** Confirms a shown revision. The call keeps its entry while the answer is in flight, so a mount during the flight still confirms it. */
179
+ async function confirm(id, path, revision) {
180
+ const kind = await send(id, { kind: 'opened', instanceId: host.instanceId, path, revision });
181
+ // Only an accepted or conflicting answer ends the call. A denial (another revision), a failed request or an unrecorded call keeps it waiting for a later mount or a live-call check.
182
+ if (kind === 'answered' || kind === 'conflict')
183
+ mounting.delete(id);
184
+ }
185
+ /** Refuses a waiting call; like `confirm`, only an accepted or conflicting answer ends it. */
186
+ async function refuse(id, reason) {
187
+ const kind = await send(id, { kind: 'refused', reason });
188
+ if (kind === 'answered' || kind === 'conflict')
189
+ mounting.delete(id);
190
+ }
191
+ return {
192
+ /** A viewer mounted and shows `path` at `revision`: confirms the calls waiting for that file. */
193
+ shown(view) {
194
+ for (const [id, path] of mounting)
195
+ if (path === view.path)
196
+ void confirm(id, path, view.revision);
197
+ lastShown = view;
198
+ },
199
+ /** A viewer could not show `path` (a kind's `onFailed`, or its renderer threw): refuses the calls waiting for that file with `reason`. */
200
+ failed(view) {
201
+ if (lastShown?.path === view.path)
202
+ lastShown = undefined;
203
+ const reason = view.reason.trim().slice(0, 200) || 'the view failed';
204
+ for (const [id, path] of mounting)
205
+ if (path === view.path)
206
+ void refuse(id, reason);
207
+ },
208
+ /** A viewer stopped showing `path` at `revision` (it unmounted, or a custom viewer or the canvas took the file): it no longer confirms. */
209
+ hidden(view) {
210
+ if (lastShown?.path === view.path && lastShown.revision === view.revision)
211
+ lastShown = undefined;
212
+ },
213
+ /** The presents the server waits for: each is decided once, in order. Decided calls and waits that are no longer live are forgotten. */
214
+ consider(calls, context) {
215
+ const live = new Set(calls.map(call => call.id));
216
+ for (const id of decided)
217
+ if (!live.has(id))
218
+ decided.delete(id);
219
+ for (const id of mounting.keys())
220
+ if (!live.has(id))
221
+ mounting.delete(id);
222
+ for (const call of calls) {
223
+ if (decided.has(call.id))
224
+ continue;
225
+ decided.add(call.id);
226
+ if (context.closedThisTurn)
227
+ void send(call.id, { kind: 'refused', reason: 'closed by user' });
228
+ else if (context.unsaved)
229
+ void send(call.id, { kind: 'refused', reason: 'unsaved changes' });
230
+ else if (context.narrow)
231
+ void send(call.id, { kind: 'refused', reason: 'narrow screen' });
232
+ else {
233
+ mounting.set(call.id, call.path);
234
+ if (lastShown?.path === call.path && context.showing(call.path))
235
+ void confirm(call.id, call.path, lastShown.revision);
236
+ else if (context.autoOpen && !host.open(call.path)) {
237
+ mounting.delete(call.id);
238
+ void send(call.id, { kind: 'refused', reason: 'unsaved changes' });
239
+ }
240
+ }
241
+ }
242
+ },
243
+ /** Calls still waiting for a mount (test and debugging view). */
244
+ waiting: () => [...mounting.keys()],
245
+ };
246
+ }
247
+ /**
248
+ * The artifact a tool call made, as the chat places it: a finished call that carries a descriptor (its own, or one the host's `detect`
249
+ * finds) is ready; a `present` call still running while its conversation runs (`running`) is presenting. Any other call has none.
250
+ */
251
+ export function recogniseArtifact(call, result, running, detect) {
252
+ if (result) {
253
+ const artifact = detectArtifact(call, result, detect);
254
+ if (!artifact)
255
+ return undefined;
256
+ // A tool whose own result carries the descriptor is replaced by its card; a host-detected card is shown in addition to the step.
257
+ return { state: 'ready', id: artifactKey(artifact), title: artifact.title, path: artifact.target.resource.path, own: detectArtifact(call, result) !== undefined, descriptor: artifact };
258
+ }
259
+ if (running && call.name === PRESENT_TOOL)
260
+ return { state: 'presenting', id: pendingId(call), title: pendingTitle(call), path: undefined, own: true, descriptor: undefined };
261
+ return undefined;
262
+ }
263
+ /** Every version, newest conversation result first. Ordinals can tie across retries and never determine recency. */
264
+ export function artifactVersions(view, detect) {
265
+ return collectArtifacts(view, detect).reverse();
266
+ }
267
+ /** Whether this descriptor is the newest known revision of its artifact, never an ordinal comparison. */
268
+ export function follows(descriptor, versions) {
269
+ return descriptor.revision === versions.find(version => artifactKey(version) === artifactKey(descriptor))?.revision;
270
+ }
271
+ /** The number of user messages in the current native view, used to retain a manual close until the next turn. */
272
+ export function artifactTurn(view) {
273
+ return (view?.entries ?? []).reduce((count, entry) => count + (entry.model ?? []).filter(message => message.role === 'user').length, 0);
274
+ }