@fungi.computer/parakeet 0.1.0 → 0.2.0
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.
- package/README.md +45 -6
- package/dist/account-commands.js +3 -11
- package/dist/commands/advertised.d.ts +3 -1
- package/dist/commands/advertised.js +89 -4
- package/dist/commands/interactions.d.ts +22 -7
- package/dist/commands/interactions.js +41 -17
- package/dist/commands/slash.js +3 -5
- package/dist/commands/use-slash-commands.js +2 -2
- package/dist/components/AgentHead.d.ts +2 -0
- package/dist/components/AgentHead.js +5 -4
- package/dist/components/ChatComposer.d.ts +3 -1
- package/dist/components/ChatComposer.js +79 -15
- package/dist/components/ChatMessage.d.ts +4 -0
- package/dist/components/ChatMessage.js +6 -2
- package/dist/components/ClientRows.js +2 -2
- package/dist/components/InteractionPanel.d.ts +2 -4
- package/dist/components/InteractionPanel.js +2 -2
- package/dist/components/TimelineItem.d.ts +2 -0
- package/dist/components/TimelineItem.js +31 -7
- package/dist/conversation-opening.d.ts +47 -0
- package/dist/conversation-opening.js +173 -0
- package/dist/conversation.d.ts +47 -3
- package/dist/conversation.js +584 -86
- package/dist/core/author-labels.d.ts +2 -0
- package/dist/core/build-timeline.d.ts +4 -2
- package/dist/core/build-timeline.js +46 -7
- package/dist/core/client-actions.js +6 -3
- package/dist/core/client-timeline.d.ts +9 -2
- package/dist/core/client-timeline.js +15 -4
- package/dist/core/draft-storage.d.ts +63 -0
- package/dist/core/draft-storage.js +50 -0
- package/dist/core/model-identity.d.ts +2 -0
- package/dist/core/model-identity.js +6 -0
- package/dist/core/queued-recall.d.ts +35 -0
- package/dist/core/queued-recall.js +138 -0
- package/dist/core/session-projection.js +17 -0
- package/dist/core/session-query.d.ts +54 -11
- package/dist/core/session-query.js +38 -12
- package/dist/core/tool-groups.d.ts +1 -1
- package/dist/core/tool-groups.js +3 -1
- package/dist/core/tool-view.js +2 -1
- package/dist/core/use-timeline-item-key.d.ts +8 -0
- package/dist/core/use-timeline-item-key.js +29 -0
- package/dist/headless.d.ts +6 -4
- package/dist/headless.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/model-commands.js +64 -39
- package/dist/parakeet.js +17 -18
- package/dist/session-commands.d.ts +0 -9
- package/dist/session-commands.js +39 -68
- package/dist/session.d.ts +1 -1
- package/dist/store/types.d.ts +10 -4
- package/dist/styles.css +27 -12
- package/docs/api/commands/slash.md +24 -21
- package/docs/api/headless.md +375 -232
- package/docs/api/index.md +157 -45
- package/package.json +16 -10
- package/src/styles.css +0 -9
package/dist/conversation.js
CHANGED
|
@@ -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
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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?.
|
|
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) =>
|
|
201
|
-
|
|
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 }) =>
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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(
|
|
248
|
-
return buildTimelineTail(get(durableAtom), snapshot?.progress ?? null, pending.prompts, get(admissionsAtom), authorOf
|
|
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(
|
|
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
|
-
|
|
341
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
set(clearSubmittedDraftAtom, {
|
|
377
|
-
draft: admission.submittedDraft,
|
|
378
|
-
revision: admission.submittedDraftRevision,
|
|
379
|
-
});
|
|
652
|
+
set(persistAtom);
|
|
380
653
|
})
|
|
381
654
|
.catch((cause) => {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
394
|
-
if (
|
|
770
|
+
const content = draft;
|
|
771
|
+
if (get(recallStateAtom).kind === "recalling")
|
|
395
772
|
return;
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
859
|
+
images: admission.images,
|
|
860
|
+
requestId: nanoid(),
|
|
477
861
|
submittedDraft: admission.submittedDraft,
|
|
478
|
-
|
|
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,
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
631
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
...
|
|
1158
|
+
...view,
|
|
1159
|
+
cancelQueued,
|
|
681
1160
|
jumpToLatest,
|
|
682
1161
|
loadNewerHistory,
|
|
683
1162
|
loadOlderHistory,
|
|
684
1163
|
pinVisibleHistory,
|
|
685
|
-
}), [
|
|
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,
|