@hachej/boring-ui-kit 0.4.45 → 0.4.47

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 (62) hide show
  1. package/README.md +0 -4
  2. package/dist/chat-react-session.d.ts +1 -0
  3. package/dist/chat-react-session.js +12 -3
  4. package/dist/chat-view.d.ts +2 -1
  5. package/dist/native-chat.d.ts +39 -2
  6. package/dist/native-chat.js +135 -9
  7. package/dist/remote-chat.d.ts +6 -0
  8. package/dist/remote-chat.js +22 -0
  9. package/dist/shared-chat/index.d.ts +1 -1
  10. package/package.json +2 -6
  11. package/r/agent-target.json +4 -4
  12. package/r/app-shell.json +20 -15
  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/feedback.json +24 -12
  18. package/r/file-tree.json +4 -4
  19. package/r/highlights.json +3 -3
  20. package/r/html-viewer.json +3 -3
  21. package/r/looks-viewer.json +2 -2
  22. package/r/maquette-viewer.json +1 -1
  23. package/r/markdown-editor.json +3 -3
  24. package/r/mockup-viewer.json +1 -1
  25. package/r/pi-ambient.json +9 -9
  26. package/r/pi-app-kinds.json +10 -10
  27. package/r/pi-app.json +12 -12
  28. package/r/pi-artifacts.json +2 -2
  29. package/r/pi-chat-approval.json +2 -2
  30. package/r/pi-chat-attachments.json +1 -1
  31. package/r/pi-chat-markdown.json +2 -2
  32. package/r/pi-chat-mentions.json +1 -1
  33. package/r/pi-chat-pickers.json +1 -1
  34. package/r/pi-chat-plugins.json +11 -11
  35. package/r/pi-chat-question.json +1 -1
  36. package/r/pi-chat-slash.json +2 -2
  37. package/r/pi-chat-subagent.json +1 -1
  38. package/r/pi-chat-voice.json +9 -3
  39. package/r/pi-chat.json +11 -24
  40. package/r/pi-workspace.json +3 -3
  41. package/r/placed-agent.json +4 -4
  42. package/r/provider-setup.json +1 -1
  43. package/r/registry.json +1222 -1388
  44. package/r/shared-chat.json +6 -6
  45. package/r/spaces.json +44 -0
  46. package/r/task-list-viewer.json +2 -2
  47. package/r/version-history.json +2 -2
  48. package/r/viewer-code.json +2 -2
  49. package/r/viewer-frame.json +4 -4
  50. package/r/viewer-html.json +4 -4
  51. package/r/viewer-image.json +2 -2
  52. package/r/viewer-markdown.json +5 -5
  53. package/r/viewer-pdf.json +3 -3
  54. package/r/viewers.json +6 -6
  55. package/r/windows.json +1 -1
  56. package/dist/task-board.d.ts +0 -96
  57. package/dist/task-board.js +0 -48
  58. package/r/feedback-preview.json +0 -20
  59. package/r/pm-panel.json +0 -122
  60. package/r/requests-board.json +0 -33
  61. package/r/task-board.json +0 -41
  62. package/r/task-mentions.json +0 -25
