@corenel/web-ui 0.9.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 (106) hide show
  1. package/dist/AskUser.d.ts.map +1 -1
  2. package/dist/AskUser.js +3 -1
  3. package/dist/AskUser.js.map +1 -1
  4. package/dist/CommandTray.d.ts.map +1 -1
  5. package/dist/CommandTray.js +2 -1
  6. package/dist/CommandTray.js.map +1 -1
  7. package/dist/Composer.d.ts +30 -9
  8. package/dist/Composer.d.ts.map +1 -1
  9. package/dist/Composer.js +297 -231
  10. package/dist/Composer.js.map +1 -1
  11. package/dist/ContextBar.d.ts +4 -1
  12. package/dist/ContextBar.d.ts.map +1 -1
  13. package/dist/ContextBar.js +38 -2
  14. package/dist/ContextBar.js.map +1 -1
  15. package/dist/ContextManager.d.ts +10 -1
  16. package/dist/ContextManager.d.ts.map +1 -1
  17. package/dist/ContextManager.js +63 -26
  18. package/dist/ContextManager.js.map +1 -1
  19. package/dist/ContextStrip.d.ts +5 -1
  20. package/dist/ContextStrip.d.ts.map +1 -1
  21. package/dist/ContextStrip.js +4 -1
  22. package/dist/ContextStrip.js.map +1 -1
  23. package/dist/DictateButton.d.ts +26 -1
  24. package/dist/DictateButton.d.ts.map +1 -1
  25. package/dist/DictateButton.js +26 -10
  26. package/dist/DictateButton.js.map +1 -1
  27. package/dist/DockStatus.d.ts +11 -0
  28. package/dist/DockStatus.d.ts.map +1 -0
  29. package/dist/DockStatus.js +41 -0
  30. package/dist/DockStatus.js.map +1 -0
  31. package/dist/ExclusionsModal.d.ts +3 -0
  32. package/dist/ExclusionsModal.d.ts.map +1 -1
  33. package/dist/ExclusionsModal.js +28 -7
  34. package/dist/ExclusionsModal.js.map +1 -1
  35. package/dist/RichContent.d.ts.map +1 -1
  36. package/dist/RichContent.js +3 -1
  37. package/dist/RichContent.js.map +1 -1
  38. package/dist/StarterCards.d.ts +20 -0
  39. package/dist/StarterCards.d.ts.map +1 -0
  40. package/dist/StarterCards.js +30 -0
  41. package/dist/StarterCards.js.map +1 -0
  42. package/dist/SummaryTranscript.d.ts.map +1 -1
  43. package/dist/SummaryTranscript.js +4 -3
  44. package/dist/SummaryTranscript.js.map +1 -1
  45. package/dist/TodoBar.d.ts.map +1 -1
  46. package/dist/TodoBar.js +12 -6
  47. package/dist/TodoBar.js.map +1 -1
  48. package/dist/ToolFold.d.ts.map +1 -1
  49. package/dist/ToolFold.js +2 -1
  50. package/dist/ToolFold.js.map +1 -1
  51. package/dist/Transcript.d.ts +9 -1
  52. package/dist/Transcript.d.ts.map +1 -1
  53. package/dist/Transcript.js +41 -22
  54. package/dist/Transcript.js.map +1 -1
  55. package/dist/agentChat/AgentChat.d.ts +43 -7
  56. package/dist/agentChat/AgentChat.d.ts.map +1 -1
  57. package/dist/agentChat/AgentChat.js +282 -96
  58. package/dist/agentChat/AgentChat.js.map +1 -1
  59. package/dist/agentChat/OptionsDrawer.d.ts.map +1 -1
  60. package/dist/agentChat/OptionsDrawer.js +6 -3
  61. package/dist/agentChat/OptionsDrawer.js.map +1 -1
  62. package/dist/agentChat/PlanReview.d.ts.map +1 -1
  63. package/dist/agentChat/PlanReview.js +5 -3
  64. package/dist/agentChat/PlanReview.js.map +1 -1
  65. package/dist/agentChat/controller.d.ts +17 -0
  66. package/dist/agentChat/controller.d.ts.map +1 -1
  67. package/dist/agentChat/controller.js.map +1 -1
  68. package/dist/chatSkin.d.ts +20 -0
  69. package/dist/chatSkin.d.ts.map +1 -0
  70. package/dist/chatSkin.js +220 -0
  71. package/dist/chatSkin.js.map +1 -0
  72. package/dist/chatSkinContext.d.ts +55 -0
  73. package/dist/chatSkinContext.d.ts.map +1 -0
  74. package/dist/chatSkinContext.js +16 -0
  75. package/dist/chatSkinContext.js.map +1 -0
  76. package/dist/chatSkinCss.d.ts +3 -0
  77. package/dist/chatSkinCss.d.ts.map +1 -0
  78. package/dist/chatSkinCss.js +202 -0
  79. package/dist/chatSkinCss.js.map +1 -0
  80. package/dist/commandTurn.d.ts +4 -1
  81. package/dist/commandTurn.d.ts.map +1 -1
  82. package/dist/commandTurn.js +13 -1
  83. package/dist/commandTurn.js.map +1 -1
  84. package/dist/contextCopy.d.ts +13 -0
  85. package/dist/contextCopy.d.ts.map +1 -0
  86. package/dist/contextCopy.js +20 -0
  87. package/dist/contextCopy.js.map +1 -0
  88. package/dist/credentialLines.d.ts +10 -0
  89. package/dist/credentialLines.d.ts.map +1 -0
  90. package/dist/credentialLines.js +46 -0
  91. package/dist/credentialLines.js.map +1 -0
  92. package/dist/sendLater/SendLater.d.ts +8 -1
  93. package/dist/sendLater/SendLater.d.ts.map +1 -1
  94. package/dist/sendLater/SendLater.js +16 -8
  95. package/dist/sendLater/SendLater.js.map +1 -1
  96. package/dist/toolRender.d.ts.map +1 -1
  97. package/dist/toolRender.js +14 -8
  98. package/dist/toolRender.js.map +1 -1
  99. package/dist/transcriptSkin.d.ts +23 -1
  100. package/dist/transcriptSkin.d.ts.map +1 -1
  101. package/dist/transcriptSkin.js +134 -9
  102. package/dist/transcriptSkin.js.map +1 -1
  103. package/dist/transcriptSkinCss.d.ts.map +1 -1
  104. package/dist/transcriptSkinCss.js +17 -10
  105. package/dist/transcriptSkinCss.js.map +1 -1
  106. package/package.json +5 -5
package/dist/Composer.js CHANGED
@@ -1,8 +1,9 @@
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';
5
5
  import { fmtCost, fmtTok } from '@corenel/ui-kit/usage';
6
+ import { useChatSkin, plainName } from './chatSkinContext';
6
7
  // Mode gradients are reserved for the chat composer (per the brand system). These
7
8
  // back the built-in ids; a custom mode falls back to the auto gradient (or its own
8
9
  // frontmatter `accent`).
@@ -48,6 +49,25 @@ const COARSE_POINTER = typeof matchMedia !== 'undefined' && matchMedia('(pointer
48
49
  function isCoarsePointer() {
49
50
  return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
50
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
+ }
51
71
  /** Group commands by section, preserving first-seen order; '' sections render unlabeled. */
52
72
  function groupBySection(cmds) {
53
73
  const order = [];
@@ -62,7 +82,10 @@ function groupBySection(cmds) {
62
82
  }
63
83
  return order.map((s) => [s, map.get(s)]);
64
84
  }
65
- 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 = [], }) {
86
+ // A skin that reaches the whole chat may name the controls its own way;
87
+ // each one keeps its plain name for screen readers and tooltips.
88
+ const { words } = useChatSkin();
66
89
  // Named because it is used to decide which LINE the caret sits on, which is
67
90
  // the whole of the Up/Down rule below.
68
91
  const NEWLINE = '\n';
@@ -88,6 +111,30 @@ export function Composer({ value, onChange, onSend, onStop, running, placeholder
88
111
  // cap, after which it scrolls. Runs before paint (no flicker) on every value
89
112
  // change, so clearing after send snaps back to the resting height.
90
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]);
91
138
  useLayoutEffect(() => {
92
139
  const el = textareaRef.current;
93
140
  if (!el)
@@ -96,9 +143,17 @@ export function Composer({ value, onChange, onSend, onStop, running, placeholder
96
143
  // bare TEXTAREA_MAX_H here would let the element autosize past the CSS
97
144
  // cap on a short viewport and reintroduce the composer-eats-the-screen bug.
98
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
+ }
99
154
  el.style.height = 'auto';
100
155
  el.style.height = `${Math.min(el.scrollHeight, cap)}px`;
101
- }, [value]);
156
+ }, [value, stacked]);
102
157
  /* No handler, no menu: a host that supplies commands it cannot act on would
103
158
  * otherwise get an Enter key that opens a menu and then swallows the
104
159
  * keystroke instead of sending. */
