@hachej/boring-ui-kit 0.4.50 → 0.4.51

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 (56) hide show
  1. package/dist/chat-react-controller.d.ts +4 -1
  2. package/dist/chat-react-controller.js +4 -0
  3. package/dist/chat-react-remote.d.ts +16 -1
  4. package/dist/chat-react-remote.js +69 -10
  5. package/dist/chat-react-session.d.ts +2 -3
  6. package/dist/chat-react-session.js +38 -4
  7. package/dist/native-chat.d.ts +8 -1
  8. package/dist/native-chat.js +45 -8
  9. package/dist/remote-chat.js +51 -5
  10. package/package.json +2 -2
  11. package/r/agent-target.json +4 -4
  12. package/r/app-shell.json +9 -9
  13. package/r/board-viewer.json +6 -6
  14. package/r/button.json +1 -1
  15. package/r/dock.json +1 -1
  16. package/r/documents.json +12 -12
  17. package/r/drawing-viewer.json +1 -1
  18. package/r/feedback.json +6 -6
  19. package/r/file-tree.json +4 -4
  20. package/r/highlights.json +3 -3
  21. package/r/html-viewer.json +3 -3
  22. package/r/looks-viewer.json +2 -2
  23. package/r/maquette-viewer.json +1 -1
  24. package/r/markdown-editor.json +3 -3
  25. package/r/mockup-viewer.json +1 -1
  26. package/r/pi-ambient.json +8 -8
  27. package/r/pi-app-kinds.json +10 -10
  28. package/r/pi-app.json +11 -11
  29. package/r/pi-artifacts.json +2 -2
  30. package/r/pi-chat-approval.json +2 -2
  31. package/r/pi-chat-attachments.json +1 -1
  32. package/r/pi-chat-markdown.json +2 -2
  33. package/r/pi-chat-mentions.json +1 -1
  34. package/r/pi-chat-pickers.json +1 -1
  35. package/r/pi-chat-plugins.json +11 -11
  36. package/r/pi-chat-question.json +1 -1
  37. package/r/pi-chat-slash.json +2 -2
  38. package/r/pi-chat-subagent.json +1 -1
  39. package/r/pi-chat-voice.json +3 -3
  40. package/r/pi-chat.json +8 -8
  41. package/r/pi-workspace.json +3 -3
  42. package/r/placed-agent.json +4 -4
  43. package/r/provider-setup.json +1 -1
  44. package/r/registry.json +162 -162
  45. package/r/shared-chat.json +6 -6
  46. package/r/spaces.json +1 -1
  47. package/r/task-list-viewer.json +2 -2
  48. package/r/version-history.json +2 -2
  49. package/r/viewer-code.json +2 -2
  50. package/r/viewer-frame.json +4 -4
  51. package/r/viewer-html.json +4 -4
  52. package/r/viewer-image.json +2 -2
  53. package/r/viewer-markdown.json +5 -5
  54. package/r/viewer-pdf.json +3 -3
  55. package/r/viewers.json +6 -6
  56. package/r/windows.json +1 -1
