@fungi.computer/parakeet 0.1.0 → 0.2.1

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 (59) hide show
  1. package/README.md +45 -6
  2. package/dist/account-commands.js +3 -11
  3. package/dist/commands/advertised.d.ts +3 -1
  4. package/dist/commands/advertised.js +89 -4
  5. package/dist/commands/interactions.d.ts +22 -7
  6. package/dist/commands/interactions.js +41 -17
  7. package/dist/commands/slash.js +3 -5
  8. package/dist/commands/use-slash-commands.js +2 -2
  9. package/dist/components/AgentHead.d.ts +2 -0
  10. package/dist/components/AgentHead.js +5 -4
  11. package/dist/components/ChatComposer.d.ts +3 -1
  12. package/dist/components/ChatComposer.js +79 -15
  13. package/dist/components/ChatMessage.d.ts +4 -0
  14. package/dist/components/ChatMessage.js +6 -2
  15. package/dist/components/ClientRows.js +2 -2
  16. package/dist/components/InteractionPanel.d.ts +2 -4
  17. package/dist/components/InteractionPanel.js +2 -2
  18. package/dist/components/TimelineItem.d.ts +2 -0
  19. package/dist/components/TimelineItem.js +31 -7
  20. package/dist/conversation-opening.d.ts +47 -0
  21. package/dist/conversation-opening.js +173 -0
  22. package/dist/conversation.d.ts +47 -3
  23. package/dist/conversation.js +584 -86
  24. package/dist/core/author-labels.d.ts +2 -0
  25. package/dist/core/build-timeline.d.ts +4 -2
  26. package/dist/core/build-timeline.js +46 -7
  27. package/dist/core/client-actions.js +6 -3
  28. package/dist/core/client-timeline.d.ts +9 -2
  29. package/dist/core/client-timeline.js +15 -4
  30. package/dist/core/draft-storage.d.ts +63 -0
  31. package/dist/core/draft-storage.js +50 -0
  32. package/dist/core/model-identity.d.ts +2 -0
  33. package/dist/core/model-identity.js +6 -0
  34. package/dist/core/queued-recall.d.ts +35 -0
  35. package/dist/core/queued-recall.js +138 -0
  36. package/dist/core/session-projection.js +17 -0
  37. package/dist/core/session-query.d.ts +54 -11
  38. package/dist/core/session-query.js +38 -12
  39. package/dist/core/tool-groups.d.ts +1 -1
  40. package/dist/core/tool-groups.js +3 -1
  41. package/dist/core/tool-view.js +2 -1
  42. package/dist/core/use-timeline-item-key.d.ts +8 -0
  43. package/dist/core/use-timeline-item-key.js +29 -0
  44. package/dist/headless.d.ts +6 -4
  45. package/dist/headless.js +1 -1
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +1 -1
  48. package/dist/model-commands.js +64 -39
  49. package/dist/parakeet.js +17 -18
  50. package/dist/session-commands.d.ts +0 -9
  51. package/dist/session-commands.js +39 -68
  52. package/dist/session.d.ts +1 -1
  53. package/dist/store/types.d.ts +10 -4
  54. package/dist/styles.css +27 -12
  55. package/docs/api/commands/slash.md +24 -21
  56. package/docs/api/headless.md +375 -232
  57. package/docs/api/index.md +157 -45
  58. package/package.json +16 -10
  59. package/src/styles.css +0 -9
@@ -7,13 +7,15 @@ import { createParakeetInteractions, } from "./commands/interactions.js";
7
7
  import { CancelledError, useQueryClient, } from "@tanstack/react-query";
8
8
  import { atom, createStore, } from "jotai/vanilla";
9
9
  import { atomWithInfiniteQuery, atomWithMutation, atomWithQuery, } from "jotai-tanstack-query";
10
- import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore, } from "react";
10
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore, } from "react";
11
11
  import { buildDurableTimeline, buildTimelineTail, } from "./core/build-timeline.js";
12
12
  import { mergeCompactionNotices, mergeCompactions, mergeMessages, } from "./core/session-projection.js";
13
13
  import { sessionHistoryQueryOptions, sessionQueryOptions, } from "./core/session-query.js";
14
14
  import { sameSessionViewState, sessionActivityStatus, sessionViewState, } from "./core/session-view-state.js";
15
15
  import { isCancelledOutcome } from "./commands/run.js";
16
16
  import { reportFailure } from "./lib/failure.js";
17
+ import { createQueuedRecall, } from "./core/queued-recall.js";
18
+ import { draftJournal, } from "./core/draft-storage.js";
17
19
  const noMessages = Object.freeze([]);
18
20
  const noCompactions = Object.freeze([]);
19
21
  const noCompactionNotices = Object.freeze([]);
@@ -33,6 +35,18 @@ function errorMessage(cause) {
33
35
  ? cause.message
34
36
  : "Prompt admission failed";
35
37
  }
38
+ /** A lost withdrawal response must not block a later reconnect from retrying it. */
39
+ function boundWithdrawal(operation) {
40
+ let timer;
41
+ const deadline = new Promise((_resolve, reject) => {
42
+ timer = setTimeout(() => {
43
+ reject(new Error("Request recovery timed out"));
44
+ }, 10_000);
45
+ });
46
+ return Promise.race([operation, deadline]).finally(() => {
47
+ clearTimeout(timer);
48
+ });
49
+ }
36
50
  function observationCause(cause) {
37
51
  const message = cause instanceof Error && cause.message.trim().length > 0
38
52
  ? cause.message.trim()
@@ -70,19 +84,40 @@ function newestDurableCommandId(messages) {
70
84
  }
71
85
  return newest?.commandId;
72
86
  }
