@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.
- package/README.md +17 -1
- package/dist/client-contributions.d.ts +1 -0
- package/dist/commands/awaiting-driver.d.ts +20 -0
- package/dist/commands/awaiting-driver.js +121 -0
- package/dist/commands/builtins.js +6 -4
- package/dist/commands/interactions.d.ts +25 -0
- package/dist/commands/interactions.js +76 -2
- package/dist/commands/run.js +3 -3
- package/dist/commands/slash.d.ts +7 -3
- package/dist/commands/slash.js +2 -0
- package/dist/commands/use-slash-commands.d.ts +13 -0
- package/dist/commands/use-slash-commands.js +58 -1
- package/dist/components/ChatComposer.d.ts +2 -0
- package/dist/components/ChatComposer.js +158 -40
- package/dist/components/ClientRows.js +42 -26
- package/dist/components/CodeBlock.js +3 -2
- package/dist/components/CompletionMenu.d.ts +54 -0
- package/dist/components/CompletionMenu.js +52 -0
- package/dist/components/ComposerModelKey.d.ts +11 -0
- package/dist/components/ComposerModelKey.js +9 -0
- package/dist/components/ComposerShell.d.ts +47 -0
- package/dist/components/ComposerShell.js +29 -0
- package/dist/components/DecisionComposer.d.ts +30 -0
- package/dist/components/DecisionComposer.js +145 -0
- package/dist/components/InteractionPanel.d.ts +10 -1
- package/dist/components/InteractionPanel.js +9 -4
- package/dist/components/SlashMenu.d.ts +1 -3
- package/dist/components/SlashMenu.js +5 -13
- package/dist/components/TimelineItem.d.ts +1 -0
- package/dist/components/TimelineItem.js +4 -4
- package/dist/components/ToolGroup.d.ts +1 -0
- package/dist/components/ToolGroup.js +8 -8
- package/dist/conversation-opening.d.ts +1 -1
- package/dist/conversation-opening.js +2 -2
- package/dist/conversation.d.ts +5 -1
- package/dist/conversation.js +84 -29
- package/dist/core/approval-decision.d.ts +198 -0
- package/dist/core/approval-decision.js +291 -0
- package/dist/core/awaiting-answer.d.ts +229 -0
- package/dist/core/awaiting-answer.js +303 -0
- package/dist/core/build-timeline.js +61 -7
- package/dist/core/client-actions.d.ts +1 -0
- package/dist/core/client-actions.js +7 -1
- package/dist/core/composer-model-name.d.ts +3 -0
- package/dist/core/composer-model-name.js +21 -0
- package/dist/core/display-text.d.ts +2 -0
- package/dist/core/display-text.js +7 -0
- package/dist/core/draft-storage.d.ts +6 -0
- package/dist/core/draft-storage.js +25 -5
- package/dist/core/session-projection.d.ts +1 -0
- package/dist/core/session-projection.js +3 -2
- package/dist/core/session-query.d.ts +8 -0
- package/dist/core/session-query.js +13 -11
- package/dist/core/session-view-state.d.ts +2 -4
- package/dist/core/session-view-state.js +27 -9
- package/dist/core/tool-groups.d.ts +3 -0
- package/dist/core/tool-groups.js +37 -1
- package/dist/core/tool-view.d.ts +6 -0
- package/dist/core/tool-view.js +33 -0
- package/dist/core/use-timeline-item-key.d.ts +3 -4
- package/dist/core/use-timeline-item-key.js +8 -14
- package/dist/headless.d.ts +10 -5
- package/dist/headless.js +9 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/parakeet.d.ts +6 -2
- package/dist/parakeet.js +24 -17
- package/dist/store/types.d.ts +14 -1
- package/dist/styles.css +911 -162
- package/dist/use-approval-awaiting.d.ts +22 -0
- package/dist/use-approval-awaiting.js +81 -0
- package/dist/use-decision.d.ts +20 -0
- package/dist/use-decision.js +79 -0
- package/docs/api/commands/slash.md +30 -26
- package/docs/api/headless.md +917 -54
- package/docs/api/index.md +166 -5
- package/package.json +17 -8
- package/src/completion-menu.css +72 -0
- package/src/composer-shell.css +125 -0
- package/src/styles.css +99 -53
- package/dist/components/WaitingNotice.d.ts +0 -7
- package/dist/components/WaitingNotice.js +0 -8
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { formatTokens } from "../core/format-tokens.js";
|
|
3
|
-
import {
|
|
3
|
+
import { composerModelName } from "../core/composer-model-name.js";
|
|
4
4
|
import { formatContextUsage } from "../core/format-context.js";
|
|
5
|
-
import { Composer, } from "@fungi.computer/caps/blocks/composer";
|
|
6
5
|
import { isImeComposing } from "@fungi.computer/caps/lib/ime";
|
|
7
6
|
import { RunFailureNotice } from "./RunFailureNotice.js";
|
|
8
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
97
|
+
const action = completionKeyAction(event);
|
|
98
|
+
const handled = action === "next"
|
|
96
99
|
? slash.move(1)
|
|
97
|
-
:
|
|
100
|
+
: action === "previous"
|
|
98
101
|
? slash.move(-1)
|
|
99
102
|
: event.key === "Tab"
|
|
100
103
|
? slash.complete()
|
|
101
|
-
:
|
|
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(
|
|
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,
|
|
198
|
-
?
|
|
199
|
-
:
|
|
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)"),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
},
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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) =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
? "
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
31
|
+
return (_jsxs("div", { className: `relative not-prose ${className ?? ""}`, style: containerStyle, children: [header ? (_jsx("div", { className: "text-dim px-2 pt-1.5 pb-1 text-[9px] uppercase tracking-wider", children: header })) : null, !hideCopy ? (_jsx(IconButton, { "aria-label": "Copy to clipboard", className: "absolute top-1 right-1 z-10", icon: copyState === "copied" ? _jsx(Check, {}) : _jsx(Copy, {}), onClick: () => {
|
|
31
32
|
void handleCopy();
|
|
32
|
-
}, type: "button"
|
|
33
|
+
}, type: "button" })) : null, !hideCopy ? (_jsx("span", { "aria-live": "polite", className: "sr-only", role: "status", children: copyState === "failed" ? "Copy to clipboard failed." : null })) : null, _jsx("div", { className: "overflow-y-auto text-[10px]", "data-scrollable": true, style: {
|
|
33
34
|
maxHeight: maxHeight === "0" ? undefined : maxHeight,
|
|
34
35
|
padding: "6px 4px",
|
|
35
36
|
}, children: innerHtml === null ? (_jsxs("pre", { className: "whitespace-pre-wrap break-words font-mono text-base-content", style: { margin: 0 }, children: [content, content.length > HIGHLIGHT_THRESHOLD ? (_jsx("span", { className: "text-dim ml-2 italic", children: "(syntax highlighting skipped for very long content)" })) : null] })) : (_jsx("div", {
|
|
@@ -0,0 +1,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
|
+
}
|