@hachej/boring-ui-kit 0.4.23 → 0.4.24

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 (52) hide show
  1. package/README.md +4 -0
  2. package/dist/chat-view.d.ts +278 -0
  3. package/dist/chat-view.js +393 -0
  4. package/package.json +6 -2
  5. package/r/agent-target.json +4 -4
  6. package/r/app-shell.json +9 -9
  7. package/r/board-viewer.json +5 -5
  8. package/r/button.json +1 -1
  9. package/r/diagram-viewer.json +4 -4
  10. package/r/dock.json +1 -1
  11. package/r/documents.json +15 -14
  12. package/r/feedback-preview.json +1 -1
  13. package/r/feedback.json +6 -6
  14. package/r/file-tree.json +5 -5
  15. package/r/highlights.json +3 -3
  16. package/r/html-viewer.json +3 -3
  17. package/r/looks-viewer.json +1 -1
  18. package/r/maquette-viewer.json +5 -5
  19. package/r/markdown-editor.json +3 -3
  20. package/r/mockup-viewer.json +1 -1
  21. package/r/pi-ambient.json +10 -10
  22. package/r/pi-app-kinds.json +27 -0
  23. package/r/pi-app.json +16 -25
  24. package/r/pi-artifacts.json +1 -1
  25. package/r/pi-chat-approval.json +1 -1
  26. package/r/pi-chat-attachments.json +1 -1
  27. package/r/pi-chat-markdown.json +1 -1
  28. package/r/pi-chat-mentions.json +1 -1
  29. package/r/pi-chat-pickers.json +1 -1
  30. package/r/pi-chat-plugins.json +11 -11
  31. package/r/pi-chat-question.json +1 -1
  32. package/r/pi-chat-slash.json +1 -1
  33. package/r/pi-chat-subagent.json +1 -1
  34. package/r/pi-chat-voice.json +2 -2
  35. package/r/pi-chat.json +11 -11
  36. package/r/pi-workspace.json +4 -4
  37. package/r/placed-agent.json +4 -4
  38. package/r/provider-setup.json +2 -2
  39. package/r/record-viewer.json +1 -1
  40. package/r/registry.json +186 -169
  41. package/r/requests-board.json +1 -1
  42. package/r/shared-chat.json +7 -7
  43. package/r/task-list-viewer.json +2 -2
  44. package/r/version-history.json +3 -3
  45. package/r/viewer-code.json +2 -2
  46. package/r/viewer-frame.json +5 -5
  47. package/r/viewer-html.json +4 -4
  48. package/r/viewer-image.json +2 -2
  49. package/r/viewer-markdown.json +5 -5
  50. package/r/viewer-pdf.json +3 -3
  51. package/r/viewers.json +6 -6
  52. package/r/windows.json +1 -1
