@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.
Files changed (57) hide show
  1. package/README.md +56 -0
  2. package/dist/chat-outbox.d.ts +18 -0
  3. package/dist/chat-outbox.js +12 -0
  4. package/dist/mark-store.d.ts +38 -0
  5. package/dist/mark-store.js +84 -0
  6. package/dist/markdown-editor.d.ts +6 -0
  7. package/dist/markdown-editor.js +17 -2
  8. package/dist/markdown-highlight-layer.d.ts +26 -0
  9. package/dist/markdown-highlight-layer.js +107 -0
  10. package/dist/markdown-quote.d.ts +23 -0
  11. package/dist/markdown-quote.js +39 -0
  12. package/dist/native-chat.d.ts +21 -0
  13. package/dist/native-chat.js +111 -7
  14. package/dist/remote-chat.js +16 -14
  15. package/dist/shared-chat/error.d.ts +10 -0
  16. package/dist/shared-chat/error.js +13 -0
  17. package/dist/shared-chat/fake.d.ts +39 -0
  18. package/dist/shared-chat/fake.js +90 -0
  19. package/dist/shared-chat/index.d.ts +51 -0
  20. package/dist/shared-chat/index.js +255 -0
  21. package/dist/shared-chat/socket.d.ts +20 -0
  22. package/dist/shared-chat/socket.js +114 -0
  23. package/dist/shared-chat/source.d.ts +117 -0
  24. package/dist/shared-chat/source.js +6 -0
  25. package/dist/voice-capture.d.ts +43 -0
  26. package/dist/voice-capture.js +186 -0
  27. package/package.json +19 -3
  28. package/r/agent-target.json +36 -0
  29. package/r/app-shell.json +26 -11
  30. package/r/board-viewer.json +5 -5
  31. package/r/button.json +1 -1
  32. package/r/diagram-viewer.json +1 -1
  33. package/r/dock.json +1 -1
  34. package/r/documents.json +10 -10
  35. package/r/feedback-preview.json +1 -1
  36. package/r/feedback.json +5 -5
  37. package/r/file-tree.json +5 -5
  38. package/r/highlights.json +29 -0
  39. package/r/html-viewer.json +3 -3
  40. package/r/maquette-viewer.json +2 -2
  41. package/r/markdown-editor.json +3 -3
  42. package/r/mockup-viewer.json +2 -2
  43. package/r/pi-ambient.json +7 -7
  44. package/r/pi-app.json +15 -14
  45. package/r/pi-chat.json +50 -20
  46. package/r/pi-workspace.json +4 -4
  47. package/r/provider-setup.json +2 -2
  48. package/r/record-viewer.json +40 -0
  49. package/r/registry.json +380 -86
  50. package/r/requests-board.json +41 -0
  51. package/r/shared-chat.json +79 -0
  52. package/r/task-list-viewer.json +2 -2
  53. package/r/theme.json +15 -0
  54. package/r/utils.json +6 -0
  55. package/r/version-history.json +3 -3
  56. package/r/viewers.json +15 -9
  57. package/r/windows.json +28 -0
