@fungi.computer/parakeet 0.4.0 → 0.6.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 (82) hide show
  1. package/README.md +17 -1
  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 +158 -40
  15. package/dist/components/ClientRows.js +42 -26
  16. package/dist/components/CodeBlock.js +3 -2
  17. package/dist/components/CompletionMenu.d.ts +54 -0
  18. package/dist/components/CompletionMenu.js +52 -0
  19. package/dist/components/ComposerModelKey.d.ts +11 -0
  20. package/dist/components/ComposerModelKey.js +9 -0
  21. package/dist/components/ComposerShell.d.ts +47 -0
  22. package/dist/components/ComposerShell.js +29 -0
  23. package/dist/components/DecisionComposer.d.ts +30 -0
  24. package/dist/components/DecisionComposer.js +145 -0
  25. package/dist/components/InteractionPanel.d.ts +10 -1
  26. package/dist/components/InteractionPanel.js +9 -4
  27. package/dist/components/SlashMenu.d.ts +1 -3
  28. package/dist/components/SlashMenu.js +5 -13
  29. package/dist/components/TimelineItem.d.ts +1 -0
  30. package/dist/components/TimelineItem.js +4 -4
  31. package/dist/components/ToolGroup.d.ts +1 -0
  32. package/dist/components/ToolGroup.js +8 -8
  33. package/dist/conversation-opening.d.ts +1 -1
  34. package/dist/conversation-opening.js +2 -2
  35. package/dist/conversation.d.ts +5 -1
  36. package/dist/conversation.js +84 -29
  37. package/dist/core/approval-decision.d.ts +198 -0
  38. package/dist/core/approval-decision.js +291 -0
  39. package/dist/core/awaiting-answer.d.ts +229 -0
  40. package/dist/core/awaiting-answer.js +303 -0
  41. package/dist/core/build-timeline.js +61 -7
  42. package/dist/core/client-actions.d.ts +1 -0
  43. package/dist/core/client-actions.js +7 -1
  44. package/dist/core/composer-model-name.d.ts +3 -0
  45. package/dist/core/composer-model-name.js +21 -0
  46. package/dist/core/display-text.d.ts +2 -0
  47. package/dist/core/display-text.js +7 -0
  48. package/dist/core/draft-storage.d.ts +6 -0
  49. package/dist/core/draft-storage.js +25 -5
  50. package/dist/core/session-projection.d.ts +1 -0
  51. package/dist/core/session-projection.js +3 -2
  52. package/dist/core/session-query.d.ts +8 -0
  53. package/dist/core/session-query.js +13 -11
  54. package/dist/core/session-view-state.d.ts +2 -4
  55. package/dist/core/session-view-state.js +27 -9
  56. package/dist/core/tool-groups.d.ts +3 -0
  57. package/dist/core/tool-groups.js +37 -1
  58. package/dist/core/tool-view.d.ts +6 -0
  59. package/dist/core/tool-view.js +33 -0
  60. package/dist/core/use-timeline-item-key.d.ts +3 -4
  61. package/dist/core/use-timeline-item-key.js +8 -14
  62. package/dist/headless.d.ts +10 -5
  63. package/dist/headless.js +9 -4
  64. package/dist/index.d.ts +3 -0
  65. package/dist/index.js +3 -0
  66. package/dist/parakeet.d.ts +6 -2
  67. package/dist/parakeet.js +24 -17
  68. package/dist/store/types.d.ts +14 -1
  69. package/dist/styles.css +911 -162
  70. package/dist/use-approval-awaiting.d.ts +22 -0
  71. package/dist/use-approval-awaiting.js +81 -0
  72. package/dist/use-decision.d.ts +20 -0
  73. package/dist/use-decision.js +79 -0
  74. package/docs/api/commands/slash.md +30 -26
  75. package/docs/api/headless.md +917 -54
  76. package/docs/api/index.md +166 -5
  77. package/package.json +17 -8
  78. package/src/completion-menu.css +72 -0
  79. package/src/composer-shell.css +125 -0
  80. package/src/styles.css +99 -53
  81. package/dist/components/WaitingNotice.d.ts +0 -7
  82. package/dist/components/WaitingNotice.js +0 -8
@@ -1,18 +1,26 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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 { formatModelIdentity } from "../core/model-identity.js";
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 { Button } from "@fungi.computer/caps/components/button";
7
+ import { SendAction } from "@fungi.computer/caps/components/send-action";
9
8
  import { formatMoney } from "@fungi.computer/caps/lib/format-money";
10
- import { Popover, PopoverAnchor, } from "@fungi.computer/caps/components/popover";
11
9
  import { Status } from "@fungi.computer/caps/components/status";
12
- import { memo, useCallback, useId, useLayoutEffect, useRef, useState, } from "react";
10
+ import { Button } from "@fungi.computer/caps/components/button";
11
+ import { IconButton } from "@fungi.computer/caps/components/icon-button";
12
+ import { KeycapButton } from "@fungi.computer/caps/components/keycap";
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";
16
+ import { memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react";
13
17
  import { Textarea } from "@fungi.computer/caps/components/textarea";
14
18
  import { formatHints } from "../commands/hints.js";
15
- import { SlashMenu, slashOptionId } from "./SlashMenu.js";
19
+ import { awaitingTakeover } from "../core/awaiting-answer.js";
20
+ import { completionFieldProps, completionKeyAction } from "./CompletionMenu.js";
21
+ import { DecisionComposer } from "./DecisionComposer.js";
22
+ import { SlashMenu } from "./SlashMenu.js";
23
+ import { ComposerShell, composerSendState, useComposerTextareaSize, } from "./ComposerShell.js";
16
24
  const presentations = {
17
25
  connecting: {
18
26
  busy: false,
@@ -70,13 +78,7 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
70
78
  if (textarea !== null)
71
79
  textarea.setSelectionRange(textarea.value.length, textarea.value.length);
72
80
  }, [recallState, textareaRef]);
73
- useLayoutEffect(() => {
74
- const textarea = textareaRef.current;
75
- if (!textarea)
76
- return;
77
- textarea.style.height = "auto";
78
- textarea.style.height = `${String(Math.min(textarea.scrollHeight, 320))}px`;
79
- }, [textareaRef, value]);
81
+ useComposerTextareaSize(textareaRef, value);
80
82
  const saveAndSend = useCallback(() => {
81
83
  const draft = latestValue.current.trim();
82
84
  if (!draft && (queuedRecall?.images?.length ?? 0) === 0)
@@ -92,13 +94,14 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
92
94
  if (recalling)
93
95
  return;
94
96
  if (slash?.open === true) {
95
- const handled = event.key === "ArrowDown"
97
+ const action = completionKeyAction(event);
98
+ const handled = action === "next"
96
99
  ? slash.move(1)
97
- : event.key === "ArrowUp"
100
+ : action === "previous"
98
101
  ? slash.move(-1)
99
102
  : event.key === "Tab"
100
103
  ? slash.complete()
101
- : event.key === "Escape"
104
+ : action === "dismiss"
102
105
  ? slash.dismiss()
103
106
  : false;
104
107
  if (handled) {
@@ -187,20 +190,24 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
187
190
  const listOpen = slash?.open === true && slash.completion.kind !== "none"
188
191
  ? slash.completion.items.length > 0
189
192
  : false;
190
- return (_jsxs("div", { className: "relative min-w-0 flex-1", children: [_jsx("label", { className: "sr-only", htmlFor: textareaId, children: "Message" }), _jsx(Textarea, { "aria-describedby": [
193
+ return (_jsxs(_Fragment, { children: [_jsx("label", { className: "sr-only", htmlFor: textareaId, children: "Message" }), _jsx(Textarea, { "aria-describedby": [
191
194
  slash === undefined ? undefined : `${textareaId}-guidance`,
192
195
  queuedRecall === undefined
193
196
  ? undefined
194
197
  : `${textareaId}-recall-guidance`,
195
198
  ]
196
199
  .filter((id) => id !== undefined)
197
- .join(" ") || undefined, "aria-busy": recalling, readOnly: recalling, "aria-autocomplete": "list", "aria-controls": listOpen ? slashListId : undefined, "aria-expanded": listOpen, "aria-activedescendant": listOpen && slash !== undefined
198
- ? slashOptionId(slashListId, slash.selected)
199
- : undefined, className: "join-item min-h-[48px] max-h-[320px] w-full resize-none overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words px-3 py-[calc((3rem-1.25rem)/2)] font-sans text-xs text-base-content !border-0 !bg-transparent !shadow-none focus:!outline-none", id: textareaId, onChange: (event) => {
200
+ .join(" ") || undefined, "aria-busy": recalling, readOnly: recalling, ...(slash === undefined
201
+ ? {}
202
+ : completionFieldProps({
203
+ open: listOpen,
204
+ listId: slashListId,
205
+ selected: slash.selected,
206
+ })), className: "font-sans text-base-content", id: textareaId, onChange: (event) => {
200
207
  onChange(event.target.value);
201
208
  setHistoryIndex(-1);
202
209
  }, onKeyDown: onKeyDown, placeholder: placeholder ??
203
- (slash === undefined ? "Ask agent..." : "Ask agent… (/ for commands)"), role: slash === undefined ? undefined : "combobox", ref: textareaRef, rows: 1, value: value }), queuedRecall === undefined ? null : (_jsx("span", { id: `${textareaId}-recall-guidance`, className: "sr-only", children: "Press Up on an empty composer to recall your newest queued message." })), (queuedRecall?.images?.length ?? 0) === 0 ? null : (_jsxs("p", { className: "text-xs", children: [queuedRecall?.images?.length, " attached image(s)"] }))] }));
210
+ (slash === undefined ? "Ask agent..." : "Ask agent… (/ for commands)"), ref: textareaRef, rows: 1, value: value }), queuedRecall === undefined ? null : (_jsx("span", { id: `${textareaId}-recall-guidance`, className: "sr-only", children: "Press Up on an empty composer to recall your newest queued message." }))] }));
204
211
  }
205
212
  /** Full Session readout for announcements; the footer only draws attention states. */
206
213
  export function sessionReadout(status, view) {
@@ -238,38 +245,149 @@ export function sessionReadout(status, view) {
238
245
  return "idle";
239
246
  }
240
247
  }
241
- function ComposerFooter({ statusChips, model, status, viewState, usage, context, }) {
248
+ /** Read a picked image file as Session prompt image cargo; the owner admits it on send. */
249
+ async function readImage(file) {
250
+ const bytes = new Uint8Array(await file.arrayBuffer());
251
+ let binary = "";
252
+ for (const byte of bytes)
253
+ binary += String.fromCharCode(byte);
254
+ return { type: "image", mimeType: file.type, data: btoa(binary) };
255
+ }
256
+ /** The Session's settings the composer can open, by the slash alias that owns them. */
257
+ const settingsCommands = [
258
+ { alias: "thinking", label: "Thinking level…" },
259
+ { alias: "compact", label: "Compact context" },
260
+ ];
261
+ /**
262
+ * Session controls and Settings content; ComposerShell owns their row.
263
+ */
264
+ function useComposerRow({ showFooter, statusChips, model, status, viewState, usage, context, slash, attach, send, }) {
265
+ const [openId, setOpenId] = useState(null);
266
+ // Footer visibility belongs here, before deriving any internal readouts.
267
+ if (!showFooter)
268
+ return undefined;
242
269
  const tone = viewState.kind === "error" ? "error" : presentations[status].tone;
243
270
  // Admission must not replace an outstanding owner attention readout.
244
271
  const label = sessionReadout("idle", viewState);
245
272
  const needsAttention = viewState.kind === "waiting_for_you" || viewState.kind === "error";
246
- return (_jsxs("div", { className: "composer-footer", children: [needsAttention ? (_jsx(Status, { label: label, title: label, tone: tone })) : null, statusChips?.map((chip, index) => (_jsx("span", { className: "min-w-0 truncate", title: chip, children: chip }, index))), model ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "footer-divider" }), _jsx("span", { className: "font-mono font-semibold", children: formatModelIdentity(model) })] })) : null, context ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "footer-divider" }), _jsx("span", { className: "font-mono", title: "Context used / model context", children: formatContextUsage(context) })] })) : null, usage ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "footer-divider" }), _jsx("span", { className: "font-mono", children: formatTokens(usage.tokens) }), _jsx("span", { className: "footer-divider" }), _jsx("span", { className: "font-mono", children: formatMoney(usage.cost) })] })) : null] }));
273
+ const modelName = model === undefined ? undefined : composerModelName(model);
274
+ const contextText = context === undefined ? undefined : formatContextUsage(context);
275
+ const usageText = usage === undefined
276
+ ? undefined
277
+ : `${formatTokens(usage.tokens)} · ${formatMoney(usage.cost)}`;
278
+ const commands = settingsCommands.filter(({ alias }) => slash?.available(alias) === true);
279
+ const items = usageText === undefined &&
280
+ contextText === undefined &&
281
+ commands.length === 0 &&
282
+ modelName === undefined
283
+ ? []
284
+ : [
285
+ {
286
+ id: "settings",
287
+ label: (_jsxs("span", { className: "composer-settings-chip", children: [_jsx(SlidersHorizontal, { "aria-hidden": "true" }), "Settings"] })),
288
+ accessibleLabel: "Session settings",
289
+ title: "Settings",
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: () => {
291
+ setOpenId(null);
292
+ slash?.runCommand(alias);
293
+ }, children: "Open" }) }, alias)))] })),
294
+ },
295
+ ];
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
+ };
307
+ }
308
+ /** Images riding with the draft, drawn as small tiles above the text. */
309
+ function ComposerAttachments({ images, onRemove, }) {
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: () => {
311
+ onRemove(index);
312
+ }, size: "xs", variant: "outline" }))] }, index))) }));
247
313
  }
248
- export const ChatComposer = memo(function ChatComposer({ queuedRecall, placeholder, showFooter = true, statusChips, actionVariant = "primary", draft, failure, failureCause, admissionError, model, onAbort, onChange, onRetry, onSend, status, viewState, textareaRef, usage, context, slash, }) {
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, }) {
249
315
  const textareaId = useId();
250
316
  const slashListId = `${textareaId}-commands`;
317
+ const fileInput = useRef(null);
251
318
  const { busy } = presentations[status];
319
+ // A waiting decision takes the composer over an empty draft, or on request.
320
+ const takeover = slash === undefined ? undefined : awaitingTakeover(slash.awaiting, draft);
321
+ const takeoverKey = takeover?.key;
322
+ const seenTakeover = useRef(undefined);
323
+ const hadFocus = useRef(false);
324
+ if (seenTakeover.current !== takeoverKey) {
325
+ seenTakeover.current = takeoverKey;
326
+ hadFocus.current =
327
+ takeoverKey !== undefined &&
328
+ document.activeElement === textareaRef.current;
329
+ }
330
+ const restoreFocus = hadFocus.current;
331
+ useEffect(() => {
332
+ if (takeoverKey === undefined && restoreFocus)
333
+ textareaRef.current?.focus();
334
+ }, [takeoverKey, restoreFocus, textareaRef]);
335
+ const images = queuedRecall?.images ?? [];
252
336
  const recallBusy = queuedRecall?.recallState.kind === "recalling";
253
- const cannotSend = recallBusy ||
254
- (draft.trim().length === 0 && (queuedRecall?.images?.length ?? 0) === 0);
255
- const composerAction = busy ? (_jsxs("div", { className: "flex items-stretch", children: [_jsx(Button, { "aria-label": "Stop current turn", className: "join-item h-auto min-h-0 self-stretch", "data-testid": "composer-stop", disabled: recallBusy || status === "queued" || status === "cancelling", onClick: onAbort, title: "Stop the current turn. Queued messages keep running in order.", size: "lg", variant: "ghost", children: "Stop" }), _jsx(Button, { "aria-label": "Queue follow-up", className: "join-item h-auto min-h-0 self-stretch", disabled: cannotSend, onClick: () => {
256
- onSend("followUp");
257
- }, size: "lg", variant: actionVariant, children: "Queue" })] })) : (_jsx(Button, { "aria-label": "Send message", className: "join-item h-auto min-h-0 self-stretch", disabled: cannotSend, onClick: () => {
258
- onSend("steer");
259
- }, title: "Send", size: "lg", variant: actionVariant, children: "Send" }));
260
- const composerInput = (_jsx(HostComposerTextarea, { queuedRecall: queuedRecall, placeholder: placeholder, onChange: onChange, onAbort: onAbort, onSend: () => {
337
+ // One key in one place: Send when idle, Stop while the Agent works.
338
+ // Enter still queues a typed follow-up during a run.
339
+ // One key says what Enter does: Send when idle; while the Agent works,
340
+ // Stop over an empty draft and Queue once there is a draft to queue.
341
+ const sendState = composerSendState({
342
+ draft,
343
+ hasAttachments: images.length > 0,
344
+ busy,
345
+ queueWhileBusy: true,
346
+ blocked: recallBusy,
347
+ });
348
+ const sendLabel = busy ? "Queue follow-up" : "Send message";
349
+ const sendAction = (_jsx(SendAction, { "aria-label": sendLabel, className: "composer-send-key", disabled: sendState === "busy" &&
350
+ (recallBusy || status === "queued" || status === "cancelling"), onClick: () => {
261
351
  onSend(busy ? "followUp" : "steer");
262
- }, slash: slash, slashListId: slashListId, textareaId: textareaId, textareaRef: textareaRef, value: draft }));
263
- const composerProps = {
264
- action: composerAction,
265
- input: composerInput,
266
- };
267
- const footerProps = { status, viewState, statusChips };
352
+ }, onStop: onAbort, state: sendState, stopLabel: "Stop current turn", title: sendState === "busy"
353
+ ? "Stop the current turn. Queued messages keep running in order."
354
+ : sendLabel, tone: actionVariant === "primary" ? "primary" : "neutral" }));
355
+ const attach = onImagesChange === undefined ? null : (_jsxs(_Fragment, { children: [_jsx("input", { accept: "image/png,image/jpeg,image/gif,image/webp", "aria-label": "Choose images to attach", hidden: true, multiple: true, onChange: (event) => {
356
+ const picked = Array.from(event.currentTarget.files ?? []);
357
+ event.currentTarget.value = "";
358
+ void Promise.all(picked.map(readImage)).then((read) => {
359
+ onImagesChange([...images, ...read]);
360
+ });
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" })] }));
362
+ // Visitors see only the field and the send key, never internal readouts.
363
+ const footerProps = { status, viewState };
364
+ footerProps.statusChips = statusChips;
268
365
  if (model)
269
366
  footerProps.model = model;
270
367
  if (usage)
271
368
  footerProps.usage = usage;
272
369
  if (context)
273
370
  footerProps.context = context;
274
- return (_jsxs("div", { className: "chat-composer not-prose flex-shrink-0 border-t border-base-300", children: [_jsxs("div", { className: "relative", children: [admissionError === "queue_full" ? (_jsx("div", { className: "px-3 py-1.5 text-xs text-error", role: "alert", children: "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 })), _jsxs(PopoverAnchor, { className: "composer-body", children: [slash === undefined ? null : (_jsx(Popover, { open: slash.open, side: "top", "data-parakeet-slash": "", children: _jsx(SlashMenu, { slash: slash, listId: slashListId }) })), _jsx(Composer, { ...composerProps })] })] }), showFooter ? _jsx(ComposerFooter, { ...footerProps }) : null, slash === undefined ? null : (_jsx("p", { className: "sr-only", id: `${textareaId}-guidance`, children: formatHints(slash.hints) }))] }));
371
+ const row = useComposerRow({
372
+ ...footerProps,
373
+ showFooter,
374
+ slash,
375
+ attach,
376
+ send: sendAction,
377
+ });
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"
379
+ ? "One prompt is already queued; it sends when the service is back."
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
381
+ ? ""
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) }))] }));
275
393
  });
@@ -17,30 +17,46 @@ export function ClientRows({ entryId, rows, actions, onOpen, }) {
17
17
  const { inspection } = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
18
18
  if (rows.length === 0)
19
19
  return null;
20
- return (_jsx("div", { className: "px-3 text-xs text-base-content", "data-client-entry": entryId, children: rows.map((row) => (_jsxs("div", { "data-client-row": row.id, className: "py-1", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { "aria-hidden": "true", "data-client-row-dot": "", className: `w-3 shrink-0 text-center leading-4 ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: row.tone === undefined ? "" : "●" }), _jsxs("details", { className: "min-w-0 flex-1", children: [_jsxs("summary", { className: `cursor-pointer break-words leading-4 ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: [row.title, row.inspection === undefined || row.detail === undefined
21
- ? null
22
- : ` · ${row.detail}`] }), row.detail === undefined ? null : (_jsx("p", { className: "max-h-32 overflow-y-auto overscroll-contain whitespace-pre-wrap break-words pb-2", children: row.detail }))] }), row.open === undefined ? null : (_jsx(Button, { size: "xs", variant: "outline", disabled: onOpen === undefined, onClick: () => {
23
- if (row.open !== undefined)
24
- onOpen?.(row.open);
25
- }, children: "Open" })), row.actions?.map((action) => {
26
- const state = actions.state.get(action);
27
- return (_jsx(Button, { size: "xs", variant: "outline", className: "shrink-0", disabled: !actions.enabled || state?.pending === true, loading: state?.pending === true, onClick: () => {
28
- void actions.execute(entryId, action);
29
- }, children: action.label }, action.id));
30
- })] }), row.inspection !== undefined &&
31
- inspection?.entryId === entryId &&
32
- inspection.rowId === row.id ? (_jsxs("section", { "aria-label": row.title, "data-client-inspection": row.id, children: [_jsx(DataListHeading, { aside: _jsx(Button, { size: "xs", variant: "ghost", onClick: () => {
33
- store.inspect(undefined);
34
- }, children: "Close" }), children: row.title }), _jsx(DataList, { density: "compact", tabIndex: 0, className: "max-h-64 overflow-y-auto overscroll-contain", children: row.inspection.rows.map((child) => (_jsxs(DataRow, { children: [_jsx(Status, { glyph: "dot", size: "compact", tone: child.tone === "settled"
35
- ? "success"
36
- : child.tone === "running"
37
- ? "info"
38
- : child.tone === "error"
39
- ? "error"
40
- : child.tone === "attention"
41
- ? "warning"
42
- : "neutral", label: child.title }), child.detail === undefined ? null : (_jsx("span", { children: child.detail }))] }, child.id))) })] })) : null, row.actions?.map((action) => {
43
- const error = actions.state.get(action)?.error;
44
- return error === undefined ? null : (_jsx("p", { className: "text-error", role: "alert", children: error }, action.id));
45
- })] }, row.id))) }));
20
+ return (_jsx("div", { className: "px-3 text-xs text-base-content", "data-client-entry": entryId, children: rows.map((row) => {
21
+ // A row that only opens a waiting decision is one tap target: the
22
+ // whole line, so its key never outgrows the line's text.
23
+ const decision = row.open === undefined &&
24
+ row.inspection === undefined &&
25
+ row.actions?.length === 1 &&
26
+ row.actions[0]?.awaiting !== undefined
27
+ ? row.actions[0]
28
+ : undefined;
29
+ if (decision !== undefined) {
30
+ const state = actions.state.get(decision);
31
+ return (_jsxs("div", { "data-client-row": row.id, children: [_jsxs(Button, { size: "xs", variant: "ghost", className: "-mx-xs flex w-[calc(100%+2*var(--spacing-xs))] justify-start gap-2 px-xs font-normal", disabled: !actions.enabled || state?.pending === true, loading: state?.pending === true, onClick: () => {
32
+ void actions.execute(entryId, decision);
33
+ }, children: [_jsx("span", { "aria-hidden": "true", "data-client-row-dot": "", className: `w-3 shrink-0 text-center ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: row.tone === undefined ? "" : "●" }), _jsxs("span", { className: `min-w-0 flex-1 truncate text-start ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: [row.title, row.detail === undefined ? null : (_jsxs("span", { className: "text-dim", children: [" \u00B7 ", row.detail] }))] }), _jsx("span", { className: "shrink-0 text-dim", "data-client-row-action": "", children: decision.label })] }), state?.error === undefined ? null : (_jsx("p", { className: "text-error", role: "alert", children: state.error }))] }, row.id));
34
+ }
35
+ return (_jsxs("div", { "data-client-row": row.id, className: "py-1", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { "aria-hidden": "true", "data-client-row-dot": "", className: `w-3 shrink-0 text-center leading-4 ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: row.tone === undefined ? "" : "●" }), _jsxs("details", { className: "min-w-0 flex-1", children: [_jsxs("summary", { className: `cursor-pointer break-words leading-4 ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: [row.title, row.inspection === undefined || row.detail === undefined
36
+ ? null
37
+ : ` · ${row.detail}`] }), row.detail === undefined ? null : (_jsx("p", { className: "max-h-32 overflow-y-auto overscroll-contain whitespace-pre-wrap break-words pb-2", children: row.detail }))] }), row.open === undefined ? null : (_jsx(Button, { size: "xs", variant: "outline", disabled: onOpen === undefined, onClick: () => {
38
+ if (row.open !== undefined)
39
+ onOpen?.(row.open);
40
+ }, children: "Open" })), row.actions?.map((action) => {
41
+ const state = actions.state.get(action);
42
+ return (_jsx(Button, { size: "xs", variant: "outline", className: "shrink-0", disabled: !actions.enabled || state?.pending === true, loading: state?.pending === true, onClick: () => {
43
+ void actions.execute(entryId, action);
44
+ }, children: action.label }, action.id));
45
+ })] }), row.inspection !== undefined &&
46
+ inspection?.entryId === entryId &&
47
+ inspection.rowId === row.id ? (_jsxs("section", { "aria-label": row.title, "data-client-inspection": row.id, children: [_jsx(DataListHeading, { aside: _jsx(Button, { size: "xs", variant: "ghost", onClick: () => {
48
+ store.inspect(undefined);
49
+ }, children: "Close" }), children: row.title }), _jsx(DataList, { density: "compact", tabIndex: 0, className: "max-h-64 overflow-y-auto overscroll-contain", children: row.inspection.rows.map((child) => (_jsxs(DataRow, { children: [_jsx(Status, { glyph: "dot", size: "compact", tone: child.tone === "settled"
50
+ ? "success"
51
+ : child.tone === "running"
52
+ ? "info"
53
+ : child.tone === "error"
54
+ ? "error"
55
+ : child.tone === "attention"
56
+ ? "warning"
57
+ : "neutral", label: child.title }), child.detail === undefined ? null : (_jsx("span", { children: child.detail }))] }, child.id))) })] })) : null, row.actions?.map((action) => {
58
+ const error = actions.state.get(action)?.error;
59
+ return error === undefined ? null : (_jsx("p", { className: "text-error", role: "alert", children: error }, action.id));
60
+ })] }, row.id));
61
+ }) }));
46
62
  }
@@ -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("button", { "aria-label": "Copy to clipboard", className: "btn btn-circle btn-ghost btn-xs text-dim absolute top-1 right-1 z-10 hover:text-base-content", onClick: () => {
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", children: copyState === "copied" ? _jsx(Check, { size: 10 }) : _jsx(Copy, { size: 10 }) })) : 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
+ }, 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,54 @@
1
+ import type { KeyboardEvent, ReactNode } from "react";
2
+ /** One row of a completion menu. */
3
+ export type CompletionRow = Readonly<{
4
+ key: string;
5
+ label: ReactNode;
6
+ detail?: string | undefined;
7
+ /** Key hint shown at the row's end. */
8
+ keys?: string | undefined;
9
+ /** Decorative leading visual, such as a thumbnail. */
10
+ media?: ReactNode;
11
+ disabled?: boolean | undefined;
12
+ }>;
13
+ /** Stable id of one completion row, for `aria-activedescendant`. */
14
+ export declare function completionOptionId(listId: string, index: number): string;
15
+ /** What a key press asks of an open completion menu. */
16
+ export type CompletionKeyAction = "next" | "previous" | "accept" | "dismiss" | null;
17
+ /**
18
+ * The one reading of keys for a completion menu whose focus stays in the text
19
+ * field: arrows move, Enter or Tab accept, Escape dismisses. Shift+Enter is
20
+ * a newline, never an accept.
21
+ */
22
+ export declare function completionKeyAction(event: Pick<KeyboardEvent, "key" | "shiftKey">): CompletionKeyAction;
23
+ /** The ARIA attributes a combobox text field carries for an open menu. */
24
+ export declare function completionFieldProps(menu: {
25
+ open: boolean;
26
+ listId: string;
27
+ selected: number;
28
+ }): {
29
+ readonly role: "combobox";
30
+ readonly "aria-autocomplete": "list";
31
+ readonly "aria-expanded": boolean;
32
+ readonly "aria-controls": string | undefined;
33
+ readonly "aria-activedescendant": string | undefined;
34
+ };
35
+ /**
36
+ * The shared completion menu: a Caps popover with listbox rows. It never
37
+ * takes focus; the text field keeps typing and points at the highlighted row
38
+ * with `aria-activedescendant`. Grove's slash commands and Prints' slash and
39
+ * `@character` menus all draw it.
40
+ */
41
+ export declare function CompletionMenu({ open, rows, selected, listId, label, kind, hint, empty, onHighlight, onChoose, }: Readonly<{
42
+ open: boolean;
43
+ rows: readonly CompletionRow[];
44
+ selected: number;
45
+ listId: string;
46
+ /** Accessible name of the listbox. */
47
+ label: string;
48
+ kind: string;
49
+ hint?: string | undefined;
50
+ /** Shown instead of rows when there are none; omitted draws nothing. */
51
+ empty?: string | undefined;
52
+ onHighlight: (index: number) => void;
53
+ onChoose: (index: number) => void;
54
+ }>): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Popover, PopoverOption, } from "@fungi.computer/caps/components/popover";
3
+ /** Stable id of one completion row, for `aria-activedescendant`. */
4
+ export function completionOptionId(listId, index) {
5
+ return `${listId}-${String(index)}`;
6
+ }
7
+ /**
8
+ * The one reading of keys for a completion menu whose focus stays in the text
9
+ * field: arrows move, Enter or Tab accept, Escape dismisses. Shift+Enter is
10
+ * a newline, never an accept.
11
+ */
12
+ export function completionKeyAction(event) {
13
+ switch (event.key) {
14
+ case "ArrowDown":
15
+ return "next";
16
+ case "ArrowUp":
17
+ return "previous";
18
+ case "Enter":
19
+ return event.shiftKey ? null : "accept";
20
+ case "Tab":
21
+ return "accept";
22
+ case "Escape":
23
+ return "dismiss";
24
+ default:
25
+ return null;
26
+ }
27
+ }
28
+ /** The ARIA attributes a combobox text field carries for an open menu. */
29
+ export function completionFieldProps(menu) {
30
+ return {
31
+ role: "combobox",
32
+ "aria-autocomplete": "list",
33
+ "aria-expanded": menu.open,
34
+ "aria-controls": menu.open ? menu.listId : undefined,
35
+ "aria-activedescendant": menu.open
36
+ ? completionOptionId(menu.listId, menu.selected)
37
+ : undefined,
38
+ };
39
+ }
40
+ /**
41
+ * The shared completion menu: a Caps popover with listbox rows. It never
42
+ * takes focus; the text field keeps typing and points at the highlighted row
43
+ * with `aria-activedescendant`. Grove's slash commands and Prints' slash and
44
+ * `@character` menus all draw it.
45
+ */
46
+ export function CompletionMenu({ open, rows, selected, listId, label, kind, hint, empty, onHighlight, onChoose, }) {
47
+ return (_jsx(Popover, { open: open, side: "top", "data-parakeet-slash": "", children: _jsxs("div", { className: "parakeet-slash-menu", "data-kind": kind, children: [hint === undefined ? null : (_jsx("p", { className: "parakeet-slash-hint", children: hint })), rows.length === 0 ? (empty === undefined ? null : (_jsx("p", { className: "parakeet-slash-hint", children: empty }))) : (_jsx("div", { id: listId, role: "listbox", "aria-label": label, children: rows.map((row, index) => (_jsxs(PopoverOption, { id: completionOptionId(listId, index), active: index === selected, "aria-disabled": row.disabled === true ? true : undefined, "data-disabled": row.disabled === true, onMouseEnter: () => {
48
+ onHighlight(index);
49
+ }, onClick: () => {
50
+ onChoose(index);
51
+ }, children: [row.media === undefined ? null : (_jsx("span", { "aria-hidden": "true", className: "parakeet-slash-media", children: row.media })), _jsx("span", { className: "parakeet-slash-label", children: row.label }), _jsx("span", { className: "parakeet-slash-detail", children: row.detail }), row.keys === undefined || row.keys.length === 0 ? null : (_jsx("kbd", { className: "parakeet-slash-keys", children: row.keys }))] }, row.key))) }))] }) }));
52
+ }
@@ -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
+ }