package/README.md CHANGED
@@ -18,10 +18,6 @@ See [scaffold guide](../../docs/contracts/SCAFFOLD.md) and [audit](../../docs/co
18
18
 
19
19
  ## Business task board
20
20
 
21
- `@hachej/boring-ui-kit/task-board` is a dependency-free host-record model: `TaskRecord`, `TaskState`, `TaskFilter`, `selectTasks`, `taskReference`, `taskFromUrl`, `taskUrl` and `TASK_STATES`. `selectTasks(tasks, { filter, currentUser, limit })` filters then sorts newest-first without mutating input. `mine` requires the host adapter's `requesterId`; it is never an authorization boundary. These business tasks are not native Pi execution tasks. The model also accepts host display metadata (`age`, `requester`, `refiner`, `captureCount`, `folder`), question `askedBy`/`askedAt`, and file `kind: 'markdown' | 'mockup' | 'capture'`. These fields never determine ownership. `deriveTaskStatus` prioritizes the native viewer flag, explicit `waiting_on.type: 'builder'`, then high risk; unknown status yields `none`. `taskStatusLabel(status, locale?, viewerAction?)` supplies EN/FR question versus confirmation wording without inferring workflow authority.
22
-
23
- Copyable `TaskBoard`, `TaskDetail`, `TaskRequestCard` (also `TaskCard`) and `TaskStatusChip` presentation lives in the [`task-board` registry item](../../registry/README.md#task-board-task-board), using the app's Base UI controls/tokens. The [feature map](../../features/task-board.md) and [fictional example](../../examples/task-board/README.md) drive responsive columns/detail, URL navigation and compact own-request rows.
24
-
25
21
  ## Exact proposals and commands
26
22
 
27
23
  `actions.propose(selection, edits, summary)` retains an immutable proposal with its exact resource/scope/view/revision, viewer instance/epoch, buffer version, before/after text and ordered edits. It performs no publication and preserves existing human text. Every nonempty `find` must occur once; a missing or ambiguous edit rejects the entire transform.
@@ -142,6 +142,7 @@ export declare function useChatSession<Content = unknown>(options: ChatSessionOp
142
142
  loading: boolean;
143
143
  developer: boolean;
144
144
  natural: boolean;
145
+ sending: boolean;
145
146
  send: () => void;
146
147
  putBack: (text: string, images: readonly ChatAttachment[]) => void;
147
148
  upload: (files: readonly File[], paste?: {
@@ -49,7 +49,14 @@ export function useChatSession(options) {
49
49
  return derive({ ...state.view, entries, docs: {} }, { mode, presentation, hideLine, renderEntry: undefined, renderTool: undefined, groupTool: undefined, artifacts: artifactDetect, subagent }).rows
50
50
  .map(row => ({ ...row, key: `history:${row.key}`, historical: true }));
51
51
  }, [state.view, state.history, mode, presentation, hideLine, artifactDetect, subagent]);
52
- const rows = useMemo(() => [...earlier, ...derived.rows], [earlier, derived.rows]);
52
+ // What the person just sent is on screen at once, as their own message, until the server's copy arrives (the controller drops it then).
53
+ const echo = state.echo;
54
+ const echoRows = useMemo(() => (echo ?? []).map(item => ({ key: `echo:${item.key}`, type: 'user', local: item.state,
55
+ message: { role: 'user', timestamp: item.sentAt, content: item.draft.attachments.length ? [{ type: 'text', text: item.draft.text }, ...item.draft.attachments.map(attachment => attachment.content)] : item.draft.text } })), [echo]);
56
+ const rows = useMemo(() => [...earlier, ...derived.rows, ...echoRows], [earlier, derived.rows, echoRows]);
57
+ // Tells the controller the echoed messages are on screen (after the commit that drew them), for its timing record.
58
+ useLayoutEffect(() => { if (echo?.length)
59
+ controller.markShown?.(echo.map(item => item.key)); }, [echo, controller]);
53
60
  const files = useMemo(() => {
54
61
  const found = new Map();
55
62
  const open = onOpenFile ?? mentions?.open;
@@ -121,7 +128,9 @@ export function useChatSession(options) {
121
128
  if (images.length)
122
129
  controller.setAttachments([...images, ...draft.attachments.filter(item => !images.some(image => image.id === item.id))]);
123
130
  };
124
- const working = Boolean(run);
131
+ // Working from the moment of sending: the status shows before the server has taken the message.
132
+ const sending = Boolean(echo?.some(item => item.state === 'sending'));
133
+ const working = Boolean(run) || sending;
125
134
  const waitingForAnswer = waitsForAnswer(derived.rows);
126
135
  const send = () => {
127
136
  if (sendBlocked || !current())
@@ -356,7 +365,7 @@ export function useChatSession(options) {
356
365
  const empty = connected && !state.view?.entries.length && !rows.length;
357
366
  const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;
358
367
  return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current,
359
- files, empty, loading, developer, natural, send, putBack, upload, pasteText, picked, pending, uploading, activateMention,
368
+ files, empty, loading, developer, natural, sending, send, putBack, upload, pasteText, picked, pending, uploading, activateMention,
360
369
  pickMention: (path) => setPicked(value => value.includes(path) ? value : [...value, path]),
361
370
  dismissUpload: (id) => setPending(items => items.filter(item => item.id !== id)),
362
371
  currentModel: optimistic.model ?? viewModel, currentEffort: optimistic.effort ?? viewEffort,
@@ -111,7 +111,8 @@ export type Part<Content = unknown> = {
111
111
  export type Row<Content = unknown> = ({
112
112
  readonly key: string;
113
113
  readonly type: 'user';
114
- readonly message: UserMessage; /** `messageStyle="messaging"`: first of a run (see `markLeads`). */
114
+ readonly message: UserMessage; /** A message just sent that the server has not shown yet (`sending`), or whose send was not confirmed (`failed`). */
115
+ readonly local?: 'sending' | 'failed'; /** `messageStyle="messaging"`: first of a run (see `markLeads`). */
115
116
  readonly lead?: boolean;
116
117
  } | {
117
118
  readonly key: string;
@@ -98,6 +98,34 @@ export type ChatHistoryState = {
98
98
  readonly hasMore: boolean;
99
99
  readonly error: unknown;
100
100
  };
101
+ /**
102
+ * A message the person sent that the server has not shown yet. The chat draws it as their message at once (the echo), and drops it
103
+ * when the server's own copy arrives in the view, so it never shows twice. `failed`: the send was not confirmed (see `send`: `unknown`).
104
+ */
105
+ export interface EchoMessage {
106
+ readonly key: string;
107
+ readonly draft: ChatDraft;
108
+ /** Wall-clock ms (`Date.now()`) when it was sent. */
109
+ readonly sentAt: number;
110
+ readonly state: 'sending' | 'failed';
111
+ }
112
+ /** Times in ms on `performance.now()` for one sent message, for tests and harnesses; a step not reached yet is absent. */
113
+ export interface SendTiming {
114
+ readonly key: string;
115
+ /** The person pressed Enter (the controller's `send` was called). */
116
+ readonly enter: number;
117
+ /** The message was on screen (the chat's layout effect after it committed the echo). */
118
+ readonly shown?: number;
119
+ /** The first words of the reply reached the view. */
120
+ readonly firstWords?: number;
121
+ /** The reply was complete (the run ended). */
122
+ readonly done?: number;
123
+ }
124
+ export interface ChatTiming {
125
+ /** The connection first became usable (the composer can send). */
126
+ readonly inputReady?: number;
127
+ readonly sends: readonly SendTiming[];
128
+ }
101
129
  export interface NativeChatSnapshot {
102
130
  readonly identity: ChatIdentity;
103
131
  readonly conversationId: Conversation['id'];
@@ -106,13 +134,20 @@ export interface NativeChatSnapshot {
106
134
  readonly draft: ChatDraft;
107
135
  /** Messages already taken from the composer that wait, in order, for the current send to be confirmed before they are submitted. */
108
136
  readonly outbox: readonly ChatDraft[];
137
+ /** Sent messages not yet in the server's view, oldest first: drawn at once as the person's messages. */
138
+ readonly echo: readonly EchoMessage[];
139
+ /** The steps of each send, for tests and harnesses. Also written as `performance.mark('boring-chat:<step>')`. */
140
+ readonly timing: ChatTiming;
109
141
  readonly history: ChatHistoryState;
110
142
  readonly send: ChatSendState;
111
143
  readonly stop: 'idle' | 'requested' | 'confirmed' | 'unconfirmed';
112
144
  readonly disposed: boolean;
113
145
  }
114
146
  /** Structural presentation seam. Native controllers retain their concrete IDs, methods and recovery return values. */
115
- export type ChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send'> & {
147
+ export type ChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send' | 'echo' | 'timing'> & {
148
+ /** Absent from a controller that does not echo sent messages. */
149
+ readonly echo?: NativeChatSnapshot['echo'];
150
+ readonly timing?: NativeChatSnapshot['timing'];
116
151
  readonly conversationId: NativeChatSnapshot['conversationId'] | string;
117
152
  readonly send: ChatSendState | {
118
153
  readonly kind: 'idle' | 'submitting' | 'admitted';
@@ -122,7 +157,7 @@ export type ChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send'> &
122
157
  readonly retryable: boolean;
123
158
  };
124
159
  };
125
- export type ChatController = Pick<NativeChatController, 'subscribe' | 'setText' | 'setAttachments' | 'clearHistory' | 'loadEarlier' | 'connect' | 'stop' | 'dispose'> & {
160
+ export type ChatController = Partial<Pick<NativeChatController, 'markShown'>> & Pick<NativeChatController, 'subscribe' | 'setText' | 'setAttachments' | 'clearHistory' | 'loadEarlier' | 'connect' | 'stop' | 'dispose'> & {
126
161
  readonly getSnapshot: () => ChatSnapshot;
127
162
  readonly send: (whenBusy?: ChatAttempt['whenBusy'], input?: ChatSendInput) => Promise<unknown>;
128
163
  readonly reconcile: () => Promise<unknown>;
@@ -147,6 +182,8 @@ export interface NativeChatOptions {
147
182
  export declare function createNativeChatController(options: NativeChatOptions): {
148
183
  conversation: ChatConversation;
149
184
  getSnapshot: () => NativeChatSnapshot;
185
+ /** The chat reports which echoed messages are now on screen (called after the commit that drew them); it stamps `timing`. */
186
+ markShown: (keys: readonly string[]) => void;
150
187
  /** Reads the exact native submission with this controller's Context; observing never aborts or owns its execution. */
151
188
  submissionStatus: (submission: Submission) => Promise<SubmissionRecord>;
152
189
  subscribe: (listener: () => void) => (() => void);
@@ -20,7 +20,7 @@ export function createNativeChatController(options) {
20
20
  if (Object.values(identity).some(value => typeof value !== 'string' || !value))
21
21
  throw new TypeError('Explicit runtime, scope and principal identity are required');
22
22
  let snapshot = { identity, conversationId: conversation.id, connection: { kind: 'idle' },
23
- draft: draft('', [], 0), outbox: Object.freeze([]), history: emptyHistory(), send: { kind: 'idle' }, stop: 'idle', disposed: false };
23
+ draft: draft('', [], 0), outbox: Object.freeze([]), echo: Object.freeze([]), timing: Object.freeze({ sends: Object.freeze([]) }), history: emptyHistory(), send: { kind: 'idle' }, stop: 'idle', disposed: false };
24
24
  const listeners = new Set();
25
25
  let watch, opening, disposing;
26
26
  const publish = (change) => {
@@ -79,11 +79,101 @@ export function createNativeChatController(options) {
79
79
  item.textObserved = true;
80
80
  }
81
81
  };
82
+ let echoSeq = 0;
83
+ const echoes = [];
84
+ /** A sent message still not in the view after this long is dropped from the echo (the server may have changed its words); the transcript is the truth. */
85
+ const ECHO_LIFETIME_MS = 120_000;
86
+ const now = () => (globalThis.performance?.now?.() ?? Date.now());
87
+ const textOfContent = (content) => typeof content === 'string' ? content
88
+ : Array.isArray(content) ? content.flatMap(part => part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string' ? [part.text] : []).join('\n') : '';
89
+ /** The texts of the people's messages the view holds: placed ones and those waiting in the inbox. */
90
+ const userTexts = (view) => {
91
+ if (!view)
92
+ return [];
93
+ const texts = view.entries.flatMap(entry => (entry.model ?? []).flatMap(message => message.role === 'user' ? [textOfContent(message.content)] : []));
94
+ const items = view.docs['pi.inbox'] && typeof view.docs['pi.inbox'] === 'object' && !Array.isArray(view.docs['pi.inbox']) ? view.docs['pi.inbox']['items'] : undefined;
95
+ if (Array.isArray(items))
96
+ for (const item of items)
97
+ if (item && typeof item === 'object' && !Array.isArray(item))
98
+ texts.push(textOfContent(item['content']));
99
+ return texts;
100
+ };
101
+ const countOf = (view, match) => userTexts(view).filter(text => match === '' || text.includes(match)).length;
102
+ const echoView = () => Object.freeze(echoes.map(item => Object.freeze({ key: item.key, draft: item.draft, sentAt: item.sentAt, state: item.failed ? 'failed' : 'sending' })));
103
+ /** Applies a change to the echo list and returns the snapshot field for it. */
104
+ const publishEcho = (change = {}) => publish({ ...change, echo: echoView() });
105
+ const echoOf = (draft) => echoes.find(item => item.draft === draft);
106
+ const dropEcho = (drafts) => {
107
+ let changed = false;
108
+ for (const item of drafts) {
109
+ const found = echoOf(item);
110
+ if (found) {
111
+ echoes.splice(echoes.indexOf(found), 1);
112
+ changed = true;
113
+ }
114
+ }
115
+ return changed;
116
+ };
117
+ // Timing: one record per send, kept for the last 20, readable from the snapshot and as performance marks.
118
+ const timings = [];
119
+ const mark = (step) => {
120
+ const at = now();
121
+ try {
122
+ globalThis.performance?.mark?.(`boring-chat:${step}`);
123
+ }
124
+ catch { /* marks are optional */ }
125
+ return at;
126
+ };
127
+ const stamp = (key, step) => {
128
+ const index = timings.findIndex(item => item.key === key);
129
+ if (index < 0 || timings[index][step] !== undefined)
130
+ return;
131
+ timings[index] = Object.freeze({ ...timings[index], [step]: mark(`${step}:${key}`) });
132
+ };
133
+ const publishTiming = (change = {}) => publish({ ...change, timing: Object.freeze({ ...(snapshot.timing.inputReady !== undefined ? { inputReady: snapshot.timing.inputReady } : {}), sends: Object.freeze([...timings]) }) });
134
+ /** Reads the view for the steps of the sends: the first words of a reply and its end. */
135
+ const observeTiming = (view) => {
136
+ const open = timings.filter(item => item.done === undefined && item.enter !== undefined);
137
+ if (!open.length)
138
+ return;
139
+ const live = view.docs['pi.live'], run = live && typeof live === 'object' && !Array.isArray(live) ? live['run'] : undefined;
140
+ const running = Boolean(run) && typeof run === 'object';
141
+ const generation = live && typeof live === 'object' && !Array.isArray(live) ? live['generation'] : undefined;
142
+ const message = generation && typeof generation === 'object' && !Array.isArray(generation) ? generation['message'] : undefined;
143
+ const streamed = message && typeof message === 'object' && !Array.isArray(message) && hasText([{ role: 'assistant', content: message['content'] }]);
144
+ const key = open[0].key;
145
+ const placed = view.entries.slice(firstEntries.get(key) ?? view.entries.length).some(entry => hasText(entry.model));
146
+ if (streamed || placed)
147
+ stamp(key, 'firstWords');
148
+ if (!running && timings.find(item => item.key === key)?.firstWords !== undefined)
149
+ stamp(key, 'done');
150
+ };
151
+ /** How many entries the view held when a message was sent: text after them is the reply. */
152
+ const firstEntries = new Map();
153
+ /** Drops the echoes whose message the view now holds (or that waited too long). */
154
+ const settleEchoes = (view) => {
155
+ if (!echoes.length)
156
+ return false;
157
+ const seen = new Map();
158
+ const remaining = echoes.filter(item => {
159
+ if (now() - item.at > ECHO_LIFETIME_MS && item.admitted)
160
+ return false;
161
+ const count = seen.get(item.match) ?? countOf(view, item.match);
162
+ seen.set(item.match, count);
163
+ return count < item.base + 1;
164
+ });
165
+ if (remaining.length === echoes.length)
166
+ return false;
167
+ echoes.splice(0, echoes.length, ...remaining);
168
+ return true;
169
+ };
82
170
  const acceptView = (view) => {
83
171
  observeText(view);
84
172
  const previous = snapshot.view?.entries;
85
173
  const appendOnly = !previous || previous.every((entry, index) => view.entries[index]?.id === entry.id);
86
- publish({ view, ...(!appendOnly ? { history: invalidateHistory() } : {}) });
174
+ const changed = settleEchoes(view);
175
+ observeTiming(view);
176
+ publish({ view, ...(!appendOnly ? { history: invalidateHistory() } : {}), ...(changed ? { echo: echoView() } : {}), timing: Object.freeze({ ...(snapshot.timing.inputReady !== undefined ? { inputReady: snapshot.timing.inputReady } : {}), sends: Object.freeze([...timings]) }) });
87
177
  };
88
178
  const stillPending = (attempt) => (snapshot.send.kind === 'unknown' || snapshot.send.kind === 'submitting') && snapshot.send.attempt.requestId === attempt.requestId;
89
179
  const active = () => { if (snapshot.disposed)
@@ -93,7 +183,12 @@ export function createNativeChatController(options) {
93
183
  const markAdmitted = (attempt, submissionId, record) => {
94
184
  if (snapshot.disposed || !stillPending(attempt))
95
185
  return false;
96
- publish({ send: { kind: 'admitted', attempt, submissionId, ...(record ? { record } : {}) } });
186
+ const found = echoOf(attempt.draft);
187
+ if (found) {
188
+ found.failed = false;
189
+ found.admitted = true;
190
+ }
191
+ publish({ send: { kind: 'admitted', attempt, submissionId, ...(record ? { record } : {}) }, ...(found ? { echo: echoView() } : {}) });
97
192
  return true;
98
193
  };
99
194
  /** Messages taken from the composer that wait for the current send (`outbox` in the snapshot). */
@@ -111,7 +206,8 @@ export function createNativeChatController(options) {
111
206
  item.resolve(undefined);
112
207
  if (snapshot.disposed)
113
208
  return;
114
- putBack([...drafts, ...left.map(item => item.draft)], { ...change, ...(left.length || snapshot.outbox.length ? { outbox: Object.freeze([]) } : {}) });
209
+ const gone = dropEcho([...drafts, ...left.map(item => item.draft)]);
210
+ putBack([...drafts, ...left.map(item => item.draft)], { ...change, ...(gone ? { echo: echoView() } : {}), ...(left.length || snapshot.outbox.length ? { outbox: Object.freeze([]) } : {}) });
115
211
  };
116
212
  function putBack(selected, change = {}) {
117
213
  if (snapshot.disposed)
@@ -177,7 +273,10 @@ export function createNativeChatController(options) {
177
273
  })();
178
274
  }
179
275
  async function submitAttempt(attempt) {
180
- publish({ send: { kind: 'submitting', attempt }, ...(!stopping && snapshot.stop !== 'idle' ? { stop: 'idle' } : {}) });
276
+ const retried = echoOf(attempt.draft);
277
+ if (retried)
278
+ retried.failed = false;
279
+ publish({ send: { kind: 'submitting', attempt }, ...(retried ? { echo: echoView() } : {}), ...(!stopping && snapshot.stop !== 'idle' ? { stop: 'idle' } : {}) });
181
280
  if (snapshot.disposed)
182
281
  return;
183
282
  try {
@@ -196,8 +295,12 @@ export function createNativeChatController(options) {
196
295
  // Refused: the message goes back into the composer. Unknown: it stays held by the attempt for `reconcile`/`retrySameRequest`.
197
296
  if (denied)
198
297
  restore([attempt.draft], { send: { kind: 'blocked', reason: error instanceof Error ? error.message : 'Submission was not admitted' } });
199
- else
200
- publish({ send: { kind: 'unknown', attempt } });
298
+ else {
299
+ const found = echoOf(attempt.draft);
300
+ if (found)
301
+ found.failed = true;
302
+ publish({ send: { kind: 'unknown', attempt }, ...(found ? { echo: echoView() } : {}) });
303
+ }
201
304
  return undefined;
202
305
  }
203
306
  }
@@ -228,6 +331,16 @@ export function createNativeChatController(options) {
228
331
  const controller = {
229
332
  conversation,
230
333
  getSnapshot: () => snapshot,
334
+ /** The chat reports which echoed messages are now on screen (called after the commit that drew them); it stamps `timing`. */
335
+ markShown: (keys) => {
336
+ if (snapshot.disposed)
337
+ return;
338
+ const before = timings.map(item => item.shown);
339
+ for (const key of keys)
340
+ stamp(key, 'shown');
341
+ if (timings.some((item, index) => item.shown !== before[index]))
342
+ publishTiming();
343
+ },
231
344
  /** Reads the exact native submission with this controller's Context; observing never aborts or owns its execution. */
232
345
  submissionStatus: async (submission) => {
233
346
  active();
@@ -331,7 +444,8 @@ export function createNativeChatController(options) {
331
444
  throw new Error('Chat source returned another conversation');
332
445
  }
333
446
  watch = acquired;
334
- publish({ connection: live(), view: acquired.value });
447
+ 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]) }) } : {}) });
335
449
  acquired.start(async (view) => {
336
450
  if (snapshot.disposed || watch !== acquired)
337
451
  return;
@@ -376,7 +490,19 @@ export function createNativeChatController(options) {
376
490
  }
377
491
  if (input?.restore)
378
492
  restoreOf.set(selected, draft(input.restore.text, input.restore.attachments ?? [], 0));
379
- const cleared = input ? {} : { draft: draft('', [], current.version + 1) };
493
+ // The message is the person's from this moment: it is drawn at once (the echo) and the composer is emptied, before any
494
+ // validation or network call. The server's copy replaces it when it arrives; a refusal puts the words back in the composer.
495
+ // (A steer takes a queued message back and sends it again, so it is not echoed twice.)
496
+ if (whenBusy !== 'steer') {
497
+ const enter = mark('enter');
498
+ 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 });
500
+ firstEntries.set(key, snapshot.view?.entries.length ?? 0);
501
+ timings.push(Object.freeze({ key, enter }));
502
+ if (timings.length > 20)
503
+ firstEntries.delete(timings.shift().key);
504
+ }
505
+ const cleared = { ...(input ? {} : { draft: draft('', [], current.version + 1) }), echo: echoView(), timing: Object.freeze({ ...(snapshot.timing.inputReady !== undefined ? { inputReady: snapshot.timing.inputReady } : {}), sends: Object.freeze([...timings]) }) };
380
506
  if (sending) {
381
507
  return new Promise(resolve => {
382
508
  waiting.push({ draft: selected, whenBusy, resolve });
@@ -45,6 +45,12 @@ export interface RemoteChatOptions {
45
45
  readonly open?: (url: URL) => WebSocket;
46
46
  readonly pingMs?: number;
47
47
  };
48
+ /**
49
+ * How long the first view of a connection may take before the chat gives up and reports an error (default 18000). Without it a
50
+ * host that accepts the watch request but never sends a view leaves the page on « Connecting… » for good. Later views are
51
+ * bounded by the heartbeat.
52
+ */
53
+ readonly firstViewTimeoutMs?: number;
48
54
  /** Host events the socket carries beside the chat (`broadcast` on the server, for example presence or the conversation list). */
49
55
  readonly onEvent?: (event: unknown) => void;
50
56
  }
@@ -99,6 +99,7 @@ export async function createRemoteChat(options) {
99
99
  return;
100
100
  closed = true;
101
101
  clearInterval(timer);
102
+ clearTimeout(firstView);
102
103
  if (current === socket)
103
104
  current = undefined;
104
105
  notify();
@@ -122,6 +123,14 @@ export async function createRemoteChat(options) {
122
123
  socket.close(1000);
123
124
  }
124
125
  catch { /* closing */ } finish(); };
126
+ // A socket that opens but never sends its first view is closed (then HTTP is tried, or the error is reported).
127
+ const firstView = setTimeout(() => { if (!opened) {
128
+ try {
129
+ socket.close();
130
+ }
131
+ catch { /* closing */ }
132
+ finish();
133
+ } }, options.firstViewTimeoutMs ?? 18_000);
125
134
  socket.onclose = finish;
126
135
  socket.onerror = () => { try {
127
136
  socket.close();
@@ -169,6 +178,19 @@ export async function createRemoteChat(options) {
169
178
  }
170
179
  async function httpSource() {
171
180
  const stopped = new AbortController();
181
+ // The whole wait for the first view (the response, then its first line) is bounded: a host that never answers is an error with retry.
182
+ const limit = options.firstViewTimeoutMs ?? 18_000;
183
+ let giveUp;
184
+ const late = new Promise((_, reject) => { giveUp = setTimeout(() => { stopped.abort(); reject(new WatchRefused('Remote chat watch timed out before its first view', 408)); }, limit); });
185
+ late.catch(() => { });
186
+ try {
187
+ return await Promise.race([openHttp(stopped), late]);
188
+ }
189
+ finally {
190
+ clearTimeout(giveUp);
191
+ }
192
+ }
193
+ async function openHttp(stopped) {
172
194
  const response = await send(new Request(url('watch'), { signal: stopped.signal }));
173
195
  if (!response.ok || !response.body)
174
196
  throw new WatchRefused(`Remote chat watch failed: ${await reason(response)}`, response.status);
@@ -3,7 +3,7 @@ export { ChannelRequestError } from './error.js';
3
3
  import type { Card, ChannelMessage, ChannelSource, Member, SharedChatState } from './source.js';
4
4
  export type * from './source.js';
5
5
  export { channelAvatarStates, cardActions } from './source.js';
6
- export type SharedChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send' | 'shared'> & {
6
+ export type SharedChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send' | 'shared' | 'echo' | 'timing'> & {
7
7
  readonly conversationId: string;
8
8
  readonly shared: SharedChatState;
9
9
  readonly send: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.45",
3
+ "version": "0.4.47",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -147,14 +147,10 @@
147
147
  "./mark-store": {
148
148
  "types": "./dist/mark-store.d.ts",
149
149
  "import": "./dist/mark-store.js"
150
- },
151
- "./task-board": {
152
- "types": "./dist/task-board.d.ts",
153
- "import": "./dist/task-board.js"
154
150
  }
155
151
  },
156
152
  "peerDependencies": {
157
- "@hachej/boring-files": "^0.4.45",
153
+ "@hachej/boring-files": "^0.4.47",
158
154
  "@earendil-works/pi-durable": "~1.4.0",
159
155
  "react": "^19.0.0",
160
156
  "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.45",
8
- "@hachej/boring-feedback@^0.4.45",
7
+ "@hachej/boring-ui-kit@^0.4.47",
8
+ "@hachej/boring-feedback@^0.4.47",
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.45/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat.json"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-chat.json"
16
16
  ],
17
17
  "files": [
18
18
  {