@@ -207,241 +262,244 @@ export function Composer({ value, onChange, onSend, onStop, running, placeholder
207
262
  document.addEventListener('mousedown', onDoc);
208
263
  return () => document.removeEventListener('mousedown', onDoc);
209
264
  }, [modeOpen]);
210
- return (_jsxs("div", { style: S.wrap, "data-testid": "composer-wrap", 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) => {
211
315
  const files = Array.from(e.dataTransfer?.files ?? []);
212
316
  if (files.length) {
213
317
  e.preventDefault();
214
318
  onDropFiles(files);
215
319
  }
216
- } : undefined, children: [mentionHits.length > 0 && (_jsxs("div", { style: S.slash, 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, children: [groupBySection(filtered).map(([section, items]) => (_jsxs("div", { children: [section && _jsx("div", { style: S.slashSection, children: section }), items.map((c) => {
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) => {
217
321
  const idx = filtered.indexOf(c);
218
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));
219
- })] }, 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 && (() => {
220
- /* WHICH ONE SURVIVES THE FOLD. Not blindly the first: an `injecting`
221
- * message carries "Interrupt now", the one control here that is worth
222
- * anything only in the seconds before the step lands. Folding that out
223
- * of sight would be the same defect as collapsing the row to a bare
224
- * count -- the space would be won by hiding the thing you needed. So an
225
- * injecting chip is the one that stays; otherwise the oldest, which is
226
- * the next to be sent. */
227
- const spotlight = Math.max(0, pending.findIndex((p) => p.state === 'injecting'));
228
- const shown = queueOpen ? pending : [pending[spotlight]];
229
- const hidden = pending.length - shown.length;
230
- return (_jsxs("div", { style: S.pendingRow, children: [shown.map((p) => {
231
- const editing = editingPendingId === p.id;
232
- const commitEdit = () => {
233
- setEditingPendingId(null);
234
- // Escape already threw this text away -- a same-unmount blur
235
- // reaching us here must not resurrect it.
236
- if (abandoningEditRef.current) {
237
- abandoningEditRef.current = false;
238
- return;
239
- }
240
- // The host removed this item while it was being edited -- there
241
- // is nothing left to send the edit to.
242
- if (!pendingIdsRef.current.has(p.id))
243
- return;
244
- if (onEditPending)
245
- onEditPending(p.id, editingPendingText);
246
- };
247
- 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) => {
248
- if (e.key === 'Enter') {
249
- e.preventDefault();
250
- commitEdit();
251
- return;
252
- }
253
- if (e.key === 'Escape') {
254
- e.preventDefault();
255
- abandoningEditRef.current = true;
256
- setEditingPendingId(null);
257
- }
258
- } })) : (_jsx("span", { style: onEditPending ? { ...S.pendingText, cursor: 'text' } : S.pendingText, onClick: onEditPending ? () => {
259
- abandoningEditRef.current = false;
260
- setEditingPendingId(p.id);
261
- setEditingPendingText(p.text);
262
- } : 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 && (
263
- // The chip half of the Alt+Enter ladder: cut the model stream
264
- // now instead of waiting for the turn to finish.
265
- _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));
266
- }), (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` }))] }));
267
- })(), _jsxs("div", { style: { ...S.box, borderColor: boxFocused ? 'var(--accent)' : 'var(--border-default)' }, "data-testid": "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))
268
324
  setBoxFocused(false); }, onMouseDown: (e) => {
269
325
  const el = e.target;
270
326
  if (el.dataset.boxSurface === undefined)
271
327
  return;
272
328
  e.preventDefault();
273
329
  textareaRef.current?.focus();
274
- }, "data-box-surface": "", children: [(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", children: [_jsx(IcPlus, { size: 11 }), " 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) => {
275
- setSlashHidden(false);
276
- setMentionHidden(false);
277
- // Typing leaves the history: what is in the box is now a draft, and a
278
- // later Down should not jump to an entry the user has since edited
279
- // away from.
280
- histIdx.current = -1;
281
- onChange(e.target.value);
282
- }, onPaste: onPaste, onKeyDown: (e) => {
283
- // While the "/" menu is open it owns the arrows + Enter/Tab/Escape, so a
284
- // command can be navigated and picked without leaving the keyboard.
285
- if (mentionHits.length > 0) {
286
- if (e.key === 'ArrowDown') {
287
- e.preventDefault();
288
- setMentionActive((a) => Math.min(a + 1, mentionHits.length - 1));
289
- return;
290
- }
291
- if (e.key === 'ArrowUp') {
292
- e.preventDefault();
293
- setMentionActive((a) => Math.max(a - 1, 0));
294
- return;
295
- }
296
- if (e.key === 'Escape') {
297
- e.preventDefault();
298
- setMentionHidden(true);
299
- return;
300
- }
301
- if ((e.key === 'Enter' || e.key === 'Tab') && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
302
- e.preventDefault();
303
- pickMention(mentionHits[Math.min(mentionActive, mentionHits.length - 1)].name);
304
- return;
305
- }
306
- }
307
- if (showSlash && filtered.length > 0) {
308
- if (e.key === 'ArrowDown') {
309
- e.preventDefault();
310
- setSlashActive((a) => Math.min(a + 1, filtered.length - 1));
311
- return;
312
- }
313
- if (e.key === 'ArrowUp') {
314
- e.preventDefault();
315
- setSlashActive((a) => Math.max(a - 1, 0));
316
- return;
317
- }
318
- if (e.key === 'Escape') {
319
- e.preventDefault();
320
- setSlashHidden(true);
321
- return;
322
- }
323
- if ((e.key === 'Enter' || e.key === 'Tab') && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
324
- e.preventDefault();
325
- onPickSlash?.(filtered[Math.min(slashActive, filtered.length - 1)].name);
326
- return;
327
- }
328
- }
329
- /* Up/Down recall previous messages, the way a shell does.
330
- *
331
- * ONLY FROM THE EDGES OF THE DRAFT. Up recalls when the caret is on
332
- * the first line and Down when it is on the last, because otherwise
333
- * the arrows could not move through a multi-line message you are
334
- * still writing - and a composer that will not let you navigate your
335
- * own text is a worse trade than no history at all. A selection is
336
- * left alone for the same reason.
337
- *
338
- * The slash menu above claims the arrows first while it is open, so
339
- * this cannot steal them from command navigation.
340
- */
341
- if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && history && history.length > 0
342
- && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
343
- const el = e.currentTarget;
344
- const at = el.selectionStart;
345
- const noSelection = at === el.selectionEnd;
346
- const onFirstLine = !value.slice(0, at).includes(NEWLINE);
347
- const onLastLine = !value.slice(at).includes(NEWLINE);
348
- const stepBack = e.key === 'ArrowUp' && noSelection && onFirstLine;
349
- const stepForward = e.key === 'ArrowDown' && noSelection && onLastLine && histIdx.current >= 0;
350
- 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;
351
471
  e.preventDefault();
352
- // Stash the live draft on the way in, so stepping back past the
353
- // newest entry returns what you were writing.
354
- if (histIdx.current < 0)
355
- histDraft.current = value;
356
- const next = stepBack
357
- ? Math.min(histIdx.current + 1, history.length - 1)
358
- : histIdx.current - 1;
359
- histIdx.current = next;
360
- const text = next < 0 ? histDraft.current : history[history.length - 1 - next];
361
- onChange(text);
362
- setSlashHidden(true);
363
- // Caret to the end, as a shell does: the common case is a
364
- // single-line entry you want to edit or resend immediately, and
365
- // end-of-text leaves the next Up still on the first line.
366
- requestAnimationFrame(() => {
367
- const t = textareaRef.current;
368
- if (t)
369
- t.setSelectionRange(text.length, text.length);
370
- });
371
- return;
372
- }
373
- }
374
- if (e.key !== 'Enter')
375
- return;
376
- // Cmd/Ctrl+Enter: don't handle it -- let it bubble to the editor's
377
- // global handler so it runs the prompt. Shift+Enter: newline.
378
- if (e.metaKey || e.ctrlKey || e.shiftKey)
379
- return;
380
- // Alt+Enter pushes into the turn already running.
381
- if (e.altKey) {
382
- if (!running || !onInject)
383
- return;
384
- // An empty composer normally has nothing to push -- EXCEPT when a
385
- // message is already in flight, where the second Alt+Enter is the
386
- // ladder's escalation to a hard inject.
387
- if (!canSend && !pending?.some((p) => p.state === 'injecting'))
388
- return;
389
- e.preventDefault();
390
- onInject();
391
- return;
392
- }
393
- // Touch keyboards have no separate newline gesture within easy reach,
394
- // so on a coarse pointer plain Enter behaves like Shift+Enter (a
395
- // newline) instead of committing the message -- returning here without
396
- // preventDefault lets the browser insert it natively, same as the
397
- // shiftKey branch above. Only the two explicit chords (Alt+Enter,
398
- // Cmd/Ctrl+Enter) still act on Enter; the mention/slash acceptance
399
- // above already returned before this point.
400
- if (isCoarsePointer())
401
- return;
402
- // Only take the queue path when the host actually wired one. A host
403
- // that renders `running` but has not adopted the queue must get
404
- // today's behavior unchanged -- Enter falls through to onSend()
405
- // below exactly as it always has.
406
- if (running && onQueue) {
407
- if (!canSend)
408
- return;
409
- e.preventDefault();
410
- onQueue();
411
- return;
412
- }
413
- if (!canSend)
414
- return;
415
- e.preventDefault();
416
- histIdx.current = -1;
417
- histDraft.current = '';
418
- onSend();
419
- }, placeholder: (() => {
420
- /* Listening beats every other invitation: the box is empty because
421
- nothing has been heard yet, and the send/newline hints describe keys
422
- that are disarmed right now anyway. */
423
- if (dictating)
424
- return LEAD_LISTENING;
425
- // The invitation is the host's; the key hints are this component's.
426
- const lead = placeholder ?? (onAttach ? LEAD_WITH_PASTE : LEAD_PLAIN);
427
- if (COARSE_POINTER)
428
- return lead;
429
- if (pending && onQueue && running) {
430
- return `${lead} · ↵ queue · ⌥↵ push into run · ⇧↵ newline`;
431
- }
432
- const base = `${lead} · ↵ send · ⇧↵ newline`;
433
- return runHint ? `${base} · ⌘↵ run` : base;
434
- })(),
435
- // Rests at one line and grows with the text, up to the cap.
436
- 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: S.stanceName, "data-testid": "composer-stance-name", children: curMode.label }), _jsx(IcChevron, { size: 12 })] }))), hasMode && _jsx("span", { style: S.stanceDivider }), running ? (_jsxs(_Fragment, { children: [onQueue && (_jsx("button", { style: S.queueBtn, 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: "Queue" })), _jsxs("button", { style: { ...S.sendBtn, background: 'var(--grad-stop)' }, onClick: onStop, children: [_jsx(IcStop, { size: 12 }), " 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'] })] })) : (
437
493
  // One primary per app: a host that declares --grad-send (corenel.ai)
438
494
  // paints Send like every other primary button, and mode colour stays
439
495
  // on the dot. Without it (prompd.app) this falls back to the mode
440
496
  // gradient, which is the historical behaviour.
441
- _jsxs("button", { style: { ...S.sendBtn, background: `var(--grad-send, ${hasMode ? modeAccent(curMode) : 'var(--accent)'})`, color: 'var(--text-on-accent, #fff)' }, onClick: onSend, disabled: !canSend, children: [_jsx(IcPlay, { size: 12 }), " ", sendLabel] }))] })] })] })] }));
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] }))] })] })] })] }));
442
498
  }
443
499
  const S = {
444
- 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 },
445
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' },
446
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' }),
447
505
  slashIcon: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 16, color: 'var(--text-tertiary)', flexShrink: 0 },
@@ -450,13 +508,14 @@ const S = {
450
508
  slashSection: { padding: '6px 9px 3px', fontSize: 10.5, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.07em', color: 'var(--text-tertiary)' },
451
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)' },
452
510
  chips: { display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' },
453
- // marginLeft:auto pushes it to the right edge; minWidth:0 lets its own contents
454
- // truncate rather than shoving the context chips off the row.
455
- 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' },
456
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 },
457
513
  chipLabel: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
458
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 },
459
- 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 },
460
519
  thumbs: { display: 'flex', flexWrap: 'wrap', gap: 8 },
461
520
  thumbWrap: { position: 'relative', width: 60, height: 60 },
462
521
  thumb: { width: 60, height: 60, objectFit: 'cover', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-subtle)', cursor: 'zoom-in', display: 'block' },
@@ -467,25 +526,32 @@ const S = {
467
526
  // it rests at one line (rows={1}) and grows with the text.
468
527
  // One bordered box holds the chip row, this textarea and the send row (see
469
528
  // the JSX), so the textarea itself is borderless and transparent.
470
- 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 },
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)' },
530
+ boxTitleState: { marginLeft: 'auto', color: 'var(--success)' },
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 },
471
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' },
472
- // flexWrap + minWidth:0 are belt-and-braces: the token estimate (the main
473
- // space hog) has already moved to the chips row (item A), but mic/history/
474
- // lead-slot/queue can still fill this row on a narrow screen, so it must be
475
- // 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.
476
535
  bottom: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 },
477
- // marginLeft:auto keeps it pushed toward chipsRight in the chips row;
478
- // minWidth:0 + overflow/ellipsis make it the FIRST thing to truncate when
479
- // 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.
480
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' },
481
545
  modeDot: { width: 8, height: 8, borderRadius: 999, flexShrink: 0 },
482
546
  // Fused stance + Send: one segmented control on the right of the bar.
483
547
  // flexShrink:0 -- Send must never be the thing that gets clipped when the
484
548
  // bottom row is crowded; the mode label truncates instead (S.stanceName).
485
- 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)' },
486
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' },
487
551
  // maxWidth caps a long mode label so it truncates instead of widening the
488
552
  // fused control and pushing Send further toward (or off) the edge.
553
+ // The skin's own label sets its case; the button's capitalize must not reach it.
554
+ stanceNamePrefixed: { textTransform: 'none', maxWidth: 170, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
489
555
  stanceName: { textTransform: 'capitalize', maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
490
556
  stanceOpt: (active) => ({ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '0 9px', border: 0, background: active ? 'var(--surface-1)' : 'transparent', color: active ? 'var(--text-primary)' : 'var(--text-tertiary)', fontSize: 12, fontWeight: 600, fontFamily: 'inherit', textTransform: 'capitalize', cursor: 'pointer' }),
491
557
  stanceDivider: { width: 1, alignSelf: 'stretch', background: 'var(--border-subtle)' },