@@ -1,5 +1,8 @@
1
- export type ConversationControllerState<C> = {
1
+ export type ConversationControllerState<C> =
2
+ /** `controller`: created, its `connect` still running (absent before creation). Hosts that wait for `ready` can ignore it. */
3
+ {
2
4
  readonly status: 'connecting';
5
+ readonly controller?: C;
3
6
  } | {
4
7
  readonly status: 'offline';
5
8
  readonly error: unknown;
@@ -34,6 +34,7 @@ export function useControllerPerConversation(options) {
34
34
  await retire();
35
35
  return;
36
36
  }
37
+ publish({ status: 'connecting', controller: owned });
37
38
  await connect?.(owned);
38
39
  if (off) {
39
40
  await retire();
@@ -43,6 +44,9 @@ export function useControllerPerConversation(options) {
43
44
  return;
44
45
  }
45
46
  catch (error) {
47
+ // A controller handed out while connecting is withdrawn before it is disposed, so no host holds a disposed one.
48
+ if (owned)
49
+ publish({ status: 'connecting' });
46
50
  try {
47
51
  await retire();
48
52
  }
@@ -4,18 +4,32 @@ export type ChatPlacement = 'docked' | 'floating';
4
4
  /** Reports the placement of the chat showing `controller` while the caller is mounted. */
5
5
  export declare function useChatPlacement(controller: ChatController, placement: ChatPlacement): void;
6
6
  export declare function readChatPlacement(controller: ChatController | undefined): ChatPlacement | undefined;
7
+ type RemoteActions = Partial<Pick<RemoteChat, 'answer' | 'withdraw'>>;
8
+ /**
9
+ * `connecting` carries the controller as soon as it exists (D-1434, before the host's first view lands): its composer takes messages at
10
+ * once, shows them as the person's own and sends them, in order, once connected; the transcript appears with the first view. `actions` and
11
+ * `configure` wait for the connection. `ready` still means the first view landed, with the same controller; a host that waits for `ready`
12
+ * (or ignores `controller` while connecting) behaves as before. If connecting fails the state turns `offline` without a controller, and
13
+ * the messages not sent come back in the next attempt's composer.
14
+ */
7
15
  export type RemoteChatState = {
8
16
  readonly status: 'connecting';
9
17
  readonly conversationId: string | undefined;
18
+ readonly controller?: NativeChatController;
19
+ readonly actions?: RemoteActions;
20
+ readonly configure?: RemoteChat['configure'];
10
21
  } | {
11
22
  readonly status: 'offline';
12
23
  readonly conversationId: string | undefined;
13
24
  readonly error: string;
25
+ readonly controller?: undefined;
26
+ readonly actions?: undefined;
27
+ readonly configure?: undefined;
14
28
  } | {
15
29
  readonly status: 'ready';
16
30
  readonly conversationId: string;
17
31
  readonly controller: NativeChatController;
18
- readonly actions: Partial<Pick<RemoteChat, 'answer' | 'withdraw'>>;
32
+ readonly actions: RemoteActions;
19
33
  readonly configure: RemoteChat['configure'];
20
34
  };
21
35
  export interface RemoteChatHookOptions {
@@ -30,4 +44,5 @@ export interface RemoteChatHookOptions {
30
44
  }
31
45
  /** One owned native controller per selection; the remote transport itself reopens dropped streams. Failed acquisition retries each second. */
32
46
  export declare function useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext, transport, webSocket }: RemoteChatHookOptions): RemoteChatState;
47
+ export {};
33
48
  //# sourceMappingURL=chat-react-remote.d.ts.map
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { useLayoutEffect, useMemo, useRef } from 'react';
3
+ import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
3
4
  import { createNativeChatController } from './native-chat.js';
4
5
  import { createRemoteChat } from './remote-chat.js';
5
6
  import { useControllerPerConversation } from './chat-react-controller.js';
@@ -24,6 +25,34 @@ export function useChatPlacement(controller, placement) {
24
25
  export function readChatPlacement(controller) {
25
26
  return controller ? placements.get(controller)?.at(-1)?.placement : undefined;
26
27
  }
28
+ /**
29
+ * The conversation and link the controller borrows while the remote chat opens. Each member waits for the remote and then forwards
30
+ * to it unchanged; the identity is the selection until the server's view names it (the controller reads it again on connect).
31
+ */
32
+ function pendingConversation(pending, selection) {
33
+ const forward = (name) => (async (...args) => (await pending.remote).conversation[name](...args));
34
+ const conversation = {
35
+ get id() { return (pending.current?.conversation.id ?? selection); },
36
+ watch: forward('watch'), submit: forward('submit'), abort: forward('abort'), entries: forward('entries'),
37
+ commit: async (change, context) => (await pending.remote).conversation.commit(change, context),
38
+ };
39
+ const link = {
40
+ reconnecting: () => pending.current?.link.reconnecting() ?? false,
41
+ subscribe: listener => {
42
+ let off, gone = false;
43
+ void pending.remote.then(remote => { if (!gone)
44
+ off = remote.link.subscribe(listener); }, () => { });
45
+ return () => { gone = true; off?.(); };
46
+ },
47
+ };
48
+ return { conversation, link };
49
+ }
50
+ /** What a controller that never connected still holds (messages taken from the composer, then the draft), to give to the next attempt. */
51
+ function unsent(controller) {
52
+ const snapshot = controller.getSnapshot(), drafts = [...snapshot.echo.map(item => item.draft), snapshot.draft];
53
+ const attachments = drafts.flatMap(item => item.attachments).filter((item, index, all) => all.findIndex(other => other.id === item.id) === index);
54
+ return { text: drafts.map(item => item.text).filter(text => text.trim()).join('\n'), attachments };
55
+ }
27
56
  /** One owned native controller per selection; the remote transport itself reopens dropped streams. Failed acquisition retries each second. */
28
57
  export function useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext, transport, webSocket }) {
29
58
  const url = conversationId === undefined ? undefined : String(endpoint(conversationId));
@@ -32,30 +61,57 @@ export function useRemoteChat({ conversationId, endpoint, fetch, identity, reque
32
61
  useLayoutEffect(() => { latest.current = { fetch, requestContext, webSocket }; });
33
62
  // Transport ownership stays with the controller that acquired it, including when connect or selection changes race.
34
63
  const remotes = useMemo(() => new WeakMap(), []);
64
+ // Messages a failed attempt never sent, by selection, for the next attempt's composer.
65
+ const carried = useMemo(() => new Map(), []);
35
66
  const selected = useControllerPerConversation({ key,
36
- create: async () => {
67
+ // The controller exists at once; the remote chat opens behind it (D-1434). Its watch, submits and actions wait for the first view.
68
+ create: () => {
37
69
  let controller;
38
- const remote = await createRemoteChat({ endpoint: url, fetch: request => latest.current.fetch(request),
70
+ const remote = createRemoteChat({ endpoint: url, fetch: request => latest.current.fetch(request),
39
71
  requestContext: () => {
40
72
  const placement = readChatPlacement(controller), screen = latest.current.requestContext?.();
41
73
  return placement || screen ? { ...screen, ...(placement && { placement }) } : undefined;
42
74
  }, ...(transport ? { transport } : {}), ...(latest.current.webSocket ? { webSocket: latest.current.webSocket } : {}) });
75
+ const pending = { remote,
76
+ actions: { answer: async (...args) => (await remote).answer(...args), withdraw: async (...args) => (await remote).withdraw(...args) },
77
+ configure: async (...args) => (await remote).configure(...args) };
78
+ void remote.then(value => { pending.current = value; }, () => { });
43
79
  try {
44
- controller = createNativeChatController({ identity: identity, ...remote });
45
- remotes.set(controller, remote);
46
- return controller;
80
+ const { conversation, link } = pendingConversation(pending, conversationId);
81
+ controller = createNativeChatController({ identity: identity, conversation, context: BACKGROUND_CONTEXT, link,
82
+ definitelyNotAdmitted: error => pending.current?.definitelyNotAdmitted(error) ?? false });
47
83
  }
48
84
  catch (error) {
49
- await remote.close();
85
+ void remote.then(value => value.close(), () => { });
50
86
  throw error;
51
87
  }
88
+ remotes.set(controller, pending);
89
+ const back = carried.get(String(key));
90
+ if (back) {
91
+ carried.delete(String(key));
92
+ if (back.text)
93
+ controller.setText(back.text);
94
+ if (back.attachments.length)
95
+ controller.setAttachments(back.attachments);
96
+ }
97
+ // `connecting` at once, so the chat shows it and takes messages; the native watch opening waits for the remote's first view.
98
+ void controller.connect();
99
+ return controller;
52
100
  },
53
- connect: controller => controller.connect(),
101
+ connect: async (controller) => { await remotes.get(controller).remote; await controller.connect(); },
54
102
  dispose: async (controller) => {
103
+ const remote = remotes.get(controller);
104
+ // Disposed before its first view (a failed connect, or another selection): what it took was held, never submitted (see `send`). Its
105
+ // words come back in the composer of this selection's next controller, never sent by themselves.
106
+ if (!controller.getSnapshot().view) {
107
+ const back = unsent(controller);
108
+ if (back.text || back.attachments.length)
109
+ carried.set(String(key), back);
110
+ }
55
111
  // Start both: closing a transport may release a pending native watch opening.
56
112
  const pending = controller.dispose();
57
113
  try {
58
- await remotes.get(controller)?.close();
114
+ await (remote?.current ?? await remote?.remote.catch(() => undefined))?.close();
59
115
  }
60
116
  finally {
61
117
  await pending;
@@ -63,10 +119,13 @@ export function useRemoteChat({ conversationId, endpoint, fetch, identity, reque
63
119
  }
64
120
  }, });
65
121
  if (selected.status === 'ready' && conversationId !== undefined) {
66
- const remote = remotes.get(selected.controller);
122
+ const remote = remotes.get(selected.controller).current;
67
123
  const actions = { ...(typeof remote.answer === 'function' ? { answer: remote.answer } : {}),
68
124
  ...(typeof remote.withdraw === 'function' ? { withdraw: remote.withdraw } : {}) };
69
125
  return { status: 'ready', conversationId, controller: selected.controller, actions, configure: remote.configure };
70
126
  }
71
- return selected.status === 'offline' ? { status: 'offline', conversationId, error: String(selected.error?.message ?? selected.error) } : { status: 'connecting', conversationId };
127
+ if (selected.status === 'offline')
128
+ return { status: 'offline', conversationId, error: String(selected.error?.message ?? selected.error) };
129
+ const early = selected.controller && remotes.get(selected.controller);
130
+ return early && selected.controller ? { status: 'connecting', conversationId, controller: selected.controller, actions: early.actions, configure: early.configure } : { status: 'connecting', conversationId };
72
131
  }
@@ -1,5 +1,5 @@
1
1
  import type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';
2
- import type { ChatAttachment, ChatController } from './native-chat.js';
2
+ import type { ChatAttachment, ChatController, ChatSnapshot } from './native-chat.js';
3
3
  import type { ChatCard, DeriveOptions, Mode, QueuedMessage, Row } from './chat-view.js';
4
4
  /** A conversation's observed file; opening remains a host operation, not workspace ownership. */
5
5
  export interface ConversationFile {
@@ -116,9 +116,8 @@ export interface ChatSessionOptions<Content = unknown> extends Partial<DeriveOpt
116
116
  /** Focus is presentation; called only by actions, never during render. */
117
117
  readonly onFocus?: (() => void) | undefined;
118
118
  }
119
- /** Headless session over the public native controller snapshot/commands. Borrows it; cleanup only aborts this hook's uploads. */
120
119
  export declare function useChatSession<Content = unknown>(options: ChatSessionOptions<Content>): {
121
- state: import("./native-chat.js").ChatSnapshot;
120
+ state: ChatSnapshot;
122
121
  derived: import("./chat-view.js").Derived<Content>;
123
122
  rows: Row<Content>[];
124
123
  queued: {
@@ -3,6 +3,30 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalS
3
3
  import { derive, isFileBlock, object, queuedMessages, waitingForAnswer as waitsForAnswer } from './chat-view.js';
4
4
  import { appendToDraft, displayName, kindMentionDraft, looksLikeMarkdown, mentionKind, mentionToken, pieces, prependToDraft, withoutKindMentions, writeKindMentionDraft } from './chat-text.js';
5
5
  /** Headless session over the public native controller snapshot/commands. Borrows it; cleanup only aborts this hook's uploads. */
6
+ /**
7
+ * The snapshot once the controller has its first view (its conversation id is then the server's); undefined if it is disposed, fails or
8
+ * `signal` aborts first.
9
+ */
10
+ function connectedSnapshot(controller, signal) {
11
+ return new Promise(resolve => {
12
+ let off = () => { }, done = false;
13
+ const finish = (value) => { if (done)
14
+ return; done = true; off(); signal.removeEventListener('abort', aborted); resolve(value); };
15
+ const aborted = () => finish(undefined);
16
+ const check = () => {
17
+ const snapshot = controller.getSnapshot(), kind = snapshot.connection.kind;
18
+ if (signal.aborted)
19
+ finish(undefined);
20
+ else if (snapshot.view || snapshot.disposed || kind === 'error' || kind === 'closed')
21
+ finish(snapshot.view && !snapshot.disposed ? snapshot : undefined);
22
+ };
23
+ check();
24
+ if (done)
25
+ return;
26
+ off = controller.subscribe(check);
27
+ signal.addEventListener('abort', aborted, { once: true });
28
+ });
29
+ }
6
30
  export function useChatSession(options) {
7
31
  const { controller, activeController: active, mode, presentation, actions, renderEntry, hideLine, renderTool: catchAll, toolRenderers, groupTool, subagent, onOpenImage, onFiles, mentions, slash, attachments, model, effort, artifacts, afterSend, feedback, onOpenFile, onPickFiles, commandMentions, onFocus, labels } = options;
8
32
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
@@ -118,7 +142,10 @@ export function useChatSession(options) {
118
142
  // Only a submission whose outcome is unknown blocks, until it is checked or retried.
119
143
  const unconfirmed = state.send.kind === 'unknown';
120
144
  const connected = state.connection.kind === 'connected';
121
- const sendBlocked = uploading > 0 || state.disposed || !connected || unconfirmed;
145
+ // Before the first view the composer already takes messages: the controller holds them, shown as the person's own, and sends them once
146
+ // connected (or puts them back if the connection fails).
147
+ const firstConnect = state.connection.kind === 'connecting' && !state.view;
148
+ const sendBlocked = uploading > 0 || state.disposed || !(connected || firstConnect) || unconfirmed;
122
149
  /** Puts a message back into the composer ahead of whatever is there now (nothing is dropped). */
123
150
  const putBack = (text, images) => {
124
151
  if (controller.getSnapshot().disposed)
@@ -271,8 +298,15 @@ export function useChatSession(options) {
271
298
  paths = [...paths, result.path];
272
299
  }
273
300
  }
274
- else
275
- added = await onFiles(files, { identity: state.identity, conversationId: state.conversationId, signal: abort.signal });
301
+ else {
302
+ // While the chat connects its conversation id is still the host's selection: the host's upload waits for the first view's.
303
+ const target = await connectedSnapshot(controller, abort.signal);
304
+ if (abort.signal.aborted || !current())
305
+ return;
306
+ if (!target)
307
+ throw new Error(labels.uploadFailedShort);
308
+ added = await onFiles(files, { identity: target.identity, conversationId: target.conversationId, signal: abort.signal });
309
+ }
276
310
  if (current() && !abort.signal.aborted) {
277
311
  if (added.length)
278
312
  controller.setAttachments([...controller.getSnapshot().draft.attachments, ...added]);
@@ -363,7 +397,7 @@ export function useChatSession(options) {
363
397
  act(async () => catalog.onActivate({ name: selected.name, behavior: selected.behavior }));
364
398
  };
365
399
  const empty = connected && !state.view?.entries.length && !rows.length;
366
- const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;
400
+ const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view && !echo?.length;
367
401
  return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current,
368
402
  files, empty, loading, developer, natural, sending, send, putBack, upload, pasteText, picked, pending, uploading, activateMention,
369
403
  pickMention: (path) => setPicked(value => value.includes(path) ? value : [...value, path]),
@@ -86,6 +86,12 @@ export interface NativeChatHistorySource {
86
86
  readonly id: Conversation['id'];
87
87
  readonly entries: Conversation['entries'];
88
88
  }
89
+ /**
90
+ * Whether entries older than the view's first one exist, when the host says so. A host that sends a slice of the conversation sets
91
+ * `hasEarlier` on the view it sends (a boolean beside the native fields); without it, a native `head` on a kept entry means earlier entries
92
+ * exist (a reset or compaction starts the view there). `undefined`: the view does not say.
93
+ */
94
+ export declare function viewHasEarlier(view: ConversationView | undefined): boolean | undefined;
89
95
  export type ChatHistoryState = {
90
96
  readonly kind: 'disabled' | 'idle';
91
97
  } | {
@@ -195,7 +201,8 @@ export declare function createNativeChatController(options: NativeChatOptions):
195
201
  /**
196
202
  * Sends the composer draft (or `input`) and empties the composer at once. While another send is still being confirmed the message
197
203
  * waits in `outbox` and is submitted, in order, after it. A refused message, and any waiting behind it, go back into the composer
198
- * ahead of what was typed since; nothing is dropped or merged into another message. Resolves with the native submission, or
204
+ * ahead of what was typed since; nothing is dropped or merged into another message. Sent before any view (while connecting), it is
205
+ * held until the first view arrives and then submitted, in order; if the connection fails first it goes back into the composer. Resolves with the native submission, or
199
206
  * undefined when the message was not admitted (refused, unknown, or the controller was disposed).
200
207
  */
201
208
  send: (whenBusy?: ChatAttempt["whenBusy"], input?: ChatSendInput) => Promise<Submission | undefined>;
@@ -1,5 +1,14 @@
1
1
  import { awaitWithContext, withCancel } from '@earendil-works/chord/context';
2
2
  import { randomUUID } from '@hachej/boring-files/platform';
3
+ /**
4
+ * Whether entries older than the view's first one exist, when the host says so. A host that sends a slice of the conversation sets
5
+ * `hasEarlier` on the view it sends (a boolean beside the native fields); without it, a native `head` on a kept entry means earlier entries
6
+ * exist (a reset or compaction starts the view there). `undefined`: the view does not say.
7
+ */
8
+ export function viewHasEarlier(view) {
9
+ const said = view?.hasEarlier;
10
+ return typeof said === 'boolean' ? said : undefined;
11
+ }
3
12
  function draft(text, attachments, version) {
4
13
  if (typeof text !== 'string' || attachments.some(item => typeof item.id !== 'string' || !item.id || typeof item.name !== 'string'
5
14
  || item.content.type !== 'image' || typeof item.content.data !== 'string' || typeof item.content.mimeType !== 'string'))
@@ -191,6 +200,17 @@ export function createNativeChatController(options) {
191
200
  publish({ send: { kind: 'admitted', attempt, submissionId, ...(record ? { record } : {}) }, ...(found ? { echo: echoView() } : {}) });
192
201
  return true;
193
202
  };
203
+ /** Resolves true once a view is there, false if the controller is disposed or its connection fails first. */
204
+ const viewed = () => snapshot.view ? Promise.resolve(true) : new Promise(resolve => {
205
+ const check = () => {
206
+ const failed = snapshot.disposed || snapshot.connection.kind === 'error' || snapshot.connection.kind === 'closed';
207
+ if (snapshot.view || failed) {
208
+ listeners.delete(check);
209
+ resolve(Boolean(snapshot.view) && !snapshot.disposed);
210
+ }
211
+ };
212
+ listeners.add(check);
213
+ });
194
214
  /** Messages taken from the composer that wait for the current send (`outbox` in the snapshot). */
195
215
  const waiting = [];
196
216
  let sending = false;
@@ -367,15 +387,16 @@ export function createNativeChatController(options) {
367
387
  if (previous.kind === 'ready' && !previous.hasMore)
368
388
  return Promise.resolve();
369
389
  if (!historyScan) {
370
- let maxEntryId;
390
+ // Earlier means below the view's first entry: the view's own entries are on screen already, so the first page never repeats them.
391
+ let firstEntryId;
371
392
  for (const entry of snapshot.view?.entries ?? [])
372
- if (maxEntryId === undefined || entry.id > maxEntryId)
373
- maxEntryId = entry.id;
374
- if (maxEntryId === undefined) {
393
+ if (firstEntryId === undefined || entry.id < firstEntryId)
394
+ firstEntryId = entry.id;
395
+ if (firstEntryId === undefined || firstEntryId <= 0 || viewHasEarlier(snapshot.view) === false) {
375
396
  publish({ history: { kind: 'ready', entries: Object.freeze([]), hasMore: false } });
376
397
  return Promise.resolve();
377
398
  }
378
- historyScan = { maxEntryId };
399
+ historyScan = { maxEntryId: firstEntryId - 1 };
379
400
  }
380
401
  const scan = historyScan, generation = historyGeneration;
381
402
  const entries = 'entries' in previous ? previous.entries : Object.freeze([]);
@@ -445,7 +466,14 @@ export function createNativeChatController(options) {
445
466
  }
446
467
  watch = acquired;
447
468
  const ready = snapshot.timing.inputReady === undefined;
448
- publish({ connection: live(), view: acquired.value, ...(ready ? { timing: Object.freeze({ inputReady: mark('input-ready'), sends: Object.freeze([...timings]) }) } : {}) });
469
+ // Messages sent before any view were counted against none; they are held until now (see `send`), so the first view is their baseline.
470
+ for (const [index, item] of echoes.entries())
471
+ if (item.blind) {
472
+ item.blind = false;
473
+ firstEntries.set(item.key, acquired.value.entries.length);
474
+ item.base = countOf(acquired.value, item.match) + echoes.slice(0, index).filter(other => other.match === item.match).length;
475
+ }
476
+ publish({ connection: live(), view: acquired.value, ...(snapshot.conversationId !== conversation.id ? { conversationId: conversation.id } : {}), ...(ready ? { timing: Object.freeze({ inputReady: mark('input-ready'), sends: Object.freeze([...timings]) }) } : {}) });
449
477
  acquired.start(async (view) => {
450
478
  if (snapshot.disposed || watch !== acquired)
451
479
  return;
@@ -474,7 +502,8 @@ export function createNativeChatController(options) {
474
502
  /**
475
503
  * Sends the composer draft (or `input`) and empties the composer at once. While another send is still being confirmed the message
476
504
  * waits in `outbox` and is submitted, in order, after it. A refused message, and any waiting behind it, go back into the composer
477
- * ahead of what was typed since; nothing is dropped or merged into another message. Resolves with the native submission, or
505
+ * ahead of what was typed since; nothing is dropped or merged into another message. Sent before any view (while connecting), it is
506
+ * held until the first view arrives and then submitted, in order; if the connection fails first it goes back into the composer. Resolves with the native submission, or
478
507
  * undefined when the message was not admitted (refused, unknown, or the controller was disposed).
479
508
  */
480
509
  send: async (whenBusy = 'followUp', input) => {
@@ -490,13 +519,15 @@ export function createNativeChatController(options) {
490
519
  }
491
520
  if (input?.restore)
492
521
  restoreOf.set(selected, draft(input.restore.text, input.restore.attachments ?? [], 0));
522
+ // Connecting with no view yet: the message is held until the first view (below), which is then its echo's baseline.
523
+ const held = !snapshot.view && snapshot.connection.kind === 'connecting';
493
524
  // The message is the person's from this moment: it is drawn at once (the echo) and the composer is emptied, before any
494
525
  // validation or network call. The server's copy replaces it when it arrives; a refusal puts the words back in the composer.
495
526
  // (A steer takes a queued message back and sends it again, so it is not echoed twice.)
496
527
  if (whenBusy !== 'steer') {
497
528
  const enter = mark('enter');
498
529
  const match = selected.text.trim(), key = `m${++echoSeq}`;
499
- echoes.push({ key, draft: selected, match, base: countOf(snapshot.view, match) + echoes.filter(item => item.match === match).length, at: enter, sentAt: Date.now(), failed: false, admitted: false });
530
+ echoes.push({ key, draft: selected, match, base: countOf(snapshot.view, match) + echoes.filter(item => item.match === match).length, blind: held, at: enter, sentAt: Date.now(), failed: false, admitted: false });
500
531
  firstEntries.set(key, snapshot.view?.entries.length ?? 0);
501
532
  timings.push(Object.freeze({ key, enter }));
502
533
  if (timings.length > 20)
@@ -512,6 +543,12 @@ export function createNativeChatController(options) {
512
543
  sending = true;
513
544
  publish(cleared);
514
545
  try {
546
+ // Sent while connecting: held (shown as the person's own) until the first view, so nothing is submitted before the connection is up.
547
+ // If the connection fails first, it and everything behind it go back into the composer, marked not sent.
548
+ if (held && !await viewed()) {
549
+ restore([selected], { send: { kind: 'blocked', reason: 'Not connected: the message was not sent' } });
550
+ return;
551
+ }
515
552
  let next = { draft: selected, whenBusy, resolve: () => { } };
516
553
  const first = await dispatch(next);
517
554
  let result = first;
@@ -14,6 +14,50 @@ export class SubmissionRefused extends Error {
14
14
  code = 'submission-refused';
15
15
  constructor(message) { super(message); this.name = 'SubmissionRefused'; }
16
16
  }
17
+ /**
18
+ * A photo the host sends as a link (an image part with a `url`, `data: ''`, so the first view stays small) is handed out with its
19
+ * `url` resolved against the chat endpoint: a relative link names the endpoint's own route (`?op=image&…`), whatever page the chat is
20
+ * on. The wire replica stays as sent, so later native operations (a field set, a string append) replay on the host's own values; the
21
+ * linked copy is derived after each replay. Values are immutable, so a node maps to one linked node for good: unchanged entries keep
22
+ * their identity and only changed paths are walked again.
23
+ */
24
+ function imageLinker(base) {
25
+ const linked = new WeakMap();
26
+ const link = (node) => {
27
+ if (!node || typeof node !== 'object')
28
+ return node;
29
+ if (linked.has(node))
30
+ return linked.get(node);
31
+ let out = node;
32
+ if (Array.isArray(node)) {
33
+ const items = node.map(link);
34
+ if (items.some((item, index) => item !== node[index]))
35
+ out = items;
36
+ }
37
+ else {
38
+ const record = node;
39
+ let copy;
40
+ for (const key of Object.keys(record)) {
41
+ const value = record[key];
42
+ const next = key === 'url' && record.type === 'image' && typeof value === 'string' ? resolved(value) : link(value);
43
+ if (next !== value)
44
+ (copy ??= { ...record })[key] = next;
45
+ }
46
+ if (copy)
47
+ out = copy;
48
+ }
49
+ linked.set(node, out);
50
+ return out;
51
+ };
52
+ // A link that is not a URL is left as sent: the chat draws a placeholder.
53
+ const resolved = (url) => { try {
54
+ return new URL(url, base).href;
55
+ }
56
+ catch {
57
+ return url;
58
+ } };
59
+ return (value) => link(value);
60
+ }
17
61
  async function reason(response) {
18
62
  try {
19
63
  const value = await response.json();
@@ -30,6 +74,7 @@ async function reason(response) {
30
74
  */
31
75
  export async function createRemoteChat(options) {
32
76
  const base = new URL(options.endpoint, globalThis.location?.href);
77
+ const linkImages = imageLinker(base);
33
78
  const send = options.fetch, pollMs = options.pollMs ?? 500;
34
79
  const backoff = { base: options.reconnect?.baseDelayMs ?? 1000, max: options.reconnect?.maxDelayMs ?? 30_000 };
35
80
  const url = (op, extra = {}) => {
@@ -265,7 +310,7 @@ export async function createRemoteChat(options) {
265
310
  settle(end);
266
311
  } };
267
312
  const handle = {
268
- get value() { return value; },
313
+ get value() { return linkImages(value); },
269
314
  closed,
270
315
  lost: () => lost,
271
316
  stop: async () => { finish({ reason: 'stopped' }); return closed; },
@@ -288,7 +333,7 @@ export async function createRemoteChat(options) {
288
333
  // Native operation batches are replayed with the upstream delta implementation; a view frame resets the replica.
289
334
  value = frame.kind === 'view' ? frame.view : applyImmutable(value, frame.ops);
290
335
  try {
291
- await listener(value, [], context);
336
+ await listener(linkImages(value), [], context);
292
337
  }
293
338
  catch (error) {
294
339
  return finish({ reason: 'listener_error', error: error instanceof Error ? error : new Error(String(error)) });
@@ -345,7 +390,8 @@ export async function createRemoteChat(options) {
345
390
  cancelRetry();
346
391
  setReconnecting(false);
347
392
  settle(end);
348
- const live = current;
393
+ // Stopped before it started (the owner left while the first view was on its way, D-1434): the first watch is still open, close it.
394
+ const live = current ?? (listener ? undefined : first);
349
395
  current = undefined;
350
396
  void live?.stop();
351
397
  };
@@ -478,11 +524,11 @@ export async function createRemoteChat(options) {
478
524
  // The controller only reconciles a submission by request ID; no other transaction work crosses the wire.
479
525
  commit: async (change, _context) => change({ submissionByRequest: (_conversationId, requestId) => lookup(requestId) }),
480
526
  abort: async () => { await post('abort', {}); },
481
- entries: async (query, limit, cursor) => (await call('entries', {}, { limit: String(limit),
527
+ entries: async (query, limit, cursor) => linkImages((await call('entries', {}, { limit: String(limit),
482
528
  ...(query.maxEntryId === undefined ? {} : { maxEntryId: String(query.maxEntryId) }),
483
529
  ...(query.minEntryId === undefined ? {} : { minEntryId: String(query.minEntryId) }),
484
530
  ...(query.order === undefined ? {} : { order: query.order }),
485
- ...(cursor === undefined ? {} : { cursor: JSON.stringify(cursor) }) })).page,
531
+ ...(cursor === undefined ? {} : { cursor: JSON.stringify(cursor) }) })).page),
486
532
  };
487
533
  const context = BACKGROUND_CONTEXT;
488
534
  const close = async () => { const first = unclaimed; unclaimed = undefined; await first?.stop(); };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.50",
3
+ "version": "0.4.51",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -154,7 +154,7 @@
154
154
  }
155
155
  },
156
156
  "peerDependencies": {
157
- "@hachej/boring-files": "^0.4.50",
157
+ "@hachej/boring-files": "^0.4.51",
158
158
  "@earendil-works/pi-durable": "~1.4.0",
159
159
  "react": "^19.0.0",
160
160
  "react-dom": "^19.0.0",
@@ -4,15 +4,15 @@
4
4
  "title": "Point and dictate on app data",
5
5
  "description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.50",
8
- "@hachej/boring-feedback@^0.4.50",
7
+ "@hachej/boring-ui-kit@^0.4.51",
8
+ "@hachej/boring-feedback@^0.4.51",
9
9
  "@earendil-works/pi-durable@~1.4.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-chat.json"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-chat.json"
16
16
  ],
17
17
  "files": [
18
18
  {
package/r/app-shell.json CHANGED
@@ -4,21 +4,21 @@
4
4
  "title": "App shell: layouts, list/detail and agent layer",
5
5
  "description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on explicitly pinned Base UI drawer and dropdown-menu recipes, plus the sidebar and avatar of the host's own shadcn style.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.50",
8
- "@hachej/boring-ui-kit@^0.4.50",
7
+ "@hachej/boring-feedback@^0.4.51",
8
+ "@hachej/boring-ui-kit@^0.4.51",
9
9
  "@base-ui/react@^1.9.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-chat-plugins.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-chat-voice.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/dock.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/feedback.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/utils.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/button.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-chat-plugins.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-chat-voice.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/dock.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/feedback.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/utils.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/button.json"
22
22
  ],
23
23
  "files": [
24
24
  {
@@ -4,17 +4,17 @@
4
4
  "title": "Board viewer",
5
5
  "description": "A board@1 artifact kind for *.board.json. Lazy native tldraw projection of maquette screens, sandboxed HTML mock-ups, goto arrows and notes. Conditional board-file autosave; phone viewing without accidental frame edits. Hosts supply their resource client, asset URLs and license key.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.50",
8
- "@hachej/boring-files@^0.4.50",
7
+ "@hachej/boring-ui-kit@^0.4.51",
8
+ "@hachej/boring-files@^0.4.51",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@1.51.0",
11
11
  "tldraw@5.5.2"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/maquette-viewer.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/mockup-viewer.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/pi-artifacts.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/utils.json"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/maquette-viewer.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/mockup-viewer.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-artifacts.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/utils.json"
18
18
  ],
19
19
  "files": [
20
20
  {
package/r/button.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.50/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {