@hachej/boring-ui-kit 0.4.2 → 0.4.4

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 (60) hide show
  1. package/README.md +51 -1
  2. package/bin/boring-ui-blocks.mjs +29 -0
  3. package/dist/canvas-editor-native.d.ts +8 -1
  4. package/dist/canvas-editor-native.js +13 -7
  5. package/dist/canvas-editor.d.ts +4 -2
  6. package/dist/canvas-editor.js +5 -0
  7. package/dist/chat-outbox.d.ts +18 -0
  8. package/dist/chat-outbox.js +12 -0
  9. package/dist/file-tree-view.d.ts +29 -1
  10. package/dist/file-tree-view.js +74 -7
  11. package/dist/mark-store.d.ts +38 -0
  12. package/dist/mark-store.js +84 -0
  13. package/dist/markdown-editor.d.ts +6 -0
  14. package/dist/markdown-editor.js +17 -2
  15. package/dist/markdown-highlight-layer.d.ts +26 -0
  16. package/dist/markdown-highlight-layer.js +107 -0
  17. package/dist/markdown-quote.d.ts +23 -0
  18. package/dist/markdown-quote.js +39 -0
  19. package/dist/native-chat.d.ts +21 -0
  20. package/dist/native-chat.js +111 -7
  21. package/dist/remote-chat.js +16 -14
  22. package/dist/shared-chat/error.d.ts +10 -0
  23. package/dist/shared-chat/error.js +13 -0
  24. package/dist/shared-chat/fake.d.ts +39 -0
  25. package/dist/shared-chat/fake.js +90 -0
  26. package/dist/shared-chat/index.d.ts +51 -0
  27. package/dist/shared-chat/index.js +255 -0
  28. package/dist/shared-chat/socket.d.ts +20 -0
  29. package/dist/shared-chat/socket.js +114 -0
  30. package/dist/shared-chat/source.d.ts +117 -0
  31. package/dist/shared-chat/source.js +6 -0
  32. package/package.json +15 -3
  33. package/r/agent-target.json +36 -0
  34. package/r/app-shell.json +26 -11
  35. package/r/board-viewer.json +49 -0
  36. package/r/button.json +1 -1
  37. package/r/diagram-viewer.json +42 -0
  38. package/r/dock.json +1 -1
  39. package/r/documents.json +11 -11
  40. package/r/feedback-preview.json +1 -1
  41. package/r/feedback.json +6 -6
  42. package/r/file-tree.json +7 -6
  43. package/r/highlights.json +29 -0
  44. package/r/html-viewer.json +3 -3
  45. package/r/maquette-viewer.json +10 -13
  46. package/r/markdown-editor.json +3 -3
  47. package/r/mockup-viewer.json +45 -0
  48. package/r/pi-ambient.json +7 -7
  49. package/r/pi-app.json +21 -18
  50. package/r/pi-chat.json +28 -20
  51. package/r/pi-workspace.json +4 -4
  52. package/r/provider-setup.json +2 -2
  53. package/r/record-viewer.json +40 -0
  54. package/r/registry.json +449 -83
  55. package/r/requests-board.json +41 -0
  56. package/r/shared-chat.json +79 -0
  57. package/r/task-list-viewer.json +2 -2
  58. package/r/theme.json +15 -0
  59. package/r/version-history.json +3 -3
  60. package/r/viewers.json +16 -10
@@ -1,5 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { captureMarkdownQuote } from './markdown-quote.js';
4
+ import { captureRichMarkdownQuote, installMarkdownHighlights } from './markdown-highlight-layer.js';
5
+ export { markdownMarkerBackground } from './markdown-highlight-layer.js';
3
6
  import { TextDraftControls } from './text-draft-controls.js';
4
7
  import { useMarkdownAutosave } from './markdown-autosave.js';
5
8
  import { Fragment, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
@@ -61,7 +64,7 @@ function useKeyboardInset(active) {
61
64
  }, [active]);
62
65
  return inset;
63
66
  }
64
- function MarkdownEditorSession({ components, controller, title = 'Document', initialMode = 'rich', placeholder = 'Start writing', className, onMountedTools, header = true, showOutline = header, mode: controlledMode, onModeChange, onRichSafety, resolveImage, formattingToolbar = 'pinned', autosave = true, onAutosaveChange, onSaveResult, activeController: active }) {
67
+ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActivate, components, controller, title = 'Document', initialMode = 'rich', placeholder = 'Start writing', className, onMountedTools, header = true, showOutline = header, mode: controlledMode, onModeChange, onRichSafety, resolveImage, formattingToolbar = 'pinned', autosave = true, onAutosaveChange, onSaveResult, activeController: active }) {
65
68
  const Control = components?.button ?? 'button';
66
69
  const Bubble = components?.bubble ?? Fragment;
67
70
  const Source = components?.textarea ?? 'textarea';
@@ -150,6 +153,18 @@ function MarkdownEditorSession({ components, controller, title = 'Document', ini
150
153
  quote: current.isActive('blockquote'), codeBlock: current.isActive('codeBlock'), link: current.isActive('link'), table: current.isActive('table'),
151
154
  href: current.isActive('link') ? String(current.getAttributes('link')['href'] ?? '') : '',
152
155
  } : null });
156
+ useEffect(() => {
157
+ if (!editor || editor.isDestroyed || mode !== 'rich' || !highlights)
158
+ return;
159
+ return installMarkdownHighlights(editor, state.text, highlights, onHighlightActivate);
160
+ }, [editor, state.text, mode, highlights, onHighlightActivate]);
161
+ useEffect(() => {
162
+ if (!editor || editor.isDestroyed || mode !== 'rich' || !onQuoteSelection)
163
+ return;
164
+ const selected = () => onQuoteSelection(captureRichMarkdownQuote(editor, controller.getSnapshot().text));
165
+ editor.on('selectionUpdate', selected);
166
+ return () => { editor.off('selectionUpdate', selected); };
167
+ }, [editor, controller, mode, onQuoteSelection]);
153
168
  const outline = useEditorState({ editor, selector: ({ editor: current }) => {
154
169
  if (!current)
155
170
  return null;
@@ -285,7 +300,7 @@ function MarkdownEditorSession({ components, controller, title = 'Document', ini
285
300
  }).catch(() => { if (active.current === controller)
286
301
  setError('The heading could not be selected'); });
287
302
  }, children: heading.text || 'Untitled heading' }) }, heading.position)) }) }), uncertain && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["Save acknowledgement was lost. Keep editing while its outcome is checked.", _jsx(Control, { type: "button", onClick: () => run(controller.actions.reconcile), children: "Check save outcome" }), _jsx(Control, { type: "button", onClick: () => run(controller.actions.abandon), children: "Stop checking and refresh, keeping my draft" })] }), conflict && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["The saved document changed. Your local text has been kept.", _jsx(Control, { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.refresh), children: "Check saved version" }), _jsx(Control, { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.discardToRemote), children: "Discard local changes and reload" })] }), (outcome?.kind === 'denied' || outcome?.kind === 'unavailable' || outcome?.kind === 'file_too_large') && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: outcome.reason }), error && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: error }), state.proposals.map(proposal => _jsxs("article", { "data-boring": "proposal", className: "boring-markdown-proposal", children: [_jsx("h3", { children: proposal.summary || 'Proposed edit' }), _jsxs("details", { children: [_jsx("summary", { children: "Review text changes" }), _jsx(MarkdownProposalDiff, { components: components, proposal: proposal }), _jsxs("details", { children: [_jsx("summary", { children: "Exact before and after" }), _jsx("h4", { children: "Before" }), _jsx("pre", { children: proposal.before }), _jsx("h4", { children: "After" }), _jsx("pre", { children: proposal.after })] })] }), _jsx(Control, { type: "button", disabled: unavailable || pending || uncertain || proposal.adopted, onClick: () => run(() => controller.actions.accept(proposal.id)), children: proposal.adopted ? 'Adopted' : 'Accept and save' }), _jsx(Control, { type: "button", onClick: () => controller.actions.reject(proposal.id), children: "Dismiss proposal" })] }, proposal.id)), mode === 'source'
288
- ? _jsx(Source, { ref: sourceInput, className: "boring-markdown-source", "aria-label": `${title} Markdown source`, value: state.text, readOnly: unavailable, spellCheck: false, onChange: event => controller.actions.edit(event.currentTarget.value) })
303
+ ? _jsx(Source, { ref: sourceInput, className: "boring-markdown-source", "aria-label": `${title} Markdown source`, value: state.text, readOnly: unavailable, spellCheck: false, onSelect: event => onQuoteSelection?.(captureMarkdownQuote(state.text, { start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd })), onChange: event => controller.actions.edit(event.currentTarget.value) })
289
304
  : _jsx("div", { className: "boring-markdown-surface", onKeyDownCapture: markInteraction, onBeforeInputCapture: markInteraction, onInputCapture: markInteraction, onPasteCapture: markInteraction, onDropCapture: markInteraction, onChangeCapture: markInteraction, onClickCapture: event => { markInteraction(); if (event.target instanceof Element && event.target.closest('a'))
290
305
  event.preventDefault(); }, onAuxClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
291
306
  event.preventDefault(); }, children: _jsx(EditorContent, { editor: editor }) })] });
