@fungi.computer/parakeet 0.5.0 → 0.6.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.
- package/README.md +17 -1
- package/dist/components/ChatComposer.js +66 -60
- package/dist/components/CodeBlock.js +3 -2
- package/dist/components/ComposerModelKey.d.ts +11 -0
- package/dist/components/ComposerModelKey.js +9 -0
- package/dist/components/ComposerShell.d.ts +47 -0
- package/dist/components/ComposerShell.js +29 -0
- package/dist/components/DecisionComposer.js +1 -2
- package/dist/components/TimelineItem.js +1 -1
- package/dist/components/ToolGroup.js +2 -2
- package/dist/conversation-opening.d.ts +1 -1
- package/dist/conversation-opening.js +2 -2
- package/dist/conversation.js +12 -9
- package/dist/core/build-timeline.js +5 -2
- package/dist/core/composer-model-name.d.ts +3 -0
- package/dist/core/composer-model-name.js +21 -0
- package/dist/core/display-text.d.ts +2 -0
- package/dist/core/display-text.js +7 -0
- package/dist/core/draft-storage.d.ts +2 -0
- package/dist/core/draft-storage.js +14 -1
- package/dist/core/session-projection.d.ts +1 -0
- package/dist/core/session-projection.js +3 -2
- package/dist/core/session-view-state.d.ts +2 -4
- package/dist/core/session-view-state.js +33 -13
- package/dist/core/tool-groups.d.ts +3 -0
- package/dist/core/tool-groups.js +37 -1
- package/dist/core/tool-view.d.ts +6 -0
- package/dist/core/tool-view.js +33 -0
- package/dist/core/use-timeline-item-key.d.ts +3 -4
- package/dist/core/use-timeline-item-key.js +8 -14
- package/dist/headless.d.ts +4 -3
- package/dist/headless.js +4 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/parakeet.js +9 -11
- package/dist/store/types.d.ts +9 -0
- package/dist/styles.css +581 -114
- package/docs/api/headless.md +124 -41
- package/docs/api/index.md +153 -1
- package/package.json +11 -8
- package/src/completion-menu.css +2 -1
- package/src/composer-shell.css +125 -0
- package/src/styles.css +5 -87
- package/dist/components/WaitingNotice.d.ts +0 -7
- package/dist/components/WaitingNotice.js +0 -8
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ export function Conversation({ session }: { session: ParakeetSession }) {
|
|
|
60
60
|
Parakeet never reads a message's host metadata itself. To show who wrote a line,
|
|
61
61
|
pass `authorOf`: your reading of that metadata into a `ParakeetAuthor`
|
|
62
62
|
(`{ id, name }`). A Fungi app passes `messageAuthor` from
|
|
63
|
-
`@fungi.computer/
|
|
63
|
+
`@fungi.computer/agar/agents`; without it, lines show no author.
|
|
64
64
|
|
|
65
65
|
The view presents the Session's observed history, live work, composer and
|
|
66
66
|
errors. Screen readers receive the same Session status as the composer footer
|
|
@@ -78,6 +78,22 @@ provider, query cache, observations and prompt admission with typed fixtures.
|
|
|
78
78
|
The browser contract uses the real component with a fixture Session. A fake
|
|
79
79
|
Session checks the component, not hosted authentication or a model provider.
|
|
80
80
|
|
|
81
|
+
## Shared composer
|
|
82
|
+
|
|
83
|
+
`ComposerShell` is the shared chat and generation composer surface. It owns the
|
|
84
|
+
Card, field inset, height limits and one non-wrapping control row; callers
|
|
85
|
+
supply their field, controls, tray contents and action, not another toolbar
|
|
86
|
+
layout. `useComposerTextareaSize(ref, value)` grows either field within those
|
|
87
|
+
shared limits. The full stylesheet includes this layout; hosts compiling their
|
|
88
|
+
own Caps sheet import `@fungi.computer/parakeet/composer-shell.css` alongside
|
|
89
|
+
the completion-menu stylesheet.
|
|
90
|
+
|
|
91
|
+
The real Grove/Prints workbench proof is
|
|
92
|
+
`pnpm --filter @fungi.computer/grove test:composer-parity`. It requires both
|
|
93
|
+
workbenches (`GROVE_WORKBENCH_URL` / `PRINTS_WORKBENCH_URL`) and checks equal
|
|
94
|
+
geometry, a right-edge Send key and no wrapped controls at 390px in both themes.
|
|
95
|
+
`COMPOSER_PARITY_SHOTS` optionally saves the six matching side-by-side images.
|
|
96
|
+
|
|
81
97
|
## Core concepts
|
|
82
98
|
|
|
83
99
|
| Surface | What it owns |
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { formatTokens } from "../core/format-tokens.js";
|
|
3
|
-
import {
|
|
3
|
+
import { composerModelName } from "../core/composer-model-name.js";
|
|
4
4
|
import { formatContextUsage } from "../core/format-context.js";
|
|
5
|
-
import { Composer } from "@fungi.computer/caps/blocks/composer";
|
|
6
5
|
import { isImeComposing } from "@fungi.computer/caps/lib/ime";
|
|
7
6
|
import { RunFailureNotice } from "./RunFailureNotice.js";
|
|
8
|
-
import { SendAction
|
|
7
|
+
import { SendAction } from "@fungi.computer/caps/components/send-action";
|
|
9
8
|
import { formatMoney } from "@fungi.computer/caps/lib/format-money";
|
|
10
|
-
import { PopoverAnchor } from "@fungi.computer/caps/components/popover";
|
|
11
9
|
import { Status } from "@fungi.computer/caps/components/status";
|
|
12
10
|
import { Button } from "@fungi.computer/caps/components/button";
|
|
13
|
-
import { Card } from "@fungi.computer/caps/components/card";
|
|
14
11
|
import { IconButton } from "@fungi.computer/caps/components/icon-button";
|
|
15
12
|
import { KeycapButton } from "@fungi.computer/caps/components/keycap";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
13
|
+
import { ComposerModelKey } from "./ComposerModelKey.js";
|
|
14
|
+
import { StatusTrayList, StatusTrayRow, } from "@fungi.computer/caps/blocks/status-tray";
|
|
15
|
+
import { Plus, SlidersHorizontal, X } from "lucide-react";
|
|
18
16
|
import { memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react";
|
|
19
17
|
import { Textarea } from "@fungi.computer/caps/components/textarea";
|
|
20
18
|
import { formatHints } from "../commands/hints.js";
|
|
@@ -22,6 +20,7 @@ import { awaitingTakeover } from "../core/awaiting-answer.js";
|
|
|
22
20
|
import { completionFieldProps, completionKeyAction } from "./CompletionMenu.js";
|
|
23
21
|
import { DecisionComposer } from "./DecisionComposer.js";
|
|
24
22
|
import { SlashMenu } from "./SlashMenu.js";
|
|
23
|
+
import { ComposerShell, composerSendState, useComposerTextareaSize, } from "./ComposerShell.js";
|
|
25
24
|
const presentations = {
|
|
26
25
|
connecting: {
|
|
27
26
|
busy: false,
|
|
@@ -79,13 +78,7 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
|
|
|
79
78
|
if (textarea !== null)
|
|
80
79
|
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
81
80
|
}, [recallState, textareaRef]);
|
|
82
|
-
|
|
83
|
-
const textarea = textareaRef.current;
|
|
84
|
-
if (!textarea)
|
|
85
|
-
return;
|
|
86
|
-
textarea.style.height = "auto";
|
|
87
|
-
textarea.style.height = `${String(Math.min(textarea.scrollHeight, 320))}px`;
|
|
88
|
-
}, [textareaRef, value]);
|
|
81
|
+
useComposerTextareaSize(textareaRef, value);
|
|
89
82
|
const saveAndSend = useCallback(() => {
|
|
90
83
|
const draft = latestValue.current.trim();
|
|
91
84
|
if (!draft && (queuedRecall?.images?.length ?? 0) === 0)
|
|
@@ -210,7 +203,7 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
|
|
|
210
203
|
open: listOpen,
|
|
211
204
|
listId: slashListId,
|
|
212
205
|
selected: slash.selected,
|
|
213
|
-
})), className: "
|
|
206
|
+
})), className: "font-sans text-base-content", id: textareaId, onChange: (event) => {
|
|
214
207
|
onChange(event.target.value);
|
|
215
208
|
setHistoryIndex(-1);
|
|
216
209
|
}, onKeyDown: onKeyDown, placeholder: placeholder ??
|
|
@@ -266,26 +259,27 @@ const settingsCommands = [
|
|
|
266
259
|
{ alias: "compact", label: "Compact context" },
|
|
267
260
|
];
|
|
268
261
|
/**
|
|
269
|
-
*
|
|
270
|
-
* chip that sums up the Session's readouts, and the one send key at the end.
|
|
271
|
-
* Built from the same Caps StatusTray row Prints seats its controls in.
|
|
262
|
+
* Session controls and Settings content; ComposerShell owns their row.
|
|
272
263
|
*/
|
|
273
|
-
function
|
|
264
|
+
function useComposerRow({ showFooter, statusChips, model, status, viewState, usage, context, slash, attach, send, }) {
|
|
274
265
|
const [openId, setOpenId] = useState(null);
|
|
266
|
+
// Footer visibility belongs here, before deriving any internal readouts.
|
|
267
|
+
if (!showFooter)
|
|
268
|
+
return undefined;
|
|
275
269
|
const tone = viewState.kind === "error" ? "error" : presentations[status].tone;
|
|
276
270
|
// Admission must not replace an outstanding owner attention readout.
|
|
277
271
|
const label = sessionReadout("idle", viewState);
|
|
278
272
|
const needsAttention = viewState.kind === "waiting_for_you" || viewState.kind === "error";
|
|
279
|
-
const modelName = model === undefined ? undefined :
|
|
280
|
-
const canPickModel = slash?.available("model") === true;
|
|
273
|
+
const modelName = model === undefined ? undefined : composerModelName(model);
|
|
281
274
|
const contextText = context === undefined ? undefined : formatContextUsage(context);
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
usage === undefined ? undefined : formatMoney(usage.cost),
|
|
286
|
-
].filter((part) => part !== undefined);
|
|
275
|
+
const usageText = usage === undefined
|
|
276
|
+
? undefined
|
|
277
|
+
: `${formatTokens(usage.tokens)} · ${formatMoney(usage.cost)}`;
|
|
287
278
|
const commands = settingsCommands.filter(({ alias }) => slash?.available(alias) === true);
|
|
288
|
-
const items =
|
|
279
|
+
const items = usageText === undefined &&
|
|
280
|
+
contextText === undefined &&
|
|
281
|
+
commands.length === 0 &&
|
|
282
|
+
modelName === undefined
|
|
289
283
|
? []
|
|
290
284
|
: [
|
|
291
285
|
{
|
|
@@ -293,21 +287,29 @@ function ComposerToolbar({ statusChips, model, status, viewState, usage, context
|
|
|
293
287
|
label: (_jsxs("span", { className: "composer-settings-chip", children: [_jsx(SlidersHorizontal, { "aria-hidden": "true" }), "Settings"] })),
|
|
294
288
|
accessibleLabel: "Session settings",
|
|
295
289
|
title: "Settings",
|
|
296
|
-
content: (_jsxs(StatusTrayList, { className: "composer-settings", children: [modelName === undefined ? null : (_jsx(StatusTrayRow, { primary: "Model", secondary: modelName })), contextText === undefined ? null : (_jsx(StatusTrayRow, { primary: "Context", secondary: contextText, title: "Context used / model context" })),
|
|
290
|
+
content: (_jsxs(StatusTrayList, { className: "composer-settings", children: [modelName === undefined ? null : (_jsx(StatusTrayRow, { primary: "Model", secondary: modelName })), contextText === undefined ? null : (_jsx(StatusTrayRow, { primary: "Context", secondary: contextText, title: "Context used / model context" })), usageText === undefined ? null : (_jsx(StatusTrayRow, { primary: "Usage", secondary: usageText })), commands.map(({ alias, label: commandLabel }) => (_jsx(StatusTrayRow, { primary: commandLabel, actions: _jsx(Button, { size: "xs", variant: "ghost", onClick: () => {
|
|
297
291
|
setOpenId(null);
|
|
298
292
|
slash?.runCommand(alias);
|
|
299
293
|
}, children: "Open" }) }, alias)))] })),
|
|
300
294
|
},
|
|
301
295
|
];
|
|
302
|
-
return
|
|
303
|
-
|
|
304
|
-
|
|
296
|
+
return {
|
|
297
|
+
items,
|
|
298
|
+
openId,
|
|
299
|
+
onOpenChange: setOpenId,
|
|
300
|
+
action: send,
|
|
301
|
+
controls: (_jsxs(_Fragment, { children: [attach, modelName === undefined ? null : (_jsx(ComposerModelKey, { "aria-label": `Model: ${modelName}. Open settings`, "aria-haspopup": "dialog", expanded: openId === "settings", label: modelName, onClick: () => {
|
|
302
|
+
setOpenId((id) => (id === "settings" ? null : "settings"));
|
|
303
|
+
}, title: `${modelName} · Settings` })), statusChips?.map((chip, index) => (_jsx("span", { className: "composer-status-chip min-w-0 truncate", title: chip, children: chip }, index)))] })),
|
|
304
|
+
status: needsAttention ? (_jsx(Status, { className: "parakeet-composer-status", label: _jsx("span", { className: "composer-attention-label", children: label }), title: label, tone: tone })) : null,
|
|
305
|
+
readout: usageText === undefined ? undefined : (_jsx("span", { title: "Tokens \u00B7 cost", children: usageText })),
|
|
306
|
+
};
|
|
305
307
|
}
|
|
306
308
|
/** Images riding with the draft, drawn as small tiles above the text. */
|
|
307
309
|
function ComposerAttachments({ images, onRemove, }) {
|
|
308
310
|
return (_jsx("ul", { "aria-label": "Attached images", className: "composer-attachments", children: images.map((image, index) => (_jsxs("li", { children: [_jsx("img", { alt: `Attached image ${String(index + 1)}`, className: "mask mask-squircle", src: `data:${image.mimeType};base64,${image.data}` }), onRemove === undefined ? null : (_jsx(IconButton, { "aria-label": `Remove attached image ${String(index + 1)}`, className: "composer-attachment-remove", icon: _jsx(X, {}), onClick: () => {
|
|
309
311
|
onRemove(index);
|
|
310
|
-
} }))] }, index))) }));
|
|
312
|
+
}, size: "xs", variant: "outline" }))] }, index))) }));
|
|
311
313
|
}
|
|
312
314
|
export const ChatComposer = memo(function ChatComposer({ queuedRecall, onImagesChange, placeholder, showFooter = true, statusChips, actionVariant = "primary", draft, failure, failureCause, admissionError, model, onAbort, onChange, onRetry, onSend, status, viewState, textareaRef, usage, context, slash, }) {
|
|
313
315
|
const textareaId = useId();
|
|
@@ -332,16 +334,17 @@ export const ChatComposer = memo(function ChatComposer({ queuedRecall, onImagesC
|
|
|
332
334
|
}, [takeoverKey, restoreFocus, textareaRef]);
|
|
333
335
|
const images = queuedRecall?.images ?? [];
|
|
334
336
|
const recallBusy = queuedRecall?.recallState.kind === "recalling";
|
|
335
|
-
const cannotSend = recallBusy || (draft.trim().length === 0 && images.length === 0);
|
|
336
337
|
// One key in one place: Send when idle, Stop while the Agent works.
|
|
337
338
|
// Enter still queues a typed follow-up during a run.
|
|
338
339
|
// One key says what Enter does: Send when idle; while the Agent works,
|
|
339
340
|
// Stop over an empty draft and Queue once there is a draft to queue.
|
|
340
|
-
const sendState =
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
:
|
|
341
|
+
const sendState = composerSendState({
|
|
342
|
+
draft,
|
|
343
|
+
hasAttachments: images.length > 0,
|
|
344
|
+
busy,
|
|
345
|
+
queueWhileBusy: true,
|
|
346
|
+
blocked: recallBusy,
|
|
347
|
+
});
|
|
345
348
|
const sendLabel = busy ? "Queue follow-up" : "Send message";
|
|
346
349
|
const sendAction = (_jsx(SendAction, { "aria-label": sendLabel, className: "composer-send-key", disabled: sendState === "busy" &&
|
|
347
350
|
(recallBusy || status === "queued" || status === "cancelling"), onClick: () => {
|
|
@@ -358,30 +361,33 @@ export const ChatComposer = memo(function ChatComposer({ queuedRecall, onImagesC
|
|
|
358
361
|
}, ref: fileInput, type: "file" }), _jsx(KeycapButton, { "aria-label": "Attach images", className: "composer-attach-key", icon: _jsx(Plus, {}), label: null, onClick: () => fileInput.current?.click(), title: "Attach images" })] }));
|
|
359
362
|
// Visitors see only the field and the send key, never internal readouts.
|
|
360
363
|
const footerProps = { status, viewState };
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
364
|
+
footerProps.statusChips = statusChips;
|
|
365
|
+
if (model)
|
|
366
|
+
footerProps.model = model;
|
|
367
|
+
if (usage)
|
|
368
|
+
footerProps.usage = usage;
|
|
369
|
+
if (context)
|
|
370
|
+
footerProps.context = context;
|
|
371
|
+
const row = useComposerRow({
|
|
372
|
+
...footerProps,
|
|
373
|
+
showFooter,
|
|
374
|
+
slash,
|
|
375
|
+
attach,
|
|
376
|
+
send: sendAction,
|
|
377
|
+
});
|
|
370
378
|
return (_jsxs("div", { className: "chat-composer not-prose flex-shrink-0", children: [admissionError !== undefined ? (_jsx("div", { className: "px-3 py-1.5 text-xs text-error", role: "alert", children: admissionError === "offline_queue_full"
|
|
371
379
|
? "One prompt is already queued; it sends when the service is back."
|
|
372
380
|
: "Too many prompts are still awaiting admission. Try again shortly." })) : null, failure !== undefined ? (_jsx(RunFailureNotice, { failure: failure, cause: failureCause, onRetry: onRetry })) : null, slash?.interactions.notice === undefined ? null : (_jsx("div", { className: "parakeet-command-notice px-3 py-1.5 text-xs", "data-tone": slash.interactions.notice.tone, role: slash.interactions.notice.tone === "error" ? "alert" : "status", children: slash.interactions.notice.message })), _jsx("div", { "aria-live": "polite", className: "sr-only", children: takeover === undefined || slash === undefined
|
|
373
381
|
? ""
|
|
374
|
-
: `Agent ${takeover.kind} ${String(slash.awaiting.position)} of ${String(slash.awaiting.total)} needs your answer` }),
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
// A decision brings its own Later and Send row to the bottom edge.
|
|
386
|
-
takeover !== undefined || !showFooter ? undefined : (_jsx(ComposerToolbar, { ...footerProps, slash: slash, attach: attach, send: sendAction })) }) })] }), slash === undefined ? null : (_jsx("p", { className: "sr-only", id: `${textareaId}-guidance`, children: formatHints(slash.hints) }))] }));
|
|
382
|
+
: `Agent ${takeover.kind} ${String(slash.awaiting.position)} of ${String(slash.awaiting.total)} needs your answer` }), _jsx(ComposerShell, { "aria-label": "Message composer", action: takeover === undefined && row === undefined ? sendAction : undefined, menu: slash === undefined ? undefined : (_jsx(SlashMenu, { slash: slash, listId: slashListId })), attachments: takeover === undefined && images.length > 0 ? (_jsx(ComposerAttachments, { images: images, onRemove: onImagesChange === undefined
|
|
383
|
+
? undefined
|
|
384
|
+
: (index) => {
|
|
385
|
+
onImagesChange(images.filter((_image, at) => at !== index));
|
|
386
|
+
} })) : undefined, input: _jsxs(_Fragment, { children: [takeover === undefined || slash === undefined ? null : (_jsx(DecisionComposer, { item: takeover, position: slash.awaiting.position, total: slash.awaiting.total, onLater: () => {
|
|
387
|
+
slash.store.awaiting.later(takeover.key);
|
|
388
|
+
}, takeFocus: hadFocus.current }, takeover.key)), _jsx("div", { hidden: takeover !== undefined, children: _jsx(HostComposerTextarea, { queuedRecall: queuedRecall, placeholder: placeholder, onChange: onChange, onAbort: onAbort, onSend: () => {
|
|
389
|
+
onSend(busy ? "followUp" : "steer");
|
|
390
|
+
}, slash: slash, slashListId: slashListId, textareaId: textareaId, textareaRef: textareaRef, value: draft }) })] }), row:
|
|
391
|
+
// A decision brings its own Later and Send row to the bottom edge.
|
|
392
|
+
takeover !== undefined ? undefined : row }), slash === undefined ? null : (_jsx("p", { className: "sr-only", id: `${textareaId}-guidance`, children: formatHints(slash.hints) }))] }));
|
|
387
393
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { IconButton } from "@fungi.computer/caps/components/icon-button";
|
|
2
3
|
import { Check, Copy } from "lucide-react";
|
|
3
4
|
import { useState } from "react";
|
|
4
5
|
import { reportFailure } from "../lib/failure.js";
|
|
@@ -27,9 +28,9 @@ export function CodeBlock({ className, content, header, hideCopy, language, maxH
|
|
|
27
28
|
color: "var(--color-base-content)",
|
|
28
29
|
...style,
|
|
29
30
|
};
|
|
30
|
-
return (_jsxs("div", { className: `relative not-prose ${className ?? ""}`, style: containerStyle, children: [header ? (_jsx("div", { className: "text-dim px-2 pt-1.5 pb-1 text-[9px] uppercase tracking-wider", children: header })) : null, !hideCopy ? (_jsx(
|
|
31
|
+
return (_jsxs("div", { className: `relative not-prose ${className ?? ""}`, style: containerStyle, children: [header ? (_jsx("div", { className: "text-dim px-2 pt-1.5 pb-1 text-[9px] uppercase tracking-wider", children: header })) : null, !hideCopy ? (_jsx(IconButton, { "aria-label": "Copy to clipboard", className: "absolute top-1 right-1 z-10", icon: copyState === "copied" ? _jsx(Check, {}) : _jsx(Copy, {}), onClick: () => {
|
|
31
32
|
void handleCopy();
|
|
32
|
-
}, type: "button"
|
|
33
|
+
}, type: "button" })) : null, !hideCopy ? (_jsx("span", { "aria-live": "polite", className: "sr-only", role: "status", children: copyState === "failed" ? "Copy to clipboard failed." : null })) : null, _jsx("div", { className: "overflow-y-auto text-[10px]", "data-scrollable": true, style: {
|
|
33
34
|
maxHeight: maxHeight === "0" ? undefined : maxHeight,
|
|
34
35
|
padding: "6px 4px",
|
|
35
36
|
}, children: innerHtml === null ? (_jsxs("pre", { className: "whitespace-pre-wrap break-words font-mono text-base-content", style: { margin: 0 }, children: [content, content.length > HIGHLIGHT_THRESHOLD ? (_jsx("span", { className: "text-dim ml-2 italic", children: "(syntax highlighting skipped for very long content)" })) : null] })) : (_jsx("div", {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type KeycapButtonProps } from "@fungi.computer/caps/components/keycap";
|
|
2
|
+
/** A model label on the shared native key; callers own selection and menus. */
|
|
3
|
+
export type ComposerModelKeyProps = Omit<KeycapButtonProps, "icon" | "readout" | "label"> & {
|
|
4
|
+
/** Friendly catalog name, or the real model identity when no name is known. */
|
|
5
|
+
label: string;
|
|
6
|
+
};
|
|
7
|
+
/** The same model face and chevron in chat and generation composers. */
|
|
8
|
+
export declare const ComposerModelKey: import("react").ForwardRefExoticComponent<Omit<KeycapButtonProps, "label" | "icon" | "readout"> & {
|
|
9
|
+
/** Friendly catalog name, or the real model identity when no name is known. */
|
|
10
|
+
label: string;
|
|
11
|
+
} & import("react").RefAttributes<HTMLButtonElement>>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
import { KeycapButton, } from "@fungi.computer/caps/components/keycap";
|
|
5
|
+
import { cn } from "@fungi.computer/caps/lib/cn";
|
|
6
|
+
/** The same model face and chevron in chat and generation composers. */
|
|
7
|
+
export const ComposerModelKey = forwardRef(function ComposerModelKey({ className, label, ...props }, ref) {
|
|
8
|
+
return (_jsx(KeycapButton, { ...props, ref: ref, className: cn("parakeet-composer-model-key", className), label: label, readout: _jsx(ChevronDown, { "aria-hidden": "true", className: "size-[var(--stipe-icon-size-sm)] shrink-0" }) }));
|
|
9
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type HTMLAttributes, type ReactNode, type RefObject } from "react";
|
|
2
|
+
import { type StatusTrayItem } from "@fungi.computer/caps/blocks/status-tray";
|
|
3
|
+
import type { SendActionState } from "@fungi.computer/caps/components/send-action";
|
|
4
|
+
/** One input-to-action rule for both Apps; Caps owns the resulting key's look. */
|
|
5
|
+
export declare function composerSendState({ draft, hasAttachments, busy, queueWhileBusy, blocked, }: {
|
|
6
|
+
draft: string;
|
|
7
|
+
hasAttachments?: boolean;
|
|
8
|
+
busy: boolean;
|
|
9
|
+
/** Chat can queue a follow-up; generation stays busy until it settles. */
|
|
10
|
+
queueWhileBusy?: boolean;
|
|
11
|
+
/** An input owner can temporarily block submission, such as during recall. */
|
|
12
|
+
blocked?: boolean;
|
|
13
|
+
}): SendActionState;
|
|
14
|
+
/** App-owned controls and tray contents; Parakeet owns their single row. */
|
|
15
|
+
export type ComposerShellRow = Readonly<{
|
|
16
|
+
/** App-specific leading keys and status indicators. */
|
|
17
|
+
controls: ReactNode;
|
|
18
|
+
/** Send or Generate key, held at the row's right edge. */
|
|
19
|
+
action: ReactNode;
|
|
20
|
+
/** Persistent status beside usage and Send, never between leading keys. */
|
|
21
|
+
status?: ReactNode;
|
|
22
|
+
/** Quiet context summary; hidden when the shell is narrow. */
|
|
23
|
+
readout?: ReactNode;
|
|
24
|
+
/** Native tray keys and panels supplied by the App. */
|
|
25
|
+
items: readonly StatusTrayItem[];
|
|
26
|
+
/** Open tray item, or null when the tray is closed. */
|
|
27
|
+
openId: string | null;
|
|
28
|
+
/** Delegate tray selection to the caller's state owner. */
|
|
29
|
+
onOpenChange: (id: string | null) => void;
|
|
30
|
+
}>;
|
|
31
|
+
/** Content slots for the shared chat and generation composer surface. */
|
|
32
|
+
export type ComposerShellProps = HTMLAttributes<HTMLDivElement> & {
|
|
33
|
+
/** Draft field or decision surface, using the shared input geometry. */
|
|
34
|
+
input: ReactNode;
|
|
35
|
+
/** Optional draft attachments rendered by the Composer primitive. */
|
|
36
|
+
attachments?: ReactNode;
|
|
37
|
+
/** Retained field-and-Send mode without an internal control row. */
|
|
38
|
+
action?: ReactNode;
|
|
39
|
+
/** Optional completion menu anchored to this shell. */
|
|
40
|
+
menu?: ReactNode;
|
|
41
|
+
/** Omit while a decision owns the input and its own actions. */
|
|
42
|
+
row?: ComposerShellRow | undefined;
|
|
43
|
+
};
|
|
44
|
+
/** One field surface and one non-wrapping control row, with Send at its end. */
|
|
45
|
+
export declare function ComposerShell({ attachments, action, children, className, input, menu, row, ...props }: ComposerShellProps): import("react/jsx-runtime").JSX.Element;
|
|
46
|
+
/** Grow either App's field with its draft; shell CSS owns the height limits. */
|
|
47
|
+
export declare function useComposerTextareaSize(ref: RefObject<HTMLTextAreaElement>, value: string): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, } from "react";
|
|
3
|
+
import { Card } from "@fungi.computer/caps/components/card";
|
|
4
|
+
import { Composer } from "@fungi.computer/caps/blocks/composer";
|
|
5
|
+
import { PopoverAnchor } from "@fungi.computer/caps/components/popover";
|
|
6
|
+
import { StatusTray, } from "@fungi.computer/caps/blocks/status-tray";
|
|
7
|
+
import { cn } from "@fungi.computer/caps/lib/cn";
|
|
8
|
+
import { KeycapButton } from "@fungi.computer/caps/components/keycap";
|
|
9
|
+
/** One input-to-action rule for both Apps; Caps owns the resulting key's look. */
|
|
10
|
+
export function composerSendState({ draft, hasAttachments = false, busy, queueWhileBusy = false, blocked = false, }) {
|
|
11
|
+
const hasInput = !blocked && (draft.trim().length > 0 || hasAttachments);
|
|
12
|
+
if (busy && (!queueWhileBusy || !hasInput))
|
|
13
|
+
return "busy";
|
|
14
|
+
return hasInput ? "ready" : "empty";
|
|
15
|
+
}
|
|
16
|
+
/** One field surface and one non-wrapping control row, with Send at its end. */
|
|
17
|
+
export function ComposerShell({ attachments, action, children, className, input, menu, row, ...props }) {
|
|
18
|
+
return (_jsxs(PopoverAnchor, { className: "parakeet-composer-anchor", children: [menu, _jsxs(Card, { ...props, className: cn("parakeet-composer-shell", className), children: [_jsx(Composer, { action: action, attachments: attachments, input: input, toolbar: row === undefined ? undefined : (_jsx(StatusTray, { className: "parakeet-composer-row", align: "start", items: row.items, openId: row.openId, onOpenChange: row.onOpenChange, renderTrigger: ({ children: label, ...trigger }) => (_jsx(KeycapButton, { ...trigger, label: label, expanded: trigger["aria-expanded"] === true })), leading: row.controls, trailing: _jsxs("span", { className: "parakeet-composer-trailing", children: [row.status, row.readout == null ? null : (_jsx("span", { className: "parakeet-composer-readout", children: row.readout })), _jsx("span", { className: "parakeet-composer-action", children: row.action })] }) })) }), children] })] }));
|
|
19
|
+
}
|
|
20
|
+
/** Grow either App's field with its draft; shell CSS owns the height limits. */
|
|
21
|
+
export function useComposerTextareaSize(ref, value) {
|
|
22
|
+
useLayoutEffect(() => {
|
|
23
|
+
const field = ref.current;
|
|
24
|
+
if (!field)
|
|
25
|
+
return;
|
|
26
|
+
field.style.height = "auto";
|
|
27
|
+
field.style.height = `${String(field.scrollHeight)}px`;
|
|
28
|
+
}, [ref, value]);
|
|
29
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Button } from "@fungi.computer/caps/components/button";
|
|
3
2
|
import { Checkbox } from "@fungi.computer/caps/components/checkbox";
|
|
4
3
|
import { KeycapButton } from "@fungi.computer/caps/components/keycap";
|
|
5
4
|
import { Input } from "@fungi.computer/caps/components/input";
|
|
@@ -140,7 +139,7 @@ export function DecisionPanel({ view, dispatch, onLater, takeFocus, }) {
|
|
|
140
139
|
},
|
|
141
140
|
}) }), view.status === undefined ? null : (_jsx("p", { className: view.status.tone === "error"
|
|
142
141
|
? "text-sm text-error"
|
|
143
|
-
: "text-sm opacity-70", role: view.status.tone === "error" ? "alert" : "status", children: view.status.text })), _jsxs("div", { className: "parakeet-decision-actions", children: [_jsx(
|
|
142
|
+
: "text-sm opacity-70", role: view.status.tone === "error" ? "alert" : "status", children: view.status.text })), _jsxs("div", { className: "parakeet-decision-actions", children: [_jsx(KeycapButton, { label: "Later", tone: "neutral", onClick: onLater }), _jsx(KeycapButton, { disabled: !view.canSend, label: "Send", onClick: () => {
|
|
144
143
|
dispatch({ type: "enter" });
|
|
145
144
|
}, tone: "primary" })] })] }));
|
|
146
145
|
}
|
|
@@ -25,7 +25,7 @@ export const TimelineItem = memo(function TimelineItem({ item, onDismissFailed,
|
|
|
25
25
|
onCancelQueued !== undefined ? (_jsx("div", { className: "flex justify-end px-3", children: _jsx(Button, { "aria-label": "Cancel queued message", disabled: cancellingQueued, size: "sm", variant: "ghost", onClick: () => {
|
|
26
26
|
onCancelQueued(commandId);
|
|
27
27
|
}, children: "Cancel" }) })) : null;
|
|
28
|
-
const dismissAction = requestId === undefined || onDismissFailed === undefined ? null : (_jsx(
|
|
28
|
+
const dismissAction = requestId === undefined || onDismissFailed === undefined ? null : (_jsx(Button, { "aria-label": "Dismiss failed prompt", variant: "outline", size: "xs", onClick: () => {
|
|
29
29
|
onDismissFailed(requestId);
|
|
30
30
|
}, type: "button", children: "Dismiss" }));
|
|
31
31
|
const failed = item.status === "failed" ? (_jsxs("div", { className: "flex items-center gap-2 px-3 text-xs text-error", role: "alert", children: [_jsx("span", { children: item.author?.kind === "agent"
|
|
@@ -6,7 +6,7 @@ import { Status } from "@fungi.computer/caps/components/status";
|
|
|
6
6
|
import { Sigil } from "@fungi.computer/sigils";
|
|
7
7
|
import { memo } from "react";
|
|
8
8
|
import { disclosureOpen, toolCallDefaultOpen, toolGroupDefaultOpen, visibleToolCalls, } from "../core/tool-groups.js";
|
|
9
|
-
import { formatToolDuration, toolStatusTone as statusTone, toolKindSigils, } from "../core/tool-view.js";
|
|
9
|
+
import { formatToolDuration, toolStatusTone as statusTone, toolKindSigils, toolLiveLines, } from "../core/tool-view.js";
|
|
10
10
|
import { CodeBlock } from "./CodeBlock.js";
|
|
11
11
|
/** Items beyond a section's preview, unless the reader asked for all. */
|
|
12
12
|
function visible(items, preview, full) {
|
|
@@ -69,7 +69,7 @@ function CallRow({ call, tool, disclosureId, size, disclosure, onToggle, renderT
|
|
|
69
69
|
const running = call.status === "running";
|
|
70
70
|
return (_jsxs("div", { "data-parakeet-call": call.kind, "data-status": call.status, children: [_jsx(Disclosure, { leading: _jsx(Sigil, { name: toolKindSigils[call.kind], size: 16 }), onOpenChange: () => {
|
|
71
71
|
onToggle(disclosureId, defaultOpen);
|
|
72
|
-
}, open: open, size: size, summary: _jsxs(_Fragment, { children: [_jsx("span", { className: "text-base-content", children: running ? call.activeVerb : call.verb }), " ", _jsx("span", { className: "font-mono", children: call.label })] }), tone: call.status === "error" ? "error" : "neutral", trailing: _jsxs(_Fragment, { children: [call.exitCode === undefined ? null : (_jsxs(Badge, { size: "sm", tone: "error", children: ["exit ", String(call.exitCode)] })), call.durationMs === undefined ? null : (_jsx("span", { children: formatToolDuration(call.durationMs) })), _jsx(Status, { motion: running ? "bounce" : "none", title: call.status === "unknown" ? "result unavailable" : call.status, tone: statusTone(call.status) })] }), children: _jsx(CallBody, { call: call, disclosure: disclosure, onToggle: onToggle, mediaClaimed: tool !== undefined && claimsToolMedia?.(tool) === true, tool: tool }) }), call.status === "completed" && tool?.result !== undefined
|
|
72
|
+
}, open: open, size: size, summary: _jsxs(_Fragment, { children: [_jsx("span", { className: "text-base-content", children: running ? call.activeVerb : call.verb }), " ", _jsx("span", { className: "font-mono", children: call.label })] }), tone: call.status === "error" ? "error" : "neutral", trailing: _jsxs(_Fragment, { children: [call.exitCode === undefined ? null : (_jsxs(Badge, { size: "sm", tone: "error", children: ["exit ", String(call.exitCode)] })), call.durationMs === undefined ? null : (_jsx("span", { children: formatToolDuration(call.durationMs) })), _jsx(Status, { motion: running ? "bounce" : "none", title: call.status === "unknown" ? "result unavailable" : call.status, tone: statusTone(call.status) })] }), children: _jsx(CallBody, { call: call, disclosure: disclosure, onToggle: onToggle, mediaClaimed: tool !== undefined && claimsToolMedia?.(tool) === true, tool: tool }) }), call.live === undefined ? null : (_jsx("pre", { className: monoBlock, "aria-label": "Live tool output", children: toolLiveLines(call, open).map((line, index) => (_jsx("span", { className: "block whitespace-pre", children: line || "\u00a0" }, String(index)))) })), call.status === "completed" && tool?.result !== undefined
|
|
73
73
|
? renderToolActions?.(tool)
|
|
74
74
|
: null] }));
|
|
75
75
|
}
|
|
@@ -41,7 +41,7 @@ export declare function createConversationOpeningOwner<Session extends ParakeetS
|
|
|
41
41
|
/** Only the primary conversation is the selection the host shell sees. */
|
|
42
42
|
reportsSelection?: boolean;
|
|
43
43
|
}>): {
|
|
44
|
-
select: (agentId: string, sessionId: string, fromOpenRequest?: boolean
|
|
44
|
+
select: (agentId: string, sessionId: string, fromOpenRequest?: boolean) => void;
|
|
45
45
|
clear: () => void;
|
|
46
46
|
close: () => void;
|
|
47
47
|
};
|
|
@@ -68,7 +68,7 @@ export function createConversationOpeningOwner(options) {
|
|
|
68
68
|
let disposed = false;
|
|
69
69
|
const same = (left, right) => left?.agentId === right.agentId && left.sessionId === right.sessionId;
|
|
70
70
|
const isCurrent = (opening) => !disposed && pending === opening;
|
|
71
|
-
const select = (agentId, sessionId, fromOpenRequest = false
|
|
71
|
+
const select = (agentId, sessionId, fromOpenRequest = false) => {
|
|
72
72
|
if (disposed)
|
|
73
73
|
return;
|
|
74
74
|
const selection = { agentId, sessionId };
|
|
@@ -114,7 +114,7 @@ export function createConversationOpeningOwner(options) {
|
|
|
114
114
|
void Promise.allSettled([client.close(), transport.close()]);
|
|
115
115
|
};
|
|
116
116
|
await observation.ready;
|
|
117
|
-
if (
|
|
117
|
+
if (options.reportsSelection !== false) {
|
|
118
118
|
await connection.ui.reportAgentSessionSelection({
|
|
119
119
|
agentId,
|
|
120
120
|
sessionId,
|
package/dist/conversation.js
CHANGED
|
@@ -9,6 +9,7 @@ import { atom, createStore, } from "jotai/vanilla";
|
|
|
9
9
|
import { atomWithInfiniteQuery, atomWithMutation, atomWithQuery, } from "jotai-tanstack-query";
|
|
10
10
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore, } from "react";
|
|
11
11
|
import { buildDurableTimeline, buildTimelineTail, } from "./core/build-timeline.js";
|
|
12
|
+
import { overlayToolOutput } from "./core/tool-groups.js";
|
|
12
13
|
import { mergeCompactionNotices, mergeCompactions, mergeMessages, } from "./core/session-projection.js";
|
|
13
14
|
import { sessionHistoryQueryOptions, sessionQueryOptions, } from "./core/session-query.js";
|
|
14
15
|
import { sameSessionViewState, sessionActivityStatus, sessionViewState, } from "./core/session-view-state.js";
|
|
@@ -100,12 +101,15 @@ function acquireDraftStorage(supplied) {
|
|
|
100
101
|
const browser = globalThis;
|
|
101
102
|
const storage = browser.localStorage;
|
|
102
103
|
return storage === undefined
|
|
103
|
-
? {
|
|
104
|
+
? {
|
|
105
|
+
status: "unavailable",
|
|
106
|
+
cause: new Error("Browser draft storage is unavailable"),
|
|
107
|
+
}
|
|
104
108
|
: { status: "available", storage };
|
|
105
109
|
}
|
|
106
|
-
catch {
|
|
110
|
+
catch (cause) {
|
|
107
111
|
// An opaque-origin sandbox denies even reading the browser's getter.
|
|
108
|
-
return { status: "unavailable" };
|
|
112
|
+
return { status: "unavailable", cause };
|
|
109
113
|
}
|
|
110
114
|
}
|
|
111
115
|
function createConversationAtoms(session, queryClient, authorOf, interactions, draftStorage, draftScope, contributions, shown) {
|
|
@@ -535,7 +539,9 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
|
|
|
535
539
|
hasMoreHistory: get(hasMoreHistoryAtom),
|
|
536
540
|
hasNewerHistory: get(hasNewerHistoryAtom),
|
|
537
541
|
loadingOlderHistory: get(loadingOlderHistoryAtom),
|
|
538
|
-
settled: durable.items,
|
|
542
|
+
settled: overlayToolOutput(durable.items, get(toolActivityAtom) === undefined
|
|
543
|
+
? []
|
|
544
|
+
: (get(snapshotAtom)?.toolOutput ?? [])),
|
|
539
545
|
tail: get(tailAtom),
|
|
540
546
|
};
|
|
541
547
|
return durable.model === undefined || durable.usage === undefined
|
|
@@ -1125,11 +1131,8 @@ export function ParakeetProvider({ children, session, clients = noClients, inter
|
|
|
1125
1131
|
}, [scope, onToast]);
|
|
1126
1132
|
useEffect(() => {
|
|
1127
1133
|
if (acquiredDraftStorage.status === "unavailable")
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
message: "Drafts are session-only because browser storage is unavailable. They will be lost when this conversation closes.",
|
|
1131
|
-
});
|
|
1132
|
-
}, [scope, acquiredDraftStorage]);
|
|
1134
|
+
reportFailure("acquire draft storage", acquiredDraftStorage.cause);
|
|
1135
|
+
}, [acquiredDraftStorage]);
|
|
1133
1136
|
useEffect(() => {
|
|
1134
1137
|
const reconcile = () => {
|
|
1135
1138
|
scope.store.set(scope.atoms.reconcileQueuedCancellationAtom);
|
|
@@ -53,11 +53,14 @@ function progressItem(progress) {
|
|
|
53
53
|
id: progress.messageId,
|
|
54
54
|
kind: "assistant_message",
|
|
55
55
|
pending: true,
|
|
56
|
-
text: progress.text
|
|
56
|
+
text: `${progress.textTruncated ? "…" : ""}${progress.text}`,
|
|
57
57
|
};
|
|
58
58
|
return progress.thinking === ""
|
|
59
59
|
? base
|
|
60
|
-
: {
|
|
60
|
+
: {
|
|
61
|
+
...base,
|
|
62
|
+
thinking: `${progress.thinkingTruncated ? "…" : ""}${progress.thinking}`,
|
|
63
|
+
};
|
|
61
64
|
}
|
|
62
65
|
function collectAssistantBlocks(value) {
|
|
63
66
|
const text = [];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ANTHROPIC_MODELS } from "@earendil-works/pi-ai/providers/anthropic.models";
|
|
2
|
+
import { CLOUDFLARE_WORKERS_AI_MODELS } from "@earendil-works/pi-ai/providers/cloudflare-workers-ai.models";
|
|
3
|
+
import { OPENAI_MODELS } from "@earendil-works/pi-ai/providers/openai.models";
|
|
4
|
+
import { OPENAI_CODEX_MODELS } from "@earendil-works/pi-ai/providers/openai-codex.models";
|
|
5
|
+
import { formatModelIdentity } from "./model-identity.js";
|
|
6
|
+
// Read the same maintained metadata as the providers, without loading their
|
|
7
|
+
// execution/auth adapters or maintaining another table of display names.
|
|
8
|
+
const names = new Map([
|
|
9
|
+
...Object.values(ANTHROPIC_MODELS),
|
|
10
|
+
...Object.values(CLOUDFLARE_WORKERS_AI_MODELS),
|
|
11
|
+
...Object.values(OPENAI_MODELS),
|
|
12
|
+
...Object.values(OPENAI_CODEX_MODELS),
|
|
13
|
+
].map((model) => [
|
|
14
|
+
formatModelIdentity({ provider: model.provider, model: model.id }),
|
|
15
|
+
model.name,
|
|
16
|
+
]));
|
|
17
|
+
/** A maintained catalog label, or the real identity rather than a guessed name. */
|
|
18
|
+
export function composerModelName(model) {
|
|
19
|
+
const identity = formatModelIdentity(model);
|
|
20
|
+
return names.get(identity) ?? identity;
|
|
21
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import stripAnsi from "strip-ansi";
|
|
2
|
+
/** Display text keeps shaping characters, never terminal programs or bidi overrides. */
|
|
3
|
+
export function sanitizeDisplayText(value) {
|
|
4
|
+
return stripAnsi(value)
|
|
5
|
+
.replace(/\r\n?/gu, "\n")
|
|
6
|
+
.replace(/[\p{Cc}\u200b\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069\u2060\ufeff]/gu, (character) => character === "\n" || character === "\t" ? character : "");
|
|
7
|
+
}
|
|
@@ -12,6 +12,8 @@ export type ParakeetDraftStorage = Readonly<{
|
|
|
12
12
|
getItem: (key: string) => string | null;
|
|
13
13
|
/** Atomically replace the journal; throw if it cannot be committed. */
|
|
14
14
|
setItem: (key: string, value: string) => void;
|
|
15
|
+
/** Remove a journal, doing nothing when the key is absent. */
|
|
16
|
+
removeItem: (key: string) => void;
|
|
15
17
|
}>;
|
|
16
18
|
declare const journal: z.ZodObject<{
|
|
17
19
|
draft: z.ZodString;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { reportFailure } from "../lib/failure.js";
|
|
2
3
|
const images = z
|
|
3
4
|
.array(z.object({
|
|
4
5
|
type: z.literal("image"),
|
|
@@ -33,18 +34,30 @@ const journal = z.object({
|
|
|
33
34
|
/** One owner-scoped journal commits pending text and composer text together. */
|
|
34
35
|
export function draftJournal(scope, storage) {
|
|
35
36
|
const key = `fungi.parakeet.drafts.v2:${scope}`;
|
|
37
|
+
let discardObsolete = true;
|
|
36
38
|
return {
|
|
37
39
|
read() {
|
|
38
40
|
if (storage === undefined)
|
|
39
41
|
return { draft: "", pending: [] };
|
|
42
|
+
if (discardObsolete) {
|
|
43
|
+
discardObsolete = false;
|
|
44
|
+
try {
|
|
45
|
+
// Delete preview data without interpreting its superseded grammar.
|
|
46
|
+
storage.removeItem(`fungi.parakeet.drafts.v1:${scope}`);
|
|
47
|
+
}
|
|
48
|
+
catch (cause) {
|
|
49
|
+
reportFailure("discard obsolete draft journal", cause);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
40
52
|
try {
|
|
41
53
|
const saved = storage.getItem(key);
|
|
42
54
|
return saved === null
|
|
43
55
|
? { draft: "", pending: [] }
|
|
44
56
|
: journal.parse(JSON.parse(saved));
|
|
45
57
|
}
|
|
46
|
-
catch {
|
|
58
|
+
catch (cause) {
|
|
47
59
|
// Preview journals are disposable; a broken load must not break chat.
|
|
60
|
+
reportFailure("read saved draft", cause);
|
|
48
61
|
return { draft: "", pending: [] };
|
|
49
62
|
}
|
|
50
63
|
},
|