@corenel/web-ui 0.10.0 → 0.11.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 (46) hide show
  1. package/dist/Composer.d.ts +30 -9
  2. package/dist/Composer.d.ts.map +1 -1
  3. package/dist/Composer.js +288 -230
  4. package/dist/Composer.js.map +1 -1
  5. package/dist/ContextBar.d.ts +4 -1
  6. package/dist/ContextBar.d.ts.map +1 -1
  7. package/dist/ContextBar.js +15 -2
  8. package/dist/ContextBar.js.map +1 -1
  9. package/dist/DictateButton.d.ts +26 -1
  10. package/dist/DictateButton.d.ts.map +1 -1
  11. package/dist/DictateButton.js +26 -10
  12. package/dist/DictateButton.js.map +1 -1
  13. package/dist/DockStatus.d.ts +11 -0
  14. package/dist/DockStatus.d.ts.map +1 -0
  15. package/dist/DockStatus.js +41 -0
  16. package/dist/DockStatus.js.map +1 -0
  17. package/dist/StarterCards.d.ts +20 -0
  18. package/dist/StarterCards.d.ts.map +1 -0
  19. package/dist/StarterCards.js +30 -0
  20. package/dist/StarterCards.js.map +1 -0
  21. package/dist/Transcript.d.ts +9 -1
  22. package/dist/Transcript.d.ts.map +1 -1
  23. package/dist/Transcript.js +11 -2
  24. package/dist/Transcript.js.map +1 -1
  25. package/dist/agentChat/AgentChat.d.ts +43 -7
  26. package/dist/agentChat/AgentChat.d.ts.map +1 -1
  27. package/dist/agentChat/AgentChat.js +220 -93
  28. package/dist/agentChat/AgentChat.js.map +1 -1
  29. package/dist/agentChat/OptionsDrawer.d.ts.map +1 -1
  30. package/dist/agentChat/OptionsDrawer.js +5 -2
  31. package/dist/agentChat/OptionsDrawer.js.map +1 -1
  32. package/dist/agentChat/controller.d.ts +3 -0
  33. package/dist/agentChat/controller.d.ts.map +1 -1
  34. package/dist/agentChat/controller.js.map +1 -1
  35. package/dist/credentialLines.d.ts +10 -0
  36. package/dist/credentialLines.d.ts.map +1 -0
  37. package/dist/credentialLines.js +46 -0
  38. package/dist/credentialLines.js.map +1 -0
  39. package/dist/sendLater/SendLater.d.ts +8 -1
  40. package/dist/sendLater/SendLater.d.ts.map +1 -1
  41. package/dist/sendLater/SendLater.js +16 -8
  42. package/dist/sendLater/SendLater.js.map +1 -1
  43. package/dist/toolRender.d.ts.map +1 -1
  44. package/dist/toolRender.js +7 -2
  45. package/dist/toolRender.js.map +1 -1
  46. package/package.json +5 -5
package/dist/Composer.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useRef, useEffect, useLayoutEffect, useMemo } from 'react';
3
3
  import { IcPlay, IcStop, IcX, IcFile, IcCode, IcPlus, IcChevron } from '@corenel/ui-kit/icons';
4
4
  import { estimateTokens } from '@corenel/ui-kit/ui-helpers';
@@ -49,6 +49,25 @@ const COARSE_POINTER = typeof matchMedia !== 'undefined' && matchMedia('(pointer
49
49
  function isCoarsePointer() {
50
50
  return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
51
51
  }
52
+ /* A PHONE-WIDTH SCREEN, live. The compact row cannot fit the left controls, a
53
+ * usable text box and Auto/Send in ~350px, so below this width the text box
54
+ * always takes the first row (see `stacked`). Live rather than a module const
55
+ * because a tablet rotates across it, and the layout must follow. */
56
+ const PHONE_QUERY = '(max-width: 640px)';
57
+ function usePhoneWidth() {
58
+ const read = () => typeof matchMedia === 'function' && matchMedia(PHONE_QUERY).matches;
59
+ const [phone, setPhone] = useState(read);
60
+ useEffect(() => {
61
+ if (typeof matchMedia !== 'function')
62
+ return;
63
+ const mq = matchMedia(PHONE_QUERY);
64
+ const on = () => setPhone(mq.matches);
65
+ mq.addEventListener?.('change', on);
66
+ on();
67
+ return () => mq.removeEventListener?.('change', on);
68
+ }, []);
69
+ return phone;
70
+ }
52
71
  /** Group commands by section, preserving first-seen order; '' sections render unlabeled. */
53
72
  function groupBySection(cmds) {
54
73
  const order = [];
@@ -63,7 +82,7 @@ function groupBySection(cmds) {
63
82
  }
64
83
  return order.map((s) => [s, map.get(s)]);
65
84
  }
66
- export function Composer({ value, onChange, onSend, onStop, running, placeholder, runHint = true, mode, onModeChange, modes = DEFAULT_MODES, chips = [], onRemoveChip, onAddContext, slashCommands = [], onPickSlash, attachments = [], onAttach, onRemoveAttachment, onImageClick, inPrice, showContext, onDropFiles, rightSlot, history, sendLater, pending, onQueue, onInject, onRemovePending, onEditPending, sendLabel = 'Send', ariaLabel, mentionables = [], leadSlot, dictating = false, }) {
85
+ export function Composer({ value, onChange, onSend, onStop, running, placeholder, runHint = true, mode, onModeChange, modes = DEFAULT_MODES, chips = [], onRemoveChip, onAddContext, slashCommands = [], onPickSlash, attachments = [], onAttach, onRemoveAttachment, onImageClick, inPrice, showContext, onDropFiles, rightSlot, history, sendLater, pending, onQueue, onInject, onRemovePending, onEditPending, sendLabel = 'Send', ariaLabel, mentionables = [], leadSlot, dictating = false, status, statusHidden = false, docked = true, toggleChips = [], }) {
67
86
  // A skin that reaches the whole chat may name the controls its own way;
68
87
  // each one keeps its plain name for screen readers and tooltips.
69
88
  const { words } = useChatSkin();
@@ -92,6 +111,30 @@ export function Composer({ value, onChange, onSend, onStop, running, placeholder
92
111
  // cap, after which it scrolls. Runs before paint (no flicker) on every value
93
112
  // change, so clearing after send snaps back to the resting height.
94
113
  const textareaRef = useRef(null);
114
+ /* COMPACT UNTIL THE DRAFT OUTGROWS ONE LINE (wide screens; a phone is always
115
+ * stacked, below). At rest the textarea shares the bottom row with the controls. A newline, or text that wraps, gives it its
116
+ * own line above them; it stays there until the draft is emptied, so the
117
+ * layout never flips back and forth while you type. The SAME element moves
118
+ * (CSS order + flex-basis), never a remount, so focus and caret stay put. */
119
+ const [grown, setGrown] = useState(false);
120
+ /* On a phone the text box is ALWAYS above the controls, from rest: there is
121
+ * no width at which the compact row fits there. */
122
+ const phone = usePhoneWidth();
123
+ const stacked = grown || phone;
124
+ useLayoutEffect(() => {
125
+ if (!value) {
126
+ setGrown(false);
127
+ return;
128
+ }
129
+ if (grown)
130
+ return;
131
+ const el = textareaRef.current;
132
+ if (!el)
133
+ return;
134
+ const line = parseFloat(getComputedStyle(el).lineHeight) || 21;
135
+ if (value.includes(NEWLINE) || el.scrollHeight > line * 1.5 + 8)
136
+ setGrown(true);
137
+ }, [value, grown]);
95
138
  useLayoutEffect(() => {
96
139
  const el = textareaRef.current;
97
140
  if (!el)
@@ -100,9 +143,17 @@ export function Composer({ value, onChange, onSend, onStop, running, placeholder
100
143
  // bare TEXTAREA_MAX_H here would let the element autosize past the CSS
101
144
  // cap on a short viewport and reintroduce the composer-eats-the-screen bug.
102
145
  const cap = typeof window !== 'undefined' ? Math.min(TEXTAREA_MAX_H, window.innerHeight * 0.3) : TEXTAREA_MAX_H;
146
+ if (!value) {
147
+ // Resting row: one line, never scrollHeight. A placeholder that wraps in
148
+ // the narrow compact width must not make the resting row taller.
149
+ const lh = getComputedStyle(el).lineHeight;
150
+ const line = lh.endsWith('px') ? parseFloat(lh) : 21;
151
+ el.style.height = `${line + 8}px`;
152
+ return;
153
+ }
103
154
  el.style.height = 'auto';
104
155
  el.style.height = `${Math.min(el.scrollHeight, cap)}px`;
105
- }, [value]);
156
+ }, [value, stacked]);
106
157
  /* No handler, no menu: a host that supplies commands it cannot act on would
107
158
  * otherwise get an Enter key that opens a menu and then swallows the
108
159
  * keystroke instead of sending. */
@@ -211,7 +262,56 @@ export function Composer({ value, onChange, onSend, onStop, running, placeholder
211
262
  document.addEventListener('mousedown', onDoc);
212
263
  return () => document.removeEventListener('mousedown', onDoc);
213
264
  }, [modeOpen]);
214
- return (_jsxs("div", { style: S.wrap, "data-testid": "composer-wrap", "data-chat-part": "composer", onDragOver: onDropFiles ? (e) => { e.preventDefault(); } : undefined, onDrop: onDropFiles ? (e) => {
265
+ const pendingEl = pending && pending.length > 0 ? (() => {
266
+ /* WHICH ONE SURVIVES THE FOLD. Not blindly the first: an `injecting`
267
+ * message carries "Interrupt now", the one control here that is worth
268
+ * anything only in the seconds before the step lands. Folding that out
269
+ * of sight would be the same defect as collapsing the row to a bare
270
+ * count -- the space would be won by hiding the thing you needed. So an
271
+ * injecting chip is the one that stays; otherwise the oldest, which is
272
+ * the next to be sent. */
273
+ const spotlight = Math.max(0, pending.findIndex((p) => p.state === 'injecting'));
274
+ const shown = queueOpen ? pending : [pending[spotlight]];
275
+ const hidden = pending.length - shown.length;
276
+ return (_jsxs("div", { style: S.pendingRow, children: [shown.map((p) => {
277
+ const editing = editingPendingId === p.id;
278
+ const commitEdit = () => {
279
+ setEditingPendingId(null);
280
+ // Escape already threw this text away -- a same-unmount blur
281
+ // reaching us here must not resurrect it.
282
+ if (abandoningEditRef.current) {
283
+ abandoningEditRef.current = false;
284
+ return;
285
+ }
286
+ // The host removed this item while it was being edited -- there
287
+ // is nothing left to send the edit to.
288
+ if (!pendingIdsRef.current.has(p.id))
289
+ return;
290
+ if (onEditPending)
291
+ onEditPending(p.id, editingPendingText);
292
+ };
293
+ return (_jsxs("span", { style: S.pendingChip(p.state), children: [editing ? (_jsx("input", { autoFocus: true, style: S.pendingEdit, value: editingPendingText, onChange: (e) => setEditingPendingText(e.target.value), onBlur: commitEdit, onKeyDown: (e) => {
294
+ if (e.key === 'Enter') {
295
+ e.preventDefault();
296
+ commitEdit();
297
+ return;
298
+ }
299
+ if (e.key === 'Escape') {
300
+ e.preventDefault();
301
+ abandoningEditRef.current = true;
302
+ setEditingPendingId(null);
303
+ }
304
+ } })) : (_jsx("span", { style: onEditPending ? { ...S.pendingText, cursor: 'text' } : S.pendingText, onClick: onEditPending ? () => {
305
+ abandoningEditRef.current = false;
306
+ setEditingPendingId(p.id);
307
+ setEditingPendingText(p.text);
308
+ } : undefined, children: p.text })), p.note && _jsx("span", { style: S.pendingNote, children: p.note }), p.state === 'injecting' && _jsx("span", { style: S.pendingNote, children: "lands after this step" }), p.state === 'injecting' && onInject && (
309
+ // The chip half of the Alt+Enter ladder: cut the model stream
310
+ // now instead of waiting for the turn to finish.
311
+ _jsx("button", { style: S.pendingAction, onClick: () => onInject(p.id), children: "Interrupt now" })), p.state === 'held' && onInject && (_jsx("button", { style: S.pendingAction, onClick: () => onInject(p.id), children: "Send now" })), onRemovePending && (_jsx("button", { style: S.pendingX, "aria-label": "Remove queued message", onClick: () => onRemovePending(p.id), children: "x" }))] }, p.id));
312
+ }), (hidden > 0 || queueOpen) && (_jsx("button", { style: S.queueMore, onClick: () => setQueueOpen((o) => !o), "aria-expanded": queueOpen, title: queueOpen ? 'Show only the next queued message' : 'Show every queued message', children: queueOpen ? 'Show less' : `+${hidden} queued` }))] }));
313
+ })() : null;
314
+ return (_jsxs("div", { style: docked ? S.wrap : S.wrapFloating, "data-testid": "composer-wrap", "data-chat-part": "composer", onDragOver: onDropFiles ? (e) => { e.preventDefault(); } : undefined, onDrop: onDropFiles ? (e) => {
215
315
  const files = Array.from(e.dataTransfer?.files ?? []);
216
316
  if (files.length) {
217
317
  e.preventDefault();
@@ -220,224 +320,176 @@ export function Composer({ value, onChange, onSend, onStop, running, placeholder
220
320
  } : undefined, children: [mentionHits.length > 0 && (_jsxs("div", { style: S.slash, "data-chat-part": "popover", children: [mentionHits.map((p, i) => (_jsxs("button", { style: S.slashItem(i === mentionActive), onMouseEnter: () => setMentionActive(i), onClick: () => pickMention(p.name), children: [_jsxs("span", { style: S.slashName, children: ["@", p.name] }), _jsx("span", { style: S.slashHint, children: p.hint ?? '' })] }, p.name))), _jsxs("div", { style: S.slashFoot, children: [_jsx("span", { children: "\u2191\u2193 navigate" }), _jsx("span", { children: "\u21B5 select" }), _jsx("span", { children: "esc dismiss" })] })] })), showSlash && filtered.length > 0 && (_jsxs("div", { style: S.slash, "data-chat-part": "popover", children: [groupBySection(filtered).map(([section, items]) => (_jsxs("div", { children: [section && _jsx("div", { style: S.slashSection, children: section }), items.map((c) => {
221
321
  const idx = filtered.indexOf(c);
222
322
  return (_jsxs("button", { style: S.slashItem(idx === slashActive), onMouseEnter: () => setSlashActive(idx), onClick: () => onPickSlash?.(c.name), children: [_jsx("span", { style: S.slashIcon, children: c.icon }), _jsxs("span", { style: S.slashName, children: ["/", c.name] }), _jsx("span", { style: S.slashHint, children: c.hint })] }, c.name));
223
- })] }, section || '_'))), _jsxs("div", { style: S.slashFoot, children: [_jsx("span", { children: "\u2191\u2193 navigate" }), _jsx("span", { children: "\u21B5 select" }), _jsx("span", { children: "esc dismiss" })] })] })), attachments.length > 0 && (_jsx("div", { style: S.thumbs, children: attachments.map((src, i) => (_jsxs("div", { style: S.thumbWrap, children: [_jsx("img", { src: src, alt: `Attachment ${i + 1}`, style: S.thumb, className: "ask-thumb", onClick: () => onImageClick?.(src), title: "Click to expand" }), _jsx("button", { style: S.thumbX, onClick: () => onRemoveAttachment?.(i), "aria-label": "Remove image", children: _jsx(IcX, { size: 11 }) })] }, i))) })), pending && pending.length > 0 && (() => {
224
- /* WHICH ONE SURVIVES THE FOLD. Not blindly the first: an `injecting`
225
- * message carries "Interrupt now", the one control here that is worth
226
- * anything only in the seconds before the step lands. Folding that out
227
- * of sight would be the same defect as collapsing the row to a bare
228
- * count -- the space would be won by hiding the thing you needed. So an
229
- * injecting chip is the one that stays; otherwise the oldest, which is
230
- * the next to be sent. */
231
- const spotlight = Math.max(0, pending.findIndex((p) => p.state === 'injecting'));
232
- const shown = queueOpen ? pending : [pending[spotlight]];
233
- const hidden = pending.length - shown.length;
234
- return (_jsxs("div", { style: S.pendingRow, children: [shown.map((p) => {
235
- const editing = editingPendingId === p.id;
236
- const commitEdit = () => {
237
- setEditingPendingId(null);
238
- // Escape already threw this text away -- a same-unmount blur
239
- // reaching us here must not resurrect it.
240
- if (abandoningEditRef.current) {
241
- abandoningEditRef.current = false;
242
- return;
243
- }
244
- // The host removed this item while it was being edited -- there
245
- // is nothing left to send the edit to.
246
- if (!pendingIdsRef.current.has(p.id))
247
- return;
248
- if (onEditPending)
249
- onEditPending(p.id, editingPendingText);
250
- };
251
- return (_jsxs("span", { style: S.pendingChip(p.state), children: [editing ? (_jsx("input", { autoFocus: true, style: S.pendingEdit, value: editingPendingText, onChange: (e) => setEditingPendingText(e.target.value), onBlur: commitEdit, onKeyDown: (e) => {
252
- if (e.key === 'Enter') {
253
- e.preventDefault();
254
- commitEdit();
255
- return;
256
- }
257
- if (e.key === 'Escape') {
258
- e.preventDefault();
259
- abandoningEditRef.current = true;
260
- setEditingPendingId(null);
261
- }
262
- } })) : (_jsx("span", { style: onEditPending ? { ...S.pendingText, cursor: 'text' } : S.pendingText, onClick: onEditPending ? () => {
263
- abandoningEditRef.current = false;
264
- setEditingPendingId(p.id);
265
- setEditingPendingText(p.text);
266
- } : undefined, children: p.text })), p.note && _jsx("span", { style: S.pendingNote, children: p.note }), p.state === 'injecting' && _jsx("span", { style: S.pendingNote, children: "lands after this step" }), p.state === 'injecting' && onInject && (
267
- // The chip half of the Alt+Enter ladder: cut the model stream
268
- // now instead of waiting for the turn to finish.
269
- _jsx("button", { style: S.pendingAction, onClick: () => onInject(p.id), children: "Interrupt now" })), p.state === 'held' && onInject && (_jsx("button", { style: S.pendingAction, onClick: () => onInject(p.id), children: "Send now" })), onRemovePending && (_jsx("button", { style: S.pendingX, "aria-label": "Remove queued message", onClick: () => onRemovePending(p.id), children: "x" }))] }, p.id));
270
- }), (hidden > 0 || queueOpen) && (_jsx("button", { style: S.queueMore, onClick: () => setQueueOpen((o) => !o), "aria-expanded": queueOpen, title: queueOpen ? 'Show only the next queued message' : 'Show every queued message', children: queueOpen ? 'Show less' : `+${hidden} queued` }))] }));
271
- })(), _jsxs("div", { style: { ...S.box, borderColor: boxFocused ? 'var(--accent)' : 'var(--border-default)' }, "data-testid": "composer-box", "data-chat-part": "composer-box", onFocus: () => setBoxFocused(true), onBlur: (e) => { if (!e.currentTarget.contains(e.relatedTarget))
323
+ })] }, section || '_'))), _jsxs("div", { style: S.slashFoot, children: [_jsx("span", { children: "\u2191\u2193 navigate" }), _jsx("span", { children: "\u21B5 select" }), _jsx("span", { children: "esc dismiss" })] })] })), attachments.length > 0 && (_jsx("div", { style: S.thumbs, children: attachments.map((src, i) => (_jsxs("div", { style: S.thumbWrap, children: [_jsx("img", { src: src, alt: `Attachment ${i + 1}`, style: S.thumb, className: "ask-thumb", onClick: () => onImageClick?.(src), title: "Click to expand" }), _jsx("button", { style: S.thumbX, onClick: () => onRemoveAttachment?.(i), "aria-label": "Remove image", children: _jsx(IcX, { size: 11 }) })] }, i))) })), _jsxs("div", { style: { ...S.box, borderColor: boxFocused ? 'var(--accent)' : 'var(--border-default)' }, "data-testid": "composer-box", "data-stacked": stacked ? 'true' : 'false', "data-chat-part": "composer-box", onFocus: () => setBoxFocused(true), onBlur: (e) => { if (!e.currentTarget.contains(e.relatedTarget))
272
324
  setBoxFocused(false); }, onMouseDown: (e) => {
273
325
  const el = e.target;
274
326
  if (el.dataset.boxSurface === undefined)
275
327
  return;
276
328
  e.preventDefault();
277
329
  textareaRef.current?.focus();
278
- }, "data-box-surface": "", children: [words.composerTitle && (_jsxs("div", { style: S.boxTitle, "data-chat-part": "composer-title", "data-box-surface": "", children: [_jsx("span", { children: words.composerTitle }), _jsx("span", { style: S.boxTitleState, children: running ? (onQueue ? words.composerBusy : null) : (dictating ? null : words.composerIdle) })] })), (canAddContext || chips.length > 0 || rightSlot || estTok > 0) && (_jsxs("div", { style: S.chips, "data-testid": "composer-chips", "data-box-surface": "", children: [chips.map((c) => (_jsxs("span", { style: S.chip, title: c.label, children: [c.id === 'selection' ? _jsx(IcCode, { size: 11 }) : _jsx(IcFile, { size: 11 }), _jsx("span", { style: S.chipLabel, children: c.label }), c.removable && _jsx("button", { style: S.chipX, onClick: () => onRemoveChip?.(c.id), "aria-label": "Remove context", children: _jsx(IcX, { size: 10 }) })] }, c.id))), canAddContext && _jsxs("button", { style: S.addCtx, onClick: onAddContext, title: "Add context", ...(words.addContext ? { 'aria-label': 'Add context' } : {}), children: [_jsx(IcPlus, { size: 11 }), " ", words.addContext ?? 'context'] }), estTok > 0 && (_jsxs("span", { style: S.est, "data-testid": "composer-est", title: "Rough estimate of this message's input tokens and cost (text only)", children: ["~", fmtTok(estTok), " tok", estCost != null ? ` · est ${fmtCost(estCost)}` : ''] })), rightSlot && _jsx("span", { style: S.chipsRight, children: rightSlot })] })), _jsx("textarea", { ref: textareaRef, style: S.textarea, "aria-label": ariaLabel, value: value, readOnly: dictating, "aria-live": dictating ? 'polite' : undefined, onChange: (e) => {
279
- setSlashHidden(false);
280
- setMentionHidden(false);
281
- // Typing leaves the history: what is in the box is now a draft, and a
282
- // later Down should not jump to an entry the user has since edited
283
- // away from.
284
- histIdx.current = -1;
285
- onChange(e.target.value);
286
- }, onPaste: onPaste, onKeyDown: (e) => {
287
- // While the "/" menu is open it owns the arrows + Enter/Tab/Escape, so a
288
- // command can be navigated and picked without leaving the keyboard.
289
- if (mentionHits.length > 0) {
290
- if (e.key === 'ArrowDown') {
291
- e.preventDefault();
292
- setMentionActive((a) => Math.min(a + 1, mentionHits.length - 1));
293
- return;
294
- }
295
- if (e.key === 'ArrowUp') {
296
- e.preventDefault();
297
- setMentionActive((a) => Math.max(a - 1, 0));
298
- return;
299
- }
300
- if (e.key === 'Escape') {
301
- e.preventDefault();
302
- setMentionHidden(true);
303
- return;
304
- }
305
- if ((e.key === 'Enter' || e.key === 'Tab') && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
306
- e.preventDefault();
307
- pickMention(mentionHits[Math.min(mentionActive, mentionHits.length - 1)].name);
308
- return;
309
- }
310
- }
311
- if (showSlash && filtered.length > 0) {
312
- if (e.key === 'ArrowDown') {
313
- e.preventDefault();
314
- setSlashActive((a) => Math.min(a + 1, filtered.length - 1));
315
- return;
316
- }
317
- if (e.key === 'ArrowUp') {
318
- e.preventDefault();
319
- setSlashActive((a) => Math.max(a - 1, 0));
320
- return;
321
- }
322
- if (e.key === 'Escape') {
323
- e.preventDefault();
324
- setSlashHidden(true);
325
- return;
326
- }
327
- if ((e.key === 'Enter' || e.key === 'Tab') && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
328
- e.preventDefault();
329
- onPickSlash?.(filtered[Math.min(slashActive, filtered.length - 1)].name);
330
- return;
331
- }
332
- }
333
- /* Up/Down recall previous messages, the way a shell does.
334
- *
335
- * ONLY FROM THE EDGES OF THE DRAFT. Up recalls when the caret is on
336
- * the first line and Down when it is on the last, because otherwise
337
- * the arrows could not move through a multi-line message you are
338
- * still writing - and a composer that will not let you navigate your
339
- * own text is a worse trade than no history at all. A selection is
340
- * left alone for the same reason.
341
- *
342
- * The slash menu above claims the arrows first while it is open, so
343
- * this cannot steal them from command navigation.
344
- */
345
- if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && history && history.length > 0
346
- && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
347
- const el = e.currentTarget;
348
- const at = el.selectionStart;
349
- const noSelection = at === el.selectionEnd;
350
- const onFirstLine = !value.slice(0, at).includes(NEWLINE);
351
- const onLastLine = !value.slice(at).includes(NEWLINE);
352
- const stepBack = e.key === 'ArrowUp' && noSelection && onFirstLine;
353
- const stepForward = e.key === 'ArrowDown' && noSelection && onLastLine && histIdx.current >= 0;
354
- if (stepBack || stepForward) {
330
+ }, "data-box-surface": "", children: [words.composerTitle && (_jsxs("div", { style: S.boxTitle, "data-chat-part": "composer-title", "data-box-surface": "", children: [_jsx("span", { children: words.composerTitle }), _jsx("span", { style: S.boxTitleState, children: running ? (onQueue ? words.composerBusy : null) : (dictating ? null : words.composerIdle) })] })), (status != null || pendingEl) && (_jsxs("div", { style: statusHidden ? { ...S.status, display: 'none' } : S.status, "data-chat-part": "status", "data-testid": "composer-status", "data-box-surface": "", children: [status, pendingEl] })), (chips.length > 0 || toggleChips.length > 0) && (_jsxs("div", { style: S.chips, "data-testid": "composer-chips", "data-box-surface": "", children: [toggleChips.map((t) => (_jsxs("span", { style: S.toggleChip, "data-testid": `composer-toggle-${t.id}`, title: t.title ?? `${t.label} is on`, children: [t.icon, _jsx("span", { style: S.chipLabel, children: t.label }), _jsx("button", { style: S.toggleChipX, onClick: t.onRemove, "aria-label": `Turn off ${t.label}`, children: _jsx(IcX, { size: 10 }) })] }, `toggle:${t.id}`))), chips.map((c) => (_jsxs("span", { style: S.chip, title: c.label, children: [c.id === 'selection' ? _jsx(IcCode, { size: 11 }) : _jsx(IcFile, { size: 11 }), _jsx("span", { style: S.chipLabel, children: c.label }), c.removable && _jsx("button", { style: S.chipX, onClick: () => onRemoveChip?.(c.id), "aria-label": "Remove context", children: _jsx(IcX, { size: 10 }) })] }, c.id)))] })), _jsxs("div", { style: S.bottom, "data-testid": "composer-bottom", "data-box-surface": "", children: [canAddContext && (_jsxs("button", { style: S.add, "data-testid": "composer-add", onClick: onAddContext, title: "Add context", "aria-label": "Add context", children: [_jsx(IcPlus, { size: 14 }), words.addContext ? _jsx("span", { children: words.addContext }) : null] })), leadSlot, !running && sendLater, rightSlot && _jsx("span", { style: S.hostSlot, "data-testid": "composer-host-slot", children: rightSlot }), _jsx("textarea", { ref: textareaRef, style: { ...S.textarea, ...(stacked ? S.textareaStacked : S.textareaCompact) }, "aria-label": ariaLabel, value: value, readOnly: dictating, "aria-live": dictating ? 'polite' : undefined, onChange: (e) => {
331
+ setSlashHidden(false);
332
+ setMentionHidden(false);
333
+ // Typing leaves the history: what is in the box is now a draft, and a
334
+ // later Down should not jump to an entry the user has since edited
335
+ // away from.
336
+ histIdx.current = -1;
337
+ onChange(e.target.value);
338
+ }, onPaste: onPaste, onKeyDown: (e) => {
339
+ // While the "/" menu is open it owns the arrows + Enter/Tab/Escape, so a
340
+ // command can be navigated and picked without leaving the keyboard.
341
+ if (mentionHits.length > 0) {
342
+ if (e.key === 'ArrowDown') {
343
+ e.preventDefault();
344
+ setMentionActive((a) => Math.min(a + 1, mentionHits.length - 1));
345
+ return;
346
+ }
347
+ if (e.key === 'ArrowUp') {
348
+ e.preventDefault();
349
+ setMentionActive((a) => Math.max(a - 1, 0));
350
+ return;
351
+ }
352
+ if (e.key === 'Escape') {
353
+ e.preventDefault();
354
+ setMentionHidden(true);
355
+ return;
356
+ }
357
+ if ((e.key === 'Enter' || e.key === 'Tab') && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
358
+ e.preventDefault();
359
+ pickMention(mentionHits[Math.min(mentionActive, mentionHits.length - 1)].name);
360
+ return;
361
+ }
362
+ }
363
+ if (showSlash && filtered.length > 0) {
364
+ if (e.key === 'ArrowDown') {
365
+ e.preventDefault();
366
+ setSlashActive((a) => Math.min(a + 1, filtered.length - 1));
367
+ return;
368
+ }
369
+ if (e.key === 'ArrowUp') {
370
+ e.preventDefault();
371
+ setSlashActive((a) => Math.max(a - 1, 0));
372
+ return;
373
+ }
374
+ if (e.key === 'Escape') {
375
+ e.preventDefault();
376
+ setSlashHidden(true);
377
+ return;
378
+ }
379
+ if ((e.key === 'Enter' || e.key === 'Tab') && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
380
+ e.preventDefault();
381
+ onPickSlash?.(filtered[Math.min(slashActive, filtered.length - 1)].name);
382
+ return;
383
+ }
384
+ }
385
+ /* Up/Down recall previous messages, the way a shell does.
386
+ *
387
+ * ONLY FROM THE EDGES OF THE DRAFT. Up recalls when the caret is on
388
+ * the first line and Down when it is on the last, because otherwise
389
+ * the arrows could not move through a multi-line message you are
390
+ * still writing - and a composer that will not let you navigate your
391
+ * own text is a worse trade than no history at all. A selection is
392
+ * left alone for the same reason.
393
+ *
394
+ * The slash menu above claims the arrows first while it is open, so
395
+ * this cannot steal them from command navigation.
396
+ */
397
+ if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && history && history.length > 0
398
+ && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
399
+ const el = e.currentTarget;
400
+ const at = el.selectionStart;
401
+ const noSelection = at === el.selectionEnd;
402
+ const onFirstLine = !value.slice(0, at).includes(NEWLINE);
403
+ const onLastLine = !value.slice(at).includes(NEWLINE);
404
+ const stepBack = e.key === 'ArrowUp' && noSelection && onFirstLine;
405
+ const stepForward = e.key === 'ArrowDown' && noSelection && onLastLine && histIdx.current >= 0;
406
+ if (stepBack || stepForward) {
407
+ e.preventDefault();
408
+ // Stash the live draft on the way in, so stepping back past the
409
+ // newest entry returns what you were writing.
410
+ if (histIdx.current < 0)
411
+ histDraft.current = value;
412
+ const next = stepBack
413
+ ? Math.min(histIdx.current + 1, history.length - 1)
414
+ : histIdx.current - 1;
415
+ histIdx.current = next;
416
+ const text = next < 0 ? histDraft.current : history[history.length - 1 - next];
417
+ onChange(text);
418
+ setSlashHidden(true);
419
+ // Caret to the end, as a shell does: the common case is a
420
+ // single-line entry you want to edit or resend immediately, and
421
+ // end-of-text leaves the next Up still on the first line.
422
+ requestAnimationFrame(() => {
423
+ const t = textareaRef.current;
424
+ if (t)
425
+ t.setSelectionRange(text.length, text.length);
426
+ });
427
+ return;
428
+ }
429
+ }
430
+ if (e.key !== 'Enter')
431
+ return;
432
+ // Cmd/Ctrl+Enter: don't handle it -- let it bubble to the editor's
433
+ // global handler so it runs the prompt. Shift+Enter: newline.
434
+ if (e.metaKey || e.ctrlKey || e.shiftKey)
435
+ return;
436
+ // Alt+Enter pushes into the turn already running.
437
+ if (e.altKey) {
438
+ if (!running || !onInject)
439
+ return;
440
+ // An empty composer normally has nothing to push -- EXCEPT when a
441
+ // message is already in flight, where the second Alt+Enter is the
442
+ // ladder's escalation to a hard inject.
443
+ if (!canSend && !pending?.some((p) => p.state === 'injecting'))
444
+ return;
445
+ e.preventDefault();
446
+ onInject();
447
+ return;
448
+ }
449
+ // Touch keyboards have no separate newline gesture within easy reach,
450
+ // so on a coarse pointer plain Enter behaves like Shift+Enter (a
451
+ // newline) instead of committing the message -- returning here without
452
+ // preventDefault lets the browser insert it natively, same as the
453
+ // shiftKey branch above. Only the two explicit chords (Alt+Enter,
454
+ // Cmd/Ctrl+Enter) still act on Enter; the mention/slash acceptance
455
+ // above already returned before this point.
456
+ if (isCoarsePointer())
457
+ return;
458
+ // Only take the queue path when the host actually wired one. A host
459
+ // that renders `running` but has not adopted the queue must get
460
+ // today's behavior unchanged -- Enter falls through to onSend()
461
+ // below exactly as it always has.
462
+ if (running && onQueue) {
463
+ if (!canSend)
464
+ return;
465
+ e.preventDefault();
466
+ onQueue();
467
+ return;
468
+ }
469
+ if (!canSend)
470
+ return;
355
471
  e.preventDefault();
356
- // Stash the live draft on the way in, so stepping back past the
357
- // newest entry returns what you were writing.
358
- if (histIdx.current < 0)
359
- histDraft.current = value;
360
- const next = stepBack
361
- ? Math.min(histIdx.current + 1, history.length - 1)
362
- : histIdx.current - 1;
363
- histIdx.current = next;
364
- const text = next < 0 ? histDraft.current : history[history.length - 1 - next];
365
- onChange(text);
366
- setSlashHidden(true);
367
- // Caret to the end, as a shell does: the common case is a
368
- // single-line entry you want to edit or resend immediately, and
369
- // end-of-text leaves the next Up still on the first line.
370
- requestAnimationFrame(() => {
371
- const t = textareaRef.current;
372
- if (t)
373
- t.setSelectionRange(text.length, text.length);
374
- });
375
- return;
376
- }
377
- }
378
- if (e.key !== 'Enter')
379
- return;
380
- // Cmd/Ctrl+Enter: don't handle it -- let it bubble to the editor's
381
- // global handler so it runs the prompt. Shift+Enter: newline.
382
- if (e.metaKey || e.ctrlKey || e.shiftKey)
383
- return;
384
- // Alt+Enter pushes into the turn already running.
385
- if (e.altKey) {
386
- if (!running || !onInject)
387
- return;
388
- // An empty composer normally has nothing to push -- EXCEPT when a
389
- // message is already in flight, where the second Alt+Enter is the
390
- // ladder's escalation to a hard inject.
391
- if (!canSend && !pending?.some((p) => p.state === 'injecting'))
392
- return;
393
- e.preventDefault();
394
- onInject();
395
- return;
396
- }
397
- // Touch keyboards have no separate newline gesture within easy reach,
398
- // so on a coarse pointer plain Enter behaves like Shift+Enter (a
399
- // newline) instead of committing the message -- returning here without
400
- // preventDefault lets the browser insert it natively, same as the
401
- // shiftKey branch above. Only the two explicit chords (Alt+Enter,
402
- // Cmd/Ctrl+Enter) still act on Enter; the mention/slash acceptance
403
- // above already returned before this point.
404
- if (isCoarsePointer())
405
- return;
406
- // Only take the queue path when the host actually wired one. A host
407
- // that renders `running` but has not adopted the queue must get
408
- // today's behavior unchanged -- Enter falls through to onSend()
409
- // below exactly as it always has.
410
- if (running && onQueue) {
411
- if (!canSend)
412
- return;
413
- e.preventDefault();
414
- onQueue();
415
- return;
416
- }
417
- if (!canSend)
418
- return;
419
- e.preventDefault();
420
- histIdx.current = -1;
421
- histDraft.current = '';
422
- onSend();
423
- }, placeholder: (() => {
424
- /* Listening beats every other invitation: the box is empty because
425
- nothing has been heard yet, and the send/newline hints describe keys
426
- that are disarmed right now anyway. */
427
- if (dictating)
428
- return LEAD_LISTENING;
429
- // The invitation is the host's; the key hints are this component's.
430
- const lead = placeholder ?? (onAttach ? LEAD_WITH_PASTE : LEAD_PLAIN);
431
- if (COARSE_POINTER)
432
- return lead;
433
- if (pending && onQueue && running) {
434
- return `${lead} · ↵ queue · ⌥↵ push into run · ⇧↵ newline`;
435
- }
436
- const base = `${lead} · ↵ send · ⇧↵ newline`;
437
- return runHint ? `${base} · ⌘↵ run` : base;
438
- })(),
439
- // Rests at one line and grows with the text, up to the cap.
440
- rows: 1 }), _jsxs("div", { style: S.bottom, "data-testid": "composer-bottom", "data-box-surface": "", children: [leadSlot, !running && sendLater, _jsxs("div", { ref: stanceRef, style: S.stance, "data-testid": "composer-stance", children: [hasMode && (modeOpen ? (modeList.map((mm) => (_jsxs("button", { style: S.stanceOpt(mm.id === mode), title: mm.description || mm.label, onClick: () => { onModeChange?.(mm.id); setModeOpen(false); }, children: [_jsx("span", { style: { ...S.modeDot, background: modeAccent(mm) } }), mm.label] }, mm.id)))) : (_jsxs("button", { style: S.stanceCurrent, onClick: () => setModeOpen(true), title: curMode.description || curMode.label, children: [_jsx("span", { style: { ...S.modeDot, background: modeAccent(curMode) } }), _jsx("span", { style: words.modeLabel ? S.stanceNamePrefixed : S.stanceName, "data-testid": "composer-stance-name", children: words.modeLabel ? words.modeLabel(curMode.label) : curMode.label }), _jsx(IcChevron, { size: 12 })] }))), hasMode && _jsx("span", { style: S.stanceDivider }), running ? (_jsxs(_Fragment, { children: [onQueue && (_jsx("button", { style: S.queueBtn, "data-chat-part": "queue", ...(words.queue ? { 'aria-label': 'Queue' } : {}), onClick: onQueue, disabled: dictating || (!value.trim() && attachments.length === 0), title: "Park this for after the run (Enter). Alt+Enter pushes it into the run instead.", children: words.queue ?? 'Queue' })), _jsxs("button", { style: { ...S.sendBtn, background: 'var(--grad-stop)' }, "data-chat-part": "stop", ...plainName(words.stop, 'Stop'), onClick: onStop, children: [_jsx(IcStop, { size: 12 }), " ", words.stop ?? 'Stop'] })] })) : (
472
+ histIdx.current = -1;
473
+ histDraft.current = '';
474
+ onSend();
475
+ }, placeholder: (() => {
476
+ /* Listening beats every other invitation: the box is empty because
477
+ nothing has been heard yet, and the send/newline hints describe keys
478
+ that are disarmed right now anyway. */
479
+ if (dictating)
480
+ return LEAD_LISTENING;
481
+ // The invitation is the host's; the key hints are this component's.
482
+ const lead = placeholder ?? (onAttach ? LEAD_WITH_PASTE : LEAD_PLAIN);
483
+ if (COARSE_POINTER)
484
+ return lead;
485
+ if (pending && onQueue && running) {
486
+ return `${lead} · ↵ queue · ⌥↵ push into run · ⇧↵ newline`;
487
+ }
488
+ const base = `${lead} · ↵ send · ⇧↵ newline`;
489
+ return runHint ? `${base} · ⌘↵ run` : base;
490
+ })(),
491
+ // Rests at one line and grows with the text, up to the cap.
492
+ rows: 1 }), estTok > 0 && (_jsxs("span", { style: S.est, "data-testid": "composer-est", title: "Rough estimate of this message's input tokens and cost (text only)", children: ["~", fmtTok(estTok), " tok", estCost != null ? ` · est ${fmtCost(estCost)}` : ''] })), _jsxs("div", { ref: stanceRef, style: S.stance, "data-testid": "composer-stance", children: [hasMode && (modeOpen ? (modeList.map((mm) => (_jsxs("button", { style: S.stanceOpt(mm.id === mode), title: mm.description || mm.label, onClick: () => { onModeChange?.(mm.id); setModeOpen(false); }, children: [_jsx("span", { style: { ...S.modeDot, background: modeAccent(mm) } }), mm.label] }, mm.id)))) : (_jsxs("button", { style: S.stanceCurrent, onClick: () => setModeOpen(true), title: curMode.description || curMode.label, children: [_jsx("span", { style: { ...S.modeDot, background: modeAccent(curMode) } }), _jsx("span", { style: words.modeLabel ? S.stanceNamePrefixed : S.stanceName, "data-testid": "composer-stance-name", children: words.modeLabel ? words.modeLabel(curMode.label) : curMode.label }), _jsx(IcChevron, { size: 12 })] }))), hasMode && _jsx("span", { style: S.stanceDivider }), running ? (_jsxs(_Fragment, { children: [onQueue && (_jsx("button", { style: S.queueBtn, "data-chat-part": "queue", ...(words.queue ? { 'aria-label': 'Queue' } : {}), onClick: onQueue, disabled: dictating || (!value.trim() && attachments.length === 0), title: "Park this for after the run (Enter). Alt+Enter pushes it into the run instead.", children: words.queue ?? 'Queue' })), _jsxs("button", { style: { ...S.sendBtn, background: 'var(--grad-stop)' }, "data-chat-part": "stop", ...plainName(words.stop, 'Stop'), onClick: onStop, children: [_jsx(IcStop, { size: 12 }), " ", words.stop ?? 'Stop'] })] })) : (
441
493
  // One primary per app: a host that declares --grad-send (corenel.ai)
442
494
  // paints Send like every other primary button, and mode colour stays
443
495
  // on the dot. Without it (prompd.app) this falls back to the mode
@@ -445,7 +497,9 @@ export function Composer({ value, onChange, onSend, onStop, running, placeholder
445
497
  _jsxs("button", { style: { ...S.sendBtn, background: `var(--grad-send, ${hasMode ? modeAccent(curMode) : 'var(--accent)'})`, color: 'var(--text-on-accent, #fff)' }, "data-chat-part": "send", ...plainName(words.send, sendLabel), onClick: onSend, disabled: !canSend, children: [_jsx(IcPlay, { size: 12 }), " ", words.send ?? sendLabel] }))] })] })] })] }));
446
498
  }
447
499
  const S = {
448
- wrap: { position: 'relative', borderTop: '1px solid var(--border-subtle)', background: 'var(--surface-1)', padding: 10, display: 'flex', flexDirection: 'column', gap: 8 },
500
+ wrap: { position: 'relative', borderTop: '1px solid var(--border-subtle)', background: 'var(--surface-1)', padding: '8px 10px 10px', display: 'flex', flexDirection: 'column', gap: 8 },
501
+ // S.wrap minus the divider line, on no surface band of its own.
502
+ wrapFloating: { position: 'relative', background: 'transparent', padding: '8px 10px 10px', display: 'flex', flexDirection: 'column', gap: 8 },
449
503
  slash: { position: 'absolute', bottom: 'calc(100% + 6px)', left: 10, right: 10, background: 'var(--surface-1)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-3)', padding: 4, zIndex: 200, maxHeight: 360, overflow: 'auto' },
450
504
  slashItem: (active) => ({ display: 'flex', alignItems: 'center', gap: 9, width: '100%', padding: '7px 9px', border: 0, borderRadius: 'var(--radius-sm)', background: active ? 'var(--accent-soft)' : 'transparent', color: 'var(--text-primary)', cursor: 'pointer', textAlign: 'left' }),
451
505
  slashIcon: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 16, color: 'var(--text-tertiary)', flexShrink: 0 },
@@ -454,13 +508,14 @@ const S = {
454
508
  slashSection: { padding: '6px 9px 3px', fontSize: 10.5, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.07em', color: 'var(--text-tertiary)' },
455
509
  slashFoot: { display: 'flex', gap: 14, padding: '7px 10px 4px', marginTop: 4, borderTop: '1px solid var(--border-subtle)', fontSize: 11, color: 'var(--text-disabled)', fontFamily: 'var(--font-mono)' },
456
510
  chips: { display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' },
457
- // marginLeft:auto pushes it to the right edge; minWidth:0 lets its own contents
458
- // truncate rather than shoving the context chips off the row.
459
- chipsRight: { marginLeft: 'auto', minWidth: 0, maxWidth: '60%', display: 'flex', justifyContent: 'flex-end' },
511
+ status: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, minHeight: 28, padding: '0 6px', borderBottom: '1px solid var(--border-subtle)', fontSize: 12, color: 'var(--text-tertiary)', fontVariantNumeric: 'tabular-nums' },
460
512
  chip: { display: 'inline-flex', alignItems: 'center', gap: 5, height: 22, padding: '0 6px 0 8px', borderRadius: 'var(--radius-pill)', background: 'var(--surface-3)', border: '1px solid var(--border-subtle)', color: 'var(--text-secondary)', fontSize: 11.5, fontFamily: 'var(--font-mono)', maxWidth: 200 },
461
513
  chipLabel: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
462
514
  chipX: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 14, height: 14, border: 0, borderRadius: 999, background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', padding: 0 },
463
- addCtx: { display: 'inline-flex', alignItems: 'center', gap: 4, height: 22, padding: '0 8px', borderRadius: 'var(--radius-pill)', background: 'transparent', border: '1px dashed var(--border-default)', color: 'var(--text-tertiary)', fontSize: 11.5, cursor: 'pointer' },
515
+ toggleChip: { display: 'inline-flex', alignItems: 'center', gap: 5, height: 22, padding: '0 6px 0 8px', borderRadius: 'var(--radius-pill)', background: 'var(--accent-soft)', border: '1px solid var(--accent)', color: 'var(--accent)', fontSize: 11.5, fontWeight: 600, maxWidth: 200 },
516
+ toggleChipX: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 14, height: 14, border: 0, borderRadius: 999, background: 'transparent', color: 'var(--accent)', cursor: 'pointer', padding: 0 },
517
+ add: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4, minWidth: 32, height: 32, padding: '0 6px', border: 0, borderRadius: 'var(--radius-sm)', background: 'transparent', color: 'var(--text-secondary)', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', flexShrink: 0 },
518
+ hostSlot: { display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 },
464
519
  thumbs: { display: 'flex', flexWrap: 'wrap', gap: 8 },
465
520
  thumbWrap: { position: 'relative', width: 60, height: 60 },
466
521
  thumb: { width: 60, height: 60, objectFit: 'cover', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-subtle)', cursor: 'zoom-in', display: 'block' },
@@ -471,24 +526,27 @@ const S = {
471
526
  // it rests at one line (rows={1}) and grows with the text.
472
527
  // One bordered box holds the chip row, this textarea and the send row (see
473
528
  // the JSX), so the textarea itself is borderless and transparent.
474
- boxTitle: { display: 'flex', alignItems: 'center', gap: 8, margin: '-6px -6px 0', padding: '5px 10px', borderBottom: '1px solid var(--border-default)', fontSize: 10, letterSpacing: '0.14em', color: 'var(--text-tertiary)' },
529
+ boxTitle: { display: 'flex', alignItems: 'center', gap: 8, margin: '-4px -4px 0', padding: '5px 10px', borderBottom: '1px solid var(--border-default)', fontSize: 10, letterSpacing: '0.14em', color: 'var(--text-tertiary)' },
475
530
  boxTitleState: { marginLeft: 'auto', color: 'var(--success)' },
476
- box: { display: 'flex', flexDirection: 'column', gap: 4, padding: 6, border: '1px solid var(--border-default)', borderRadius: 'var(--radius-lg, 12px)', background: 'var(--surface-2)', minWidth: 0 },
531
+ box: { display: 'flex', flexDirection: 'column', gap: 4, padding: 4, border: '1px solid var(--border-default)', borderRadius: 'var(--radius-lg, 12px)', background: 'var(--surface-2)', minWidth: 0 },
477
532
  textarea: { width: '100%', boxSizing: 'border-box', resize: 'none', padding: '4px 6px', border: 0, borderRadius: 0, background: 'transparent', color: 'var(--text-primary)', fontSize: 14, fontFamily: 'inherit', lineHeight: 1.5, outline: 'none', minHeight: 0, maxHeight: 'min(220px, 30vh)', overflowY: 'auto' },
478
- // flexWrap + minWidth:0 are belt-and-braces: the token estimate (the main
479
- // space hog) has already moved to the chips row (item A), but mic/history/
480
- // lead-slot/queue can still fill this row on a narrow screen, so it must be
481
- // able to wrap rather than clip.
533
+ // flexWrap + minWidth:0: the textarea, "+", mic, host icons and stance share
534
+ // this row, so on a narrow screen it must be able to wrap rather than clip.
482
535
  bottom: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 },
483
- // marginLeft:auto keeps it pushed toward chipsRight in the chips row;
484
- // minWidth:0 + overflow/ellipsis make it the FIRST thing to truncate when
485
- // that row is crowded, since it is purely informational.
536
+ // Basis 0 and a small minWidth: the textarea takes whatever the controls leave,
537
+ // so the resting row stays ONE line down to a ~350px box (a phone). A 160px
538
+ // basis wrapped the empty composer onto two rows at 390px.
539
+ textareaCompact: { flex: '1 1 0', minWidth: 48, order: 0 },
540
+ textareaStacked: { flex: '1 1 100%', order: -1 },
541
+ // Sits in the bottom row right before the stance; marginLeft:auto pushes both
542
+ // to the right. minWidth:0 + overflow/ellipsis make it the FIRST thing to
543
+ // truncate when the row is crowded, since it is purely informational.
486
544
  est: { marginLeft: 'auto', minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 11.5, color: 'var(--text-tertiary)', fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' },
487
545
  modeDot: { width: 8, height: 8, borderRadius: 999, flexShrink: 0 },
488
546
  // Fused stance + Send: one segmented control on the right of the bar.
489
547
  // flexShrink:0 -- Send must never be the thing that gets clipped when the
490
548
  // bottom row is crowded; the mode label truncates instead (S.stanceName).
491
- stance: { marginLeft: 'auto', flexShrink: 0, display: 'inline-flex', alignItems: 'stretch', height: 34, border: '1px solid var(--border-default)', borderRadius: 'var(--radius-sm)', overflow: 'hidden', background: 'var(--surface-2)', boxShadow: 'var(--shadow-1)' },
549
+ stance: { marginLeft: 'auto', flexShrink: 0, display: 'inline-flex', alignItems: 'stretch', height: 32, border: '1px solid var(--border-default)', borderRadius: 'var(--radius-sm)', overflow: 'hidden', background: 'var(--surface-2)', boxShadow: 'var(--shadow-1)' },
492
550
  stanceCurrent: { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '0 10px', border: 0, background: 'transparent', color: 'var(--text-primary)', fontSize: 12.5, fontWeight: 600, fontFamily: 'inherit', textTransform: 'capitalize', cursor: 'pointer' },
493
551
  // maxWidth caps a long mode label so it truncates instead of widening the
494
552
  // fused control and pushing Send further toward (or off) the edge.