@@ -0,0 +1,26 @@
1
+ import type { Editor } from '@tiptap/core';
2
+ import { captureMarkdownQuote, type MarkdownQuoteAnchor, type MarkdownQuoteRange } from './markdown-quote.js';
3
+ import type { MarkColour } from './mark-store.js';
4
+ export interface MarkdownDecoration {
5
+ readonly id: string;
6
+ readonly anchor: MarkdownQuoteAnchor;
7
+ readonly colour: MarkColour;
8
+ readonly active?: boolean;
9
+ readonly commented?: boolean;
10
+ readonly commentLabel?: string;
11
+ readonly confirmed?: {
12
+ readonly source: string;
13
+ readonly range: MarkdownQuoteRange;
14
+ };
15
+ }
16
+ export type MarkdownQuoteCapture = ReturnType<typeof captureMarkdownQuote>;
17
+ export declare function markdownMarkerBackground(colour: MarkColour): string;
18
+ /** The native serializer proves the complete source unchanged after removing two sentinels.
19
+ * If syntax/escaping/normalization changes, there is no source projection. */
20
+ export declare function markdownTextProjection(editor: Editor, source: string): readonly {
21
+ readonly rich: number;
22
+ readonly source: MarkdownQuoteRange;
23
+ }[];
24
+ export declare function captureRichMarkdownQuote(editor: Editor, source: string): MarkdownQuoteCapture;
25
+ export declare function installMarkdownHighlights(editor: Editor, source: string, marks: readonly MarkdownDecoration[], activate?: (id: string) => void): () => void;
26
+ //# sourceMappingURL=markdown-highlight-layer.d.ts.map
@@ -0,0 +1,107 @@
1
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
2
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
3
+ import { finishRichMarkdown } from './markdown-safety.js';
4
+ import { captureMarkdownQuote, markdownQuoteResolution } from './markdown-quote.js';
5
+ const key = new PluginKey('boring-markdown-highlights');
6
+ const left = 'BORINGQUOTELEFTBOUNDARY', right = 'BORINGQUOTERIGHTBOUNDARY';
7
+ export function markdownMarkerBackground(colour) {
8
+ return `color-mix(in oklab,var(--${colour}) 12%,color-mix(in oklab,var(--chart-4,var(--accent)) 24%,var(--background)))`;
9
+ }
10
+ /** The native serializer proves the complete source unchanged after removing two sentinels.
11
+ * If syntax/escaping/normalization changes, there is no source projection. */
12
+ export function markdownTextProjection(editor, source) {
13
+ if (!editor.markdown || source.includes(left) || source.includes(right) || finishRichMarkdown(editor.getMarkdown(), source) !== source)
14
+ return [];
15
+ const leaves = [];
16
+ editor.state.doc.descendants((node, position) => { if (node.isText && node.text)
17
+ leaves.push({ position, text: node.text }); });
18
+ return leaves.flatMap((leaf, index) => {
19
+ const json = editor.state.doc.toJSON();
20
+ let count = 0;
21
+ const insert = (node) => { if (node.type === 'text') {
22
+ if (count === index)
23
+ node.text = left + node.text + right;
24
+ count++;
25
+ } node.content?.forEach(insert); };
26
+ insert(json);
27
+ const marked = finishRichMarkdown(editor.markdown.serialize(json), source);
28
+ const start = marked.indexOf(left), end = marked.indexOf(right);
29
+ if (start < 0 || end < start || marked.indexOf(left, start + left.length) !== -1 || marked.indexOf(right, end + right.length) !== -1)
30
+ return [];
31
+ if (marked.slice(0, start) + marked.slice(start + left.length, end) + marked.slice(end + right.length) !== source
32
+ || marked.slice(start + left.length, end) !== leaf.text)
33
+ return [];
34
+ return [{ rich: leaf.position, source: { start, end: end - left.length } }];
35
+ });
36
+ }
37
+ export function captureRichMarkdownQuote(editor, source) {
38
+ const selection = editor.state.selection;
39
+ if (selection.empty)
40
+ return captureMarkdownQuote(source, { start: 0, end: 0 });
41
+ const mapped = markdownTextProjection(editor, source).find(p => selection.from >= p.rich && selection.to <= p.rich + p.source.end - p.source.start);
42
+ return mapped ? captureMarkdownQuote(source, { start: mapped.source.start + selection.from - mapped.rich, end: mapped.source.start + selection.to - mapped.rich })
43
+ : { kind: 'refused', reason: 'This rich selection has no exact source projection. Select the Markdown source instead.' };
44
+ }
45
+ export function installMarkdownHighlights(editor, source, marks, activate) {
46
+ const doc = editor.state.doc, projection = markdownTextProjection(editor, source), decorations = [];
47
+ for (const mark of marks) {
48
+ if (!['accent', 'primary', 'muted'].includes(mark.colour))
49
+ continue;
50
+ const placed = markdownQuoteResolution.resolve(mark.anchor, source, source);
51
+ const confirmed = mark.confirmed;
52
+ const range = confirmed?.source === source && placed.kind === 'ambiguous'
53
+ && placed.candidates.some(candidate => candidate.start === confirmed.range.start && candidate.end === confirmed.range.end)
54
+ ? confirmed.range : placed.kind === 'exact' || placed.kind === 'moved' ? placed.range : null;
55
+ if (!range)
56
+ continue;
57
+ let indicatorPosition;
58
+ for (const leaf of projection) {
59
+ const from = Math.max(leaf.source.start, range.start), to = Math.min(leaf.source.end, range.end);
60
+ if (from < to) {
61
+ indicatorPosition = leaf.rich + to - leaf.source.start;
62
+ decorations.push(Decoration.inline(leaf.rich + from - leaf.source.start, indicatorPosition, { 'data-mark-id': mark.id, 'data-mark-colour': mark.colour, 'data-mark-active': String(mark.active === true), role: 'button', tabindex: '0', 'aria-label': mark.anchor.fallback,
63
+ style: `background-color:${markdownMarkerBackground(mark.colour)};color:var(--foreground);${mark.active ? 'outline:var(--border-width, 1px) solid var(--ring);' : ''}` }));
64
+ }
65
+ }
66
+ if (mark.commented && indicatorPosition !== undefined)
67
+ decorations.push(Decoration.widget(indicatorPosition, () => {
68
+ const document = editor.view.dom.ownerDocument, indicator = document.createElement('span');
69
+ indicator.dataset['markComment'] = mark.id;
70
+ indicator.setAttribute('role', 'img');
71
+ indicator.setAttribute('aria-label', mark.commentLabel ?? 'Has a comment');
72
+ indicator.style.cssText = 'display:inline-flex;vertical-align:super;margin-inline-start:0.15em;color:var(--muted-foreground)';
73
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
74
+ icon.setAttribute('viewBox', '0 0 16 16');
75
+ icon.setAttribute('width', '0.75em');
76
+ icon.setAttribute('height', '0.75em');
77
+ icon.setAttribute('aria-hidden', 'true');
78
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
79
+ path.setAttribute('d', 'M3 2.5h10v8H7l-4 3v-11Z');
80
+ path.setAttribute('fill', 'none');
81
+ path.setAttribute('stroke', 'currentColor');
82
+ icon.append(path);
83
+ indicator.append(icon);
84
+ return indicator;
85
+ }, { side: -1, key: `comment-${mark.id}` }));
86
+ }
87
+ const activateElement = (event) => {
88
+ const element = event.target;
89
+ const target = element?.closest?.('[data-mark-id], [data-mark-comment]');
90
+ const id = target?.getAttribute('data-mark-id') ?? target?.getAttribute('data-mark-comment');
91
+ if (!id)
92
+ return false;
93
+ activate?.(id);
94
+ return true;
95
+ };
96
+ const decorationSet = DecorationSet.create(doc, decorations);
97
+ editor.registerPlugin(new Plugin({ key, props: {
98
+ decorations: state => state.doc === doc ? decorationSet : DecorationSet.empty,
99
+ handleDOMEvents: { click: (_view, event) => activateElement(event), contextmenu: (_view, event) => { if (!activateElement(event))
100
+ return false; event.preventDefault(); return true; },
101
+ keydown: (_view, event) => { if (event.key !== 'Enter' && event.key !== ' ')
102
+ return false; if (!activateElement(event))
103
+ return false; event.preventDefault(); return true; } },
104
+ } }));
105
+ return () => { if (!editor.isDestroyed)
106
+ editor.unregisterPlugin(key); };
107
+ }
@@ -0,0 +1,23 @@
1
+ import type { Anchor, AnchorResolution } from './contracts.js';
2
+ /** Offsets address the canonical Markdown string in UTF-16, never rendered text. */
3
+ export interface MarkdownQuoteRange {
4
+ readonly start: number;
5
+ readonly end: number;
6
+ }
7
+ export interface MarkdownQuoteAnchor extends Anchor {
8
+ readonly kind: 'markdown.quote@1';
9
+ readonly exact: string;
10
+ readonly prefix: string;
11
+ readonly suffix: string;
12
+ readonly start: number;
13
+ readonly fallback: string;
14
+ }
15
+ export declare function captureMarkdownQuote(source: string, range: MarkdownQuoteRange): {
16
+ readonly kind: 'captured';
17
+ readonly anchor: MarkdownQuoteAnchor;
18
+ } | {
19
+ readonly kind: 'refused';
20
+ readonly reason: string;
21
+ };
22
+ export declare const markdownQuoteResolution: AnchorResolution<MarkdownQuoteAnchor, string, MarkdownQuoteRange>;
23
+ //# sourceMappingURL=markdown-quote.d.ts.map
@@ -0,0 +1,39 @@
1
+ const boundary = (text, at) => !(at > 0 && at < text.length && /[\uD800-\uDBFF]/.test(text[at - 1]) && /[\uDC00-\uDFFF]/.test(text[at]));
2
+ const offset = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
3
+ function parse(value) {
4
+ if (!value || typeof value !== 'object')
5
+ throw new TypeError('Expected Markdown quote anchor');
6
+ const a = value;
7
+ if (a['kind'] !== 'markdown.quote@1' || typeof a['exact'] !== 'string' || !a['exact'] || typeof a['prefix'] !== 'string'
8
+ || typeof a['suffix'] !== 'string' || !offset(a['start']) || typeof a['fallback'] !== 'string' || !a['fallback'].trim())
9
+ throw new TypeError('Invalid Markdown quote anchor');
10
+ return { kind: 'markdown.quote@1', exact: a['exact'], prefix: a['prefix'], suffix: a['suffix'], start: a['start'], fallback: a['fallback'] };
11
+ }
12
+ export function captureMarkdownQuote(source, range) {
13
+ if (!offset(range.start) || !offset(range.end) || range.end <= range.start || range.end > source.length || !boundary(source, range.start) || !boundary(source, range.end))
14
+ return { kind: 'refused', reason: 'Select a nonempty canonical source range' };
15
+ const exact = source.slice(range.start, range.end);
16
+ return { kind: 'captured', anchor: { kind: 'markdown.quote@1', exact, prefix: source.slice(Math.max(0, range.start - 32), range.start), suffix: source.slice(range.end, range.end + 32), start: range.start, fallback: exact } };
17
+ }
18
+ export const markdownQuoteResolution = {
19
+ kind: 'markdown.quote@1',
20
+ schema: { jsonSchema: { type: 'object', properties: { kind: { const: 'markdown.quote@1' }, exact: { type: 'string', minLength: 1 }, prefix: { type: 'string' }, suffix: { type: 'string' }, start: { type: 'integer', minimum: 0 }, fallback: { type: 'string', minLength: 1 } }, required: ['kind', 'exact', 'prefix', 'suffix', 'start', 'fallback'], additionalProperties: false }, parse },
21
+ fallback: anchor => anchor.fallback,
22
+ resolve: (anchor, source, evaluated) => {
23
+ if (anchor.kind !== 'markdown.quote@1')
24
+ return { kind: 'unsupported', evaluated };
25
+ const a = parse(anchor), matches = [];
26
+ for (let at = source.indexOf(a.exact); at !== -1; at = source.indexOf(a.exact, at + 1)) {
27
+ if (boundary(source, at) && boundary(source, at + a.exact.length))
28
+ matches.push({ start: at, end: at + a.exact.length });
29
+ }
30
+ if (!matches.length)
31
+ return { kind: 'missing', evaluated };
32
+ const contextual = matches.filter(r => source.slice(Math.max(0, r.start - a.prefix.length), r.start) === a.prefix && source.slice(r.end, r.end + a.suffix.length) === a.suffix);
33
+ const candidates = contextual.length ? contextual : matches;
34
+ if (candidates.length !== 1)
35
+ return { kind: 'ambiguous', candidates, evaluated };
36
+ const range = candidates[0];
37
+ return { kind: range.start === a.start ? 'exact' : 'moved', range, evaluated };
38
+ },
39
+ };
@@ -111,6 +111,23 @@ export interface NativeChatSnapshot {
111
111
  readonly stop: 'idle' | 'requested' | 'confirmed' | 'unconfirmed';
112
112
  readonly disposed: boolean;
113
113
  }