@@ -0,0 +1,393 @@
1
+ /** The tool that asks the person a question (`ask_user` of `@hachej/boring-agent/ask-user`). Its calls are question parts. */
2
+ export const ASK_USER_TOOL = 'ask_user';
3
+ /** Key of `{ summary, decision? }` in a gated call's details: `APPROVAL_DETAILS` of `@hachej/boring-agent/approval` (a contract test keeps them equal). */
4
+ export const APPROVAL_DETAILS = 'boring.approval';
5
+ /** The host adds file content for `@path` mentions as parts that start like this; the message already shows the mention itself. */
6
+ export const isFileBlock = (text) => text.startsWith('<file path="');
7
+ /** A marked app context stays in the native message; presentation collapses only a valid leading target block to a chip. */
8
+ export function targetContent(text) {
9
+ if (!text.startsWith('<target>'))
10
+ return;
11
+ const end = text.indexOf('</target>');
12
+ if (end < 0 || text.slice(end + 9, end + 11) !== '\n\n')
13
+ return;
14
+ const json = text.slice(8, end);
15
+ if (new TextEncoder().encode(json).length > 1024)
16
+ return;
17
+ try {
18
+ const target = object(JSON.parse(json));
19
+ if (!target)
20
+ return;
21
+ const label = Object.entries(target).map(([key, value]) => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? `${key}: ${value}` : key).join(' · ') || 'Target';
22
+ return { target, speech: text.slice(end + 11), label };
23
+ }
24
+ catch {
25
+ return;
26
+ }
27
+ }
28
+ export function object(value) {
29
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
30
+ }
31
+ export function streamParts(value) {
32
+ if (!Array.isArray(value))
33
+ return [];
34
+ return value.flatMap((item) => {
35
+ const part = object(item);
36
+ if (part?.['type'] === 'text' && typeof part['text'] === 'string')
37
+ return [{ type: 'text', text: part['text'] }];
38
+ if (part?.['type'] === 'thinking' && typeof part['thinking'] === 'string')
39
+ return [{ type: 'thinking', thinking: part['thinking'] }];
40
+ const args = object(part?.['arguments']);
41
+ if (part?.['type'] === 'toolCall' && typeof part['id'] === 'string' && typeof part['name'] === 'string' && args)
42
+ return [{ type: 'toolCall', id: part['id'], name: part['name'], arguments: args }];
43
+ return [];
44
+ });
45
+ }
46
+ export function queuedMessages(view) {
47
+ const items = object(view?.docs['pi.inbox'])?.['items'];
48
+ if (!Array.isArray(items))
49
+ return [];
50
+ return items.filter((item) => {
51
+ const value = object(item);
52
+ return value !== undefined && (value['mode'] === 'steer' || value['mode'] === 'followUp') && value['id'] !== undefined;
53
+ });
54
+ }
55
+ /** The approval record a gated call publishes: its summary and, once made, the decision (`approved`, `denied` or `cancelled`). */
56
+ const approvalRecord = (details) => {
57
+ const record = object(object(details)?.[APPROVAL_DETAILS]);
58
+ const summary = record?.['summary'], decision = record?.['decision'];
59
+ if (typeof summary !== 'string' || !summary.trim())
60
+ return undefined;
61
+ return { summary: summary.trim(), ...(decision === 'approved' || decision === 'denied' || decision === 'cancelled' ? { decision } : {}) };
62
+ };
63
+ function visibleText(text, streaming, hideLine) {
64
+ if (!hideLine)
65
+ return text;
66
+ const lines = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
67
+ return lines.filter(line => (!streaming || line.endsWith('\n')) && !hideLine(line.replace(/\r?\n$/, ''))).join('');
68
+ }
69
+ const textOf = (parts) => parts.flatMap(part => part.kind === 'text' ? [part.text] : []).join('\n');
70
+ /** The display rows of a native conversation view, plus its run, retry, compaction and pinned cards. */
71
+ export function derive(view, options) {
72
+ const { mode, renderEntry, renderTool } = options;
73
+ const natural = options.presentation === 'natural';
74
+ const developer = mode === 'developer' && !natural;
75
+ const live = object(view?.docs['pi.live']);
76
+ const run = object(live?.['run']), generation = object(live?.['generation']);
77
+ const liveMessage = object(generation?.['message']), retry = object(generation?.['retry']);
78
+ const taskId = run?.['taskId'];
79
+ let stageStatus;
80
+ const items = [], results = new Map(), counts = new Map(), questionIds = new Map();
81
+ const pendingCalls = new Map();
82
+ for (const entry of view?.entries ?? []) {
83
+ const custom = renderEntry?.(entry);
84
+ if (custom && 'status' in custom)
85
+ stageStatus = custom.status;
86
+ const card = custom && (developer || custom.required || custom.inline) ? custom : undefined;
87
+ if (card)
88
+ items.push({ key: `entry:${entry.id}`, entry, card });
89
+ if (!entry.model?.length) {
90
+ if (!card && developer)
91
+ items.push({ key: `entry:${entry.id}`, entry });
92
+ continue;
93
+ }
94
+ for (const [index, message] of entry.model.entries()) {
95
+ if (message.role === 'assistant')
96
+ for (const part of message.content)
97
+ if (part.type === 'toolCall') {
98
+ pendingCalls.set(part.id, part);
99
+ questionIds.set(part, JSON.stringify([entry.id, part.id]));
100
+ }
101
+ if (message.role === 'toolResult') {
102
+ const call = pendingCalls.get(message.toolCallId);
103
+ if (call) {
104
+ results.set(call, message);
105
+ pendingCalls.delete(message.toolCallId);
106
+ }
107
+ }
108
+ let key = `entry:${entry.id}:${index}`;
109
+ if (message.role === 'assistant' && entry.byTaskId !== undefined) {
110
+ const ordinal = counts.get(entry.byTaskId) ?? 0;
111
+ counts.set(entry.byTaskId, ordinal + 1);
112
+ key = `assistant:${entry.byTaskId}:${ordinal}`;
113
+ }
114
+ if (!card)
115
+ items.push({ key, entry, message });
116
+ }
117
+ }
118
+ const matched = new Set(results.values());
119
+ const pinned = [];
120
+ for (const item of items)
121
+ if (item.card?.required)
122
+ pinned.push({ key: item.key, content: item.card.content });
123
+ const status = (result) => result ? (result.isError ? 'failed' : 'completed') : run ? 'running' : 'unfinished';
124
+ function toolPart(call, key, committed) {
125
+ const result = results.get(call);
126
+ if (call.name === ASK_USER_TOOL)
127
+ return { kind: 'question', key, call, result, live: !committed, questionId: questionIds.get(call) };
128
+ // A call gated by `requireApproval` identifies itself: it publishes its summary under APPROVAL_DETAILS before it asks.
129
+ const slotDetails = result ? undefined : (Array.isArray(live?.['tools']) ? live['tools'] : []).map(object).find(slot => slot?.['callId'] === call.id)?.['details'];
130
+ const record = approvalRecord(result ? result.details : slotDetails);
131
+ if (record)
132
+ return { kind: 'approval', key, call, result, live: !committed, questionId: questionIds.get(call), summary: record.summary, ...(record.decision ? { decision: record.decision } : {}), ...(result ? { entry: { key: `${key}:step`, call, result, status: status(result) } } : {}) };
133
+ // A `delegate` call (or any tool publishing `DelegateDetails`) is a sub-agent card, live from its running slot, when the sub-agent item draws it.
134
+ const delegated = options.subagent?.(call, result ? result.details : slotDetails, result);
135
+ if (delegated !== undefined)
136
+ return { kind: 'subagent', key, call, result, content: delegated, ...(result ? { entry: { key: `${key}:step`, call, result, status: status(result) } } : {}) };
137
+ // With the artifacts item, a finished call that carries a descriptor becomes a card and a running `present` an "opening" card. A failed call stays an ordinary (failed) tool step.
138
+ const artifact = options.artifacts?.recognise(call, result, !result && run !== undefined, options.artifacts.detect);
139
+ // A tool whose own result carries the descriptor is replaced by its card; a host-detected card is shown in addition to the step.
140
+ if (artifact)
141
+ return { kind: 'artifact', key, call, artifact, ...(result && !artifact.own ? { entry: { key, call, result, status: status(result) } } : {}) };
142
+ const custom = committed ? renderTool?.(call, result) : undefined;
143
+ if (custom?.required) {
144
+ pinned.push({ key, content: custom.content });
145
+ return undefined;
146
+ }
147
+ if (natural)
148
+ return undefined;
149
+ if (custom && (developer || result?.isError || custom.inline))
150
+ return { kind: 'tool', key, entry: { key, call, result, status: status(result), custom: custom.content } };
151
+ // A host that folds only some calls (`groupTool`) keeps the rest as cards, which expert mode hides unless they failed.
152
+ if (!developer && !result?.isError && options.groupTool && !(result && safe(options.groupTool, { key, call, result, status: status(result) })))
153
+ return undefined;
154
+ return { kind: 'tool', key, entry: { key, call, result, status: status(result) } };
155
+ }
156
+ function partsOf(content, rowKey, streaming, committed) {
157
+ const parts = [];
158
+ for (const [index, part] of content.entries()) {
159
+ if (part.type === 'text') {
160
+ const text = visibleText(part.text, streaming && index === content.length - 1, options.hideLine);
161
+ if (text.length)
162
+ parts.push({ kind: 'text', key: `${rowKey}:text:${index}`, text });
163
+ }
164
+ else if (part.type === 'thinking') {
165
+ if (developer && part.thinking.length)
166
+ parts.push({ kind: 'thinking', key: `${rowKey}:thinking:${index}`, text: part.thinking, streaming: streaming && index === content.length - 1 });
167
+ }
168
+ else {
169
+ const made = toolPart(part, `${rowKey}:tool:${index}`, committed);
170
+ if (!natural && (made?.kind === 'artifact' || made?.kind === 'approval' || made?.kind === 'subagent') && made.entry)
171
+ parts.push({ kind: 'tool', key: `${made.key}:step`, entry: made.entry });
172
+ if (made)
173
+ parts.push(made);
174
+ }
175
+ }
176
+ return parts;
177
+ }
178
+ const imagesOf = (content) => content.flatMap(part => part.type === 'toolCall'
179
+ ? (results.get(part)?.content ?? []).filter((item) => item.type === 'image') : []);
180
+ // Pass 2: rows.
181
+ const rows = [];
182
+ for (const item of items) {
183
+ if (item.card) {
184
+ if (!item.card.required)
185
+ rows.push({ key: item.key, type: 'card', card: item.card });
186
+ continue;
187
+ }
188
+ const message = item.message;
189
+ if (!message) {
190
+ if (developer)
191
+ rows.push({ key: item.key, type: 'event', label: item.entry.kind });
192
+ continue;
193
+ }
194
+ if (message.role === 'system') {
195
+ if (developer)
196
+ rows.push({ key: item.key, type: 'system', text: typeof message.content === 'string' ? message.content : message.content.map(part => part.text).join('\n') });
197
+ }
198
+ else if (message.role === 'toolResult') {
199
+ // A result with its call is shown on the call's card. Only an unmatched failure gets its own row.
200
+ if (!natural && message.isError && !matched.has(message))
201
+ rows.push({ key: item.key, type: 'orphan-result', message });
202
+ }
203
+ else if (message.role === 'user')
204
+ rows.push({ key: item.key, type: 'user', message });
205
+ else {
206
+ const parts = partsOf(message.content, item.key, false, true), images = imagesOf(message.content);
207
+ // Empty/tool-only rounds still contribute their native metadata to the answer.
208
+ rows.push({ key: item.key, type: 'assistant', parts, ...(images.length ? { images } : {}), streaming: false, stopReason: message.stopReason, ...(message.errorMessage && !natural ? { errorMessage: message.errorMessage } : {}), text: textOf(parts), ...replyMeta(message), entryId: String(item.entry.id) });
209
+ }
210
+ }
211
+ if (typeof taskId === 'number' && liveMessage) {
212
+ const key = `assistant:${taskId}:${counts.get(taskId) ?? 0}`, content = streamParts(liveMessage['content']);
213
+ const parts = partsOf(content, key, true, false), images = imagesOf(content);
214
+ if (!natural || parts.length || images.length)
215
+ rows.push({ key, type: 'assistant', parts, ...(images.length ? { images } : {}), streaming: true, text: textOf(parts) });
216
+ }
217
+ const merged = mergeTurns(rows).filter(row => row.type !== 'assistant' || row.parts.length || row.images?.length || row.stopReason === 'error' || row.stopReason === 'aborted' || row.streaming && !natural).map(row => row.type === 'assistant' && options.artifacts ? { ...row, parts: oneCardPerArtifact(row.parts) } : row);
218
+ const last = merged[merged.length - 1];
219
+ if (run && last?.type === 'assistant')
220
+ merged[merged.length - 1] = { ...last, active: true };
221
+ return { rows: merged, pinned, run, retry, ...(stageStatus !== undefined ? { stageStatus } : {}), compacting: Array.isArray(live?.['compactions']) && live['compactions'].length > 0 };
222
+ }
223
+ /** What a reply reports about the model round that wrote it, read from Pi's own message (each field only when it is there). */
224
+ export function replyMeta(message) {
225
+ return {
226
+ ...(typeof message.timestamp === 'number' ? { timestamp: message.timestamp } : {}),
227
+ ...(typeof message.provider === 'string' && typeof message.model === 'string' ? { model: { provider: message.provider, modelId: message.model } } : {}),
228
+ ...(typeof message.durationMs === 'number' ? { durationMs: message.durationMs } : {}),
229
+ };
230
+ }
231
+ /**
232
+ * Pi commits one assistant message per model round, so a turn with tool use is several messages in a row.
233
+ * They read as one answer: merge consecutive assistant rows (unless one ended in an error or stop) so tool calls
234
+ * group across rounds and the turn has a single copy action. The first row's key is kept, so identity is stable.
235
+ */
236
+ function mergeTurns(rows) {
237
+ const out = [];
238
+ for (const row of rows) {
239
+ const last = out[out.length - 1];
240
+ if (last?.type === 'assistant' && row.type === 'card' && row.card.inline) {
241
+ out[out.length - 1] = { ...last, parts: [...last.parts, { kind: 'card', key: row.key, content: row.card.content }] };
242
+ }
243
+ else if (last?.type === 'assistant' && row.type === 'assistant' && !last.stopReason?.match(/^(error|aborted)$/)) {
244
+ out[out.length - 1] = { key: last.key, type: 'assistant', parts: [...last.parts, ...row.parts], ...((last.images?.length || row.images?.length) ? { images: [...(last.images ?? []), ...(row.images ?? [])] } : {}), streaming: row.streaming,
245
+ ...(row.stopReason ? { stopReason: row.stopReason } : {}), ...(row.errorMessage ? { errorMessage: row.errorMessage } : {}),
246
+ text: [last.text, row.text].filter(Boolean).join('\n\n'), ...(row.timestamp !== undefined ? { timestamp: row.timestamp } : {}), ...(row.model ? { model: row.model } : {}), ...(last.durationMs !== undefined || row.durationMs !== undefined ? { durationMs: (last.durationMs ?? 0) + (row.durationMs ?? 0) } : {}),
247
+ ...(row.entryId !== undefined ? { entryId: row.entryId } : last.entryId !== undefined ? { entryId: last.entryId } : {}) };
248
+ }
249
+ else
250
+ out.push(row);
251
+ }
252
+ return out;
253
+ }
254
+ /**
255
+ * A turn that revises an artifact several times shows one card for it: the last version, at the place of the last step.
256
+ * A running `present` of a file already shown takes over the title of its card.
257
+ */
258
+ function oneCardPerArtifact(parts) {
259
+ const idOf = (part) => part.kind === 'artifact' ? part.artifact.id : undefined;
260
+ const last = new Map();
261
+ for (const [index, part] of parts.entries()) {
262
+ const id = idOf(part);
263
+ if (id !== undefined)
264
+ last.set(id, index);
265
+ }
266
+ const out = [];
267
+ for (const [index, part] of parts.entries()) {
268
+ const id = idOf(part);
269
+ if (id === undefined || last.get(id) === index) {
270
+ if (part.kind === 'artifact' && part.artifact.state === 'presenting' && part.artifact.title === undefined && id !== undefined) {
271
+ const earlier = parts.find(other => other.kind === 'artifact' && other.artifact.state === 'ready' && other.artifact.id === id);
272
+ if (earlier?.kind === 'artifact') {
273
+ out.push({ ...part, artifact: { ...part.artifact, title: earlier.artifact.title } });
274
+ continue;
275
+ }
276
+ }
277
+ out.push(part);
278
+ }
279
+ }
280
+ return out;
281
+ }
282
+ export function segments(parts, groupTool) {
283
+ const out = [];
284
+ let run = [];
285
+ const flush = () => {
286
+ const first = run[0];
287
+ if (first)
288
+ out.push({ kind: 'activity', key: `activity:${first.key}`, steps: run });
289
+ run = [];
290
+ };
291
+ for (const part of parts) {
292
+ if (part.kind === 'thinking')
293
+ run.push({ kind: 'thinking', key: part.key, text: part.text, streaming: part.streaming });
294
+ else if (part.kind === 'tool' && !part.entry.custom && (!groupTool || safe(groupTool, part.entry)))
295
+ run.push({ kind: 'tool', key: part.key, entry: part.entry });
296
+ else {
297
+ flush();
298
+ out.push({ kind: 'part', part });
299
+ }
300
+ }
301
+ flush();
302
+ return out;
303
+ }
304
+ function safe(groupTool, entry) {
305
+ try {
306
+ return entry.result !== undefined && !entry.result.isError && groupTool(entry.call, entry.result) === true;
307
+ }
308
+ catch {
309
+ return false;
310
+ }
311
+ }
312
+ const LEAD_GAP_MS = 15 * 60_000;
313
+ const leads = new WeakMap();
314
+ const stamp = (row) => row.type === 'user' ? row.message.timestamp : row.type === 'assistant' ? row.timestamp : undefined;
315
+ /**
316
+ * `messageStyle="messaging"`: flags the first user or assistant row of each run (a change of side, or more than 15 minutes since the
317
+ * last message) so only it gets a tail. A row keeps its identity while its flag does not change, so memoized rows stay still.
318
+ */
319
+ export function markLeads(rows) {
320
+ return rows.map((row, at) => {
321
+ if (row.type !== 'user' && row.type !== 'assistant')
322
+ return row;
323
+ const before = rows[at - 1], time = stamp(row), last = before && stamp(before);
324
+ const lead = !before || before.type !== row.type || (time !== undefined && last !== undefined && time - last > LEAD_GAP_MS);
325
+ if (row.lead === lead)
326
+ return row;
327
+ const cached = leads.get(row), hit = lead ? cached?.yes : cached?.no;
328
+ if (hit)
329
+ return hit;
330
+ const next = { ...row, lead };
331
+ leads.set(row, { ...cached, [lead ? 'yes' : 'no']: next });
332
+ return next;
333
+ });
334
+ }
335
+ /**
336
+ * The newest question or approval that waits for the person: committed (not still being generated), with no result yet and, for an
337
+ * approval, no decision recorded. An approval whose decision is made is not waiting: the run carries on (or stops) by itself.
338
+ */
339
+ export function waitingPart(rows) {
340
+ for (let at = rows.length - 1; at >= 0; at--) {
341
+ const row = rows[at];
342
+ if (row?.type === 'assistant')
343
+ for (const part of row.parts)
344
+ if (waits(part))
345
+ return part;
346
+ }
347
+ return undefined;
348
+ }
349
+ /** Whether this one part waits for the person (the rule of `waitingPart`). */
350
+ export function waits(part) {
351
+ return (part.kind === 'question' || (part.kind === 'approval' && !part.decision)) && !part.live && !part.result;
352
+ }
353
+ /** Whether the conversation waits for the person to answer a question or decide an approval (`waitingPart`). */
354
+ export const waitingForAnswer = (rows) => waitingPart(rows) !== undefined;
355
+ /** `presentation="natural"`: whether the one "preparing" status shows. Any work (a run, a retry, a compaction) counts, except a confirmed stop with no run left; a question waiting for the person does not. */
356
+ export function preparing(input) {
357
+ const { natural, waitingForAnswer, run, retry, compacting, stop } = input;
358
+ return natural && !waitingForAnswer && Boolean(run || retry || compacting) && !(stop === 'confirmed' && !run);
359
+ }
360
+ /** The words of run notices (toasts and system notifications). One table, so a surface localizes them in one place. */
361
+ export const defaultNoticeText = {
362
+ /** What a waiting approval asks. */
363
+ approve: (summary) => `Approve: ${summary}`,
364
+ /** A failed run that reported no error message. */
365
+ runFailed: 'The run failed',
366
+ /** A finished run whose reply has no text. */
367
+ finished: 'Finished',
368
+ };
369
+ /** One line from a Markdown reply: its first non-empty line without Markdown marks, cut with an ellipsis. */
370
+ export function summaryOf(text, limit = 140) {
371
+ const line = text.split('\n').map(item => item.trim()).find(item => item && !/^(```|---+$)/.test(item)) ?? '';
372
+ const plain = line.replace(/^(#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/, '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+/g, '').replace(/\s+/g, ' ').trim();
373
+ return plain.length > limit ? `${plain.slice(0, limit - 1).trimEnd()}…` : plain;
374
+ }
375
+ /**
376
+ * What a notice says about the question or approval waiting for the person (`waitingPart`), if one waits. `question` reads the
377
+ * question text from an `ask_user` call (the question item owns that grammar).
378
+ */
379
+ export function pendingNotice(rows, question, text = defaultNoticeText) {
380
+ const part = waitingPart(rows);
381
+ if (!part)
382
+ return undefined;
383
+ return part.kind === 'question' ? question(part.call) : text.approve(part.summary);
384
+ }
385
+ /** What a notice says about a run that just ended, from the conversation's rows; `undefined` when the person stopped it. */
386
+ export function finishNotice(rows, text = defaultNoticeText) {
387
+ const last = [...rows].reverse().find((row) => row.type === 'assistant');
388
+ if (last?.stopReason === 'aborted')
389
+ return undefined;
390
+ if (last?.stopReason === 'error')
391
+ return { kind: 'error', summary: last.errorMessage ?? text.runFailed };
392
+ return { kind: 'done', summary: summaryOf(last?.text ?? '') || text.finished };
393
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -55,6 +55,10 @@
55
55
  "types": "./dist/native-chat.d.ts",
56
56
  "import": "./dist/native-chat.js"
57
57
  },
58
+ "./chat-view": {
59
+ "types": "./dist/chat-view.d.ts",
60
+ "import": "./dist/chat-view.js"
61
+ },
58
62
  "./voice-capture": {
59
63
  "types": "./dist/voice-capture.d.ts",
60
64
  "import": "./dist/voice-capture.js"
@@ -134,7 +138,7 @@
134
138
  }
135
139
  },
136
140
  "peerDependencies": {
137
- "@hachej/boring-files": "^0.4.23",
141
+ "@hachej/boring-files": "^0.4.24",
138
142
  "@earendil-works/pi-durable": "~1.2.0",
139
143
  "react": "^19.0.0",
140
144
  "react-dom": "^19.0.0",
@@ -4,15 +4,15 @@
4
4
  "title": "Point and dictate on app data",
5
5
  "description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.23",
8
- "@hachej/boring-feedback@^0.4.23",
7
+ "@hachej/boring-ui-kit@^0.4.24",
8
+ "@hachej/boring-feedback@^0.4.24",
9
9
  "@earendil-works/pi-durable@~1.2.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/pi-chat.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat.json",
16
16
  "button",
17
17
  "card",
18
18
  "message",
package/r/app-shell.json CHANGED
@@ -4,21 +4,21 @@
4
4
  "title": "App shell: layouts, list/detail and agent layer",
5
5
  "description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on explicitly pinned Base UI drawer and dropdown-menu recipes, plus the sidebar and avatar of the host's own shadcn style.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.23",
8
- "@hachej/boring-ui-kit@^0.4.23",
7
+ "@hachej/boring-feedback@^0.4.24",
8
+ "@hachej/boring-ui-kit@^0.4.24",
9
9
  "@base-ui/react@^1.9.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/pi-chat-plugins.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/pi-chat-voice.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/dock.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/feedback.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/utils.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat-plugins.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat-voice.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/dock.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/feedback.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/utils.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/button.json",
22
22
  "drawer",
23
23
  "sidebar",
24
24
  "avatar",
@@ -4,16 +4,16 @@
4
4
  "title": "Board viewer",
5
5
  "description": "A board@1 artifact kind for *.board.json. Lazy native tldraw projection of maquette screens, sandboxed HTML mock-ups, goto arrows and notes. Conditional board-file autosave; phone viewing without accidental frame edits. Hosts supply their resource client, asset URLs and license key.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.23",
8
- "@hachej/boring-files@^0.4.23",
7
+ "@hachej/boring-ui-kit@^0.4.24",
8
+ "@hachej/boring-files@^0.4.24",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@1.51.0",
11
11
  "tldraw@5.5.2"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/maquette-viewer.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/mockup-viewer.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/pi-artifacts.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/maquette-viewer.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/mockup-viewer.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-artifacts.json",
17
17
  "button",
18
18
  "alert",
19
19
  "card",
package/r/button.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/utils.json",
13
13
  "dropdown-menu",
14
14
  "button",
15
15
  "toggle",
@@ -2,13 +2,13 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "diagram-viewer",
4
4
  "title": "Diagram viewer",
5
- "description": "DiagramViewer, DiagramView and diagramKind: an agent's *.diagram.json (format v1, versioned: nodes, edges, optional groups, highlighted path and note) as boxes and numbered arrows with a legend that says what happens and who acts. Layers run left to right while they fit and top to bottom on a phone, scrolling inside their own box; no layout library. A superset of a maquette's workflow, which the maquette viewer draws with DiagramView. The pi-app artifact panel shows .diagram.json files with it by default; app artifactKinds win.",
5
+ "description": "DiagramViewer, DiagramView and diagramKind: an agent's *.diagram.json (format v1, versioned: nodes, edges, optional groups, highlighted path and note) as boxes and numbered arrows with a legend that says what happens and who acts. Layers run left to right while they fit and top to bottom on a phone, scrolling inside their own box; no layout library. A superset of a maquette's workflow, which the maquette viewer draws with DiagramView. Its kind is in the pi-app-kinds default list, which an app passes to the pi-app artifact panel; an app kind placed before it wins.",
6
6
  "dependencies": [
7
7
  "react@^19.0.0",
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/theme.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/theme.json",
12
12
  "alert",
13
13
  "badge"
14
14
  ],
@@ -33,13 +33,13 @@
33
33
  },
34
34
  {
35
35
  "path": "registry/diagram-viewer/diagram-viewer.tsx",
36
- "content": "'use client';\n\n// The diagram viewer: an agent's `*.diagram.json` (format v1) drawn by `DiagramView`, with its highlighted path and note. `diagramKind` (diagram-kind.ts) is\n// the kind the pi-app artifact panel uses by default for `.diagram.json` files; an app's own kind in `artifactKinds` wins. The maquette\n// viewer draws its workflow (\"How it works\") with the same `DiagramView`. It draws data only: nothing in the file runs.\nimport { useMemo } from 'react';\nimport { InfoIcon, TriangleAlertIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { DIAGRAM_FORMAT_VERSION, parseDiagram } from './diagram';\nimport { diagramLabelsFor } from './diagram-labels';\nimport type { DiagramLabels, DiagramLocale } from './diagram-labels';\nimport { DiagramView } from './diagram-view';\n\nexport { DIAGRAM_FORMAT_VERSION, isDiagramPath, parseDiagram } from './diagram';\nexport type { Diagram, DiagramEdge, DiagramFileV1, DiagramGroup, DiagramNode, DiagramParse } from './diagram';\nexport { DiagramView } from './diagram-view';\nexport type { DiagramViewLabels } from './diagram-view';\nexport { defaultDiagramLabels, diagramLabelsFor, frenchDiagramLabels } from './diagram-labels';\nexport type { DiagramLabels, DiagramLocale } from './diagram-labels';\n\nexport interface DiagramViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The base words: English by default. */\n readonly locale?: DiagramLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<DiagramLabels> | undefined;\n}\n\n/** A diagram file; a newer format version shows a notice, an unreadable file an inline error. */\nexport function DiagramViewer({ text, locale, labels: given }: DiagramViewerProps) {\n const labels = useMemo(() => diagramLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseDiagram(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > DIAGRAM_FORMAT_VERSION;\n return <section data-boring=\"diagram-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.diagram.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-3 px-3 pt-2 pb-4 sm:px-6\">\n {newer && <Alert data-testid=\"diagram-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>}\n {parsed.kind === 'ok' ? <DiagramView diagram={parsed.diagram} labels={labels} />\n : <Alert variant=\"destructive\" data-testid=\"diagram-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n",
36
+ "content": "'use client';\n\n// The diagram viewer: an agent's `*.diagram.json` (format v1) drawn by `DiagramView`, with its highlighted path and note. `diagramKind` (diagram-kind.ts) is\n// the kind of the pi-app-kinds default list for `.diagram.json` files; an app kind placed before it in `artifactKinds` wins. The maquette\n// viewer draws its workflow (\"How it works\") with the same `DiagramView`. It draws data only: nothing in the file runs.\nimport { useMemo } from 'react';\nimport { InfoIcon, TriangleAlertIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { DIAGRAM_FORMAT_VERSION, parseDiagram } from './diagram';\nimport { diagramLabelsFor } from './diagram-labels';\nimport type { DiagramLabels, DiagramLocale } from './diagram-labels';\nimport { DiagramView } from './diagram-view';\n\nexport { DIAGRAM_FORMAT_VERSION, isDiagramPath, parseDiagram } from './diagram';\nexport type { Diagram, DiagramEdge, DiagramFileV1, DiagramGroup, DiagramNode, DiagramParse } from './diagram';\nexport { DiagramView } from './diagram-view';\nexport type { DiagramViewLabels } from './diagram-view';\nexport { defaultDiagramLabels, diagramLabelsFor, frenchDiagramLabels } from './diagram-labels';\nexport type { DiagramLabels, DiagramLocale } from './diagram-labels';\n\nexport interface DiagramViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The base words: English by default. */\n readonly locale?: DiagramLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<DiagramLabels> | undefined;\n}\n\n/** A diagram file; a newer format version shows a notice, an unreadable file an inline error. */\nexport function DiagramViewer({ text, locale, labels: given }: DiagramViewerProps) {\n const labels = useMemo(() => diagramLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseDiagram(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > DIAGRAM_FORMAT_VERSION;\n return <section data-boring=\"diagram-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.diagram.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-3 px-3 pt-2 pb-4 sm:px-6\">\n {newer && <Alert data-testid=\"diagram-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>}\n {parsed.kind === 'ok' ? <DiagramView diagram={parsed.diagram} labels={labels} />\n : <Alert variant=\"destructive\" data-testid=\"diagram-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n",
37
37
  "type": "registry:component",
38
38
  "target": "components/diagram-viewer/diagram-viewer.tsx"
39
39
  },
40
40
  {
41
41
  "path": "registry/diagram-viewer/diagram-kind.tsx",
42
- "content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { WorkflowIcon } from 'lucide-react';\nimport { isDiagramPath } from './diagram';\nimport { diagramLabelsFor } from './diagram-labels';\nimport type { DiagramLabels, DiagramLocale } from './diagram-labels';\n\nconst DiagramViewer = lazy(() => import('./diagram-viewer').then(module => ({ default: module.DiagramViewer })));\n\n/**\n * The artifact kind of a diagram file (structurally an `ArtifactKind` of pi-artifacts): it matches `*.diagram.json`, names it, gives its icon\n * and draws it with `DiagramViewer`, loaded on first open. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted first.\n */\nexport interface DiagramKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string }) => ReactNode;\n}\n\n/** A diagram kind in `locale` (\"Diagram\" in English, \"Diagramme\" in French) with any word overridden. */\nexport function createDiagramKind({ locale, labels }: { readonly locale?: DiagramLocale | undefined; readonly labels?: Partial<DiagramLabels> | undefined } = {}): DiagramKind {\n return {\n match: target => isDiagramPath(target.resource.path),\n label: diagramLabelsFor(locale, labels).kind,\n icon: WorkflowIcon,\n renderer: ({ text }) => <DiagramViewer text={text} locale={locale} labels={labels} />,\n };\n}\n\n/** The built-in diagram kind, in English. */\nexport const diagramKind: DiagramKind = createDiagramKind();\n",
42
+ "content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { WorkflowIcon } from 'lucide-react';\nimport { isDiagramPath } from './diagram';\nimport { diagramLabelsFor } from './diagram-labels';\nimport type { DiagramLabels, DiagramLocale } from './diagram-labels';\n\nconst DiagramViewer = lazy(() => import('./diagram-viewer').then(module => ({ default: module.DiagramViewer })));\n\n/**\n * The artifact kind of a diagram file (structurally an `ArtifactKind` of pi-artifacts): it matches `*.diagram.json`, names it, gives its icon\n * and draws it with `DiagramViewer`, loaded on first open. It is in the pi-app-kinds default list (`defaultKinds`); kinds in `artifactKinds` are consulted in order.\n */\nexport interface DiagramKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string }) => ReactNode;\n}\n\n/** A diagram kind in `locale` (\"Diagram\" in English, \"Diagramme\" in French) with any word overridden. */\nexport function createDiagramKind({ locale, labels }: { readonly locale?: DiagramLocale | undefined; readonly labels?: Partial<DiagramLabels> | undefined } = {}): DiagramKind {\n return {\n match: target => isDiagramPath(target.resource.path),\n label: diagramLabelsFor(locale, labels).kind,\n icon: WorkflowIcon,\n renderer: ({ text }) => <DiagramViewer text={text} locale={locale} labels={labels} />,\n };\n}\n\n/** The built-in diagram kind, in English. */\nexport const diagramKind: DiagramKind = createDiagramKind();\n",
43
43
  "type": "registry:component",
44
44
  "target": "components/diagram-viewer/diagram-kind.tsx"
45
45
  }
package/r/dock.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.23/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {