@fungi.computer/parakeet 0.3.0 → 0.5.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.
Files changed (63) hide show
  1. package/dist/account-commands.js +20 -7
  2. package/dist/client-contributions.d.ts +1 -0
  3. package/dist/commands/awaiting-driver.d.ts +20 -0
  4. package/dist/commands/awaiting-driver.js +121 -0
  5. package/dist/commands/builtins.js +6 -4
  6. package/dist/commands/interactions.d.ts +25 -0
  7. package/dist/commands/interactions.js +76 -2
  8. package/dist/commands/run.js +3 -3
  9. package/dist/commands/slash.d.ts +7 -3
  10. package/dist/commands/slash.js +2 -0
  11. package/dist/commands/use-slash-commands.d.ts +13 -0
  12. package/dist/commands/use-slash-commands.js +58 -1
  13. package/dist/components/ChatComposer.d.ts +2 -0
  14. package/dist/components/ChatComposer.js +150 -38
  15. package/dist/components/ClientRows.js +42 -26
  16. package/dist/components/CompletionMenu.d.ts +54 -0
  17. package/dist/components/CompletionMenu.js +52 -0
  18. package/dist/components/DecisionComposer.d.ts +30 -0
  19. package/dist/components/DecisionComposer.js +146 -0
  20. package/dist/components/InteractionPanel.d.ts +10 -1
  21. package/dist/components/InteractionPanel.js +24 -5
  22. package/dist/components/RunFailureNotice.js +11 -2
  23. package/dist/components/SlashMenu.d.ts +1 -3
  24. package/dist/components/SlashMenu.js +5 -13
  25. package/dist/components/TimelineItem.d.ts +1 -0
  26. package/dist/components/TimelineItem.js +3 -3
  27. package/dist/components/ToolGroup.d.ts +1 -0
  28. package/dist/components/ToolGroup.js +7 -7
  29. package/dist/conversation.d.ts +5 -1
  30. package/dist/conversation.js +72 -20
  31. package/dist/core/approval-decision.d.ts +198 -0
  32. package/dist/core/approval-decision.js +291 -0
  33. package/dist/core/awaiting-answer.d.ts +229 -0
  34. package/dist/core/awaiting-answer.js +303 -0
  35. package/dist/core/build-timeline.js +56 -5
  36. package/dist/core/client-actions.d.ts +1 -0
  37. package/dist/core/client-actions.js +8 -21
  38. package/dist/core/draft-storage.d.ts +4 -0
  39. package/dist/core/draft-storage.js +12 -5
  40. package/dist/core/picker-text.d.ts +7 -0
  41. package/dist/core/picker-text.js +24 -0
  42. package/dist/core/session-query.d.ts +8 -0
  43. package/dist/core/session-query.js +13 -11
  44. package/dist/core/session-view-state.d.ts +1 -1
  45. package/dist/core/session-view-state.js +14 -6
  46. package/dist/headless.d.ts +7 -2
  47. package/dist/headless.js +6 -1
  48. package/dist/model-commands.js +14 -15
  49. package/dist/parakeet.d.ts +6 -2
  50. package/dist/parakeet.js +15 -6
  51. package/dist/session-commands.js +6 -2
  52. package/dist/store/types.d.ts +5 -1
  53. package/dist/styles.css +407 -113
  54. package/dist/use-approval-awaiting.d.ts +22 -0
  55. package/dist/use-approval-awaiting.js +81 -0
  56. package/dist/use-decision.d.ts +20 -0
  57. package/dist/use-decision.js +79 -0
  58. package/docs/api/commands/slash.md +30 -26
  59. package/docs/api/headless.md +822 -41
  60. package/docs/api/index.md +13 -4
  61. package/package.json +15 -8
  62. package/src/completion-menu.css +71 -0
  63. package/src/styles.css +174 -46
@@ -0,0 +1,146 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from "@fungi.computer/caps/components/button";
3
+ import { Checkbox } from "@fungi.computer/caps/components/checkbox";
4
+ import { KeycapButton } from "@fungi.computer/caps/components/keycap";
5
+ import { Input } from "@fungi.computer/caps/components/input";
6
+ import { Listbox } from "@fungi.computer/caps/components/listbox";
7
+ import { isImeComposing } from "@fungi.computer/caps/lib/ime";
8
+ import { useEffect, useRef } from "react";
9
+ import { useDecision } from "../use-decision.js";
10
+ import { PromptHeader } from "./InteractionPanel.js";
11
+ const kindNames = { question: "Question", approval: "Approval" };
12
+ /**
13
+ * The composer while the Agent waits on the person: the decision's rows,
14
+ * an Other line, and Send / Later. It binds one waiting decision to its
15
+ * reducer and draws the result.
16
+ */
17
+ export function DecisionComposer({ item, position, total, onLater, takeFocus, }) {
18
+ const decision = useDecision(item, { position, total, later: onLater });
19
+ if (decision.view === undefined)
20
+ return null;
21
+ return (_jsx(DecisionPanel, { view: decision.view, dispatch: decision.dispatch, onLater: decision.later, takeFocus: takeFocus }));
22
+ }
23
+ /**
24
+ * Draw Parakeet's decision view with Caps parts and forward keys and clicks as
25
+ * intents; the rules live in the reducer, so every surface answers alike.
26
+ */
27
+ export function DecisionPanel({ view, dispatch, onLater, takeFocus, }) {
28
+ const list = useRef(null);
29
+ const input = useRef(null);
30
+ const typing = view.phase === "other";
31
+ const wasTyping = useRef(false);
32
+ useEffect(() => {
33
+ if (takeFocus)
34
+ list.current?.focus();
35
+ }, [takeFocus]);
36
+ useEffect(() => {
37
+ if (typing)
38
+ input.current?.focus();
39
+ else if (wasTyping.current && document.activeElement === input.current)
40
+ list.current?.focus();
41
+ wasTyping.current = typing;
42
+ }, [typing]);
43
+ const rows = view.rows;
44
+ const field = view.other;
45
+ const sending = view.phase === "sending";
46
+ const act = (event, handled, run) => {
47
+ if (!handled)
48
+ return false;
49
+ event.preventDefault();
50
+ run();
51
+ return true;
52
+ };
53
+ const onListKey = (event) => {
54
+ // Keys typed in the Other row's field belong to the field.
55
+ if (event.target instanceof HTMLInputElement)
56
+ return;
57
+ if (isImeComposing(event))
58
+ return;
59
+ if (event.ctrlKey || event.metaKey || event.altKey)
60
+ return;
61
+ const { key } = event;
62
+ if (act(event, key === "ArrowDown", () => {
63
+ dispatch({ type: "move", delta: 1 });
64
+ }) ||
65
+ act(event, key === "ArrowUp", () => {
66
+ dispatch({ type: "move", delta: -1 });
67
+ }) ||
68
+ act(event, key === "Enter", () => {
69
+ dispatch({ type: "enter" });
70
+ }) ||
71
+ act(event, key === " ", () => {
72
+ dispatch({ type: "toggle" });
73
+ }) ||
74
+ act(event, key === "Escape", () => {
75
+ dispatch({ type: "escape" });
76
+ }) ||
77
+ act(event, /^[1-9]$/u.test(key), () => {
78
+ dispatch({ type: "pick", number: Number(key) });
79
+ }) ||
80
+ act(event, key.length === 1, () => {
81
+ dispatch({ type: "append", text: key });
82
+ }))
83
+ return;
84
+ };
85
+ const onOtherKey = (event) => {
86
+ if (isImeComposing(event))
87
+ return;
88
+ const { key } = event;
89
+ if (act(event, key === "ArrowDown", () => {
90
+ dispatch({ type: "move", delta: 1 });
91
+ }) ||
92
+ act(event, key === "ArrowUp", () => {
93
+ dispatch({ type: "move", delta: -1 });
94
+ }) ||
95
+ act(event, key === "Enter", () => {
96
+ dispatch({ type: "enter" });
97
+ }) ||
98
+ act(event, key === "Escape", () => {
99
+ dispatch({ type: "escape" });
100
+ }))
101
+ return;
102
+ };
103
+ // A multiple decision's marker is a Caps check, not `[x]` text. The row
104
+ // itself is the control: the option owns aria-selected and the click, so
105
+ // the check is drawn for sight only.
106
+ const check = (row) => (_jsx("span", { "aria-hidden": "true", className: "pointer-events-none flex shrink-0", children: _jsx(Checkbox, { checked: row.toggled, readOnly: true, tabIndex: -1 }) }));
107
+ const words = (row) => (_jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: row.destructive ? "text-error" : undefined, children: view.multiple ? row.caption : row.text }), row.description === undefined ? null : (_jsx("span", { className: "text-sm opacity-70", children: row.description }))] }));
108
+ const rowContent = (row) => view.multiple ? (_jsxs("span", { className: "flex items-start gap-xs", children: [check(row), words(row)] })) : (words(row));
109
+ const picked = rows.find((row) => row.highlighted);
110
+ return (_jsxs("div", { "aria-label": view.label, className: "parakeet-decision flex flex-col gap-xs p-sm", "data-kind": view.kind, "data-phase": view.phase, role: "group", children: [_jsx(PromptHeader, { request: {
111
+ message: view.title,
112
+ summary: view.summary,
113
+ facts: view.facts,
114
+ body: view.body,
115
+ bodyLabel: view.bodyLabel,
116
+ details: view.details.length === 0 ? undefined : view.details,
117
+ }, trailing: _jsx("span", { className: "shrink-0 text-sm opacity-70", children: view.counter ?? kindNames[view.kind] }) }), _jsx(Listbox, { "aria-label": view.title, className: "parakeet-decision-list", getKey: (row) => String(row.index), getLabel: (row) => row.label, isDisabled: () => sending, items: rows, rootRef: list, renderItem: (row) => row.kind === "other" && field !== undefined ? (_jsxs("span", { className: "flex items-center gap-xs", "data-highlight": row.highlighted || undefined, "data-row": "other", children: [view.multiple ? check(row) : null, _jsx("span", { className: "shrink-0", children: view.multiple ? row.caption : row.text }), row.highlighted || field.text !== "" ? (_jsx(Input, { "aria-label": field.label, autoComplete: "off", disabled: sending, maxLength: field.maxLength, onChange: (event) => {
118
+ dispatch({ type: "type", text: event.target.value });
119
+ }, onKeyDown: onOtherKey, placeholder: field.placeholder, ref: input, value: field.text })) : null] })) : (_jsx("span", { "data-highlight": row.highlighted || undefined, children: rowContent(row) })), onKeyDown: onListKey, ...(view.multiple
120
+ ? {
121
+ multiple: true,
122
+ value: rows
123
+ .filter((row) => row.toggled)
124
+ .map((row) => String(row.index)),
125
+ onValueChange: (_value, selected) => {
126
+ const clicked = rows.find((row) => selected.some((chosen) => chosen.index === row.index) !==
127
+ row.toggled);
128
+ if (clicked === undefined)
129
+ return;
130
+ dispatch({ type: "pick", number: clicked.index + 1 });
131
+ if (clicked.kind === "option")
132
+ dispatch({ type: "toggle" });
133
+ },
134
+ }
135
+ : {
136
+ value: picked === undefined ? null : String(picked.index),
137
+ onValueChange: (_value, row) => {
138
+ if (row !== null)
139
+ dispatch({ type: "pick", number: row.index + 1 });
140
+ },
141
+ }) }), view.status === undefined ? null : (_jsx("p", { className: view.status.tone === "error"
142
+ ? "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(Button, { variant: "ghost", onClick: onLater, children: "Later" }), _jsx(KeycapButton, { disabled: !view.canSend, label: "Send", onClick: () => {
144
+ dispatch({ type: "enter" });
145
+ }, tone: "primary" })] })] }));
146
+ }
@@ -1,4 +1,5 @@
1
- import type { WhistleInteractionHost } from "@fungi.computer/whistle/interaction";
1
+ import type { WhistleInteractionHost, WhistlePendingPrompt } from "@fungi.computer/whistle/interaction";
2
+ import { type ReactNode } from "react";
2
3
  /** The interaction to draw and the page's close callback. */
3
4
  export type InteractionPanelProps = Readonly<{
4
5
  /** The renderer-neutral interaction store. */
@@ -6,6 +7,14 @@ export type InteractionPanelProps = Readonly<{
6
7
  /** Close a settled interaction. */
7
8
  onClose?: () => void;
8
9
  }>;
10
+ /** The text every prompt carries above its choices. */
11
+ export type PromptContent = Pick<WhistlePendingPrompt["request"], "message" | "summary" | "facts" | "body" | "bodyLabel" | "details">;
12
+ /** Summary, facts, long text and on-demand details: the shared modal anatomy. */
13
+ export declare function PromptHeader({ request, trailing, }: Readonly<{
14
+ request: PromptContent;
15
+ /** Drawn at the end of the title line, e.g. a `1 of 3` marker. */
16
+ trailing?: ReactNode;
17
+ }>): import("react/jsx-runtime").JSX.Element;
9
18
  /**
10
19
  * Browser renderer for one Whistle interaction. It draws the same host
11
20
  * snapshot the terminal draws; the command, not the page, defines the steps.
@@ -1,27 +1,46 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Alert } from "@fungi.computer/caps/components/alert";
3
3
  import { Button } from "@fungi.computer/caps/components/button";
4
+ import { DataList, DataRow } from "@fungi.computer/caps/components/data-list";
5
+ import { Disclosure } from "@fungi.computer/caps/components/disclosure";
4
6
  import { Field } from "@fungi.computer/caps/components/field";
5
7
  import { Input } from "@fungi.computer/caps/components/input";
6
8
  import { Listbox } from "@fungi.computer/caps/components/listbox";
7
9
  import { Status } from "@fungi.computer/caps/components/status";
8
10
  import { Display } from "@fungi.computer/caps/components/surface";
9
- import { useState, useSyncExternalStore } from "react";
11
+ import { useState, useSyncExternalStore, } from "react";
12
+ /** Summary, facts, long text and on-demand details: the shared modal anatomy. */
13
+ export function PromptHeader({ request, trailing, }) {
14
+ const [open, setOpen] = useState(false);
15
+ const entries = (list) => list === undefined ? null : (_jsx(DataList, { density: "compact", children: list.map((entry) => (
16
+ // Facts read as one block of label/value lines, with no rules between.
17
+ _jsxs(DataRow, { className: "after:hidden", children: [_jsx("span", { className: "text-sm opacity-70", children: entry.label }), " ", _jsx("span", { className: "break-words", children: entry.value })] }, entry.label))) }));
18
+ return (_jsxs(_Fragment, { children: [trailing === undefined ? (_jsx("p", { className: "font-medium", children: request.message })) : (_jsxs("div", { className: "flex items-baseline justify-between gap-sm", children: [_jsx("p", { className: "font-medium", children: request.message }), trailing] })), request.summary === undefined ? null : (_jsx("p", { className: "text-sm opacity-70", children: request.summary })), entries(request.facts), request.body === undefined ? null : (_jsxs("p", { className: "line-clamp-3 whitespace-pre-line break-words", children: [request.bodyLabel === undefined ? null : (_jsxs("span", { className: "text-sm opacity-70", children: [request.bodyLabel, " "] })), request.body] })), request.details === undefined && request.body === undefined ? null : (_jsxs(Disclosure, { open: open, onOpenChange: setOpen, summary: "Details", children: [request.body === undefined ? null : (_jsx("p", { className: "whitespace-pre-line break-words", children: request.body })), entries(request.details)] }))] }));
19
+ }
10
20
  function PromptView({ host, pending, }) {
11
21
  const [text, setText] = useState("");
12
22
  const request = pending.request;
13
23
  if (request.type === "choice")
14
- return (_jsxs("div", { className: "flex flex-col gap-xs", children: [_jsx("p", { className: "font-medium", children: request.message }), _jsx(Listbox, { "aria-label": request.message, items: request.options, getKey: (option) => option.value, getLabel: (option) => option.label, onValueChange: (value) => {
24
+ return (_jsxs("div", { className: "flex flex-col gap-xs", children: [_jsx(PromptHeader, { request: request }), _jsx(Listbox, { "aria-label": request.message, items: request.options, getKey: (option) => option.value, getLabel: (option) => option.label, onValueChange: (value) => {
15
25
  if (value !== null)
16
26
  host.answer(pending.sequence, value);
17
- }, renderItem: (option) => (_jsxs("span", { className: "flex flex-col", children: [_jsx("span", { children: option.label }), option.description === undefined ? null : (_jsx("span", { className: "text-sm opacity-70", children: option.description }))] })) })] }));
27
+ }, renderItem: (option) => (_jsxs("span", { className: `flex flex-col${option.destructive === true ? " text-error" : ""}`, children: [_jsxs("span", { children: [option.value === request.current ? "● " : "", option.label] }), option.description === undefined ? null : (_jsx("span", { className: "text-sm opacity-70", children: option.description }))] })) })] }));
18
28
  if (request.type === "confirmation")
19
- return (_jsxs("div", { className: "flex flex-col gap-sm", children: [_jsx("p", { children: request.message }), _jsxs("div", { className: "flex justify-end gap-xs", children: [_jsx(Button, { variant: "ghost", onClick: () => host.answer(pending.sequence, false), children: request.cancelLabel ?? "Cancel" }), _jsx(Button, { onClick: () => host.answer(pending.sequence, true), children: request.confirmLabel ?? "Confirm" })] })] }));
29
+ return (_jsxs("div", { className: "flex flex-col gap-sm", children: [_jsx(PromptHeader, { request: request }), _jsxs("div", { className: "flex justify-end gap-xs", children: [_jsx(Button
30
+ // A destructive question focuses the safe answer, which comes first.
31
+ , {
32
+ // A destructive question focuses the safe answer, which comes first.
33
+ autoFocus: request.destructive === true, variant: "ghost", onClick: () => host.answer(pending.sequence, false), children: request.cancelLabel ?? "Cancel" }), _jsx(Button, { variant: request.destructive === true ? "destructive" : "primary", onClick: () => host.answer(pending.sequence, true), children: request.confirmLabel ?? "Confirm" })] })] }));
34
+ // A decision is answered in the composer; no dialog ever receives one.
35
+ if (request.type === "decision")
36
+ return null;
20
37
  const submit = (event) => {
21
38
  event.preventDefault();
22
39
  host.answer(pending.sequence, text);
23
40
  };
24
- return (_jsxs("form", { className: "flex flex-col gap-sm", onSubmit: submit, children: [_jsx(Field, { label: request.message, children: _jsx(Input, { autoComplete: "off", autoFocus: true, placeholder: request.placeholder, type: request.secret === true ? "password" : "text", value: text, onChange: (event) => {
41
+ return (_jsxs("form", { className: "flex flex-col gap-sm", onSubmit: submit, children: [request.summary === undefined &&
42
+ request.facts === undefined &&
43
+ request.body === undefined ? null : (_jsx(PromptHeader, { request: request })), _jsx(Field, { label: request.message, children: _jsx(Input, { autoComplete: "off", autoFocus: true, placeholder: request.placeholder, type: request.secret === true ? "password" : "text", value: text, onChange: (event) => {
25
44
  setText(event.target.value);
26
45
  } }) }), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "submit", children: "Continue" }) })] }));
27
46
  }
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Alert } from "@fungi.computer/caps/components/alert";
3
3
  import { Button } from "@fungi.computer/caps/components/button";
4
+ import { boundedDiagnostic } from "@fungi.computer/bird-dog/effect";
4
5
  const messages = {
5
6
  admission: "Your prompt didn’t make it through. Let’s give it another go.",
6
7
  failed: "The run stopped. Check the message above before trying again.",
@@ -9,5 +10,13 @@ const messages = {
9
10
  };
10
11
  /** Session failures do not carry a billing receipt; never promise a refund. */
11
12
  export function RunFailureNotice({ failure, cause, onRetry, }) {
12
- return (_jsx(Alert, { tone: "warning", title: failure === "cancelled" ? "Taking a breather" : "A little tangle", description: _jsxs(_Fragment, { children: [messages[failure], " If the model didn\u2019t start, there\u2019s no charge. Model work already done can still use credits.", cause ? _jsx("span", { className: "block", children: cause }) : null] }), action: failure === "cancelled" ? undefined : (_jsx(Button, { "aria-label": "Retry prompt", variant: "outline", onClick: onRetry, children: "Retry" })) }));
13
+ const bounded = cause === undefined ? undefined : boundedDiagnostic(cause);
14
+ // Diagnostic metadata is useful to the owner, not a public explanation.
15
+ const publicCause = bounded !== undefined &&
16
+ !bounded.startsWith("reason_unavailable:") &&
17
+ !/\b(?:run|session)=/iu.test(bounded)
18
+ ? bounded
19
+ : undefined;
20
+ const interruptedWithReason = failure === "interrupted" && publicCause !== undefined;
21
+ return (_jsx(Alert, { tone: "warning", title: failure === "cancelled" ? "Taking a breather" : "A little tangle", description: interruptedWithReason ? (_jsxs(_Fragment, { children: ["This turn stopped before it finished: ", publicCause, ". Your prompt is still here."] })) : (_jsxs(_Fragment, { children: [messages[failure], " If the model didn\u2019t start, there\u2019s no charge. Model work already done can still use credits.", publicCause ? _jsx("span", { className: "block", children: publicCause }) : null] })), action: failure === "cancelled" ? undefined : (_jsx(Button, { "aria-label": "Retry prompt", variant: "outline", onClick: onRetry, children: "Retry" })) }));
13
22
  }
@@ -1,7 +1,5 @@
1
1
  import type { SlashCommandController } from "../commands/use-slash-commands.js";
2
- /** Stable id of one slash row, for `aria-activedescendant`. */
3
- export declare function slashOptionId(listId: string, index: number): string;
4
- /** The browser slash menu: draws Parakeet's completion rows, nothing more. */
2
+ /** Parakeet's slash adapter: maps its completion rows onto the shared menu. */
5
3
  export declare function SlashMenu({ slash, listId, }: Readonly<{
6
4
  slash: SlashCommandController;
7
5
  listId: string;
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { PopoverOption } from "@fungi.computer/caps/components/popover";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { CompletionMenu } from "./CompletionMenu.js";
3
3
  /** Split a label into matched and unmatched runs (UTF-16 indices, like the matcher). */
4
4
  function emphasize(label, matches) {
5
5
  if (matches.length === 0)
@@ -16,13 +16,9 @@ function emphasize(label, matches) {
16
16
  }
17
17
  return runs.map((run) => run.marked ? (_jsx("mark", { className: "parakeet-slash-match", children: run.text }, run.start)) : (_jsx("span", { children: run.text }, run.start)));
18
18
  }
19
- /** Stable id of one slash row, for `aria-activedescendant`. */
20
- export function slashOptionId(listId, index) {
21
- return `${listId}-${String(index)}`;
22
- }
23
- /** The browser slash menu: draws Parakeet's completion rows, nothing more. */
19
+ /** Parakeet's slash adapter: maps its completion rows onto the shared menu. */
24
20
  export function SlashMenu({ slash, listId, }) {
25
- const { completion, selected } = slash;
21
+ const { completion } = slash;
26
22
  if (completion.kind === "none")
27
23
  return null;
28
24
  const rows = completion.kind === "commands"
@@ -42,9 +38,5 @@ export function SlashMenu({ slash, listId, }) {
42
38
  keys: "",
43
39
  disabled: false,
44
40
  }));
45
- return (_jsxs("div", { className: "parakeet-slash-menu", "data-kind": completion.kind, children: [completion.kind === "arguments" ? (_jsx("p", { className: "parakeet-slash-hint", children: completion.hint })) : null, rows.length === 0 ? null : (_jsx("div", { id: listId, role: "listbox", "aria-label": "Commands", children: rows.map((row, index) => (_jsxs(PopoverOption, { id: slashOptionId(listId, index), active: index === selected, "aria-disabled": row.disabled || undefined, "data-disabled": row.disabled, onMouseEnter: () => {
46
- slash.highlight(index);
47
- }, onClick: () => {
48
- slash.choose(index);
49
- }, children: [_jsx("span", { className: "parakeet-slash-label", children: row.label }), _jsx("span", { className: "parakeet-slash-detail", children: row.detail }), row.keys.length === 0 ? null : (_jsx("kbd", { className: "parakeet-slash-keys", children: row.keys }))] }, row.key))) }))] }));
41
+ return (_jsx(CompletionMenu, { open: slash.open, rows: rows, selected: slash.selected, listId: listId, label: "Commands", kind: completion.kind, hint: completion.kind === "arguments" ? completion.hint : undefined, onHighlight: slash.highlight, onChoose: slash.choose }));
50
42
  }
@@ -8,6 +8,7 @@ export declare const TimelineItem: import("react").NamedExoticComponent<Readonly
8
8
  onCancelQueued?: ((commandId: string) => void) | undefined;
9
9
  cancellingQueued?: boolean | undefined;
10
10
  renderToolActions?: ((item: ToolItem) => ReactNode) | undefined;
11
+ claimsToolMedia?: ((item: ToolItem) => boolean) | undefined;
11
12
  renderAssistantAttachment?: ((text: string) => ReactNode) | undefined;
12
13
  formatAssistantText?: ((text: string) => string) | undefined;
13
14
  /** View-only open state for tool groups and calls. */
@@ -14,7 +14,7 @@ const compactionNoticeLabel = {
14
14
  stale: "compaction skipped: the context moved on",
15
15
  insufficient: "compaction skipped: the summary would not fit",
16
16
  };
17
- export const TimelineItem = memo(function TimelineItem({ item, onDismissFailed, onCancelQueued, cancellingQueued = false, renderToolActions, renderAssistantAttachment, formatAssistantText, toolDisclosure = initialToolDisclosure, onToggleTool = ignoreToggle, }) {
17
+ export const TimelineItem = memo(function TimelineItem({ item, onDismissFailed, onCancelQueued, cancellingQueued = false, renderToolActions, claimsToolMedia, renderAssistantAttachment, formatAssistantText, toolDisclosure = initialToolDisclosure, onToggleTool = ignoreToggle, }) {
18
18
  switch (item.kind) {
19
19
  case "user_message": {
20
20
  const pending = item.status === "pending" || item.status === "queued";
@@ -44,11 +44,11 @@ export const TimelineItem = memo(function TimelineItem({ item, onDismissFailed,
44
44
  case "assistant_message":
45
45
  if (!assistantMessageVisible(item))
46
46
  return null;
47
- return (_jsxs(_Fragment, { children: [item.author ? (_jsx("div", { className: "px-3 text-xs text-dim", "data-author": item.author.id, children: item.author.name })) : null, _jsx(ChatMessage, { isError: item.isError === true, pending: item.pending, speaker: "agent", ...(item.thinking ? { thinking: item.thinking } : {}), children: formatAssistantText?.(item.text) ?? item.text }), item.isError && item.providerError !== undefined ? (_jsxs("div", { className: "px-3 text-error", "data-provider-error": item.id, children: [_jsx("p", { className: "whitespace-pre-wrap", children: item.providerError }), _jsxs("details", { children: [_jsx("summary", { children: "Provider details" }), _jsx("pre", { className: "whitespace-pre-wrap", children: item.providerError })] })] })) : null, !item.pending && !item.isError
47
+ return (_jsxs(_Fragment, { children: [item.author ? (_jsx("div", { className: "px-3 text-xs text-dim", "data-author": item.author.id, children: item.author.name })) : null, _jsx(ChatMessage, { isError: item.isError === true, pending: item.pending, speaker: "agent", ...(item.thinking ? { thinking: item.thinking } : {}), children: formatAssistantText?.(item.text) ?? item.text }), item.isError && item.repeatCount !== undefined ? (_jsxs("span", { className: "px-3 text-error", children: ["x", item.repeatCount] })) : null, item.isError && item.errorDetail ? (_jsx("p", { className: "px-3 text-dim", children: item.errorDetail })) : null, item.isError && item.providerError !== undefined ? (_jsx("div", { className: "px-3 text-error", "data-provider-error": item.id, children: _jsxs("details", { children: [_jsx("summary", { children: "Provider details (latest response)" }), _jsx("pre", { className: "whitespace-pre-wrap", children: item.providerError })] }) })) : null, !item.pending && !item.isError
48
48
  ? renderAssistantAttachment?.(item.text)
49
49
  : null] }));
50
50
  case "tool_group":
51
- return (_jsx(ToolGroup, { disclosure: toolDisclosure, group: item, onToggle: onToggleTool, renderToolActions: renderToolActions }));
51
+ return (_jsx(ToolGroup, { disclosure: toolDisclosure, group: item, onToggle: onToggleTool, renderToolActions: renderToolActions, claimsToolMedia: claimsToolMedia }));
52
52
  case "compaction_divider":
53
53
  return (_jsx("div", { className: "divider text-dim my-2 text-xs", role: "separator", children: item.tokensAfter === null
54
54
  ? "compacted · the agent’s memory starts below"
@@ -9,4 +9,5 @@ export declare const ToolGroup: import("react").NamedExoticComponent<Readonly<{
9
9
  disclosure: ToolDisclosureState;
10
10
  onToggle: ToggleToolDisclosure;
11
11
  renderToolActions?: ((item: ToolItem) => ReactNode) | undefined;
12
+ claimsToolMedia?: ((item: ToolItem) => boolean) | undefined;
12
13
  }>>;
@@ -53,38 +53,38 @@ function SectionView({ section, full, onShowAll, }) {
53
53
  }
54
54
  }
55
55
  }
56
- function CallBody({ call, tool, disclosure, onToggle, }) {
56
+ function CallBody({ call, tool, disclosure, onToggle, mediaClaimed, }) {
57
57
  const fullId = `${call.id}:full`;
58
58
  const full = disclosureOpen(disclosure, fullId, false);
59
59
  const showAll = () => {
60
60
  onToggle(fullId, false);
61
61
  };
62
- return (_jsxs("div", { className: "space-y-1.5 py-1.5 pr-2", "data-parakeet-call-body": true, children: [call.error === undefined ? null : (_jsx("p", { className: "not-prose m-0 font-mono text-[11px] text-error", role: "alert", children: call.error })), call.sections.map((section, index) => (_jsx(SectionView, { full: full, onShowAll: showAll, section: section }, `${section.kind}:${String(index)}`))), tool?.result?.content
62
+ return (_jsxs("div", { className: "space-y-1.5 py-1.5 pr-2", "data-parakeet-call-body": true, children: [call.error === undefined ? null : (_jsx("p", { className: "not-prose m-0 font-mono text-[11px] text-error", role: "alert", children: call.error })), call.sections.map((section, index) => (_jsx(SectionView, { full: full, onShowAll: showAll, section: section }, `${section.kind}:${String(index)}`))), (mediaClaimed ? [] : (tool?.result?.content ?? []))
63
63
  .filter((part) => part.type === "image")
64
64
  .map((part, index) => (_jsx("img", { alt: "Tool result", className: "max-w-full", src: `data:${part.mimeType};base64,${part.data}`, style: { maxHeight: 400 } }, `${call.id}-image-${String(index)}`)))] }));
65
65
  }
66
- function CallRow({ call, tool, disclosureId, size, disclosure, onToggle, renderToolActions, }) {
66
+ function CallRow({ call, tool, disclosureId, size, disclosure, onToggle, renderToolActions, claimsToolMedia, }) {
67
67
  const defaultOpen = toolCallDefaultOpen(call);
68
68
  const open = disclosureOpen(disclosure, disclosureId, defaultOpen);
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, 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.status === "completed" && tool?.result !== undefined
73
73
  ? renderToolActions?.(tool)
74
74
  : null] }));
75
75
  }
76
76
  /** Recent finished calls, or a live group, with reader-owned disclosure. */
77
- export const ToolGroup = memo(function ToolGroup({ group, disclosure, onToggle, renderToolActions, }) {
77
+ export const ToolGroup = memo(function ToolGroup({ group, disclosure, onToggle, renderToolActions, claimsToolMedia, }) {
78
78
  const defaultOpen = toolGroupDefaultOpen(group);
79
79
  const open = disclosureOpen(disclosure, group.id, defaultOpen);
80
80
  const running = group.status === "running";
81
81
  const active = group.active;
82
82
  const single = group.calls.length === 1 ? group.calls[0] : undefined;
83
83
  if (single !== undefined)
84
- return (_jsx("div", { className: "px-1", children: _jsx(CallRow, { call: single, disclosure: disclosure, disclosureId: single.id, onToggle: onToggle, renderToolActions: renderToolActions, size: "row", tool: group.tools[0] }) }));
84
+ return (_jsx("div", { className: "px-1", children: _jsx(CallRow, { call: single, disclosure: disclosure, disclosureId: single.id, onToggle: onToggle, renderToolActions: renderToolActions, claimsToolMedia: claimsToolMedia, size: "row", tool: group.tools[0] }) }));
85
85
  return (_jsxs("div", { className: "px-1", children: [_jsx(Disclosure, { className: "[&>.caps-disclosure-panel]:hidden", leading: _jsx(Sigil, { name: "list", size: 16 }), onOpenChange: () => {
86
86
  onToggle(group.id, defaultOpen);
87
- }, open: open, summary: running && active !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-base-content", children: active.activeVerb }), " ", _jsx("span", { className: "font-mono", children: active.label }), " \u00B7", " ", String(group.calls.length), " ", group.calls.length === 1 ? "call" : "calls"] })) : (_jsxs("span", { className: "text-base-content", children: [group.summary, " \u00B7 ", String(group.calls.length), " ", group.calls.length === 1 ? "call" : "calls"] })), tone: group.status === "error" ? "error" : "neutral", trailing: _jsxs(_Fragment, { children: [group.failedCount > 0 ? (_jsxs("span", { className: "text-error", children: [String(group.failedCount), " failed"] })) : null, group.durationMs === undefined ? null : (_jsx("span", { children: formatToolDuration(group.durationMs) })), _jsx(Status, { motion: running ? "bounce" : "none", title: group.status === "unknown" ? "result unavailable" : group.status, tone: statusTone(group.status) })] }) }), visibleToolCalls(group, open).map((call) => (_jsx(CallRow, { call: call, disclosure: disclosure, disclosureId: call.id, onToggle: onToggle, renderToolActions: renderToolActions, size: "row", tool: group.tools.find((tool) => tool.id === call.id) }, call.id))), group.status === "completed" && group.calls.length > 3 ? (_jsx("button", { "aria-expanded": open, className: "btn btn-ghost btn-xs text-dim", onClick: () => {
87
+ }, open: open, summary: running && active !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-base-content", children: active.activeVerb }), " ", _jsx("span", { className: "font-mono", children: active.label }), " \u00B7", " ", String(group.calls.length), " ", group.calls.length === 1 ? "call" : "calls"] })) : (_jsxs("span", { className: "text-base-content", children: [group.summary, " \u00B7 ", String(group.calls.length), " ", group.calls.length === 1 ? "call" : "calls"] })), tone: group.status === "error" ? "error" : "neutral", trailing: _jsxs(_Fragment, { children: [group.failedCount > 0 ? (_jsxs("span", { className: "text-error", children: [String(group.failedCount), " failed"] })) : null, group.durationMs === undefined ? null : (_jsx("span", { children: formatToolDuration(group.durationMs) })), _jsx(Status, { motion: running ? "bounce" : "none", title: group.status === "unknown" ? "result unavailable" : group.status, tone: statusTone(group.status) })] }) }), visibleToolCalls(group, open).map((call) => (_jsx(CallRow, { call: call, disclosure: disclosure, disclosureId: call.id, onToggle: onToggle, renderToolActions: renderToolActions, claimsToolMedia: claimsToolMedia, size: "row", tool: group.tools.find((tool) => tool.id === call.id) }, call.id))), group.status === "completed" && group.calls.length > 3 ? (_jsx("button", { "aria-expanded": open, className: "btn btn-ghost btn-xs text-dim", onClick: () => {
88
88
  onToggle(group.id, defaultOpen);
89
89
  }, type: "button", children: open ? "Show less" : `Show all ${String(group.calls.length)} calls` })) : null] }));
90
90
  });
@@ -118,7 +118,7 @@ export type ComposerController = Readonly<{
118
118
  /** Typed reason the latest prompt did not complete. */
119
119
  failure: "admission" | "failed" | "cancelled" | "interrupted" | undefined;
120
120
  /** Current send could not enter the bounded local admission queue. */
121
- admissionError: "queue_full" | undefined;
121
+ admissionError: "offline_queue_full" | "queue_full" | undefined;
122
122
  /** Retry the exact most recent failed prompt. */
123
123
  retry: () => void;
124
124
  /** Remove one failed local admission from this conversation. */
@@ -135,6 +135,8 @@ export type ComposerController = Readonly<{
135
135
  submitCommand: (run: (draft: string, consumeDraft: () => void) => Promise<WhistleExecutionOutcome | undefined>) => Promise<WhistleExecutionOutcome | undefined>;
136
136
  /** Replace the local input. */
137
137
  setDraft: (value: string) => void;
138
+ /** Replace the images riding with the draft; the Session owner admits them on send. */
139
+ setImages: (images: PromptCommand["images"]) => void;
138
140
  }>;
139
141
  /** Session activity, observation, and Agent-lifetime controls. */
140
142
  export type StatusController = Readonly<{
@@ -203,6 +205,8 @@ export declare function useParakeetSession(): ParakeetSession;
203
205
  export declare function useExtensionSlots(): SessionExtensionSlots;
204
206
  /** Read Session activity and operate its observation. */
205
207
  export declare function useStatus(): StatusController;
208
+ /** Time of the last delivered watch snapshot or frame, not an SSE comment. */
209
+ export declare function useLastSessionReplyAt(): number | undefined;
206
210
  /** Presentation admitted once from the selected Session's already observed slots. */
207
211
  export declare function useClientContributions(): readonly ClientContribution[];
208
212
  /** The selected Session's ordinary command interaction owner. */
@@ -66,7 +66,7 @@ function composerStatus(snapshot, admissions, failure) {
66
66
  return status;
67
67
  if (failure !== undefined || view.kind === "error")
68
68
  return "error";
69
- if (admissions.some(({ status: state }) => state === "accepted"))
69
+ if (admissions.some(({ status: state }) => state === "accepted" || state === "queued-local"))
70
70
  return "queued";
71
71
  if (admissions.some(({ status: state }) => state === "admitting"))
72
72
  return "sending";
@@ -113,13 +113,14 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
113
113
  const saved = journal.read();
114
114
  let mounted = false;
115
115
  const timers = new Map();
116
- const recoveryReasons = new Map(saved.pending.map(({ requestId }) => [
117
- requestId,
118
- "Closed before confirmation",
119
- ]));
116
+ const recoveryReasons = new Map();
117
+ for (const pending of saved.pending)
118
+ if (pending.status === "admitting")
119
+ recoveryReasons.set(pending.requestId, "Closed before confirmation");
120
120
  const recovering = new Set();
121
121
  const queryAtom = atomWithQuery(() => sessionQueryOptions(session), () => queryClient);
122
122
  const snapshotAtom = atom((get) => get(queryAtom).data?.snapshot);
123
+ const lastReplyAtAtom = atom((get) => get(queryAtom).data?.lastReplyAt);
123
124
  const pinnedHistoryAtom = atom(undefined);
124
125
  const historyBeforeAtom = atom((get) => (get(pinnedHistoryAtom) ?? get(snapshotAtom))?.oldestCursor ?? null);
125
126
  const historyAtom = atomWithInfiniteQuery((get) => sessionHistoryQueryOptions(session, get(historyBeforeAtom)), () => queryClient);
@@ -279,10 +280,7 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
279
280
  const recallStateAtom = atom({ kind: "idle" });
280
281
  let recallOwner;
281
282
  const draftRevisionAtom = atom(0);
282
- const rawAdmissionsAtom = atom(saved.pending.map((pending) => ({
283
- ...pending,
284
- status: "admitting",
285
- })));
283
+ const rawAdmissionsAtom = atom(saved.pending);
286
284
  const persistAtom = atom(null, (get, _set, state) => {
287
285
  // Unavailable storage is session-only, not a pretend durable journal.
288
286
  // A supplied adapter's write failures still refuse durable operations.
@@ -299,16 +297,19 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
299
297
  recall: state?.recall === null
300
298
  ? undefined
301
299
  : (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
- })),
300
+ pending: (state?.admissions ?? get(rawAdmissionsAtom)).flatMap(({ status, requestId, content, submittedDraft, delivery, images, restoreDraft, }) => status === "admitting" || status === "queued-local"
301
+ ? [
302
+ {
303
+ status,
304
+ requestId,
305
+ content,
306
+ submittedDraft,
307
+ delivery,
308
+ images,
309
+ restoreDraft,
310
+ },
311
+ ]
312
+ : []),
312
313
  });
313
314
  });
314
315
  const recallAtom = atom(null, (get, set) => {
@@ -436,6 +437,24 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
436
437
  set(draftAtom, value);
437
438
  set(admissionErrorAtom, undefined);
438
439
  });
440
+ const setImagesAtom = atom(null, (get, set, images) => {
441
+ const next = images === undefined || images.length === 0 ? undefined : images;
442
+ try {
443
+ set(persistAtom, {
444
+ draftPayload: { images: next, delivery: get(draftDeliveryAtom) },
445
+ });
446
+ }
447
+ catch (cause) {
448
+ reportFailure("save draft images", cause);
449
+ interactions.toast({
450
+ variant: "error",
451
+ message: `Images not saved: ${errorMessage(cause)}`,
452
+ });
453
+ }
454
+ set(draftImagesAtom, next);
455
+ set(draftRevisionAtom, (revision) => revision + 1);
456
+ set(admissionErrorAtom, undefined);
457
+ });
439
458
  /** Consume only the exact draft a command submitted. */
440
459
  const clearSubmittedDraftAtom = atom(null, (get, set, submitted) => {
441
460
  if (get(draftRevisionAtom) !== submitted.revision ||
@@ -616,6 +635,10 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
616
635
  set(draftDeliveryAtom, undefined);
617
636
  set(setDraftAtom, "");
618
637
  }
638
+ // The offline prompt is saved alongside the draft. Await a fresh owner
639
+ // baseline before attempting admission.
640
+ if (admission.status === "queued-local")
641
+ return;
619
642
  const prompt = {
620
643
  type: "prompt",
621
644
  text: admission.content,
@@ -730,6 +753,20 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
730
753
  const maintainRecoveryAtom = atom(null, (get, set) => {
731
754
  const observation = get(observationAtom).status;
732
755
  const admissions = get(rawAdmissionsAtom);
756
+ if (observation === "live") {
757
+ if (get(admissionErrorAtom) === "offline_queue_full")
758
+ set(admissionErrorAtom, undefined);
759
+ const queued = admissions.find(({ status }) => status === "queued-local");
760
+ if (queued !== undefined) {
761
+ const sending = {
762
+ ...queued,
763
+ status: "admitting",
764
+ };
765
+ set(rawAdmissionsAtom, (current) => current.map((candidate) => candidate.requestId === queued.requestId ? sending : candidate));
766
+ // Preserve the idempotent request id when reconnect admits the prompt.
767
+ set(dispatchAdmissionAtom, { admission: sending, consumeDraft: false });
768
+ }
769
+ }
733
770
  for (const [id, timer] of timers) {
734
771
  if (!admissions.some(({ requestId, status }) => requestId === id && status === "admitting")) {
735
772
  clearTimeout(timer);
@@ -774,6 +811,12 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
774
811
  return;
775
812
  const admissions = get(admissionsAtom);
776
813
  const unresolved = admissions.filter(({ status }) => status !== "failed");
814
+ const offline = get(observationAtom).status !== "live";
815
+ if (offline &&
816
+ unresolved.some(({ status }) => status === "queued-local")) {
817
+ set(admissionErrorAtom, "offline_queue_full");
818
+ return;
819
+ }
777
820
  if (unresolved.length >= MAX_UNRESOLVED_LOCAL_ADMISSIONS) {
778
821
  set(admissionErrorAtom, "queue_full");
779
822
  return;
@@ -785,7 +828,7 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
785
828
  requestId: nanoid(),
786
829
  submittedDraft: draft,
787
830
  restoreDraft: true,
788
- status: "admitting",
831
+ status: offline ? "queued-local" : "admitting",
789
832
  };
790
833
  set(rawAdmissionsAtom, [...admissions, admission]);
791
834
  set(latestSubmissionIdAtom, admission.requestId);
@@ -991,6 +1034,7 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
991
1034
  jumpToLatestAtom,
992
1035
  loadOlderHistoryAtom,
993
1036
  loadNewerHistoryAtom,
1037
+ lastReplyAtAtom,
994
1038
  observationAtom,
995
1039
  observedCommandIdsAtom,
996
1040
  rawAdmissionsAtom,
@@ -1002,6 +1046,7 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
1002
1046
  reconnectAtom,
1003
1047
  retryAtom,
1004
1048
  setDraftAtom,
1049
+ setImagesAtom,
1005
1050
  sendAtom,
1006
1051
  snapshotAtom,
1007
1052
  statusAtom,
@@ -1180,6 +1225,7 @@ export function useComposer() {
1180
1225
  const failure = useStoreAtom(atoms.failureAtom);
1181
1226
  const admissionError = useStoreAtom(atoms.admissionErrorAtom);
1182
1227
  const setDraft = useStoreSetter(atoms.setDraftAtom);
1228
+ const setImages = useStoreSetter(atoms.setImagesAtom);
1183
1229
  const checkpointDraft = useStoreSetter(atoms.checkpointDraftAtom);
1184
1230
  const send = useStoreSetter(atoms.sendAtom);
1185
1231
  const submitCommand = useStoreSetter(atoms.submitCommandAtom);
@@ -1202,6 +1248,7 @@ export function useComposer() {
1202
1248
  retry: retryLatest,
1203
1249
  send,
1204
1250
  setDraft,
1251
+ setImages,
1205
1252
  submitCommand,
1206
1253
  }), [
1207
1254
  admissionError,
@@ -1216,6 +1263,7 @@ export function useComposer() {
1216
1263
  retryLatest,
1217
1264
  send,
1218
1265
  setDraft,
1266
+ setImages,
1219
1267
  submitCommand,
1220
1268
  ]);
1221
1269
  }
@@ -1250,6 +1298,10 @@ export function useStatus() {
1250
1298
  viewState,
1251
1299
  }), [commandId, context, observation, reconnect, status, viewState]);
1252
1300
  }
1301
+ /** Time of the last delivered watch snapshot or frame, not an SSE comment. */
1302
+ export function useLastSessionReplyAt() {
1303
+ return useStoreAtom(useConversationScope().atoms.lastReplyAtAtom);
1304
+ }
1253
1305
  /** Presentation admitted once from the selected Session's already observed slots. */
1254
1306
  export function useClientContributions() {
1255
1307
  return useStoreAtom(useConversationScope().contributions);