@@ -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
@@ -0,0 +1,90 @@
1
+ import { ChannelRequestError } from './error.js';
2
+ /** Fictional in-memory host. The controls exercise exactly the source events a real host delivers. */
3
+ export function createFakeChannelSource(seed) {
4
+ const data = structuredClone(seed);
5
+ const listeners = new Set();
6
+ const sends = [], actions = [];
7
+ let online = true;
8
+ function emit(event) {
9
+ if (event.type === 'message') {
10
+ const index = data.messages.findIndex(message => message.id === event.message.id);
11
+ if (index < 0)
12
+ data.messages.push(structuredClone(event.message));
13
+ else
14
+ data.messages[index] = structuredClone(event.message);
15
+ }
16
+ else if (event.type === 'delta') {
17
+ const message = data.messages.find(message => message.id === event.messageId && message.conversationId === event.conversationId);
18
+ if (message) {
19
+ message.text = (message.text ?? '') + event.text;
20
+ message.streaming = true;
21
+ }
22
+ }
23
+ else if (event.type === 'step') {
24
+ const message = data.messages.find(message => message.id === event.messageId && message.conversationId === event.conversationId);
25
+ if (message && Number.isSafeInteger(event.index) && event.index >= 0) {
26
+ const steps = [...(message.steps ?? [])];
27
+ steps[event.index] = { ...event.step };
28
+ message.steps = steps;
29
+ }
30
+ }
31
+ else if (event.type === 'presence')
32
+ for (const update of event.members) {
33
+ const member = data.members.find(member => member.id === update.id);
34
+ if (member)
35
+ member.presence = update.presence;
36
+ }
37
+ else if (event.type === 'members') {
38
+ for (const member of event.members) {
39
+ const index = data.members.findIndex(existing => existing.id === member.id);
40
+ if (index < 0)
41
+ data.members.push(structuredClone(member));
42
+ else
43
+ data.members[index] = structuredClone(member);
44
+ }
45
+ const conversation = data.conversations.find(conversation => conversation.id === event.conversationId);
46
+ if (conversation)
47
+ conversation.members = event.members.map(member => member.id);
48
+ }
49
+ if (online)
50
+ for (const listener of [...listeners]) {
51
+ const id = event.type === 'message' ? event.message.conversationId : event.type === 'presence' ? undefined : event.conversationId;
52
+ if (id === undefined || id === listener.id)
53
+ listener.event(structuredClone(event));
54
+ }
55
+ }
56
+ return {
57
+ me: data.me,
58
+ conversations: async () => structuredClone(data.conversations),
59
+ members: async (id) => structuredClone(data.members.filter(member => data.conversations.find(conversation => conversation.id === id)?.members.includes(member.id))),
60
+ history: async (id, opts) => {
61
+ const all = data.messages.filter(message => message.conversationId === id);
62
+ const end = opts.before ? all.findIndex(message => message.id === opts.before) : all.length;
63
+ if (end < 0)
64
+ throw new Error('Unknown history cursor');
65
+ const start = Math.max(0, end - opts.limit);
66
+ return { messages: structuredClone(all.slice(start, end)), ...(start > 0 && all[start] ? { before: all[start].id } : {}) };
67
+ },
68
+ send: async (input) => {
69
+ sends.push({ ...input, mentions: [...input.mentions], ...(input.attachments ? { attachments: [...input.attachments] } : {}) });
70
+ if (!online)
71
+ throw new ChannelRequestError('offline', 'Offline', true);
72
+ const duplicate = data.messages.find(message => message.clientId === input.clientId && message.conversationId === input.conversationId);
73
+ if (duplicate)
74
+ return structuredClone(duplicate);
75
+ const message = { id: `sent-${input.clientId}`, conversationId: input.conversationId, author: data.me.id, at: new Date().toISOString(), text: input.text, mentions: [...input.mentions], clientId: input.clientId, ...(input.replyTo ? { replyTo: input.replyTo } : {}) };
76
+ emit({ type: 'message', message });
77
+ return structuredClone(message);
78
+ },
79
+ action: async (input) => { actions.push(structuredClone(input)); },
80
+ subscribe: (id, event, status) => {
81
+ const listener = { id, event, status };
82
+ listeners.add(listener);
83
+ status?.(online ? 'live' : 'offline');
84
+ return () => { listeners.delete(listener); };
85
+ },
86
+ emit, sends, actions,
87
+ setStatus: (status) => { online = status === 'live'; for (const listener of [...listeners])
88
+ listener.status?.(status); },
89
+ };
90
+ }
@@ -0,0 +1,51 @@
1
+ import type { ChatAttempt, ChatDraft, ChatSendInput, NativeChatSnapshot } from '../native-chat.js';
2
+ export { ChannelRequestError } from './error.js';
3
+ import type { Card, ChannelMessage, ChannelSource, Member, SharedChatState } from './source.js';
4
+ export type * from './source.js';
5
+ export { channelAvatarStates } from './source.js';
6
+ export type SharedChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send' | 'shared'> & {
7
+ readonly conversationId: string;
8
+ readonly shared: SharedChatState;
9
+ readonly send: {
10
+ readonly kind: 'idle' | 'submitting' | 'admitted';
11
+ } | {
12
+ readonly kind: 'unknown';
13
+ readonly reason: string;
14
+ readonly retryable: boolean;
15
+ } | {
16
+ readonly kind: 'blocked';
17
+ readonly reason: string;
18
+ };
19
+ };
20
+ export type { ChatController, ChatSnapshot } from '../native-chat.js';
21
+ /** Host-owned multi-party log, not a Pi conversation or an agent runtime. One adapter per selected conversation. */
22
+ export declare function createSharedChat(source: ChannelSource, options: {
23
+ readonly conversationId: string;
24
+ readonly limit?: number;
25
+ }): {
26
+ getSnapshot: () => SharedChatSnapshot;
27
+ subscribe: (listener: () => void) => () => void;
28
+ setText: (text: string) => void;
29
+ setAttachments: (attachments: ChatDraft["attachments"]) => void;
30
+ setFiles: (files: readonly File[]) => void;
31
+ setReplyTo: (messageId: string | undefined) => void;
32
+ clearHistory: () => void;
33
+ loadEarlier: () => Promise<void>;
34
+ connect: () => Promise<void>;
35
+ send: (_whenBusy?: ChatAttempt["whenBusy"], input?: ChatSendInput) => Promise<ChannelMessage | undefined>;
36
+ reconcile: () => Promise<void>;
37
+ retrySameRequest: () => Promise<void>;
38
+ stop: () => Promise<never>;
39
+ dispose: () => Promise<undefined>;
40
+ action: (card: Card) => Promise<void>;
41
+ remove: ((message: ChannelMessage) => Promise<void>) | undefined;
42
+ canRemove: (message: ChannelMessage) => boolean;
43
+ };
44
+ export type SharedChatController = ReturnType<typeof createSharedChat>;
45
+ /** The composer uses the existing quoted @ token grammar. Tokens carry stable member ids; picker labels show names. */
46
+ export declare function memberMention(member: Member): string;
47
+ export { createFakeChannelSource } from './fake.js';
48
+ export type { FakeChannelSeed } from './fake.js';
49
+ export { createChannelSocket } from './socket.js';
50
+ export type { ChannelSocketOptions, ChannelSocketSource } from './socket.js';
51
+ //# sourceMappingURL=index.d.ts.map