114
+ /** Structural presentation seam. Native controllers retain their concrete IDs, methods and recovery return values. */
115
+ export type ChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send'> & {
116
+ readonly conversationId: NativeChatSnapshot['conversationId'] | string;
117
+ readonly send: ChatSendState | {
118
+ readonly kind: 'idle' | 'submitting' | 'admitted';
119
+ } | {
120
+ readonly kind: 'unknown';
121
+ readonly reason: string;
122
+ readonly retryable: boolean;
123
+ };
124
+ };
125
+ export type ChatController = Pick<NativeChatController, 'subscribe' | 'setText' | 'setAttachments' | 'clearHistory' | 'loadEarlier' | 'connect' | 'stop' | 'dispose'> & {
126
+ readonly getSnapshot: () => ChatSnapshot;
127
+ readonly send: (whenBusy?: ChatAttempt['whenBusy'], input?: ChatSendInput) => Promise<unknown>;
128
+ readonly reconcile: () => Promise<unknown>;
129
+ readonly retrySameRequest: () => Promise<unknown>;
130
+ };
114
131
  export interface NativeChatOptions {
115
132
  readonly identity: ChatIdentity;
116
133
  readonly conversation: ChatConversation;
@@ -120,6 +137,8 @@ export interface NativeChatOptions {
120
137
  readonly history?: NativeChatHistorySource | false;
121
138
  /** Validate the exact selected draft before any native admission. */
122
139
  readonly beforeSubmit?: (draft: ChatDraft) => Promise<string | undefined>;
140
+ /** Restore a question after a native model/run error with no answer text. Opt-in, local to this controller; never replays work. */
141
+ readonly restoreOnError?: boolean;
123
142
  /** Only a qualified host boundary can prove a thrown submission was not admitted. */
124
143
  readonly definitelyNotAdmitted?: (error: unknown) => boolean;
125
144
  readonly onListenerError?: (error: unknown) => void;
@@ -128,6 +147,8 @@ export interface NativeChatOptions {
128
147
  export declare function createNativeChatController(options: NativeChatOptions): {
129
148
  conversation: ChatConversation;
130
149
  getSnapshot: () => NativeChatSnapshot;
150
+ /** Reads the exact native submission with this controller's Context; observing never aborts or owns its execution. */
151
+ submissionStatus: (submission: Submission) => Promise<SubmissionRecord>;
131
152
  subscribe: (listener: () => void) => (() => void);
132
153
  setText: (text: string) => void;
133
154
  setAttachments: (attachments: readonly ChatAttachment[]) => void;
@@ -1,3 +1,4 @@
1
+ import { awaitWithContext, withCancel } from '@earendil-works/chord/context';
1
2
  import { randomUUID } from '@hachej/boring-files/platform';
2
3
  function draft(text, attachments, version) {
3
4
  if (typeof text !== 'string' || attachments.some(item => typeof item.id !== 'string' || !item.id || typeof item.name !== 'string'
@@ -23,6 +24,8 @@ export function createNativeChatController(options) {
23
24
  const listeners = new Set();
24
25
  let watch, opening, disposing;
25
26
  const publish = (change) => {
27
+ if (snapshot.disposed)
28
+ return;
26
29
  snapshot = Object.freeze({ ...snapshot, ...change, ...(change.send ? { send: Object.freeze(change.send) } : {}),
27
30
  ...(change.connection ? { connection: Object.freeze(change.connection) } : {}),
28
31
  ...(change.history ? { history: Object.freeze(change.history) } : {}), ...(change.outbox ? { outbox: Object.freeze(change.outbox) } : {}) });
@@ -57,7 +60,27 @@ export function createNativeChatController(options) {
57
60
  historyScan = undefined;
58
61
  return emptyHistory();
59
62
  };
63
+ const restoring = new Map();
64
+ const hasText = (messages) => messages?.some(message => message.role === 'assistant' && Array.isArray(message.content) && message.content.some(part => part?.type === 'text' && typeof part.text === 'string' && part.text.length > 0)) ?? false;
65
+ const observeText = (view) => {
66
+ const live = view.docs['pi.live'], run = live?.['run'];
67
+ if (!run || typeof run !== 'object' || Array.isArray(run))
68
+ return;
69
+ const inputs = run['inputs'], taskId = run['taskId'], generation = live?.['generation'];
70
+ const message = generation && typeof generation === 'object' && !Array.isArray(generation) ? generation['message'] : undefined;
71
+ const streamed = message && typeof message === 'object' && !Array.isArray(message) && hasText([{ role: 'assistant', content: message['content'] }]);
72
+ if (!Array.isArray(inputs))
73
+ return;
74
+ for (const item of restoring.values())
75
+ if (inputs.includes(item.submissionId)) {
76
+ if (typeof taskId === 'number')
77
+ item.runTaskId = taskId;
78
+ if (streamed || view.entries.some(entry => entry.byTaskId === taskId && hasText(entry.model)))
79
+ item.textObserved = true;
80
+ }
81
+ };
60
82
  const acceptView = (view) => {
83
+ observeText(view);
61
84
  const previous = snapshot.view?.entries;
62
85
  const appendOnly = !previous || previous.every((entry, index) => view.entries[index]?.id === entry.id);
63
86
  publish({ view, ...(!appendOnly ? { history: invalidateHistory() } : {}) });
@@ -68,9 +91,10 @@ export function createNativeChatController(options) {
68
91
  const editable = () => { active(); return snapshot.draft; };
69
92
  // The composer is emptied when a message is sent, so a confirmation never has to clear it (text typed meanwhile is a new draft).
70
93
  const markAdmitted = (attempt, submissionId, record) => {
71
- if (!stillPending(attempt))
72
- return;
94
+ if (snapshot.disposed || !stillPending(attempt))
95
+ return false;
73
96
  publish({ send: { kind: 'admitted', attempt, submissionId, ...(record ? { record } : {}) } });
97
+ return true;
74
98
  };
75
99
  /** Messages taken from the composer that wait for the current send (`outbox` in the snapshot). */
76
100
  const waiting = [];
@@ -87,11 +111,16 @@ export function createNativeChatController(options) {
87
111
  item.resolve(undefined);
88
112
  if (snapshot.disposed)
89
113
  return;
90
- const current = snapshot.draft, back = [...drafts, ...left.map(item => item.draft)].map(item => restoreOf.get(item) ?? item);
114
+ putBack([...drafts, ...left.map(item => item.draft)], { ...change, ...(left.length || snapshot.outbox.length ? { outbox: Object.freeze([]) } : {}) });
115
+ };
116
+ function putBack(selected, change = {}) {
117
+ if (snapshot.disposed)
118
+ return;
119
+ const current = snapshot.draft, back = selected.map(item => restoreOf.get(item) ?? item);
91
120
  const text = [...back.map(item => item.text), current.text].filter(value => value.trim()).join('\n');
92
121
  const attachments = [...back.flatMap(item => item.attachments), ...current.attachments].filter((item, index, all) => all.findIndex(other => other.id === item.id) === index);
93
- publish({ ...change, ...(left.length || snapshot.outbox.length ? { outbox: Object.freeze([]) } : {}), ...(back.length ? { draft: draft(text, attachments, current.version + 1) } : {}) });
94
- };
122
+ publish({ ...change, ...(back.length ? { draft: draft(text, attachments, current.version + 1) } : {}) });
123
+ }
95
124
  function definitelyNotAdmitted(error) {
96
125
  try {
97
126
  return options.definitelyNotAdmitted?.(error) === true;
@@ -100,6 +129,53 @@ export function createNativeChatController(options) {
100
129
  return false;
101
130
  }
102
131
  }
132
+ function monitor(attempt, submission) {
133
+ if (!options.restoreOnError || snapshot.disposed || restoring.has(attempt.requestId))
134
+ return;
135
+ const waiter = withCancel(context);
136
+ const item = { submissionId: submission.id, cancel: waiter.cancel, textObserved: false };
137
+ restoring.set(attempt.requestId, item);
138
+ if (snapshot.view)
139
+ observeText(snapshot.view);
140
+ void (async () => {
141
+ try {
142
+ const record = await awaitWithContext(submission.wait(waiter.context), waiter.context);
143
+ if (snapshot.disposed || record.id !== item.submissionId || record.conversationId !== conversation.id || record.requestId !== attempt.requestId || record.type !== 'input'
144
+ || record.status !== 'unanswered' || !['model_error', 'no_model', 'faulted'].includes(record.reason) || record.entry === undefined || item.textObserved)
145
+ return;
146
+ // The native transcript, not watch timing or connection loss, decides whether the failed input received any text.
147
+ const minEntryId = record.entry, detail = record.detail;
148
+ const newest = await awaitWithContext(conversation.entries({}, 1, undefined, waiter.context), waiter.context);
149
+ const maxEntryId = newest.items[0]?.id;
150
+ if (maxEntryId === undefined || maxEntryId < minEntryId)
151
+ return;
152
+ let cursor, ended = false;
153
+ do {
154
+ const page = await awaitWithContext(conversation.entries({ minEntryId, maxEntryId, order: 'ascending' }, 40, cursor, waiter.context), waiter.context);
155
+ if (snapshot.disposed || item.textObserved)
156
+ return;
157
+ for (const entry of page.items) {
158
+ if (hasText(entry.model))
159
+ return;
160
+ // One run may consume several inputs and retain interrupted attempts. Neither is the final error boundary.
161
+ if (entry.model?.some(message => message.role === 'assistant' && message.stopReason === 'error'
162
+ && (item.runTaskId !== undefined ? entry.byTaskId === item.runTaskId : typeof detail === 'string' && message.errorMessage === detail))) {
163
+ ended = true;
164
+ break;
165
+ }
166
+ }
167
+ cursor = page.next;
168
+ } while (!ended && cursor !== undefined);
169
+ if (!snapshot.disposed && !item.textObserved)
170
+ putBack([attempt.draft]);
171
+ }
172
+ catch { /* Missing settlement/history is unknown, never proof of an empty failed answer. */ }
173
+ finally {
174
+ restoring.delete(attempt.requestId);
175
+ waiter.cancel();
176
+ }
177
+ })();
178
+ }
103
179
  async function submitAttempt(attempt) {
104
180
  publish({ send: { kind: 'submitting', attempt }, ...(!stopping && snapshot.stop !== 'idle' ? { stop: 'idle' } : {}) });
105
181
  if (snapshot.disposed)
@@ -109,7 +185,8 @@ export function createNativeChatController(options) {
109
185
  ? [{ type: 'text', text: attempt.draft.text }, ...attempt.draft.attachments.map(item => ({ ...item.content }))]
110
186
  : attempt.draft.text;
111
187
  const submission = await conversation.submit({ type: 'input', requestId: attempt.requestId, content, whenBusy: attempt.whenBusy }, context);
112
- markAdmitted(attempt, submission.id);
188
+ if (markAdmitted(attempt, submission.id))
189
+ monitor(attempt, submission);
113
190
  return submission;
114
191
  }
115
192
  catch (error) {
@@ -151,6 +228,15 @@ export function createNativeChatController(options) {
151
228
  const controller = {
152
229
  conversation,
153
230
  getSnapshot: () => snapshot,
231
+ /** Reads the exact native submission with this controller's Context; observing never aborts or owns its execution. */
232
+ submissionStatus: async (submission) => {
233
+ active();
234
+ const record = await submission.status(context);
235
+ active();
236
+ if (record.id !== submission.id || record.conversationId !== conversation.id)
237
+ throw new Error('Submission belongs to a different conversation');
238
+ return record;
239
+ },
154
240
  subscribe: (listener) => { if (snapshot.disposed)
155
241
  return () => { }; listeners.add(listener); return () => { listeners.delete(listener); }; },
156
242
  setText: (text) => { const current = editable(); publish({ draft: draft(text, current.attachments, current.version + 1) }); },
@@ -316,7 +402,22 @@ export function createNativeChatController(options) {
316
402
  if (record) {
317
403
  if (record.conversationId !== conversation.id || record.requestId !== attempt.requestId || record.type !== 'input')
318
404
  throw new Error('Native submission lookup returned a different binding');
319
- markAdmitted(attempt, record.id, record);
405
+ const admitted = markAdmitted(attempt, record.id, record);
406
+ if (admitted && options.restoreOnError) {
407
+ // Reconciliation observes the original request only; no duplicate submission is made to obtain a handle.
408
+ monitor(attempt, { id: record.id, wait: async (waiter) => {
409
+ for (;;) {
410
+ if (waiter.abortSignal?.aborted)
411
+ throw waiter.abortSignal.reason;
412
+ const current = await awaitWithContext(conversation.commit(tx => tx.submissionByRequest(conversation.id, attempt.requestId), waiter), waiter);
413
+ if (!current || current.id !== record.id)
414
+ throw new Error('Submission binding is unavailable');
415
+ if (current.status === 'done' || current.status === 'unanswered')
416
+ return current;
417
+ await awaitWithContext(new Promise(resolve => setTimeout(resolve, 250)), waiter);
418
+ }
419
+ } });
420
+ }
320
421
  }
321
422
  return record;
322
423
  },
@@ -367,6 +468,9 @@ export function createNativeChatController(options) {
367
468
  return disposing;
368
469
  disposing = Promise.resolve().then(async () => { await opening; const owned = watch; watch = undefined; return owned?.stop(); });
369
470
  unlink?.();
471
+ for (const item of restoring.values())
472
+ item.cancel();
473
+ restoring.clear();
370
474
  publish({ disposed: true, connection: { kind: 'closed' }, history: invalidateHistory() });
371
475
  listeners.clear();
372
476
  return disposing;
@@ -1,4 +1,6 @@
1
+ import { awaitWithContext, BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
1
2
  import { applyImmutable } from '@earendil-works/chord/delta';
3
+ import { createChatOutbox } from './chat-outbox.js';
2
4
  /** A watch request the host answered with an error status. */
3
5
  class WatchRefused extends Error {
4
6
  status;
@@ -40,17 +42,19 @@ export async function createRemoteChat(options) {
40
42
  // The transport in use: `websocket` until its first socket fails to open, then `http` for good.
41
43
  let mode = options.transport ?? 'http', current, seq = 0;
42
44
  /** Calls sent over a socket and not answered yet, by call ID: sent again unchanged on the next socket. */
43
- const outbox = new Map();
45
+ const outbox = createChatOutbox();
44
46
  function exchange(op, init, extra) {
45
47
  const http = () => new Request(url(op, extra), init);
46
48
  if (mode !== 'websocket')
47
49
  return send(http());
48
50
  const id = `c${++seq}`;
49
51
  const text = JSON.stringify({ kind: 'call', id, op, params: extra, ...(typeof init.body === 'string' ? { body: JSON.parse(init.body) } : {}) });
50
- return new Promise((resolve, reject) => { outbox.set(id, { text, http, resolve, reject }); try {
52
+ const pending = outbox.enqueue(id, { text, http });
53
+ try {
51
54
  current?.send(text);
52
55
  }
53
- catch { /* sent again on the next socket */ } });
56
+ catch { /* sent again on the next socket */ }
57
+ return pending;
54
58
  }
55
59
  /** Leave the socket for HTTP: calls still waiting for a socket go over HTTP, unchanged (same request ID, so the host deduplicates). */
56
60
  function fallBack() {
@@ -58,7 +62,7 @@ export async function createRemoteChat(options) {
58
62
  const pending = [...outbox.values()];
59
63
  outbox.clear();
60
64
  for (const call of pending)
61
- send(call.http()).then(call.resolve, call.reject);
65
+ send(call.input.http()).then(call.resolve, call.reject);
62
66
  }
63
67
  async function call(op, init = {}, extra = {}) {
64
68
  const response = await exchange(op, init, extra);
@@ -135,11 +139,7 @@ export async function createRemoteChat(options) {
135
139
  return;
136
140
  }
137
141
  if (frame.kind === 'reply') {
138
- const pending = outbox.get(frame.id);
139
- if (pending) {
140
- outbox.delete(frame.id);
141
- pending.resolve(new Response(JSON.stringify(frame.body), { status: frame.status, headers: { 'content-type': 'application/json' } }));
142
- }
142
+ outbox.resolve(frame.id, new Response(JSON.stringify(frame.body), { status: frame.status, headers: { 'content-type': 'application/json' } }));
143
143
  return;
144
144
  }
145
145
  if (frame.kind === 'event') {
@@ -158,7 +158,7 @@ export async function createRemoteChat(options) {
158
158
  current = socket;
159
159
  // Unanswered calls go again, unchanged: a submit keeps its request ID, so the host runs it once.
160
160
  for (const pending of outbox.values())
161
- socket.send(pending.text);
161
+ socket.send(pending.input.text);
162
162
  ready({ first: frame.view, next, cancel });
163
163
  return;
164
164
  }
@@ -442,11 +442,13 @@ export async function createRemoteChat(options) {
442
442
  throw new Error('Submission is unknown to the remote host'); return record; };
443
443
  return {
444
444
  id: submissionId, status,
445
- wait: async () => { for (;;) {
446
- const record = await status();
445
+ wait: async (context) => { for (;;) {
446
+ if (context.abortSignal?.aborted)
447
+ throw context.abortSignal.reason;
448
+ const record = await awaitWithContext(status(), context);
447
449
  if (record.status === 'done' || record.status === 'unanswered')
448
450
  return record;
449
- await new Promise(resolve => setTimeout(resolve, pollMs));
451
+ await awaitWithContext(new Promise(resolve => setTimeout(resolve, pollMs)), context);
450
452
  } },
451
453
  abort: () => withdraw(submissionId, requestId),
452
454
  };
@@ -460,7 +462,7 @@ export async function createRemoteChat(options) {
460
462
  ...(query.order === undefined ? {} : { order: query.order }),
461
463
  ...(cursor === undefined ? {} : { cursor: JSON.stringify(cursor) }) })).page,
462
464
  };
463
- const context = Object.freeze({});
465
+ const context = BACKGROUND_CONTEXT;
464
466
  const close = async () => { const first = unclaimed; unclaimed = undefined; await first?.stop(); };
465
467
  return { conversation, context, close, link, withdraw: id => withdraw(id), definitelyNotAdmitted: error => error instanceof SubmissionRefused,
466
468
  answer: (callId, answer) => post('answer', { callId, answer }), configure: change => post('configure', change) };
@@ -0,0 +1,10 @@
1
+ /** A channel failure's message and retryability come from the host, or from an unconfirmed transport failure. */
2
+ export declare class ChannelRequestError extends Error {
3
+ readonly code: string;
4
+ readonly retryable: boolean;
5
+ readonly refused: boolean;
6
+ constructor(code: string, message: string, retryable: boolean, options?: {
7
+ readonly refused?: boolean;
8
+ });
9
+ }
10
+ //# sourceMappingURL=error.d.ts.map
@@ -0,0 +1,13 @@
1
+ /** A channel failure's message and retryability come from the host, or from an unconfirmed transport failure. */
2
+ export class ChannelRequestError extends Error {
3
+ code;
4
+ retryable;
5
+ refused;
6
+ constructor(code, message, retryable, options) {
7
+ super(message);
8
+ this.code = code;
9
+ this.retryable = retryable;
10
+ this.name = 'ChannelRequestError';
11
+ this.refused = options?.refused ?? false;
12
+ }
13
+ }
@@ -0,0 +1,39 @@
1
+ import type { ChannelEvent, ChannelMessage, ChannelSource, Conversation, Member, SharedChatState } from './source.js';
2
+ export interface FakeChannelSeed {
3
+ readonly me: Member;
4
+ readonly members: Member[];
5
+ readonly conversations: Conversation[];
6
+ readonly messages: ChannelMessage[];
7
+ }
8
+ /** Fictional in-memory host. The controls exercise exactly the source events a real host delivers. */
9
+ export declare function createFakeChannelSource(seed: FakeChannelSeed): {
10
+ me: Member;
11
+ conversations: () => Promise<Conversation[]>;
12
+ members: (id: string) => Promise<Member[]>;
13
+ history: (id: string, opts: {
14
+ before?: string;
15
+ limit: number;
16
+ }) => Promise<{
17
+ before?: string;
18
+ messages: ChannelMessage[];
19
+ }>;
20
+ send: (input: Parameters<ChannelSource["send"]>[0]) => Promise<ChannelMessage>;
21
+ action: (input: Parameters<ChannelSource["action"]>[0]) => Promise<void>;
22
+ subscribe: (id: string, event: (event: ChannelEvent) => void, status?: (status: SharedChatState["status"]) => void) => () => void;
23
+ emit: (event: ChannelEvent) => void;
24
+ sends: {
25
+ conversationId: string;
26
+ text: string;
27
+ mentions: string[];
28
+ replyTo?: string;
29
+ clientId: string;
30
+ attachments?: File[];
31
+ }[];
32
+ actions: {
33
+ conversationId: string;
34
+ requestId: string;
35
+ actionId: string;
36
+ }[];
37
+ setStatus: (status: SharedChatState["status"]) => void;
38
+ };
39
+ //# sourceMappingURL=fake.d.ts.map