@jini-ai/chat 0.5.1 → 0.5.3

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 (50) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/dist/core/index.d.ts +1 -0
  3. package/dist/core/index.js +1 -0
  4. package/dist/core/run-activity.d.ts +12 -20
  5. package/dist/core/run-activity.js +59 -7
  6. package/dist/core/surface-expiry.d.ts +42 -0
  7. package/dist/core/surface-expiry.js +42 -0
  8. package/dist/core/tool-events.d.ts +34 -0
  9. package/dist/core/tool-events.js +61 -0
  10. package/dist/core/tools.d.ts +1 -1
  11. package/dist/core/tools.js +1 -1
  12. package/dist/react/chat-pane.d.ts +2 -2
  13. package/dist/react/chat-pane.js +1 -1
  14. package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +19 -2
  15. package/dist/react/components/AttachmentPreviewModal.hooks.js +53 -38
  16. package/dist/react/components/Markdown.js +37 -6
  17. package/dist/react/components/McpUiSurfaceCard.d.ts +7 -1
  18. package/dist/react/components/McpUiSurfaceCard.js +23 -38
  19. package/dist/react/components/MessageList.d.ts +11 -0
  20. package/dist/react/components/MessageList.hooks.d.ts +19 -0
  21. package/dist/react/components/MessageList.hooks.js +82 -0
  22. package/dist/react/components/MessageList.js +3 -71
  23. package/dist/react/components/MessageList.rules.d.ts +43 -0
  24. package/dist/react/components/MessageList.rules.js +38 -0
  25. package/dist/react/components/MessageRow.js +3 -1
  26. package/dist/react/components/ToolCard.js +10 -7
  27. package/dist/react/components/mcp-ui-surface-state.d.ts +27 -0
  28. package/dist/react/components/mcp-ui-surface-state.js +33 -0
  29. package/dist/react/ext-event-renderer-registry.d.ts +7 -0
  30. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  31. package/dist/react/features/chat-pane/components/ChatPane.js +6 -5
  32. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +54 -0
  33. package/dist/react/features/chat-pane/create-typed-answer-poster.js +46 -0
  34. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +17 -1
  35. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +153 -7
  36. package/dist/react/features/chat-pane/index.d.ts +2 -1
  37. package/dist/react/features/chat-pane/index.js +1 -0
  38. package/dist/react/features/chat-pane/rules.d.ts +59 -1
  39. package/dist/react/features/chat-pane/rules.js +74 -0
  40. package/dist/react/features/chat-pane/types.d.ts +34 -0
  41. package/dist/react/hooks/composer-draft-cache.d.ts +28 -0
  42. package/dist/react/hooks/composer-draft-cache.js +56 -6
  43. package/dist/react/hooks/last-conversation-store.d.ts +58 -0
  44. package/dist/react/hooks/last-conversation-store.js +112 -0
  45. package/dist/react/hooks/useSurfaceExpiry.d.ts +20 -0
  46. package/dist/react/hooks/useSurfaceExpiry.js +35 -0
  47. package/dist/react/hooks/useToolTimeline.js +6 -3
  48. package/dist/react/index.d.ts +10 -2
  49. package/dist/react/index.js +7 -1
  50. package/package.json +7 -7
