@jini-ai/chat 0.5.2 → 0.5.4

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 (47) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/core/composer-history.d.ts +54 -0
  3. package/dist/core/composer-history.js +57 -0
  4. package/dist/core/durable-projection.d.ts +28 -0
  5. package/dist/core/durable-projection.js +71 -0
  6. package/dist/core/index.d.ts +3 -0
  7. package/dist/core/index.js +2 -0
  8. package/dist/core/run-activity.d.ts +12 -20
  9. package/dist/core/run-activity.js +59 -7
  10. package/dist/core/tool-events.d.ts +34 -0
  11. package/dist/core/tool-events.js +61 -0
  12. package/dist/core/tools.d.ts +1 -1
  13. package/dist/core/tools.js +1 -1
  14. package/dist/core/transport.d.ts +4 -0
  15. package/dist/react/components/Composer.js +6 -0
  16. package/dist/react/components/Markdown.js +37 -6
  17. package/dist/react/components/MessageList.hooks.js +8 -2
  18. package/dist/react/components/MessageList.rules.d.ts +18 -0
  19. package/dist/react/components/MessageList.rules.js +15 -0
  20. package/dist/react/components/MessageRow.js +26 -11
  21. package/dist/react/components/ToolCard.js +10 -7
  22. package/dist/react/ext-event-renderer-registry.d.ts +7 -0
  23. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  24. package/dist/react/features/chat-pane/components/ChatPane.js +5 -3
  25. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +3 -0
  26. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +42 -9
  27. package/dist/react/features/chat-pane/rules.d.ts +6 -3
  28. package/dist/react/features/chat-pane/rules.js +6 -3
  29. package/dist/react/features/chat-pane/types.d.ts +5 -0
  30. package/dist/react/hooks/composer-draft-cache.d.ts +28 -0
  31. package/dist/react/hooks/composer-draft-cache.js +56 -6
  32. package/dist/react/hooks/composer-history-storage.d.ts +8 -0
  33. package/dist/react/hooks/composer-history-storage.js +40 -0
  34. package/dist/react/hooks/last-conversation-store.d.ts +58 -0
  35. package/dist/react/hooks/last-conversation-store.js +112 -0
  36. package/dist/react/hooks/useComposer.d.ts +10 -1
  37. package/dist/react/hooks/useComposer.js +14 -2
  38. package/dist/react/hooks/useComposerHistory.hooks.d.ts +15 -0
  39. package/dist/react/hooks/useComposerHistory.hooks.js +90 -0
  40. package/dist/react/hooks/useComposerHistoryKeys.hooks.d.ts +9 -0
  41. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +90 -0
  42. package/dist/react/hooks/useConversation.js +6 -2
  43. package/dist/react/hooks/useRunStream.js +12 -2
  44. package/dist/react/hooks/useToolTimeline.js +6 -3
  45. package/dist/react/index.d.ts +8 -0
  46. package/dist/react/index.js +6 -0
  47. package/package.json +5 -5
@@ -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,8 @@
1
+ import { type ComposerHistoryStoragePort } from '../../core/composer-history.js';
2
+ /** Browser default; private/embedded hosts may replace this port without changing recall. */
3
+ export declare function createBrowserComposerHistoryStorage(_required: {}, { storage, prefix }?: {
4
+ storage?: Pick<Storage, 'getItem' | 'setItem'>;
5
+ prefix?: string;
6
+ }): ComposerHistoryStoragePort;
7
+ export declare const browserComposerHistoryStorage: ComposerHistoryStoragePort;
8
+ //# sourceMappingURL=composer-history-storage.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { normalizeComposerHistory } from '../../core/composer-history.js';
2
+ /** Browser default; private/embedded hosts may replace this port without changing recall. */
3
+ export function createBrowserComposerHistoryStorage(_required, { storage, prefix = 'jini:composer-history:v1:' } = {}) {
4
+ const memory = new Map();
5
+ function resolveStorage() {
6
+ // Accessing localStorage itself can throw (sandboxed embeds/private browsing).
7
+ return storage ?? globalThis.localStorage;
8
+ }
9
+ return {
10
+ async read({ scope }, _optional = {}) {
11
+ try {
12
+ const raw = resolveStorage()?.getItem(prefix + encodeURIComponent(scope));
13
+ if (raw) {
14
+ const parsed = JSON.parse(raw);
15
+ if (Array.isArray(parsed)) {
16
+ const entries = normalizeComposerHistory({ entries: parsed.filter((value) => typeof value === 'string') });
17
+ memory.set(scope, entries);
18
+ return entries;
19
+ }
20
+ }
21
+ }
22
+ catch {
23
+ // Storage denial/corruption must not disable draft editing or in-memory recall.
24
+ }
25
+ return memory.get(scope) ?? [];
26
+ },
27
+ async write({ scope, entries }, _optional = {}) {
28
+ const normalized = normalizeComposerHistory({ entries });
29
+ memory.set(scope, normalized);
30
+ try {
31
+ resolveStorage()?.setItem(prefix + encodeURIComponent(scope), JSON.stringify(normalized));
32
+ }
33
+ catch {
34
+ // Quota/denial falls back to the same scoped in-memory list.
35
+ }
36
+ },
37
+ };
38
+ }
39
+ export const browserComposerHistoryStorage = createBrowserComposerHistoryStorage({});
40
+ //# sourceMappingURL=composer-history-storage.js.map
@@ -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
@@ -1,4 +1,6 @@
1
- import type { ChatAttachment } from '../../core/index.js';
1
+ import type { ChatAttachment, ChatMessage } from '../../core/index.js';
2
+ import type { ComposerHistoryStoragePort } from '../../core/composer-history.js';
3
+ import { type ComposerHistoryController } from './useComposerHistory.hooks.js';
2
4
  import type { AgentSelection, ComposerSlots, MentionResult, ProjectContextValue } from '../slots.js';