73
- function newestDurablePrompt(messages, commandId) {
74
- const message = messages.find((candidate) => candidate.commandId === commandId && candidate.value.role === "user");
75
- if (message?.value.role !== "user")
76
- return undefined;
77
- const content = message.value.content;
78
- const text = Array.isArray(content)
79
- ? content
80
- .flatMap((part) => (part.type === "text" ? [part.text] : []))
81
- .join("\n")
82
- : content;
83
- return text.trim() === "" ? undefined : text;
87
+ function sameImageCargo(left, right) {
88
+ const images = left ?? [];
89
+ const originals = right ?? [];
90
+ return (images.length === originals.length &&
91
+ images.every((image, index) => {
92
+ const original = originals[index];
93
+ return (image.data === original?.data && image.mimeType === original.mimeType);
94
+ }));
84
95
  }
85
- function createConversationAtoms(session, queryClient, authorOf) {
96
+ function acquireDraftStorage(supplied) {
97
+ if (supplied !== undefined)
98
+ return { status: "available", storage: supplied };
99
+ try {
100
+ const browser = globalThis;
101
+ const storage = browser.localStorage;
102
+ return storage === undefined
103
+ ? { status: "unavailable" }
104
+ : { status: "available", storage };
105
+ }
106
+ catch {
107
+ // An opaque-origin sandbox denies even reading the browser's getter.
108
+ return { status: "unavailable" };
109
+ }
110
+ }
111
+ function createConversationAtoms(session, queryClient, authorOf, interactions, draftStorage, draftScope, contributions, shown) {
112
+ const journal = draftJournal(draftScope, draftStorage.status === "available" ? draftStorage.storage : undefined);
113
+ const saved = journal.read();
114
+ let mounted = false;
115
+ const timers = new Map();
116
+ const recoveryReasons = new Map(saved.pending.map(({ requestId }) => [
117
+ requestId,
118
+ "Closed before confirmation",
119
+ ]));
120
+ const recovering = new Set();
86
121
  const queryAtom = atomWithQuery(() => sessionQueryOptions(session), () => queryClient);
87
122
  const snapshotAtom = atom((get) => get(queryAtom).data?.snapshot);
88
123
  const pinnedHistoryAtom = atom(undefined);
@@ -169,9 +204,31 @@ function createConversationAtoms(session, queryClient, authorOf) {
169
204
  ? pages
170
205
  : mergeCompactionNotices(mergeCompactionNotices(pages, pinned), live);
171
206
  });
172
- const newestCommandAtom = atom((get) => get(queryAtom).data?.latestPrompt?.[0] ??
207
+ const newestCommandAtom = atom((get) => get(queryAtom).data?.latestPrompt?.commandId ??
173
208
  newestDurableCommandId(get(messagesAtom)));
174
209
  const pendingAtom = atom((get) => get(snapshotAtom)?.pending ?? noPending);
210
+ // Presentation only: owner snapshots and admission reconciliation stay intact.
211
+ const hiddenQueuedAtom = atom(new Set());
212
+ const cancellingQueuedAtom = atom(undefined);
213
+ const visiblePendingAtom = atom((get) => {
214
+ const pending = get(pendingAtom);
215
+ const hidden = get(hiddenQueuedAtom);
216
+ const prompts = pending.prompts.filter(({ commandId }) => !hidden.has(commandId));
217
+ return prompts.length === pending.prompts.length
218
+ ? pending
219
+ : {
220
+ ...pending,
221
+ prompts,
222
+ total: pending.total - (pending.prompts.length - prompts.length),
223
+ };
224
+ });
225
+ const reconcileQueuedCancellationAtom = atom(null, (get, set) => {
226
+ const hidden = get(hiddenQueuedAtom);
227
+ const pending = new Set(get(pendingAtom).prompts.map(({ commandId }) => commandId));
228
+ const retained = new Set([...hidden].filter((id) => id === get(cancellingQueuedAtom) || pending.has(id)));
229
+ if (retained.size !== hidden.size)
230
+ set(hiddenQueuedAtom, retained);
231
+ });
175
232
  const extensionsAtom = atom((get) => get(snapshotAtom)?.extensions ?? noSlots);
176
233
  let liveToolActivity;
177
234
  const toolActivityAtom = atom((get) => {
@@ -197,11 +254,128 @@ function createConversationAtoms(session, queryClient, authorOf) {
197
254
  const requestIds = get(promptRequestIdsAtom);
198
255
  return (commandId) => requestIds.get(commandId);
199
256
  });
200
- const durableAtom = atom((get) => buildDurableTimeline(get(messagesAtom), get(compactionsAtom), get(toolActivityAtom), authorOf, get(requestIdOfAtom), get(compactionNoticesAtom)));
201
- const draftAtom = atom("");
257
+ const durableAtom = atom((get) => {
258
+ const messages = get(messagesAtom);
259
+ const compactions = get(compactionsAtom);
260
+ const notices = get(compactionNoticesAtom);
261
+ const sequences = [
262
+ ...messages.map((message) => message.sequence),
263
+ ...compactions.map((marker) => marker.sequence),
264
+ ...notices.map((notice) => notice.sequence),
265
+ ];
266
+ const oldest = Math.min(...sequences);
267
+ const newest = Math.max(...sequences);
268
+ const positions = get(contributions).flatMap(({ value }) => value.timeline.cells.flatMap(({ afterSequence }) => afterSequence === undefined ||
269
+ (get(hasMoreHistoryAtom) && afterSequence < oldest) ||
270
+ (get(hasNewerHistoryAtom) && afterSequence > newest)
271
+ ? []
272
+ : [afterSequence]));
273
+ return buildDurableTimeline(messages, compactions, get(toolActivityAtom), authorOf, get(requestIdOfAtom), notices, positions, shown);
274
+ });
275
+ const draftAtom = atom(saved.draft);
276
+ const draftImagesAtom = atom(saved.images);
277
+ const draftDeliveryAtom = atom(saved.delivery);
278
+ const recallReservationAtom = atom(saved.recall);
279
+ const recallStateAtom = atom({ kind: "idle" });
280
+ let recallOwner;
202
281
  const draftRevisionAtom = atom(0);
203
- const rawAdmissionsAtom = atom([]);
282
+ const rawAdmissionsAtom = atom(saved.pending.map((pending) => ({
283
+ ...pending,
284
+ status: "admitting",
285
+ })));
286
+ const persistAtom = atom(null, (get, _set, state) => {
287
+ // Unavailable storage is session-only, not a pretend durable journal.
288
+ // A supplied adapter's write failures still refuse durable operations.
289
+ if (draftStorage.status === "unavailable")
290
+ return;
291
+ journal.write({
292
+ draft: state?.draft ?? get(draftAtom),
293
+ images: state?.draftPayload === undefined
294
+ ? get(draftImagesAtom)
295
+ : state.draftPayload.images,
296
+ delivery: state?.draftPayload === undefined
297
+ ? get(draftDeliveryAtom)
298
+ : state.draftPayload.delivery,
299
+ recall: state?.recall === null
300
+ ? undefined
301
+ : (state?.recall ?? get(recallReservationAtom)),
302
+ pending: (state?.admissions ?? get(rawAdmissionsAtom))
303
+ .filter(({ status }) => status === "admitting")
304
+ .map(({ requestId, content, submittedDraft, delivery, images, restoreDraft, }) => ({
305
+ requestId,
306
+ content,
307
+ submittedDraft,
308
+ delivery,
309
+ images,
310
+ restoreDraft,
311
+ })),
312
+ });
313
+ });
314
+ const recallAtom = atom(null, (get, set) => {
315
+ if (get(cancellingQueuedAtom) !== undefined)
316
+ return Promise.resolve("ignored");
317
+ recallOwner ??= createQueuedRecall({
318
+ session: {
319
+ latestPending: () => session.latestPending(),
320
+ cancel: (input) => session.command({ type: "cancel", ...input }),
321
+ },
322
+ recovery: get(recallReservationAtom),
323
+ emptyDraftRevision: () => get(draftAtom) === "" && (get(draftImagesAtom)?.length ?? 0) === 0
324
+ ? get(draftRevisionAtom)
325
+ : undefined,
326
+ reserve: (prompt) => {
327
+ set(persistAtom, { recall: prompt });
328
+ set(recallReservationAtom, prompt);
329
+ },
330
+ forget: () => {
331
+ set(persistAtom, { recall: null });
332
+ set(recallReservationAtom, undefined);
333
+ },
334
+ restore: (prompt, revision) => {
335
+ if (get(draftRevisionAtom) !== revision ||
336
+ get(draftAtom) !== "" ||
337
+ (get(draftImagesAtom)?.length ?? 0) !== 0)
338
+ return false;
339
+ set(persistAtom, {
340
+ draft: prompt.text,
341
+ draftPayload: { images: prompt.images, delivery: prompt.delivery },
342
+ recall: null,
343
+ });
344
+ set(recallReservationAtom, undefined);
345
+ set(draftImagesAtom, prompt.images);
346
+ set(draftDeliveryAtom, prompt.delivery);
347
+ set(draftAtom, prompt.text);
348
+ set(draftRevisionAtom, (current) => current + 1);
349
+ return true;
350
+ },
351
+ onState: (state) => {
352
+ set(recallStateAtom, state);
353
+ if (state.kind === "too_late" || state.kind === "uncertain")
354
+ interactions.toast({ variant: "warning", message: state.message });
355
+ },
356
+ });
357
+ return recallOwner.recall();
358
+ });
204
359
  const latestSubmissionIdAtom = atom(undefined);
360
+ const checkpointDraftAtom = atom(null, (_get, set) => {
361
+ if (!mounted)
362
+ return {
363
+ status: "failed",
364
+ reason: "Conversation is no longer mounted",
365
+ };
366
+ if (draftStorage.status === "unavailable")
367
+ return {
368
+ status: "failed",
369
+ reason: "Durable draft storage is unavailable",
370
+ };
371
+ try {
372
+ set(persistAtom);
373
+ return { status: "saved" };
374
+ }
375
+ catch (cause) {
376
+ return { status: "failed", reason: errorMessage(cause) };
377
+ }
378
+ });
205
379
  const observedCommandIdsAtom = atom((get) => new Set([
206
380
  ...get(durableAtom).commandIds,
207
381
  ...get(pendingAtom).prompts.map(({ commandId }) => commandId),
@@ -217,41 +391,127 @@ function createConversationAtoms(session, queryClient, authorOf) {
217
391
  /** Retire a successful local admission only after owner identity is observed. */
218
392
  const reconcileAdmissionsAtom = atom(null, (get, set) => {
219
393
  const knownCommandIds = get(observedCommandIdsAtom);
394
+ const observedRequests = new Map([
395
+ // Recorded replies have no command: their accepted receipt names the
396
+ // durable entry, not model admission or cancellation.
397
+ ...get(messagesAtom).flatMap(({ id, requestId, commandId }) => requestId === undefined ? [] : [[requestId, commandId ?? id]]),
398
+ ...get(pendingAtom).prompts.map(({ requestId, commandId }) => [requestId, commandId]),
399
+ ]);
400
+ for (const candidate of get(rawAdmissionsAtom)) {
401
+ const commandId = observedRequests.get(candidate.requestId);
402
+ if (commandId !== undefined) {
403
+ set(promptRequestIdsAtom, (current) => new Map(current).set(commandId, candidate.requestId));
404
+ }
405
+ }
220
406
  set(rawAdmissionsAtom, (current) => {
221
- const unresolved = current.filter(({ status, commandId }) => status !== "accepted" ||
222
- commandId === undefined ||
223
- !knownCommandIds.has(commandId));
407
+ const unresolved = current.filter(({ requestId, status, commandId }) => !observedRequests.has(requestId) &&
408
+ (status !== "accepted" ||
409
+ commandId === undefined ||
410
+ !knownCommandIds.has(commandId)));
411
+ if (unresolved.length !== current.length) {
412
+ try {
413
+ set(persistAtom, { admissions: unresolved });
414
+ }
415
+ catch (cause) {
416
+ reportFailure("save reconciled draft", cause);
417
+ }
418
+ }
224
419
  return unresolved.length === current.length ? current : unresolved;
225
420
  });
226
421
  });
227
422
  const admissionErrorAtom = atom(undefined);
228
423
  const setDraftAtom = atom(null, (get, set, value) => {
424
+ try {
425
+ set(persistAtom, { draft: value });
426
+ }
427
+ catch (cause) {
428
+ reportFailure("save current draft", cause);
429
+ interactions.toast({
430
+ variant: "error",
431
+ message: `Draft not saved: ${errorMessage(cause)}`,
432
+ });
433
+ }
229
434
  if (get(draftAtom) !== value)
230
435
  set(draftRevisionAtom, (revision) => revision + 1);
231
436
  set(draftAtom, value);
232
437
  set(admissionErrorAtom, undefined);
233
438
  });
234
- /** The one owner of "clear the draft after submit" for prompts and commands. */
439
+ /** Consume only the exact draft a command submitted. */
235
440
  const clearSubmittedDraftAtom = atom(null, (get, set, submitted) => {
236
- if (get(draftRevisionAtom) !== submitted.revision)
441
+ if (get(draftRevisionAtom) !== submitted.revision ||
442
+ get(draftAtom) !== submitted.draft)
237
443
  return;
238
- set(draftAtom, (current) => (current === submitted.draft ? "" : current));
444
+ set(persistAtom, { draft: "" });
445
+ set(draftAtom, "");
239
446
  });
240
447
  const abortAtom = atomWithMutation(() => ({
241
448
  mutationFn: (commandId) => session.command({ type: "cancel", commandId }),
242
449
  }), () => queryClient);
450
+ // One unresolved queued gesture per mounted conversation, like Recall.
451
+ // Accepted masks last only while that identity remains in the bounded owner
452
+ // pending page; stale pre-ack snapshots cannot resurrect the removed row.
453
+ let queuedCancellation;
454
+ const cancelQueuedAtom = atom(null, (get, set, commandId) => {
455
+ if (!mounted || get(recallStateAtom).kind === "recalling")
456
+ return Promise.resolve();
457
+ if (queuedCancellation !== undefined)
458
+ return queuedCancellation;
459
+ if (get(hiddenQueuedAtom).has(commandId) ||
460
+ !get(pendingAtom).prompts.some((prompt) => prompt.commandId === commandId))
461
+ return Promise.resolve();
462
+ set(hiddenQueuedAtom, new Set(get(hiddenQueuedAtom)).add(commandId));
463
+ set(cancellingQueuedAtom, commandId);
464
+ const reveal = () => {
465
+ const next = new Set(get(hiddenQueuedAtom));
466
+ next.delete(commandId);
467
+ set(hiddenQueuedAtom, next);
468
+ };
469
+ const operation = Promise.resolve()
470
+ .then(() => session.command({ type: "cancel", commandId, onlyIfPending: true }))
471
+ .then((result) => {
472
+ if (!mounted)
473
+ return;
474
+ if (result.status === "too_late") {
475
+ reveal();
476
+ interactions.toast({
477
+ variant: "warning",
478
+ message: "Couldn't remove that message: it is no longer pending.",
479
+ });
480
+ }
481
+ }, () => {
482
+ if (!mounted)
483
+ return;
484
+ reveal();
485
+ interactions.toast({
486
+ variant: "warning",
487
+ message: "Cancellation unconfirmed. That message may still run. Try again or reconnect.",
488
+ });
489
+ })
490
+ .finally(() => {
491
+ queuedCancellation = undefined;
492
+ if (!mounted)
493
+ return;
494
+ set(cancellingQueuedAtom, undefined);
495
+ set(reconcileQueuedCancellationAtom);
496
+ });
497
+ queuedCancellation = operation;
498
+ return operation;
499
+ });
243
500
  const tailAtom = atom((get) => {
244
501
  if (get(hasNewerHistoryAtom))
245
502
  return [];
246
503
  const snapshot = get(snapshotAtom);
247
- const pending = get(pendingAtom);
248
- return buildTimelineTail(get(durableAtom), snapshot?.progress ?? null, pending.prompts, get(admissionsAtom), authorOf, get(requestIdOfAtom));
504
+ const pending = get(visiblePendingAtom);
505
+ return buildTimelineTail(get(durableAtom), snapshot?.progress ?? null, pending.prompts, get(admissionsAtom), authorOf);
249
506
  });
250
507
  const timelineAtom = atom((get) => {
251
508
  const durable = get(durableAtom);
252
509
  const base = {
253
510
  pendingHasMore: get(pendingAtom).hasMore,
254
- queued: get(pendingAtom),
511
+ queued: get(visiblePendingAtom),
512
+ cancellingQueued: get(cancellingQueuedAtom) !== undefined ||
513
+ get(recallStateAtom).kind === "recalling",
514
+ positionAnchors: durable.positionAnchors,
255
515
  commandAnchors: durable.commandAnchors,
256
516
  hasMoreHistory: get(hasMoreHistoryAtom),
257
517
  hasNewerHistory: get(hasNewerHistoryAtom),
@@ -336,21 +596,43 @@ function createConversationAtoms(session, queryClient, authorOf) {
336
596
  ownerView = next;
337
597
  return next;
338
598
  });
339
- const dispatchAdmissionAtom = atom(null, (get, set, admission) => {
340
- void session
341
- .command({
599
+ const dispatchAdmissionAtom = atom(null, (get, set, { admission, consumeDraft, }) => {
600
+ try {
601
+ set(persistAtom, consumeDraft
602
+ ? { draft: "", draftPayload: {} }
603
+ : { draft: get(draftAtom) });
604
+ }
605
+ catch (cause) {
606
+ reportFailure("save pending draft before send", cause);
607
+ set(rawAdmissionsAtom, (current) => current.filter(({ requestId }) => requestId !== admission.requestId));
608
+ interactions.toast({
609
+ variant: "error",
610
+ message: `Not sent: ${errorMessage(cause)}`,
611
+ });
612
+ return;
613
+ }
614
+ if (consumeDraft) {
615
+ set(draftImagesAtom, undefined);
616
+ set(draftDeliveryAtom, undefined);
617
+ set(setDraftAtom, "");
618
+ }
619
+ const prompt = {
342
620
  type: "prompt",
343
621
  text: admission.content,
344
622
  requestId: admission.requestId,
345
623
  delivery: admission.delivery,
346
- })
624
+ };
625
+ void session
626
+ .command(admission.images === undefined
627
+ ? prompt
628
+ : { ...prompt, images: admission.images })
347
629
  .then((accepted) => {
630
+ if (!mounted ||
631
+ !get(rawAdmissionsAtom).some(({ requestId, status }) => requestId === admission.requestId && status === "admitting"))
632
+ return;
348
633
  if (accepted.status === "recorded") {
349
634
  set(rawAdmissionsAtom, (current) => current.filter((candidate) => candidate.requestId !== admission.requestId));
350
- set(clearSubmittedDraftAtom, {
351
- draft: admission.submittedDraft,
352
- revision: admission.submittedDraftRevision,
353
- });
635
+ set(persistAtom);
354
636
  return;
355
637
  }
356
638
  set(promptRequestIdsAtom, (current) => new Map(current).set(accepted.commandId, admission.requestId));
@@ -358,48 +640,139 @@ function createConversationAtoms(session, queryClient, authorOf) {
358
640
  ? {
359
641
  requestId: candidate.requestId,
360
642
  content: candidate.content,
643
+ images: candidate.images,
361
644
  submittedDraft: candidate.submittedDraft,
362
- submittedDraftRevision: candidate.submittedDraftRevision,
645
+ restoreDraft: candidate.restoreDraft,
363
646
  delivery: candidate.delivery,
364
647
  commandId: accepted.commandId,
365
648
  status: "accepted",
366
649
  }
367
650
  : candidate));
368
- const admissions = get(rawAdmissionsAtom);
369
- const admissionIndex = admissions.findIndex((candidate) => candidate.requestId === admission.requestId);
370
- const hasNewerMatchingAdmission = admissions
371
- .slice(admissionIndex + 1)
372
- .some((candidate) => candidate.status === "admitting" &&
373
- candidate.submittedDraft === admission.submittedDraft);
374
651
  set(reconcileAdmissionsAtom);
375
- if (!hasNewerMatchingAdmission)
376
- set(clearSubmittedDraftAtom, {
377
- draft: admission.submittedDraft,
378
- revision: admission.submittedDraftRevision,
379
- });
652
+ set(persistAtom);
380
653
  })
381
654
  .catch((cause) => {
382
- set(rawAdmissionsAtom, (current) => current.map((candidate) => candidate.requestId === admission.requestId
383
- ? {
384
- ...candidate,
385
- status: "failed",
386
- error: errorMessage(cause),
387
- }
388
- : candidate));
655
+ if (!mounted)
656
+ return;
657
+ recoveryReasons.set(admission.requestId, errorMessage(cause));
658
+ set(recoverAdmissionsAtom);
389
659
  });
390
660
  });
661
+ const recoverAdmissionsAtom = atom(null, (get, set) => {
662
+ if (!mounted)
663
+ return;
664
+ for (const admission of get(rawAdmissionsAtom)) {
665
+ const reason = recoveryReasons.get(admission.requestId);
666
+ if (admission.status !== "admitting" ||
667
+ reason === undefined ||
668
+ recovering.has(admission.requestId))
669
+ continue;
670
+ recovering.add(admission.requestId);
671
+ void boundWithdrawal(session.withdraw(admission.requestId))
672
+ .then((resolution) => {
673
+ if (!mounted ||
674
+ !get(rawAdmissionsAtom).some(({ requestId, status }) => requestId === admission.requestId && status === "admitting"))
675
+ return;
676
+ if (resolution.status === "accepted") {
677
+ set(promptRequestIdsAtom, (current) => new Map(current).set(resolution.commandId, admission.requestId));
678
+ set(rawAdmissionsAtom, (current) => current.map((candidate) => candidate.requestId === admission.requestId
679
+ ? {
680
+ ...candidate,
681
+ status: "accepted",
682
+ commandId: resolution.commandId,
683
+ }
684
+ : candidate));
685
+ set(persistAtom);
686
+ set(reconcileAdmissionsAtom);
687
+ return;
688
+ }
689
+ const currentDraft = get(draftAtom);
690
+ const restored = !admission.restoreDraft
691
+ ? currentDraft
692
+ : currentDraft === ""
693
+ ? admission.submittedDraft
694
+ : `${currentDraft}\n\n${admission.submittedDraft}`;
695
+ const failed = get(rawAdmissionsAtom).map((candidate) => candidate.requestId === admission.requestId
696
+ ? { ...candidate, status: "failed", error: reason }
697
+ : candidate);
698
+ // Commit restoration and removal of the pending record in ONE replacement.
699
+ const restoredImages = admission.restoreDraft
700
+ ? [...(get(draftImagesAtom) ?? []), ...(admission.images ?? [])]
701
+ : get(draftImagesAtom);
702
+ set(persistAtom, {
703
+ draft: restored,
704
+ draftPayload: {
705
+ images: restoredImages,
706
+ delivery: get(draftDeliveryAtom),
707
+ },
708
+ admissions: failed,
709
+ });
710
+ set(draftImagesAtom, restoredImages);
711
+ set(rawAdmissionsAtom, failed);
712
+ set(setDraftAtom, restored);
713
+ interactions.toast({
714
+ variant: "error",
715
+ message: `Not sent: ${reason}`,
716
+ });
717
+ })
718
+ .catch((cause) => {
719
+ if (mounted)
720
+ interactions.toast({
721
+ variant: "error",
722
+ message: `Send outcome unconfirmed; draft saved: ${errorMessage(cause)}`,
723
+ });
724
+ })
725
+ .finally(() => {
726
+ recovering.delete(admission.requestId);
727
+ });
728
+ }
729
+ });
730
+ const maintainRecoveryAtom = atom(null, (get, set) => {
731
+ const observation = get(observationAtom).status;
732
+ const admissions = get(rawAdmissionsAtom);
733
+ for (const [id, timer] of timers) {
734
+ if (!admissions.some(({ requestId, status }) => requestId === id && status === "admitting")) {
735
+ clearTimeout(timer);
736
+ timers.delete(id);
737
+ recoveryReasons.delete(id);
738
+ }
739
+ }
740
+ for (const admission of admissions) {
741
+ if (admission.status !== "admitting")
742
+ continue;
743
+ if (observation === "unavailable")
744
+ recoveryReasons.set(admission.requestId, "Disconnected");
745
+ if (!timers.has(admission.requestId)) {
746
+ timers.set(admission.requestId, setTimeout(() => {
747
+ recoveryReasons.set(admission.requestId, "Send confirmation timed out");
748
+ set(recoverAdmissionsAtom);
749
+ }, 30_000));
750
+ }
751
+ }
752
+ if (observation === "live" || observation === "unavailable")
753
+ set(recoverAdmissionsAtom);
754
+ });
755
+ const mountRecoveryAtom = atom(null, (_get, set) => {
756
+ mounted = true;
757
+ set(recallStateAtom, { kind: "idle" });
758
+ set(maintainRecoveryAtom);
759
+ return () => {
760
+ mounted = false;
761
+ recallOwner?.dispose();
762
+ recallOwner = undefined;
763
+ for (const timer of timers.values())
764
+ clearTimeout(timer);
765
+ timers.clear();
766
+ };
767
+ });
391
768
  const sendAtom = atom(null, (get, set, delivery = "steer") => {
392
769
  const draft = get(draftAtom);
393
- const content = draft.trim();
394
- if (content === "")
770
+ const content = draft;
771
+ if (get(recallStateAtom).kind === "recalling")
395
772
  return;
396
- const admissions = get(admissionsAtom);
397
- const draftRevision = get(draftRevisionAtom);
398
- const rawAdmissions = get(rawAdmissionsAtom);
399
- if (rawAdmissions.some((admission) => admission.status === "admitting" &&
400
- admission.submittedDraft === draft &&
401
- admission.submittedDraftRevision === draftRevision))
773
+ if (content.trim() === "" && (get(draftImagesAtom)?.length ?? 0) === 0)
402
774
  return;
775
+ const admissions = get(admissionsAtom);
403
776
  const unresolved = admissions.filter(({ status }) => status !== "failed");
404
777
  if (unresolved.length >= MAX_UNRESOLVED_LOCAL_ADMISSIONS) {
405
778
  set(admissionErrorAtom, "queue_full");
@@ -407,16 +780,17 @@ function createConversationAtoms(session, queryClient, authorOf) {
407
780
  }
408
781
  const admission = {
409
782
  content,
410
- delivery,
783
+ delivery: get(draftDeliveryAtom) ?? delivery,
784
+ images: get(draftImagesAtom),
411
785
  requestId: nanoid(),
412
786
  submittedDraft: draft,
413
- submittedDraftRevision: draftRevision,
787
+ restoreDraft: true,
414
788
  status: "admitting",
415
789
  };
416
790
  set(rawAdmissionsAtom, [...admissions, admission]);
417
791
  set(latestSubmissionIdAtom, admission.requestId);
418
792
  set(admissionErrorAtom, undefined);
419
- set(dispatchAdmissionAtom, admission);
793
+ set(dispatchAdmissionAtom, { admission, consumeDraft: true });
420
794
  });
421
795
  const submitCommandAtom = atom(null, async (get, set, run) => {
422
796
  const submitted = {
@@ -435,12 +809,15 @@ function createConversationAtoms(session, queryClient, authorOf) {
435
809
  if (get(failureAtom) === "interrupted" || get(failureAtom) === "failed") {
436
810
  const snapshot = get(snapshotAtom);
437
811
  const commandId = get(newestCommandAtom);
438
- if (snapshot === undefined || commandId === undefined)
439
- return;
440
- const content = newestDurablePrompt(snapshot.messages, commandId) ??
441
- get(queryAtom).data?.latestPrompt?.[1];
442
- if (content === undefined)
812
+ const prompt = get(queryAtom).data?.latestPrompt;
813
+ if (snapshot === undefined ||
814
+ prompt === undefined ||
815
+ prompt.commandId !== commandId)
443
816
  return;
817
+ const content = prompt.text;
818
+ // Equal text alone does not make a newer image draft the failed prompt.
819
+ const consumeDraft = get(draftAtom) === content &&
820
+ sameImageCargo(get(draftImagesAtom), prompt.images);
444
821
  if (admissions.filter(({ status }) => status !== "failed").length >=
445
822
  MAX_UNRESOLVED_LOCAL_ADMISSIONS) {
446
823
  set(admissionErrorAtom, "queue_full");
@@ -448,16 +825,20 @@ function createConversationAtoms(session, queryClient, authorOf) {
448
825
  }
449
826
  const retry = {
450
827
  content,
828
+ images: prompt.images,
451
829
  delivery: "followUp",
452
830
  requestId: nanoid(),
453
831
  submittedDraft: content,
454
- submittedDraftRevision: get(draftRevisionAtom),
832
+ restoreDraft: consumeDraft,
455
833
  status: "admitting",
456
834
  };
457
835
  set(rawAdmissionsAtom, [...get(rawAdmissionsAtom), retry]);
458
836
  set(latestSubmissionIdAtom, retry.requestId);
459
837
  set(admissionErrorAtom, undefined);
460
- set(dispatchAdmissionAtom, retry);
838
+ set(dispatchAdmissionAtom, {
839
+ admission: retry,
840
+ consumeDraft,
841
+ });
461
842
  return;
462
843
  }
463
844
  const admission = admissions
@@ -470,18 +851,24 @@ function createConversationAtoms(session, queryClient, authorOf) {
470
851
  set(admissionErrorAtom, "queue_full");
471
852
  return;
472
853
  }
854
+ const consumeDraft = get(draftAtom) === admission.submittedDraft &&
855
+ sameImageCargo(get(draftImagesAtom), admission.images);
473
856
  const retry = {
474
857
  content: admission.content,
475
858
  delivery: admission.delivery,
476
- requestId: admission.requestId,
859
+ images: admission.images,
860
+ requestId: nanoid(),
477
861
  submittedDraft: admission.submittedDraft,
478
- submittedDraftRevision: admission.submittedDraftRevision,
862
+ restoreDraft: consumeDraft,
479
863
  status: "admitting",
480
864
  };
481
865
  set(rawAdmissionsAtom, (current) => current.map((candidate) => candidate.requestId === admission.requestId ? retry : candidate));
482
866
  set(latestSubmissionIdAtom, retry.requestId);
483
867
  set(admissionErrorAtom, undefined);
484
- set(dispatchAdmissionAtom, retry);
868
+ set(dispatchAdmissionAtom, {
869
+ admission: retry,
870
+ consumeDraft,
871
+ });
485
872
  });
486
873
  const dismissFailedAtom = atom(null, (get, set, requestId) => {
487
874
  const admissions = get(rawAdmissionsAtom);
@@ -558,9 +945,16 @@ function createConversationAtoms(session, queryClient, authorOf) {
558
945
  inspect();
559
946
  });
560
947
  const timeout = setTimeout(() => {
561
- finish({
562
- kind: "failed",
563
- cause: "Session history did not reopen within 10 seconds",
948
+ // Streamed "opening" updates also replace TanStack's revert state.
949
+ // Cancel first, then record this real timeout on the existing query
950
+ // without replacing its last coherent snapshot.
951
+ void query.cancel({ revert: true }).then(() => {
952
+ const cause = "Session history did not reopen within 10 seconds";
953
+ query.setState({ status: "error", error: new Error(cause) });
954
+ finish({
955
+ kind: "failed",
956
+ cause,
957
+ });
564
958
  });
565
959
  }, 10_000);
566
960
  // A live stream does not settle its refetch promise until the watch closes.
@@ -578,12 +972,18 @@ function createConversationAtoms(session, queryClient, authorOf) {
578
972
  return reconnecting;
579
973
  });
580
974
  return {
975
+ cancelQueuedAtom,
976
+ reconcileQueuedCancellationAtom,
581
977
  abortAtom,
978
+ draftImagesAtom,
979
+ recallStateAtom,
980
+ recallAtom,
582
981
  commandIdAtom,
583
982
  contextAtom,
584
983
  admissionErrorAtom,
585
984
  admissionsAtom,
586
985
  cancelAtom,
986
+ checkpointDraftAtom,
587
987
  draftAtom,
588
988
  dismissFailedAtom,
589
989
  extensionsAtom,
@@ -593,6 +993,9 @@ function createConversationAtoms(session, queryClient, authorOf) {
593
993
  loadNewerHistoryAtom,
594
994
  observationAtom,
595
995
  observedCommandIdsAtom,
996
+ rawAdmissionsAtom,
997
+ maintainRecoveryAtom,
998
+ mountRecoveryAtom,
596
999
  pinVisibleHistoryAtom,
597
1000
  queryAtom,
598
1001
  reconcileAdmissionsAtom,
@@ -625,15 +1028,71 @@ function useStoreSetter(atom) {
625
1028
  return useCallback((...args) => store.set(atom, ...args), [atom, store]);
626
1029
  }
627
1030
  /** Own one renderer-neutral conversation scope over the existing query cache. */
628
- export function ParakeetProvider({ children, session, clients = noClients, interactionStore, authorOf = noAuthor, }) {
1031
+ export function ParakeetProvider({ children, session, clients = noClients, interactionStore, authorOf = noAuthor, onToast, draftStorage, draftIdentity, }) {
629
1032
  const queryClient = useQueryClient();
630
- const scope = useMemo(() => ({
631
- atoms: createConversationAtoms(session, queryClient, authorOf),
1033
+ const acquiredDraftStorage = useMemo(() => acquireDraftStorage(draftStorage), [draftStorage]);
1034
+ const draftScope = draftIdentity === undefined
1035
+ ? session.address
1036
+ : JSON.stringify([draftIdentity.agentId, draftIdentity.sessionId]);
1037
+ const footerPromptOwners = useMemo(() => new Set(clients
1038
+ .filter((entry) => entry.promptPresentation === "footer")
1039
+ .map((entry) => entry.id)), [clients]);
1040
+ const scope = useMemo(() => {
1041
+ const interactions = interactionStore ?? createParakeetInteractions();
1042
+ const contributions = atom(noContributions);
1043
+ const shown = (item) => item.kind !== "user_message" ||
1044
+ item.origin === undefined ||
1045
+ !footerPromptOwners.has(item.origin.extensionId);
1046
+ return {
1047
+ atoms: createConversationAtoms(session, queryClient, authorOf, interactions, acquiredDraftStorage, draftScope, contributions, shown),
1048
+ session,
1049
+ store: createStore(),
1050
+ contributions,
1051
+ interactions,
1052
+ shown,
1053
+ };
1054
+ }, [
1055
+ queryClient,
632
1056
  session,
633
- store: createStore(),
634
- contributions: atom(noContributions),
635
- interactions: interactionStore ?? createParakeetInteractions(),
636
- }), [queryClient, session, interactionStore, authorOf]);
1057
+ interactionStore,
1058
+ authorOf,
1059
+ footerPromptOwners,
1060
+ acquiredDraftStorage,
1061
+ draftScope,
1062
+ ]);
1063
+ useEffect(() => {
1064
+ const update = () => {
1065
+ scope.store.set(scope.atoms.maintainRecoveryAtom);
1066
+ };
1067
+ const unobserve = scope.store.sub(scope.atoms.observationAtom, update);
1068
+ const unobserveAdmissions = scope.store.sub(scope.atoms.rawAdmissionsAtom, update);
1069
+ const dispose = scope.store.set(scope.atoms.mountRecoveryAtom);
1070
+ return () => {
1071
+ dispose();
1072
+ unobserve();
1073
+ unobserveAdmissions();
1074
+ };
1075
+ }, [scope]);
1076
+ useEffect(() => {
1077
+ if (onToast === undefined)
1078
+ return undefined;
1079
+ return scope.interactions.subscribeToasts(onToast);
1080
+ }, [scope, onToast]);
1081
+ useEffect(() => {
1082
+ if (acquiredDraftStorage.status === "unavailable")
1083
+ scope.interactions.report({
1084
+ tone: "info",
1085
+ message: "Drafts are session-only because browser storage is unavailable. They will be lost when this conversation closes.",
1086
+ });
1087
+ }, [scope, acquiredDraftStorage]);
1088
+ useEffect(() => {
1089
+ const reconcile = () => {
1090
+ scope.store.set(scope.atoms.reconcileQueuedCancellationAtom);
1091
+ };
1092
+ const unsubscribe = scope.store.sub(scope.atoms.snapshotAtom, reconcile);
1093
+ reconcile();
1094
+ return unsubscribe;
1095
+ }, [scope]);
637
1096
  useEffect(() => {
638
1097
  // The live page is bounded: after a later reset an observed command may no
639
1098
  // longer be present. Retire its local row once, not just while it is visible.
@@ -676,21 +1135,52 @@ export function useTimeline() {
676
1135
  const loadNewerHistory = useStoreSetter(atoms.loadNewerHistoryAtom);
677
1136
  const jumpToLatest = useStoreSetter(atoms.jumpToLatestAtom);
678
1137
  const pinVisibleHistory = useStoreSetter(atoms.pinVisibleHistoryAtom);
1138
+ const cancelQueued = useStoreSetter(atoms.cancelQueuedAtom);
1139
+ const { shown } = useConversationScope();
1140
+ // View-only: stored prompts, queue counts and fork stay as loaded.
1141
+ const settled = useMemo(() => base.settled.filter(shown), [base.settled, shown]);
1142
+ const tail = useMemo(() => base.tail.filter(shown), [base.tail, shown]);
1143
+ const view = useMemo(() => ({ ...base, settled, tail }), [base, settled, tail]);
1144
+ const hidingOnly = view.settled.length + view.tail.length === 0 &&
1145
+ base.settled.length + base.tail.length > 0;
1146
+ const needsOlder = hidingOnly && base.hasMoreHistory && !base.loadingOlderHistory;
1147
+ // One automatic request per loaded page: a failed fetch leaves the page
1148
+ // unchanged, so it is not retried until the reader scrolls up.
1149
+ const pageKey = `${base.settled[0]?.id ?? ""}:${String(base.settled.length)}`;
1150
+ const requestedPage = useRef(undefined);
1151
+ useEffect(() => {
1152
+ if (!needsOlder || requestedPage.current === pageKey)
1153
+ return;
1154
+ requestedPage.current = pageKey;
1155
+ loadOlderHistory();
1156
+ }, [needsOlder, pageKey, loadOlderHistory]);
679
1157
  return useMemo(() => ({
680
- ...base,
1158
+ ...view,
1159
+ cancelQueued,
681
1160
  jumpToLatest,
682
1161
  loadNewerHistory,
683
1162
  loadOlderHistory,
684
1163
  pinVisibleHistory,
685
- }), [base, jumpToLatest, loadNewerHistory, loadOlderHistory, pinVisibleHistory]);
1164
+ }), [
1165
+ view,
1166
+ cancelQueued,
1167
+ jumpToLatest,
1168
+ loadNewerHistory,
1169
+ loadOlderHistory,
1170
+ pinVisibleHistory,
1171
+ ]);
686
1172
  }
687
1173
  /** Read and mutate the current conversation draft and prompt. */
688
1174
  export function useComposer() {
689
1175
  const { atoms } = useConversationScope();
690
1176
  const draft = useStoreAtom(atoms.draftAtom);
1177
+ const images = useStoreAtom(atoms.draftImagesAtom);
1178
+ const recallState = useStoreAtom(atoms.recallStateAtom);
1179
+ const recall = useStoreSetter(atoms.recallAtom);
691
1180
  const failure = useStoreAtom(atoms.failureAtom);
692
1181
  const admissionError = useStoreAtom(atoms.admissionErrorAtom);
693
1182
  const setDraft = useStoreSetter(atoms.setDraftAtom);
1183
+ const checkpointDraft = useStoreSetter(atoms.checkpointDraftAtom);
694
1184
  const send = useStoreSetter(atoms.sendAtom);
695
1185
  const submitCommand = useStoreSetter(atoms.submitCommandAtom);
696
1186
  const retry = useStoreSetter(atoms.retryAtom);
@@ -702,7 +1192,11 @@ export function useComposer() {
702
1192
  return useMemo(() => ({
703
1193
  admissionError,
704
1194
  cancel,
1195
+ checkpointDraft,
705
1196
  draft,
1197
+ images,
1198
+ recall,
1199
+ recallState,
706
1200
  dismissFailed,
707
1201
  failure,
708
1202
  retry: retryLatest,
@@ -712,7 +1206,11 @@ export function useComposer() {
712
1206
  }), [
713
1207
  admissionError,
714
1208
  cancel,
1209
+ checkpointDraft,
715
1210
  draft,
1211
+ images,
1212
+ recall,
1213
+ recallState,
716
1214
  dismissFailed,
717
1215
  failure,
718
1216
  retryLatest,