@@ -105,6 +105,80 @@ export function describeChatPaneSendBlocker(blocker) {
105
105
  export function isChatPaneQueueableBlocker({ blocker }) {
106
106
  return blocker === 'streaming';
107
107
  }
108
+ /**
109
+ * Whether the run in flight is holding a question open for the human: the newest message is the
110
+ * assistant's, and it carries an interactive surface (`kind: 'ext'`, e.g. an MCP-UI card) that
111
+ * arrived inside a still-open call to the host's question tool (`toolName`). That is the one moment
112
+ * text typed into the composer is an ANSWER rather than a next turn — the tool is parked waiting on
113
+ * it, and queueing the text behind the run would only deliver it after the question had expired, as
114
+ * a new paid run.
115
+ *
116
+ * Only the question tool counts. Other tools park on cards too — a delete confirm, an approval —
117
+ * but they take no typed answer: the server routes typed text only to the question tool, so treating
118
+ * those as waiting would refuse the text with "no longer waiting" and drop it instead of queueing.
119
+ * A call is the question tool when ANY `tool_use` for its id names it, since the same call's
120
+ * `tool_use` can arrive more than once (the CLI re-emits it). A vendor's wrapper row
121
+ * (`execute_delegated_tool`) and the daemon's canonical row do NOT share an id — the wrapper carries
122
+ * the CLI's `toolu_…` id and the canonical row a fresh UUID from `@jini-ai/mcp`. The canonical row
123
+ * opens after its wrapper, so it is the newest open call when the surface arrives, and that is
124
+ * what attributes the surface to the question tool.
125
+ *
126
+ * A surface is attributed to the newest call still open when it arrived — the same rule
127
+ * `useExtEventGroups` uses to give a card its `call` — so an older call returning does not settle
128
+ * it. Once its call returns (answered, expired, abandoned), typing is a next turn again.
129
+ *
130
+ * @complexity O(e·c): e events in the newest message, c calls open at once (one or two in practice).
131
+ */
132
+ export function isAwaitingTypedAnswer({ messages, toolName }) {
133
+ return findAwaitedTypedAnswerId({ messages, toolName }) !== null;
134
+ }
135
+ /**
136
+ * The tool-use id of the question {@link isAwaitingTypedAnswer} sees waiting, or `null` — the newest
137
+ * one when several are open. The id lets the pane tell one question from the next: a draft typed
138
+ * for a question stays tied to THAT question after it closes (see `useChatPane`'s held answer).
139
+ *
140
+ * @complexity O(e·c), as {@link isAwaitingTypedAnswer}.
141
+ */
142
+ export function findAwaitedTypedAnswerId({ messages, toolName }) {
143
+ const newest = messages.at(-1);
144
+ if (newest?.role !== 'assistant')
145
+ return null;
146
+ // Open call id -> whether it is the question tool. Insertion-ordered, so the last entry is the
147
+ // newest call still open; a repeat id keeps its slot and only ORs in its name.
148
+ const openCalls = new Map();
149
+ const awaited = new Set();
150
+ for (const event of newest.events ?? []) {
151
+ if (event.kind === 'tool_use')
152
+ openCalls.set(event.id, openCalls.get(event.id) === true || event.name === toolName);
153
+ if (event.kind === 'tool_result') {
154
+ openCalls.delete(event.toolUseId);
155
+ awaited.delete(event.toolUseId);
156
+ }
157
+ const owner = event.kind === 'ext' ? [...openCalls].at(-1) : undefined;
158
+ if (owner?.[1] === true)
159
+ awaited.add(owner[0]);
160
+ }
161
+ return [...awaited].at(-1) ?? null;
162
+ }
163
+ /**
164
+ * Whether a composer send is an answer to a waiting question rather than a next turn: a run is
165
+ * streaming ({@link isChatPaneQueueableBlocker}), the turn has no attachments (a typed answer
166
+ * carries text only), and that run is holding the `toolName` question open
167
+ * ({@link isAwaitingTypedAnswer}).
168
+ *
169
+ * @complexity O(e·c), from {@link isAwaitingTypedAnswer}.
170
+ */
171
+ export function isTypedAnswerTurn({ blocker, attachmentCount, messages, toolName }) {
172
+ return isChatPaneQueueableBlocker({ blocker }) && attachmentCount === 0 && isAwaitingTypedAnswer({ messages, toolName });
173
+ }
174
+ const TYPED_ANSWER_NOTICES = {
175
+ 'not-pending': 'That question is no longer waiting for an answer, so your message was not sent.',
176
+ failed: 'Your answer could not be delivered. Try sending it again.',
177
+ };
178
+ /** The English copy (and i18n key) for why a typed answer was not sent. */
179
+ export function describeTypedAnswerNotice({ notice }) {
180
+ return TYPED_ANSWER_NOTICES[notice];
181
+ }
108
182
  export function orderChatPaneAgents({ agents }) {
109
183
  return [...agents].sort((left, right) => {
110
184
  const availability = Number(left.available === false) - Number(right.available === false);
@@ -120,6 +120,28 @@ export interface ChatPaneComposerHandle {
120
120
  */
121
121
  insertText: (text: string) => void;
122
122
  }
123
+ /**
124
+ * What happened to text typed while the running agent waits on a question (see
125
+ * {@link ChatPaneProps.deliverTypedAnswer}): `'delivered'` — the question took it as its answer;
126
+ * `'not-pending'` — nothing is waiting any more (already answered, expired, or never open);
127
+ * `'failed'` — the host could not tell (network error, unexpected status).
128
+ */
129
+ export type TypedAnswerDelivery = 'delivered' | 'not-pending' | 'failed';
130
+ /** Why the pane did not send a typed answer — shown to the human until the next send or reset. */
131
+ export type TypedAnswerNotice = Exclude<TypedAnswerDelivery, 'delivered'>;
132
+ /**
133
+ * Delivers text typed into the composer to the question the running agent is waiting on.
134
+ *
135
+ * Carries `toolName`, the host's question tool id, so the pane routes typed text here only while a
136
+ * card raised by THAT tool is open — any other pending card (a confirm, an approval) still queues the
137
+ * text behind the run. One value, so the tool the pane waits on and the tool the answer is posted to
138
+ * cannot drift apart; `createTypedAnswerPoster` sets it.
139
+ */
140
+ export type DeliverTypedAnswer = ((input: {
141
+ text: string;
142
+ }) => Promise<TypedAnswerDelivery>) & {
143
+ readonly toolName: string;
144
+ };
123
145
  export interface ChatPaneProps {
124
146
  transport: ChatTransport;
125
147
  agents?: readonly ChatPaneAgent[];
@@ -170,6 +192,18 @@ export interface ChatPaneProps {
170
192
  * before the pane exists has nothing to call.
171
193
  */
172
194
  composerHandle?: RefObject<ChatPaneComposerHandle | null>;
195
+ /**
196
+ * Called instead of queueing when the human types while the running agent is holding a question
197
+ * open in the transcript (an interactive surface whose call to `deliverTypedAnswer.toolName` has
198
+ * not returned — `isAwaitingTypedAnswer`) and the turn carries no attachments. `'delivered'` clears the draft
199
+ * (only if unchanged since it went out); any other outcome, or a throw, leaves the draft in place with a
200
+ * notice and NEVER queues it — a queued answer would go out after the run ends as a new, paid run
201
+ * answering nothing. A draft typed while the question waited stays held after it closes, until the
202
+ * human clears it or picks "Send as a new message" (`useChatPane`'s `sendAsNewMessage`).
203
+ *
204
+ * Omit it and typing while a run streams queues exactly as before.
205
+ */
206
+ deliverTypedAnswer?: DeliverTypedAnswer;
173
207
  placeholder?: string;
174
208
  /**
175
209
  * Rotating composer suggestions, cycled every few seconds in place of one fixed `placeholder` —
@@ -115,6 +115,34 @@ export declare function clearCachedDraft({ conversationId }: {
115
115
  export declare function readCachedAttachments({ conversationId }: {
116
116
  conversationId: string | null | undefined;
117
117
  }): readonly ChatAttachment[] | null;
118
+ /**
119
+ * The upload batch `conversationId`'s cached attachments were uploaded under, so a pane that
120
+ * restores those attachments uploads the next file into the SAME batch.
121
+ *
122
+ * Why this exists: the daemon grants the agent exactly one batch directory per turn and refuses a
123
+ * turn whose attachments span two ("Attachments must belong to one batch"). A pane that restored an
124
+ * attachment after a remount, reload, or conversation switch used to upload the next file under a
125
+ * brand-new batch id, so every such turn failed before the agent started (2026-10-06).
126
+ *
127
+ * @returns The batch id, or `null` when there are no cached attachments to stay consistent with
128
+ * (none staged, attachment persistence off because the host supplied no validator, or an entry
129
+ * written before batch ids were recorded) — the caller then starts a fresh batch.
130
+ * @complexity Time/space: O(1) on an in-memory hit, O(n) in the stored count otherwise.
131
+ */
132
+ export declare function readCachedAttachmentBatchId({ conversationId }: {
133
+ conversationId: string | null | undefined;
134
+ }): string | null;
135
+ /**
136
+ * Records the upload batch of an attachment about to be staged in `conversationId`'s composer.
137
+ * Memory only: the stored envelope picks it up on the {@link writeCachedAttachments} that persists
138
+ * that attachment, so it is never written ahead of the references it describes.
139
+ *
140
+ * @complexity Time/space: O(1) amortized.
141
+ */
142
+ export declare function writeCachedAttachmentBatchId({ conversationId, batchId }: {
143
+ conversationId: string | null | undefined;
144
+ batchId: string;
145
+ }): void;
118
146
  /**
119
147
  * Stores `attachments` for `conversationId` in both tiers.
120
148
  *
@@ -62,6 +62,10 @@ export const COMPOSER_DRAFT_STORAGE_PREFIX = 'jini.chat.composer-draft.v1.';
62
62
  export const COMPOSER_ATTACHMENTS_STORAGE_PREFIX = 'jini.chat.composer-attachments.v1.';
63
63
  const drafts = new Map();
64
64
  const stagedAttachments = new Map();
65
+ // Kept beside `stagedAttachments` rather than inside it so `writeCachedAttachments` keeps its
66
+ // attachments-only signature: `useComposer` persists the list and never sees a batch id, while
67
+ // `useChatPane` (the one caller that uploads in batches) records the batch here.
68
+ const stagedBatchIds = new Map();
65
69
  /**
66
70
  * The origin's `localStorage`, or `null` when it cannot be used.
67
71
  *
@@ -282,10 +286,11 @@ function isChatAttachment(candidate) {
282
286
  return true;
283
287
  }
284
288
  /**
285
- * Reads the stored attachment references for `conversationId`.
289
+ * Reads the stored attachment references (and their upload batch) for `conversationId`.
286
290
  *
287
291
  * Elements that do not validate are dropped individually rather than failing the whole entry — one
288
- * malformed row should cost the operator that row, not every attachment on the turn.
292
+ * malformed row should cost the operator that row, not every attachment on the turn. A missing or
293
+ * malformed batch id costs only the batch (`batchId: null`), never the references.
289
294
  *
290
295
  * @returns The stored references, or `null` when there are none, storage is unavailable, or nothing
291
296
  * in the entry validated.
@@ -302,6 +307,7 @@ function readStoredAttachments(conversationId) {
302
307
  return null;
303
308
  const parsed = JSON.parse(raw);
304
309
  const list = parsed?.a;
310
+ const batchId = parsed?.b;
305
311
  if (!Array.isArray(list)) {
306
312
  store.removeItem(key);
307
313
  return null;
@@ -311,7 +317,7 @@ function readStoredAttachments(conversationId) {
311
317
  store.removeItem(key);
312
318
  return null;
313
319
  }
314
- return valid;
320
+ return { attachments: valid, batchId: typeof batchId === 'string' && batchId !== '' ? batchId : null };
315
321
  }
316
322
  catch {
317
323
  try {
@@ -345,8 +351,48 @@ export function readCachedAttachments({ conversationId }) {
345
351
  const stored = readStoredAttachments(conversationId);
346
352
  if (stored === null)
347
353
  return null;
348
- stagedAttachments.set(conversationId, stored);
349
- return stored;
354
+ stagedAttachments.set(conversationId, stored.attachments);
355
+ if (stored.batchId !== null)
356
+ stagedBatchIds.set(conversationId, stored.batchId);
357
+ return stored.attachments;
358
+ }
359
+ /**
360
+ * The upload batch `conversationId`'s cached attachments were uploaded under, so a pane that
361
+ * restores those attachments uploads the next file into the SAME batch.
362
+ *
363
+ * Why this exists: the daemon grants the agent exactly one batch directory per turn and refuses a
364
+ * turn whose attachments span two ("Attachments must belong to one batch"). A pane that restored an
365
+ * attachment after a remount, reload, or conversation switch used to upload the next file under a
366
+ * brand-new batch id, so every such turn failed before the agent started (2026-10-06).
367
+ *
368
+ * @returns The batch id, or `null` when there are no cached attachments to stay consistent with
369
+ * (none staged, attachment persistence off because the host supplied no validator, or an entry
370
+ * written before batch ids were recorded) — the caller then starts a fresh batch.
371
+ * @complexity Time/space: O(1) on an in-memory hit, O(n) in the stored count otherwise.
372
+ */
373
+ export function readCachedAttachmentBatchId({ conversationId }) {
374
+ if (!conversationId)
375
+ return null;
376
+ if (readCachedAttachments({ conversationId: conversationId }) === null)
377
+ return null;
378
+ return stagedBatchIds.get(conversationId) ?? null;
379
+ }
380
+ /**
381
+ * Records the upload batch of an attachment about to be staged in `conversationId`'s composer.
382
+ * Memory only: the stored envelope picks it up on the {@link writeCachedAttachments} that persists
383
+ * that attachment, so it is never written ahead of the references it describes.
384
+ *
385
+ * @complexity Time/space: O(1) amortized.
386
+ */
387
+ export function writeCachedAttachmentBatchId({ conversationId, batchId }) {
388
+ if (!conversationId)
389
+ return;
390
+ if (!stagedBatchIds.has(conversationId) && stagedBatchIds.size >= MAX_CACHED_CONVERSATION_DRAFTS) {
391
+ const oldest = stagedBatchIds.keys().next().value;
392
+ if (oldest !== undefined)
393
+ stagedBatchIds.delete(oldest);
394
+ }
395
+ stagedBatchIds.set(conversationId, batchId);
350
396
  }
351
397
  /**
352
398
  * Stores `attachments` for `conversationId` in both tiers.
@@ -362,6 +408,8 @@ export function writeCachedAttachments({ conversationId, attachments }) {
362
408
  const store = storage();
363
409
  if (attachments.length === 0) {
364
410
  stagedAttachments.delete(conversationId);
411
+ // A sent or cleared turn's batch is spent; the next upload starts a fresh one.
412
+ stagedBatchIds.delete(conversationId);
365
413
  try {
366
414
  store?.removeItem(key);
367
415
  }
@@ -381,7 +429,8 @@ export function writeCachedAttachments({ conversationId, attachments }) {
381
429
  try {
382
430
  if (store.getItem(key) === null)
383
431
  pruneStored(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, MAX_CACHED_CONVERSATION_DRAFTS - 1);
384
- const envelope = { v: 1, t: Date.now(), a: attachments };
432
+ const batchId = stagedBatchIds.get(conversationId);
433
+ const envelope = { v: 1, t: Date.now(), a: attachments, ...(batchId === undefined ? {} : { b: batchId }) };
385
434
  store.setItem(key, JSON.stringify(envelope));
386
435
  }
387
436
  catch {
@@ -399,6 +448,7 @@ export function writeCachedAttachments({ conversationId, attachments }) {
399
448
  export function __resetComposerDraftCacheForTests(options) {
400
449
  drafts.clear();
401
450
  stagedAttachments.clear();
451
+ stagedBatchIds.clear();
402
452
  if (options?.keepStorage === true)
403
453
  return;
404
454
  pruneStored(COMPOSER_DRAFT_STORAGE_PREFIX, 0);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @module last-conversation-store
3
+ *
4
+ * Remembers which conversation an operator last had open, so a host's chat surface can land back
5
+ * on it after a page reload, a closed tab, or a navigation away — instead of an empty new chat.
6
+ *
7
+ * Conversation SELECTION is host-owned (`ChatPane` is uncontrolled and takes a `conversationId`;
8
+ * `ConversationList` takes `activeConversationId` as a prop), so this module does not decide when
9
+ * to restore. It is only the durable slot: the host reads it once at mount, checks the id still
10
+ * exists for this caller, and writes it whenever its active conversation changes.
11
+ *
12
+ * Same storage discipline as `composer-draft-cache.ts`: a versioned key, every access wrapped, and a
13
+ * foreign or corrupt entry treated as "nothing remembered" and deleted. A private window, blocked
14
+ * site data or a quota rejection degrades this to remembering nothing — never a throw at the host.
15
+ *
16
+ * Unlike the draft cache this is NOT a module singleton. The partition here is the caller's
17
+ * identity (`scope`), which only the host knows — two users on one browser must never land in each
18
+ * other's chat — so a host constructs one store per scope and passes it down.
19
+ */
20
+ /**
21
+ * Key prefix for the stored entry. Versioned (`.v1.`) so a future change to the stored shape can be
22
+ * introduced without interpreting, or mis-interpreting, entries written by this one.
23
+ */
24
+ export declare const LAST_CONVERSATION_STORAGE_PREFIX = "jini.chat.last-conversation.v1.";
25
+ /** The subset of Web Storage this module uses — `localStorage` satisfies it; tests pass a fake. */
26
+ export interface LastConversationStorage {
27
+ getItem(key: string): string | null;
28
+ setItem(key: string, value: string): void;
29
+ removeItem(key: string): void;
30
+ }
31
+ /** The host-facing port. Every method is total: none throws, whatever storage does. */
32
+ export interface LastConversationStore {
33
+ /** The remembered conversation id, or `null` when there is none or storage is unusable. */
34
+ read(): string | null;
35
+ /** Remembers `conversationId` as the one to land on next time, replacing any earlier one. */
36
+ write(conversationId: string): void;
37
+ /** Forgets the remembered id — for a conversation that was deleted or is no longer reachable. */
38
+ clear(): void;
39
+ }
40
+ /**
41
+ * Creates the last-conversation slot for one caller identity.
42
+ *
43
+ * @param required.scope Who this memory belongs to — e.g. `${workspaceId}:${userId}`. Part of the
44
+ * key, so a different scope on the same browser reads nothing.
45
+ * @param optional.storage Defaults to the origin's `localStorage`, resolved on every call (not once)
46
+ * so a store created during SSR still works after hydration. `null` disables persistence.
47
+ * @returns A {@link LastConversationStore}; see its methods for the never-throws contract.
48
+ * @example
49
+ * const store = createLastConversationStore({ scope: `${workspaceId}:${user.id}` });
50
+ * const id = store.read(); // land here if it still exists, else store.clear()
51
+ * @complexity Time/space: O(1) per call, plus O(n) in the entry's length for the JSON parse.
52
+ */
53
+ export declare function createLastConversationStore({ scope }: {
54
+ scope: string;
55
+ }, { storage }?: {
56
+ storage?: LastConversationStorage | null;
57
+ }): LastConversationStore;
58
+ //# sourceMappingURL=last-conversation-store.d.ts.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @module last-conversation-store
3
+ *
4
+ * Remembers which conversation an operator last had open, so a host's chat surface can land back
5
+ * on it after a page reload, a closed tab, or a navigation away — instead of an empty new chat.
6
+ *
7
+ * Conversation SELECTION is host-owned (`ChatPane` is uncontrolled and takes a `conversationId`;
8
+ * `ConversationList` takes `activeConversationId` as a prop), so this module does not decide when
9
+ * to restore. It is only the durable slot: the host reads it once at mount, checks the id still
10
+ * exists for this caller, and writes it whenever its active conversation changes.
11
+ *
12
+ * Same storage discipline as `composer-draft-cache.ts`: a versioned key, every access wrapped, and a
13
+ * foreign or corrupt entry treated as "nothing remembered" and deleted. A private window, blocked
14
+ * site data or a quota rejection degrades this to remembering nothing — never a throw at the host.
15
+ *
16
+ * Unlike the draft cache this is NOT a module singleton. The partition here is the caller's
17
+ * identity (`scope`), which only the host knows — two users on one browser must never land in each
18
+ * other's chat — so a host constructs one store per scope and passes it down.
19
+ */
20
+ /**
21
+ * Key prefix for the stored entry. Versioned (`.v1.`) so a future change to the stored shape can be
22
+ * introduced without interpreting, or mis-interpreting, entries written by this one.
23
+ */
24
+ export const LAST_CONVERSATION_STORAGE_PREFIX = 'jini.chat.last-conversation.v1.';
25
+ /**
26
+ * The origin's `localStorage`, or `null` when it cannot be used — a non-browser runtime, or a
27
+ * browser that throws on the property access itself (Safari and Chrome both do with site data
28
+ * blocked), which is why this is a try/catch rather than a `typeof` check alone.
29
+ * @complexity Time/space: O(1).
30
+ */
31
+ function browserStorage() {
32
+ try {
33
+ return typeof localStorage === 'undefined' ? null : localStorage;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /**
40
+ * Validates one parsed entry as this module's own envelope.
41
+ * @complexity Time/space: O(1).
42
+ */
43
+ function storedId(parsed) {
44
+ const entry = parsed;
45
+ if (entry?.v !== 1 || typeof entry.id !== 'string' || entry.id === '')
46
+ return null;
47
+ return entry.id;
48
+ }
49
+ /**
50
+ * Creates the last-conversation slot for one caller identity.
51
+ *
52
+ * @param required.scope Who this memory belongs to — e.g. `${workspaceId}:${userId}`. Part of the
53
+ * key, so a different scope on the same browser reads nothing.
54
+ * @param optional.storage Defaults to the origin's `localStorage`, resolved on every call (not once)
55
+ * so a store created during SSR still works after hydration. `null` disables persistence.
56
+ * @returns A {@link LastConversationStore}; see its methods for the never-throws contract.
57
+ * @example
58
+ * const store = createLastConversationStore({ scope: `${workspaceId}:${user.id}` });
59
+ * const id = store.read(); // land here if it still exists, else store.clear()
60
+ * @complexity Time/space: O(1) per call, plus O(n) in the entry's length for the JSON parse.
61
+ */
62
+ export function createLastConversationStore({ scope }, { storage } = {}) {
63
+ const key = `${LAST_CONVERSATION_STORAGE_PREFIX}${encodeURIComponent(scope)}`;
64
+ const resolve = () => (storage === undefined ? browserStorage() : storage);
65
+ const remove = (store) => {
66
+ try {
67
+ store.removeItem(key);
68
+ }
69
+ catch {
70
+ /* storage is unusable; nothing further this module can do */
71
+ }
72
+ };
73
+ return {
74
+ read() {
75
+ const store = resolve();
76
+ if (!store)
77
+ return null;
78
+ try {
79
+ const raw = store.getItem(key);
80
+ if (raw === null)
81
+ return null;
82
+ const id = storedId(JSON.parse(raw));
83
+ if (id === null)
84
+ remove(store);
85
+ return id;
86
+ }
87
+ catch {
88
+ // A parse failure or a throwing read: a corrupt entry must not survive to be read again.
89
+ remove(store);
90
+ return null;
91
+ }
92
+ },
93
+ write(conversationId) {
94
+ const store = resolve();
95
+ if (!store)
96
+ return;
97
+ try {
98
+ const envelope = { v: 1, id: conversationId };
99
+ store.setItem(key, JSON.stringify(envelope));
100
+ }
101
+ catch {
102
+ /* blocked, full, or unusable storage — remembering nothing is the documented degraded mode */
103
+ }
104
+ },
105
+ clear() {
106
+ const store = resolve();
107
+ if (store)
108
+ remove(store);
109
+ },
110
+ };
111
+ }
112
+ //# sourceMappingURL=last-conversation-store.js.map
@@ -0,0 +1,20 @@
1
+ import { type SurfaceExpiry } from '../../core/index.js';
2
+ /** The clock port: the current time, and a repeating ticker that returns its own stop function. */
3
+ export interface SurfaceExpiryClock {
4
+ nowMs(): number;
5
+ every(intervalMs: number, tick: () => void): () => void;
6
+ }
7
+ /** The wall clock and `setInterval`; the default when a caller injects none. */
8
+ export declare const SYSTEM_SURFACE_EXPIRY_CLOCK: SurfaceExpiryClock;
9
+ /**
10
+ * @param input.expiresAtMs - The card's deadline (epoch ms), or `undefined` when it has none.
11
+ * @param options.clock - Time source and ticker. Defaults to {@link SYSTEM_SURFACE_EXPIRY_CLOCK}.
12
+ * @returns The deadline's state at the last tick, or `undefined` when there is no deadline.
13
+ * @complexity O(1) per render; one ticker while live.
14
+ */
15
+ export declare function useSurfaceExpiry({ expiresAtMs }: {
16
+ expiresAtMs: number | undefined;
17
+ }, { clock }?: {
18
+ clock?: SurfaceExpiryClock;
19
+ }): SurfaceExpiry | undefined;
20
+ //# sourceMappingURL=useSurfaceExpiry.d.ts.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @module useSurfaceExpiry
3
+ *
4
+ * The live countdown for a pending MCP-UI card: `describeSurfaceExpiry` re-read once a second on an
5
+ * injected clock. Ticks only while the card is answerable and has a deadline; an expired card or one
6
+ * without a deadline runs no timer.
7
+ */
8
+ import { useEffect, useState } from 'react';
9
+ import { describeSurfaceExpiry, SURFACE_EXPIRY_TICK_MS } from '../../core/index.js';
10
+ /** The wall clock and `setInterval`; the default when a caller injects none. */
11
+ export const SYSTEM_SURFACE_EXPIRY_CLOCK = {
12
+ nowMs: () => Date.now(),
13
+ every: (intervalMs, tick) => {
14
+ const timer = setInterval(tick, intervalMs);
15
+ return () => clearInterval(timer);
16
+ },
17
+ };
18
+ /**
19
+ * @param input.expiresAtMs - The card's deadline (epoch ms), or `undefined` when it has none.
20
+ * @param options.clock - Time source and ticker. Defaults to {@link SYSTEM_SURFACE_EXPIRY_CLOCK}.
21
+ * @returns The deadline's state at the last tick, or `undefined` when there is no deadline.
22
+ * @complexity O(1) per render; one ticker while live.
23
+ */
24
+ export function useSurfaceExpiry({ expiresAtMs }, { clock = SYSTEM_SURFACE_EXPIRY_CLOCK } = {}) {
25
+ const [nowMs, setNowMs] = useState(() => clock.nowMs());
26
+ const expiry = expiresAtMs === undefined ? undefined : describeSurfaceExpiry({ expiresAtMs, nowMs });
27
+ const live = expiry?.expired === false;
28
+ useEffect(() => {
29
+ if (!live)
30
+ return undefined;
31
+ return clock.every(SURFACE_EXPIRY_TICK_MS, () => setNowMs(clock.nowMs()));
32
+ }, [live, clock]);
33
+ return expiry;
34
+ }
35
+ //# sourceMappingURL=useSurfaceExpiry.js.map
@@ -3,17 +3,20 @@
3
3
  *
4
4
  * Per-tool lifecycle rows for a message's events: pairs each `tool_use` with
5
5
  * its `tool_result` (deduped by id via `@jini-ai/chat/core`'s
6
- * `dedupeToolUsesById`), derives a four-state status via
6
+ * `dedupeToolUsesById`, delegated wrapper calls folded onto their canonical
7
+ * rows via `foldDelegatedWrapperCalls`), derives a four-state status via
7
8
  * `deriveToolStatus`, and tracks per-row expand/collapse UI state. Pure over
8
9
  * `AgentEvent[]` — zero I/O. Per
9
10
  * `ADS-memory/reports/jini-port/recon/r4b-webui-design.md` §4.
10
11
  */
11
12
  import { useCallback, useMemo, useState } from 'react';
12
- import { dedupeToolUsesById, deriveToolStatus } from '../../core/index.js';
13
+ import { dedupeToolUsesById, deriveToolStatus, foldDelegatedWrapperCalls } from '../../core/index.js';
13
14
  export function useToolTimeline({ events }, options = {}) {
14
15
  const { runStreaming = false, runSucceeded = false, defaultExpanded = false } = options;
15
16
  const [overrides, setOverrides] = useState({});
16
- const deduped = useMemo(() => dedupeToolUsesById({ events: events }), [events]);
17
+ // A delegated call's wrapper row and the daemon's canonical row have different ids, so dedupe
18
+ // alone leaves both; the fold keeps the canonical one (see `foldDelegatedWrapperCalls`).
19
+ const deduped = useMemo(() => foldDelegatedWrapperCalls({ events: dedupeToolUsesById({ events: events }) }), [events]);
17
20
  const rows = useMemo(() => {
18
21
  const out = [];
19
22
  for (const ev of deduped) {
@@ -36,8 +36,8 @@ export type { ExtEventRenderProps, ExtEventRenderer, ExtEventRendererOptions, Ex
36
36
  * This alias has no removal date scheduled yet; see `chat-pane.ts` for the canonical surface and
37
37
  * the module doc there for why the split exists.
38
38
  */
39
- export { AgentRuntimePicker, CHAT_PANE_AGENT_TOOLS, ChatPane, createDaemonAttachmentUploader, createMcpUiToolCaller, defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, useChatPane, useChatPaneAgentControl, useChatPaneRuntimeInventory, useChatPaneWorkingDirectory, } from './chat-pane.js';
40
- export type { AgentRuntimePickerProps, ByokRuntimeSummary, ChatPaneActivity, ChatPaneAgent, ChatPaneAgentBridgeAccess, ChatPaneAgentControlOptions, ChatPaneAgentOption, ChatPaneAgentSelection, ChatPaneAgentToolAction, ChatPaneAgentToolDef, ChatPaneAgentToolInputSchema, ChatPaneAgentToolRisk, ChatPaneAttachmentUploadOptions, ChatPaneComposerHandle, ChatPaneProps, ChatPaneRunContext, ChatPaneRunContextInput, ChatPaneRuntimeAccess, ChatPaneVariant, ChatPaneWorkingDirectoryAccess, CreateDaemonAttachmentUploaderOptions, CreateMcpUiToolCallerOptions, McpUiToolCallRequest, RuntimePickerPlacement, UseChatPaneAgentControlOptions, UseChatPaneOptions, UseChatPaneResult, UseChatPaneRuntimeInventoryOptions, UseChatPaneRuntimeInventoryResult, UseChatPaneWorkingDirectoryOptions, UseChatPaneWorkingDirectoryResult, } from './chat-pane.js';
39
+ export { AgentRuntimePicker, CHAT_PANE_AGENT_TOOLS, ChatPane, createDaemonAttachmentUploader, createMcpUiToolCaller, createTypedAnswerPoster, defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, useChatPane, useChatPaneAgentControl, useChatPaneRuntimeInventory, useChatPaneWorkingDirectory, } from './chat-pane.js';
40
+ export type { AgentRuntimePickerProps, ByokRuntimeSummary, ChatPaneActivity, ChatPaneAgent, ChatPaneAgentBridgeAccess, ChatPaneAgentControlOptions, ChatPaneAgentOption, ChatPaneAgentSelection, ChatPaneAgentToolAction, ChatPaneAgentToolDef, ChatPaneAgentToolInputSchema, ChatPaneAgentToolRisk, ChatPaneAttachmentUploadOptions, ChatPaneComposerHandle, ChatPaneProps, ChatPaneRunContext, ChatPaneRunContextInput, ChatPaneRuntimeAccess, ChatPaneVariant, ChatPaneWorkingDirectoryAccess, CreateDaemonAttachmentUploaderOptions, CreateMcpUiToolCallerOptions, CreateTypedAnswerPosterOptions, DeliverTypedAnswer, McpUiToolCallRequest, RuntimePickerPlacement, TypedAnswerDelivery, TypedAnswerNotice, UseChatPaneAgentControlOptions, UseChatPaneOptions, UseChatPaneResult, UseChatPaneRuntimeInventoryOptions, UseChatPaneRuntimeInventoryResult, UseChatPaneWorkingDirectoryOptions, UseChatPaneWorkingDirectoryResult, } from './chat-pane.js';
41
41
  export { useRunStream, } from './hooks/useRunStream.js';
42
42
  export type { RunStreamState, RunStreamStatus, StartRunOptions, UseRunStreamResult, } from './hooks/useRunStream.js';
43
43
  export { useConversation, } from './hooks/useConversation.js';
@@ -48,6 +48,8 @@ export { useToolTimeline, } from './hooks/useToolTimeline.js';
48
48
  export type { ToolTimelineRow, UseToolTimelineOptions, UseToolTimelineResult, } from './hooks/useToolTimeline.js';
49
49
  export { useExtEventGroups, } from './hooks/useExtEventGroups.js';
50
50
  export type { ExtEventGroup, } from './hooks/useExtEventGroups.js';
51
+ export { SYSTEM_SURFACE_EXPIRY_CLOCK, useSurfaceExpiry, } from './hooks/useSurfaceExpiry.js';
52
+ export type { SurfaceExpiryClock, } from './hooks/useSurfaceExpiry.js';
51
53
  export { usePinnedTodos, } from './hooks/usePinnedTodos.js';
52
54
  export type { UsePinnedTodosResult, } from './hooks/usePinnedTodos.js';
53
55
  export { parseSubmittedAnswers, useQuestionForms, } from './hooks/useQuestionForms.js';
@@ -110,6 +112,12 @@ export type { TodoCardProps } from './components/TodoCard.js';
110
112
  */
111
113
  export { ConversationList } from './components/ConversationList.js';
112
114
  export type { ConversationListProps, ConversationListItem } from './components/ConversationList.js';
115
+ /**
116
+ * The conversation an operator last had open, per caller identity — so a host's switcher lands
117
+ * back on it after a reload instead of an empty new chat. See the module doc for the contract.
118
+ */
119
+ export { createLastConversationStore, LAST_CONVERSATION_STORAGE_PREFIX } from './hooks/last-conversation-store.js';
120
+ export type { LastConversationStorage, LastConversationStore } from './hooks/last-conversation-store.js';
113
121
  export { ToolCard } from './components/ToolCard.js';
114
122
  export type { ToolCardProps } from './components/ToolCard.js';
115
123
  export { A2uiSurfaceCard } from './components/A2uiSurfaceCard.js';
@@ -26,12 +26,13 @@ export { clearExtEventRenderers, extEventSlot, getExtEventRenderer, registerExtE
26
26
  * This alias has no removal date scheduled yet; see `chat-pane.ts` for the canonical surface and
27
27
  * the module doc there for why the split exists.
28
28
  */
29
- export { AgentRuntimePicker, CHAT_PANE_AGENT_TOOLS, ChatPane, createDaemonAttachmentUploader, createMcpUiToolCaller, defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, useChatPane, useChatPaneAgentControl, useChatPaneRuntimeInventory, useChatPaneWorkingDirectory, } from './chat-pane.js';
29
+ export { AgentRuntimePicker, CHAT_PANE_AGENT_TOOLS, ChatPane, createDaemonAttachmentUploader, createMcpUiToolCaller, createTypedAnswerPoster, defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, useChatPane, useChatPaneAgentControl, useChatPaneRuntimeInventory, useChatPaneWorkingDirectory, } from './chat-pane.js';
30
30
  export { useRunStream, } from './hooks/useRunStream.js';
31
31
  export { useConversation, } from './hooks/useConversation.js';
32
32
  export { useComposer, } from './hooks/useComposer.js';
33
33
  export { useToolTimeline, } from './hooks/useToolTimeline.js';
34
34
  export { useExtEventGroups, } from './hooks/useExtEventGroups.js';
35
+ export { SYSTEM_SURFACE_EXPIRY_CLOCK, useSurfaceExpiry, } from './hooks/useSurfaceExpiry.js';
35
36
  export { usePinnedTodos, } from './hooks/usePinnedTodos.js';
36
37
  export { parseSubmittedAnswers, useQuestionForms, } from './hooks/useQuestionForms.js';
37
38
  export { useArtifactStream, } from './hooks/useArtifactStream.js';
@@ -85,6 +86,11 @@ export { TodoCard } from './components/TodoCard.js';
85
86
  * `leadingAccessory` slot; it needs no changes to `ChatPane` itself.
86
87
  */
87
88
  export { ConversationList } from './components/ConversationList.js';
89
+ /**
90
+ * The conversation an operator last had open, per caller identity — so a host's switcher lands
91
+ * back on it after a reload instead of an empty new chat. See the module doc for the contract.
92
+ */
93
+ export { createLastConversationStore, LAST_CONVERSATION_STORAGE_PREFIX } from './hooks/last-conversation-store.js';
88
94
  export { ToolCard } from './components/ToolCard.js';
89
95
  export { A2uiSurfaceCard } from './components/A2uiSurfaceCard.js';
90
96
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/chat",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -145,15 +145,15 @@
145
145
  "registry": "https://registry.npmjs.org"
146
146
  },
147
147
  "dependencies": {
148
- "@jini-ai/agentic": "^0.4.1",
148
+ "@jini-ai/agentic": "^0.4.2",
149
+ "@jini-ai/ui": "^0.4.4",
149
150
  "@jini-ai/protocol": "^0.4.0",
150
- "@jini-ai/core": "^0.4.0",
151
- "@jini-ai/ui": "^0.4.2"
151
+ "@jini-ai/core": "^0.4.1"
152
152
  },
153
153
  "peerDependencies": {
154
154
  "react": "^18.3.0 || ^19.0.0",
155
155
  "react-dom": "^18.3.0 || ^19.0.0",
156
- "@jini-ai/db": "^0.2.0",
156
+ "@jini-ai/db": "^0.2.0 || ^0.3.0",
157
157
  "kysely": "^0.29.6",
158
158
  "better-sqlite3": "^13.0.0",
159
159
  "@electric-sql/pglite": "0.5.8",
@@ -238,8 +238,8 @@
238
238
  "@electric-sql/pglite": "0.5.8",
239
239
  "pg": "8.23.0",
240
240
  "@ag-ui/core": "0.0.58",
241
- "@jini-ai/agent-runtime": "0.4.0",
242
- "@jini-ai/db": "0.2.0"
241
+ "@jini-ai/db": "0.3.0",
242
+ "@jini-ai/agent-runtime": "0.4.0"
243
243
  },
244
244
  "typesVersions": {
245
245
  "*": {