3
5
  export interface ComposerDraftPersistence {
4
6
  read: () => string | null;
@@ -6,6 +8,11 @@ export interface ComposerDraftPersistence {
6
8
  }
7
9
  export interface UseComposerOptions {
8
10
  initialDraft?: string;
11
+ /** This principal's current transcript; only user-role text participates in recall. */
12
+ historyMessages?: readonly ChatMessage[];
13
+ /** Scopes recent prompts across conversations. Hosts with multiple users must supply an id. */
14
+ historyScope?: string;
15
+ historyStorage?: ComposerHistoryStoragePort;
9
16
  initialAgent?: AgentSelection;
10
17
  project?: ProjectContextValue;
11
18
  composerSlots?: ComposerSlots;
@@ -42,6 +49,8 @@ export interface MentionPopoverState {
42
49
  }
43
50
  export interface UseComposerResult {
44
51
  draft: string;
52
+ /** Optional for compatibility with host-created composer controllers. */
53
+ history?: ComposerHistoryController;
45
54
  setDraft: (next: string) => void;
46
55
  attachments: ChatAttachment[];
47
56
  /** Uploads via `project.uploadFiles` (when supplied) and stages the results; no-ops (with a rejected promise) when no upload port is wired. */
@@ -28,6 +28,7 @@
28
28
  * the wrong thread, so they are cleared instead.
29
29
  */
30
30
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
31
+ import { useComposerHistory } from './useComposerHistory.hooks.js';
31
32
  import { readCachedAttachments, readCachedDraft, writeCachedAttachments, writeCachedDraft, } from './composer-draft-cache.js';
32
33
  import { cacheAttachmentPreviewSource } from './attachment-preview-cache.js';
33
34
  const EMPTY_MENTION = { open: false, query: '', results: [] };
@@ -37,11 +38,21 @@ export function useComposer(options = {}) {
37
38
  const [attachments, setAttachments] = useState([]);
38
39
  const [agent, setAgent] = useState(options.initialAgent);
39
40
  const [mention, setMention] = useState(EMPTY_MENTION);
40
- const setDraft = useCallback((next) => {
41
+ const commitDraft = useCallback((next) => {
41
42
  setDraftState(next);
42
43
  persistence?.write(next);
43
44
  writeCachedDraft({ conversationId: conversationId, draft: next });
44
45
  }, [persistence, conversationId]);
46
+ const history = useComposerHistory({ setRecalledDraft: setDraftState }, {
47
+ ...(options.historyMessages === undefined ? {} : { messages: options.historyMessages }),
48
+ ...(options.historyScope === undefined ? {} : { scope: options.historyScope }),
49
+ ...(options.historyStorage === undefined ? {} : { storage: options.historyStorage }),
50
+ ...(conversationId === undefined ? {} : { conversationId }),
51
+ });
52
+ const setDraft = useCallback((next) => {
53
+ history.edit({});
54
+ commitDraft(next);
55
+ }, [commitDraft, history.edit]);
45
56
  // Lets the conversation-change effect below read the live draft without taking `draft` as a
46
57
  // dependency, which would re-run it on every keystroke.
47
58
  const draftRef = useRef(draft);
@@ -195,6 +206,7 @@ export function useComposer(options = {}) {
195
206
  const canSubmit = draft.trim().length > 0 || attachments.length > 0;
196
207
  return useMemo(() => ({
197
208
  draft,
209
+ history,
198
210
  setDraft,
199
211
  attachments,
200
212
  addAttachments,
@@ -209,6 +221,6 @@ export function useComposer(options = {}) {
209
221
  selectMention,
210
222
  canSubmit,
211
223
  reset,
212
- }), [draft, setDraft, attachments, addAttachments, addAttachment, removeAttachment, clearAttachments, agent, mention, openMention, closeMention, selectMention, canSubmit, reset]);
224
+ }), [draft, history, setDraft, attachments, addAttachments, addAttachment, removeAttachment, clearAttachments, agent, mention, openMention, closeMention, selectMention, canSubmit, reset]);
213
225
  }
214
226
  //# sourceMappingURL=useComposer.js.map
@@ -0,0 +1,15 @@
1
+ import type { ChatMessage } from '../../core/messages.js';
2
+ import { type ComposerHistoryKeyInput, type ComposerHistoryStoragePort } from '../../core/composer-history.js';
3
+ export interface ComposerHistoryController {
4
+ navigate: (input: ComposerHistoryKeyInput, optional?: {}) => boolean;
5
+ edit: (required: {}, optional?: {}) => void;
6
+ }
7
+ export declare function useComposerHistory({ setRecalledDraft }: {
8
+ setRecalledDraft: (text: string) => void;
9
+ }, { messages, scope, storage, conversationId }?: {
10
+ messages?: readonly ChatMessage[];
11
+ scope?: string;
12
+ storage?: ComposerHistoryStoragePort;
13
+ conversationId?: string | null;
14
+ }): ComposerHistoryController;
15
+ //# sourceMappingURL=useComposerHistory.hooks.d.ts.map
@@ -0,0 +1,90 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { composerHistoryKeyAction, createComposerHistoryState, mergeComposerHistory, normalizeComposerHistory, transitionComposerHistory, } from '../../core/composer-history.js';
3
+ import { browserComposerHistoryStorage } from './composer-history-storage.js';
4
+ const EMPTY_MESSAGES = [];
5
+ export function useComposerHistory({ setRecalledDraft }, { messages = EMPTY_MESSAGES, scope = 'default', storage = browserComposerHistoryStorage, conversationId } = {}) {
6
+ const browseRef = useRef(createComposerHistoryState({}));
7
+ const [recent, setRecent] = useState([]);
8
+ const hydratedPortRef = useRef(null);
9
+ const [hydratedScope, setHydratedScope] = useState(null);
10
+ const pendingRef = useRef([]);
11
+ const conversationRef = useRef(conversationId);
12
+ const scopeRef = useRef(scope);
13
+ const seenRef = useRef(new Set(messages.map((message) => message.id)));
14
+ const writeQueueRef = useRef(Promise.resolve());
15
+ const current = useMemo(() => messages.filter((message) => message.role === 'user').map((message) => message.content), [messages]);
16
+ const entries = mergeComposerHistory({ recent, current });
17
+ useEffect(() => {
18
+ let cancelled = false;
19
+ pendingRef.current = [];
20
+ hydratedPortRef.current = null;
21
+ setRecent([]);
22
+ setHydratedScope(null);
23
+ browseRef.current = createComposerHistoryState({});
24
+ // Read after this hook's outstanding writes so an injected async store cannot return stale
25
+ // entries when a scope is revisited. A remounted hook's default storage writes synchronously.
26
+ void writeQueueRef.current.then(() => storage.read({ scope }, {})).catch(() => []).then((loaded) => {
27
+ if (cancelled)
28
+ return;
29
+ // Bootstrap an empty recent store from the already-sent current transcript. This
30
+ // makes opening a new chat useful immediately after installing recall; opening an
31
+ // older transcript later does not reorder an established recent list.
32
+ const seed = loaded.length > 0 ? loaded : current;
33
+ setRecent(normalizeComposerHistory({ entries: [...seed, ...pendingRef.current] }));
34
+ hydratedPortRef.current = storage;
35
+ setHydratedScope(scope);
36
+ });
37
+ return () => { cancelled = true; };
38
+ }, [scope, storage]);
39
+ useEffect(() => {
40
+ const adopted = conversationRef.current == null && conversationId != null && scopeRef.current === scope;
41
+ conversationRef.current = conversationId;
42
+ scopeRef.current = scope;
43
+ if (adopted)
44
+ return;
45
+ browseRef.current = createComposerHistoryState({});
46
+ // A conversation's existing turns are available for navigation, but merely opening old
47
+ // conversations must not reorder the cross-conversation recent list.
48
+ seenRef.current = new Set(messages.map((message) => message.id));
49
+ // messages is deliberately absent: newly appended turns are handled below, not reseeded.
50
+ }, [conversationId, scope]);
51
+ useEffect(() => {
52
+ const added = [];
53
+ for (const message of messages) {
54
+ if (!seenRef.current.has(message.id) && message.role === 'user')
55
+ added.push(message.content);
56
+ seenRef.current.add(message.id);
57
+ }
58
+ if (added.length === 0)
59
+ return;
60
+ pendingRef.current.push(...added);
61
+ pendingRef.current = normalizeComposerHistory({ entries: pendingRef.current });
62
+ setRecent((previous) => normalizeComposerHistory({ entries: [...previous, ...added] }));
63
+ }, [messages]);
64
+ useEffect(() => {
65
+ if (hydratedScope !== scope || hydratedPortRef.current !== storage)
66
+ return;
67
+ // Serialize writes; a slow earlier write must not overwrite a later prompt.
68
+ writeQueueRef.current = writeQueueRef.current.then(() => storage.write({ scope, entries: recent }, {})).catch(() => { });
69
+ }, [hydratedScope, recent, scope, storage]);
70
+ const navigate = useCallback((input, _optional = {}) => {
71
+ const action = composerHistoryKeyAction(input);
72
+ if (action === null)
73
+ return false;
74
+ const transition = transitionComposerHistory({ state: browseRef.current, action, text: input.text, entries });
75
+ if (!transition.handled)
76
+ return false;
77
+ browseRef.current = transition.state;
78
+ // Recall is a view, not an edit to the cached working draft. Reload while copying a
79
+ // recalled entry still restores what the operator was writing before they browsed.
80
+ setRecalledDraft(transition.text);
81
+ return true;
82
+ }, [entries, setRecalledDraft]);
83
+ const edit = useCallback((_required, _optional = {}) => {
84
+ // The first edit leaves history. Its text is now the working draft; stored entries and
85
+ // the original browsing snapshot are never edited in place.
86
+ browseRef.current = createComposerHistoryState({});
87
+ }, []);
88
+ return useMemo(() => ({ navigate, edit }), [navigate, edit]);
89
+ }
90
+ //# sourceMappingURL=useComposerHistory.hooks.js.map
@@ -0,0 +1,9 @@
1
+ import { type KeyboardEvent, type RefObject } from 'react';
2
+ import type { UseComposerResult } from './useComposer.js';
3
+ export declare function useComposerHistoryKeys({ composer, textareaRef }: {
4
+ composer: UseComposerResult;
5
+ textareaRef: RefObject<HTMLTextAreaElement | null>;
6
+ }, { popupOpen }?: {
7
+ popupOpen?: boolean;
8
+ }): (event: KeyboardEvent<HTMLTextAreaElement>) => boolean;
9
+ //# sourceMappingURL=useComposerHistoryKeys.hooks.d.ts.map
@@ -0,0 +1,90 @@
1
+ import { useLayoutEffect, useReducer, useRef } from 'react';
2
+ /** Measures visual lines as well as newlines, so a wrapped paragraph keeps native arrow motion. */
3
+ function visualLineBounds({ textarea }) {
4
+ if (textarea.clientWidth === 0)
5
+ return {};
6
+ const computed = getComputedStyle(textarea);
7
+ const mirror = document.createElement('div');
8
+ for (const property of ['font', 'letter-spacing', 'word-spacing', 'line-height', 'text-indent', 'padding', 'tab-size', 'word-break', 'overflow-wrap', 'direction']) {
9
+ mirror.style.setProperty(property, computed.getPropertyValue(property));
10
+ }
11
+ mirror.style.position = 'fixed';
12
+ mirror.style.visibility = 'hidden';
13
+ mirror.style.pointerEvents = 'none';
14
+ mirror.style.whiteSpace = 'pre-wrap';
15
+ mirror.style.overflowWrap = 'break-word';
16
+ mirror.style.boxSizing = 'border-box';
17
+ mirror.style.width = `${textarea.clientWidth}px`;
18
+ const text = document.createTextNode(textarea.value);
19
+ mirror.append(text, document.createTextNode('\u200b'));
20
+ document.body.append(mirror);
21
+ try {
22
+ function lineAt(offset) {
23
+ const range = document.createRange();
24
+ range.setStart(text, offset);
25
+ range.setEnd(text, offset);
26
+ return range.getBoundingClientRect().top;
27
+ }
28
+ const first = lineAt(0);
29
+ const last = lineAt(text.length);
30
+ // Both selection endpoints must be on the boundary visual line. Selecting across soft
31
+ // wraps should stay native just like selecting across explicit newline characters.
32
+ const start = lineAt(textarea.selectionStart);
33
+ const end = lineAt(textarea.selectionEnd);
34
+ return { firstLine: Math.abs(start - first) < 1 && Math.abs(end - first) < 1, lastLine: Math.abs(start - last) < 1 && Math.abs(end - last) < 1 };
35
+ }
36
+ finally {
37
+ mirror.remove();
38
+ }
39
+ }
40
+ export function useComposerHistoryKeys({ composer, textareaRef }, { popupOpen = false } = {}) {
41
+ const pendingCaretRef = useRef(null);
42
+ // A duplicate history entry can equal the current value, so setDraft may bail out.
43
+ // Still commit the caret request now rather than carrying it into the next real edit.
44
+ const [, requestCaretCommit] = useReducer((revision) => revision + 1, 0);
45
+ useLayoutEffect(() => {
46
+ if (!pendingCaretRef.current)
47
+ return;
48
+ const placement = pendingCaretRef.current;
49
+ pendingCaretRef.current = null;
50
+ const textarea = textareaRef.current;
51
+ if (textarea) {
52
+ const offset = placement === 'start' ? 0 : textarea.value.length;
53
+ textarea.setSelectionRange(offset, offset);
54
+ }
55
+ });
56
+ return (event) => {
57
+ if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)
58
+ return false;
59
+ if (event.defaultPrevented || popupOpen || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
60
+ return false;
61
+ if (!['ArrowUp', 'ArrowDown', 'Escape'].includes(event.key))
62
+ return false;
63
+ const textarea = event.currentTarget;
64
+ const handled = composer.history?.navigate({
65
+ key: event.key,
66
+ text: textarea.value,
67
+ selectionStart: textarea.selectionStart,
68
+ selectionEnd: textarea.selectionEnd,
69
+ shiftKey: event.shiftKey,
70
+ ctrlKey: event.ctrlKey,
71
+ altKey: event.altKey,
72
+ metaKey: event.metaKey,
73
+ ...visualLineBounds({ textarea }),
74
+ }) ?? false;
75
+ if (handled) {
76
+ event.preventDefault();
77
+ // React's controlled value lands on the following commit. Placing the caret before
78
+ // that commit clamps it to the OLD draft's length and breaks multiline Down gating.
79
+ // Up stays on the first line of even a multiline recall so repeated Up can browse
80
+ // older entries. Down/Escape go to the end, including when restoring the draft.
81
+ pendingCaretRef.current = event.key === 'ArrowUp' ? 'start' : 'end';
82
+ requestCaretCommit();
83
+ // Recalling an identical string may not cause a render; the existing value is safe.
84
+ const offset = pendingCaretRef.current === 'start' ? 0 : textarea.value.length;
85
+ textarea.setSelectionRange(offset, offset);
86
+ }
87
+ return handled;
88
+ };
89
+ }
90
+ //# sourceMappingURL=useComposerHistoryKeys.hooks.js.map
@@ -1,3 +1,4 @@
1
+ import { preserveAssistantContent, mergeRunEvents } from '../../core/durable-projection.js';
1
2
  /**
2
3
  * @module useConversation
3
4
  *
@@ -113,8 +114,9 @@ export function useConversation(options) {
113
114
  return {
114
115
  ...m,
115
116
  ...(nextRunId !== undefined ? { runId: nextRunId } : {}),
116
- events: run.events,
117
- content: assistantContentFromEvents({ events: run.events }),
117
+ events: mergeRunEvents({ saved: m.events, incoming: run.events }, {}),
118
+ // Replayed completion prefixes cannot erase a saved interrupted segment.
119
+ content: preserveAssistantContent({ saved: m.content, incoming: assistantContentFromEvents({ events: run.events }) }, {}),
118
120
  runStatus,
119
121
  ...(isTerminalRunStatus({ status: runStatus }) ? { endedAt: Date.now() } : {}),
120
122
  };
@@ -160,6 +162,7 @@ export function useConversation(options) {
160
162
  setMessagesState((prev) => [...prev, userMessage, assistantMessage]);
161
163
  setScrollIntent(true);
162
164
  await run.start({
165
+ assistantMessageId: assistantMessage.id,
163
166
  history,
164
167
  ...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
165
168
  conversationId,
@@ -191,6 +194,7 @@ export function useConversation(options) {
191
194
  // attachment beside the user turn, the failure reads as the model ignoring it rather than as
192
195
  // the client never having sent it.
193
196
  await run.start({
197
+ assistantMessageId: resetAssistant.id,
194
198
  history,
195
199
  ...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
196
200
  conversationId,