@hachej/boring-ui-kit 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/dist/chat-outbox.d.ts +18 -0
- package/dist/chat-outbox.js +12 -0
- package/dist/mark-store.d.ts +38 -0
- package/dist/mark-store.js +84 -0
- package/dist/markdown-editor.d.ts +6 -0
- package/dist/markdown-editor.js +17 -2
- package/dist/markdown-highlight-layer.d.ts +26 -0
- package/dist/markdown-highlight-layer.js +107 -0
- package/dist/markdown-quote.d.ts +23 -0
- package/dist/markdown-quote.js +39 -0
- package/dist/native-chat.d.ts +21 -0
- package/dist/native-chat.js +111 -7
- package/dist/remote-chat.js +16 -14
- package/dist/shared-chat/error.d.ts +10 -0
- package/dist/shared-chat/error.js +13 -0
- package/dist/shared-chat/fake.d.ts +39 -0
- package/dist/shared-chat/fake.js +90 -0
- package/dist/shared-chat/index.d.ts +51 -0
- package/dist/shared-chat/index.js +255 -0
- package/dist/shared-chat/socket.d.ts +20 -0
- package/dist/shared-chat/socket.js +114 -0
- package/dist/shared-chat/source.d.ts +117 -0
- package/dist/shared-chat/source.js +6 -0
- package/dist/voice-capture.d.ts +43 -0
- package/dist/voice-capture.js +186 -0
- package/package.json +19 -3
- package/r/agent-target.json +36 -0
- package/r/app-shell.json +26 -11
- package/r/board-viewer.json +5 -5
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +10 -10
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +5 -5
- package/r/highlights.json +29 -0
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +2 -2
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +2 -2
- package/r/pi-ambient.json +7 -7
- package/r/pi-app.json +15 -14
- package/r/pi-chat.json +50 -20
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +40 -0
- package/r/registry.json +380 -86
- package/r/requests-board.json +41 -0
- package/r/shared-chat.json +79 -0
- package/r/task-list-viewer.json +2 -2
- package/r/theme.json +15 -0
- package/r/utils.json +6 -0
- package/r/version-history.json +3 -3
- package/r/viewers.json +15 -9
- package/r/windows.json +28 -0
package/README.md
CHANGED
|
@@ -68,8 +68,58 @@ The default schema includes headings, lists, task lists, quotes, code and text m
|
|
|
68
68
|
|
|
69
69
|
`test/packages/ui-markdown-editor.test.mjs`, `test/packages/ui-markdown-navigation.test.mjs` and `test/packages/ui-markdown-mounted.test.mjs` and `test/packages/ui-markdown-proposals.test.mjs` drive the real React/Tiptap renderer, DOM selections and SQLite provider. `npm run test:editor-consumer` requires `npm_config_cache` pointing to a writable cache with the pinned archives; it installs actual package tarballs, checks declarations with `skipLibCheck: false`, drives the controls and bundles for browsers without Pi or server filesystem imports. These checks do not establish browser layout, caret, IME or accessibility qualification. Chromium journeys remain pending.
|
|
70
70
|
|
|
71
|
+
## Optional microphone capture
|
|
72
|
+
|
|
73
|
+
`createVoiceCapture` from `@hachej/boring-ui-kit/voice-capture` records browser audio without React, files or Pi. Call `start()` from a person's gesture. It feature-detects microphone access and returns a recording or a typed refusal. `stop()` returns the Blob, recorded MIME type, page-clock sample times and `truncated`; `cancel()` discards audio and releases only this capture's microphone tracks, including a permission request that finishes late.
|
|
74
|
+
|
|
75
|
+
`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.
|
|
76
|
+
|
|
77
|
+
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`.
|
|
78
|
+
|
|
79
|
+
## Document marks
|
|
80
|
+
|
|
81
|
+
`@hachej/boring-ui-kit/markdown-quote` exports `captureMarkdownQuote` and
|
|
82
|
+
`markdownQuoteResolution`: pure exact-only anchors over canonical Markdown source.
|
|
83
|
+
`@hachej/boring-ui-kit/mark-store` exports the kit's sidecar store, independently of
|
|
84
|
+
React, Tiptap and Pi execution:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { createMarkStore } from '@hachej/boring-ui-kit/mark-store';
|
|
88
|
+
import { captureMarkdownQuote } from '@hachej/boring-ui-kit/markdown-quote';
|
|
89
|
+
|
|
90
|
+
const store = await createMarkStore({ client, identity }, locator);
|
|
91
|
+
const capture = captureMarkdownQuote(source, { start: 0, end: 12 });
|
|
92
|
+
if (capture.kind === 'captured') {
|
|
93
|
+
await store.add({ id: 'mark-1', anchor: capture.anchor, colour: 'accent',
|
|
94
|
+
note: 'A fictional comment.', author: identity.principalId, time: new Date().toISOString() });
|
|
95
|
+
}
|
|
96
|
+
await store.edit('mark-1', { note: 'Revised fictional comment.' });
|
|
97
|
+
await store.remove('mark-1');
|
|
98
|
+
store.dispose(); // Does not close the borrowed resource client/provider.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Use the same workspace `client` as the document. `identity` is expected receipt
|
|
102
|
+
metadata, never browser authentication. The store reads `<doc>.marks.json` in the
|
|
103
|
+
same view and writes it only through the existing conditional publication path.
|
|
104
|
+
`getSnapshot`/`subscribe` provide immutable marks, dirty/lifecycle and save state;
|
|
105
|
+
mutations return the exact existing `SaveResult`. A stale tab retains its local
|
|
106
|
+
marks and returns conflict; `reload()` explicitly discards them. Lost replies
|
|
107
|
+
require `reconcile()` on the original operation. No auto-merge or blind retry is
|
|
108
|
+
performed. The host owns policy, attribution and recovery after missing receipts.
|
|
109
|
+
The [owner](../../docs/architecture/FEEDBACK.md#markdown-quotes-and-document-marks)
|
|
110
|
+
defines quote/context matching and conservative rich-projection limits.
|
|
111
|
+
|
|
112
|
+
The existing `MarkdownEditor` accepts `highlights`, `onQuoteSelection` and
|
|
113
|
+
`onHighlightActivate`. Native decorations leave the document buffer clean. Rich
|
|
114
|
+
capture refuses anything outside one proven literal text leaf; source selection
|
|
115
|
+
remains available for syntax, escapes or normalization. The `highlights` registry
|
|
116
|
+
block supplies context actions, comments and an unplaced-mark tray, creating a
|
|
117
|
+
store by default or borrowing the same `MarkStore` interface from an app.
|
|
118
|
+
|
|
71
119
|
## Optional native chat
|
|
72
120
|
|
|
121
|
+
The `/native-chat` entry also owns the neutral `ChatController`/`ChatSnapshot` renderer types. Concrete native controllers retain their full APIs. Optional multi-party data (`ChannelSource`, `createSharedChat`, socket/fake sources and state types) stays exclusively at `/shared-chat`, not the headless root. Its presentation is the optional [`shared-chat` registry item](../../registry/README.md#regular-chat-or-shared-chat); regular `pi-chat` has no imports from it.
|
|
122
|
+
|
|
73
123
|
`createNativeChatController` from `@hachej/boring-ui-kit/native-chat` borrows an actual native `Conversation` and host-bound `Context`. Supply explicit runtime, scope and principal identity. These labels select a presentation instance and grant no permission. The concrete `controller.conversation` keeps the full native API available. `connect()` owns one native watch, and `dispose()` asynchronously closes that watch without aborting tasks or closing the borrowed Harness.
|
|
74
124
|
|
|
75
125
|
`setText`, `setAttachments` and `send` retain an immutable selected draft. `beforeSubmit` validates that selection before the first submission and every retry. A refused retry retains the original unknown attempt. Native authorization remains the host boundary's responsibility. A thrown admission stays unknown unless the host can prove it was not admitted. `reconcile()` looks up the original native request. `retrySameRequest()` uses its original request ID and content, including when the user has since edited the composer. `send()` empties the composer at once, like any chat app: the sent text is held by the controller as the pending submission, so text typed before the host confirms is a new draft, never appended to the sent one, and an acknowledgement never clears it. Sending again while a submission is still being confirmed queues the new message in `outbox` and submits it, in order and with its own request ID, once the previous one is admitted. A refused message (validation, or a submission the host proves was not admitted) and any messages waiting behind it go back into the composer, one per line, ahead of the text typed since. A submission whose outcome is unknown stays held for `reconcile()`/`retrySameRequest()`, and further sends are refused until it is resolved. `send(whenBusy, { text, attachments, restore })` sends explicit content without touching the composer (`restore` is what comes back on refusal). This controller retains uncertainty in memory; durable reload of an unacknowledged local draft is not implemented.
|
|
@@ -80,6 +130,8 @@ A submit the host refused before admission (status 402 `submission-refused`, for
|
|
|
80
130
|
|
|
81
131
|
`@hachej/boring-ui-kit/remote-chat` releases its watch stream while the page is hidden (`document.visibilityState`) and reopens it when the page is visible again, because browsers cap concurrent HTTP/1.1 connections per host and one idle stream per background tab starves every other request. The reopened watch starts with a complete view, so the controller keeps reading `connected`; submissions, answers and running turns are separate requests and are unaffected. The watch also survives the network: a stream that ends without an end frame (a proxy or load balancer closed it), or stays silent for 2.5 of the server's announced heartbeat intervals, is reopened with exponential backoff (`reconnect: { baseDelayMs: 1000, maxDelayMs: 30000 }`, with jitter) until the controller stops it. Meanwhile `remote.link` reports reconnecting and a controller given it (spreading the `RemoteChat` into its options does) reads `connection.kind === 'reconnecting'`; the reopened stream starts with a complete view, so nothing is duplicated. A refusal (4xx) or a final end (`retired`, `stopped`, revoked) ends the watch instead. Pass `pauseWhenHidden: false` to keep the stream open, and always call `close()` on a `RemoteChat` the controller never claimed. Node and workers have no `document` and are unaffected.
|
|
82
132
|
|
|
133
|
+
`submissionStatus(submission)` reads that exact native handle using the controller's host-bound Context, verifies its conversation and rejects after disposal. It adds no lifecycle or retry: use native `done.answer` to attribute a response, rather than matching text or assuming the next assistant row belongs to a send. The optional [point-and-dictate block](../../docs/guides/agent-target.md) uses it while chat stays closed.
|
|
134
|
+
|
|
83
135
|
`test/packages/ui-native-chat.test.mjs` uses a real native Harness and a fictional local streaming provider.
|
|
84
136
|
|
|
85
137
|
### Native conversation history
|
|
@@ -352,3 +404,7 @@ publication and retained history without separate file callbacks. Its built-in
|
|
|
352
404
|
text editor navigation asks before discarding unsaved content. The legacy
|
|
353
405
|
`resources` binding remains available for existing hosts; use one binding at a
|
|
354
406
|
time. History previews never replace the editable document.
|
|
407
|
+
|
|
408
|
+
## Staged answers in native chat
|
|
409
|
+
|
|
410
|
+
The [fictional triage-note recipe](../../examples/staged-chat/README.md) composes three sequential native Pi tasks with validation and inline stage entries, two independent chat controllers, error restoration, historical offers, host-owned unread indicators and a 24-row initial display window. It owns the exact API semantics and limitations for `hideLine`, `historicalMessageActions`, `unread`, `replyError`, `displayLimit`, stage-card `status` and `createNativeChatController({ restoreOnError: true })`. These are optional presentation/draft seams, not a kit workflow, retention service or additional transcript store.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Unanswered transport calls retain their identity and payload across reconnects. The host must deduplicate mutating calls. */
|
|
2
|
+
export declare function createChatOutbox<Input, Output>(): {
|
|
3
|
+
enqueue: (id: string, input: Input) => Promise<Output>;
|
|
4
|
+
get: (id: string) => {
|
|
5
|
+
readonly input: Input;
|
|
6
|
+
readonly resolve: (output: Output) => void;
|
|
7
|
+
readonly reject: (error: unknown) => void;
|
|
8
|
+
} | undefined;
|
|
9
|
+
values: () => {
|
|
10
|
+
readonly input: Input;
|
|
11
|
+
readonly resolve: (output: Output) => void;
|
|
12
|
+
readonly reject: (error: unknown) => void;
|
|
13
|
+
}[];
|
|
14
|
+
resolve: (id: string, output: Output) => void;
|
|
15
|
+
reject: (id: string, error: unknown) => void;
|
|
16
|
+
clear: () => void;
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=chat-outbox.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Unanswered transport calls retain their identity and payload across reconnects. The host must deduplicate mutating calls. */
|
|
2
|
+
export function createChatOutbox() {
|
|
3
|
+
const pending = new Map();
|
|
4
|
+
return {
|
|
5
|
+
enqueue: (id, input) => new Promise((resolve, reject) => { pending.set(id, { input, resolve, reject }); }),
|
|
6
|
+
get: (id) => pending.get(id),
|
|
7
|
+
values: () => [...pending.values()],
|
|
8
|
+
resolve: (id, output) => { const call = pending.get(id); pending.delete(id); call?.resolve(output); },
|
|
9
|
+
reject: (id, error) => { const call = pending.get(id); pending.delete(id); call?.reject(error); },
|
|
10
|
+
clear: () => pending.clear(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ResourceAccess, ResourceClient, ResourceLocator } from '@hachej/boring-files';
|
|
2
|
+
import type { TextBufferState } from './text-buffer-state.js';
|
|
3
|
+
import type { SaveResult } from './resources.js';
|
|
4
|
+
import { type MarkdownQuoteAnchor } from './markdown-quote.js';
|
|
5
|
+
export type MarkColour = 'accent' | 'primary' | 'muted';
|
|
6
|
+
export interface DocumentMark {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly anchor: MarkdownQuoteAnchor;
|
|
9
|
+
readonly colour: MarkColour;
|
|
10
|
+
readonly note: string;
|
|
11
|
+
readonly author: string;
|
|
12
|
+
readonly time: string;
|
|
13
|
+
}
|
|
14
|
+
export interface MarkStoreSnapshot {
|
|
15
|
+
readonly marks: readonly DocumentMark[];
|
|
16
|
+
readonly save: TextBufferState['save'];
|
|
17
|
+
readonly dirty: boolean;
|
|
18
|
+
readonly lifecycle: 'active' | 'disposed';
|
|
19
|
+
}
|
|
20
|
+
export interface MarkStore {
|
|
21
|
+
readonly getSnapshot: () => MarkStoreSnapshot;
|
|
22
|
+
readonly subscribe: (notify: () => void) => () => void;
|
|
23
|
+
readonly add: (mark: DocumentMark) => Promise<SaveResult>;
|
|
24
|
+
readonly edit: (id: string, change: Partial<Pick<DocumentMark, 'anchor' | 'colour' | 'note'>>) => Promise<SaveResult>;
|
|
25
|
+
readonly remove: (id: string) => Promise<SaveResult>;
|
|
26
|
+
readonly reconcile: () => Promise<SaveResult>;
|
|
27
|
+
/** Explicitly discard a refused local change and load current marks. Unknown writes must be reconciled first. */
|
|
28
|
+
readonly reload: () => Promise<void>;
|
|
29
|
+
readonly dispose: () => void;
|
|
30
|
+
}
|
|
31
|
+
export interface MarkResource {
|
|
32
|
+
readonly client: ResourceClient;
|
|
33
|
+
/** Expected receipt identity, never authentication authority. */
|
|
34
|
+
readonly identity: Pick<ResourceAccess, 'scopeId' | 'principalId' | 'initiatorId'>;
|
|
35
|
+
}
|
|
36
|
+
/** Host data/presentation concern; uses the workspace's existing resource publication seam, not Pi execution state. */
|
|
37
|
+
export declare function createMarkStore(resource: MarkResource, locator: ResourceLocator): Promise<MarkStore>;
|
|
38
|
+
//# sourceMappingURL=mark-store.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
2
|
+
import { createTextBuffer, freeze } from './text-buffer.js';
|
|
3
|
+
import { markdownQuoteResolution } from './markdown-quote.js';
|
|
4
|
+
function mark(value) {
|
|
5
|
+
if (!value || typeof value !== 'object')
|
|
6
|
+
throw new TypeError('Invalid document mark');
|
|
7
|
+
const m = value;
|
|
8
|
+
if (typeof m['id'] !== 'string' || !m['id'] || !['accent', 'primary', 'muted'].includes(String(m['colour']))
|
|
9
|
+
|| typeof m['note'] !== 'string' || typeof m['author'] !== 'string' || !m['author'] || typeof m['time'] !== 'string' || !Number.isFinite(Date.parse(m['time'])))
|
|
10
|
+
throw new TypeError('Invalid document mark');
|
|
11
|
+
return { id: m['id'], anchor: markdownQuoteResolution.schema.parse(m['anchor']), colour: m['colour'], note: m['note'], author: m['author'], time: m['time'] };
|
|
12
|
+
}
|
|
13
|
+
function decode(text) {
|
|
14
|
+
const data = JSON.parse(text);
|
|
15
|
+
if (!data || typeof data !== 'object' || !('format' in data) || data.format !== 'document.marks@1' || !('marks' in data) || !Array.isArray(data.marks))
|
|
16
|
+
throw new TypeError('Unsupported marks file');
|
|
17
|
+
const marks = data.marks.map(mark);
|
|
18
|
+
if (new Set(marks.map(m => m.id)).size !== marks.length)
|
|
19
|
+
throw new TypeError('Duplicate mark id');
|
|
20
|
+
return marks;
|
|
21
|
+
}
|
|
22
|
+
const encode = (marks) => JSON.stringify({ format: 'document.marks@1', marks }, null, 2) + '\n';
|
|
23
|
+
const readText = (snapshot) => {
|
|
24
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(snapshot.bytes);
|
|
25
|
+
decode(text);
|
|
26
|
+
return text;
|
|
27
|
+
};
|
|
28
|
+
/** Host data/presentation concern; uses the workspace's existing resource publication seam, not Pi execution state. */
|
|
29
|
+
export async function createMarkStore(resource, locator) {
|
|
30
|
+
const client = resource.client;
|
|
31
|
+
const identity = structuredClone(resource.identity);
|
|
32
|
+
const target = { resource: { ...locator.resource, path: `${locator.resource.path}.marks.json` }, view: structuredClone(locator.view) };
|
|
33
|
+
const read = await client.read({ target, revision: { kind: 'latest' } });
|
|
34
|
+
if (read.kind !== 'available' && read.kind !== 'missing')
|
|
35
|
+
throw new Error('Marks are unavailable');
|
|
36
|
+
if (read.kind === 'available' && (read.snapshot.ref.resource.providerId !== target.resource.providerId || read.snapshot.ref.resource.path !== target.resource.path
|
|
37
|
+
|| JSON.stringify(read.snapshot.ref.view) !== JSON.stringify(target.view)))
|
|
38
|
+
throw new Error('Marks read returned another resource');
|
|
39
|
+
const buffer = createTextBuffer({ client, identity, instanceId: randomUUID(), epoch: randomUUID(),
|
|
40
|
+
source: read.kind === 'available' ? { kind: 'saved', snapshot: read.snapshot } : { kind: 'new', target, text: encode([]) },
|
|
41
|
+
mediaType: 'application/json', emptyText: encode([]), readText });
|
|
42
|
+
let snapshot;
|
|
43
|
+
const update = () => { const s = buffer.getSnapshot(); snapshot = freeze({ marks: decode(s.text), save: s.save, dirty: s.dirty, lifecycle: s.lifecycle }); };
|
|
44
|
+
update();
|
|
45
|
+
const listeners = new Set();
|
|
46
|
+
const unsubscribe = buffer.subscribe(() => { update(); for (const notify of [...listeners])
|
|
47
|
+
notify(); });
|
|
48
|
+
let preparing = false;
|
|
49
|
+
const mutate = (transform) => {
|
|
50
|
+
const s = buffer.getSnapshot();
|
|
51
|
+
if (s.lifecycle === 'disposed')
|
|
52
|
+
return Promise.resolve({ kind: 'unavailable', reason: 'Marks store is disposed' });
|
|
53
|
+
if (preparing || s.save.kind === 'pending' || s.save.kind === 'settled' && s.save.result.kind !== 'saved')
|
|
54
|
+
return Promise.resolve({ kind: 'unavailable', reason: 'Reconcile or reload marks before another change' });
|
|
55
|
+
preparing = true;
|
|
56
|
+
try {
|
|
57
|
+
buffer.edit(encode(transform(snapshot.marks)));
|
|
58
|
+
return buffer.flush(buffer.selection());
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
preparing = false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
getSnapshot: () => snapshot,
|
|
66
|
+
subscribe: notify => { listeners.add(notify); return () => { listeners.delete(notify); }; },
|
|
67
|
+
add: input => { const captured = mark(input); return mutate(marks => { if (marks.some(m => m.id === captured.id))
|
|
68
|
+
throw new TypeError('Duplicate mark id'); return [...marks, captured]; }); },
|
|
69
|
+
edit: (id, change) => { const captured = structuredClone(change); return mutate(marks => { if (!marks.some(m => m.id === id))
|
|
70
|
+
throw new TypeError('Unknown mark id'); return marks.map(m => m.id === id ? mark({ ...m, ...captured }) : m); }); },
|
|
71
|
+
remove: id => mutate(marks => { if (!marks.some(m => m.id === id))
|
|
72
|
+
throw new TypeError('Unknown mark id'); return marks.filter(m => m.id !== id); }),
|
|
73
|
+
reconcile: buffer.reconcile,
|
|
74
|
+
reload: async () => {
|
|
75
|
+
const s = buffer.getSnapshot();
|
|
76
|
+
if (s.save.kind === 'pending' || s.save.kind === 'settled' && s.save.result.kind === 'unknown')
|
|
77
|
+
throw new Error('Reconcile the unconfirmed save first');
|
|
78
|
+
const result = await buffer.refresh(true);
|
|
79
|
+
if (result.kind !== 'available' && result.kind !== 'missing')
|
|
80
|
+
throw new Error('Marks reload failed');
|
|
81
|
+
},
|
|
82
|
+
dispose: () => { buffer.dispose(); unsubscribe(); listeners.clear(); },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { type MarkdownDecoration, type MarkdownQuoteCapture } from './markdown-highlight-layer.js';
|
|
2
|
+
export type { MarkdownDecoration, MarkdownQuoteCapture } from './markdown-highlight-layer.js';
|
|
3
|
+
export { markdownMarkerBackground } from './markdown-highlight-layer.js';
|
|
1
4
|
import type { ViewerComponents } from './viewer-components.js';
|
|
2
5
|
export type { ViewerComponents } from './viewer-components.js';
|
|
3
6
|
import type { MarkdownAutosave, MarkdownFlush } from './markdown-autosave.js';
|
|
@@ -12,6 +15,9 @@ export type { MarkdownRichSafety } from './markdown-safety.js';
|
|
|
12
15
|
export type { MarkdownImageResolver } from './markdown-extensions.js';
|
|
13
16
|
export type { MarkdownMountedTools, MarkdownMountedSubject, MarkdownMountedSelection, MarkdownMountedInspection } from './markdown-mounted.js';
|
|
14
17
|
export interface MarkdownEditorProps {
|
|
18
|
+
readonly highlights?: readonly MarkdownDecoration[];
|
|
19
|
+
readonly onQuoteSelection?: (capture: MarkdownQuoteCapture) => void;
|
|
20
|
+
readonly onHighlightActivate?: (id: string) => void;
|
|
15
21
|
/** Installed recipes supply the host's shadcn controls. Omit only for unstyled native HTML rendering. */
|
|
16
22
|
readonly components?: ViewerComponents;
|
|
17
23
|
readonly controller: MarkdownController;
|
package/dist/markdown-editor.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/native-chat.d.ts
CHANGED
|
@@ -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;
|