@corenel/web-ui 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Composer.d.ts +32 -9
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +292 -230
- package/dist/Composer.js.map +1 -1
- package/dist/ContextBar.d.ts +4 -1
- package/dist/ContextBar.d.ts.map +1 -1
- package/dist/ContextBar.js +15 -2
- package/dist/ContextBar.js.map +1 -1
- package/dist/DictateButton.d.ts +26 -1
- package/dist/DictateButton.d.ts.map +1 -1
- package/dist/DictateButton.js +26 -10
- package/dist/DictateButton.js.map +1 -1
- package/dist/DockStatus.d.ts +11 -0
- package/dist/DockStatus.d.ts.map +1 -0
- package/dist/DockStatus.js +41 -0
- package/dist/DockStatus.js.map +1 -0
- package/dist/StarterCards.d.ts +20 -0
- package/dist/StarterCards.d.ts.map +1 -0
- package/dist/StarterCards.js +30 -0
- package/dist/StarterCards.js.map +1 -0
- package/dist/Transcript.d.ts +9 -1
- package/dist/Transcript.d.ts.map +1 -1
- package/dist/Transcript.js +11 -2
- package/dist/Transcript.js.map +1 -1
- package/dist/agentChat/AgentChat.d.ts +53 -7
- package/dist/agentChat/AgentChat.d.ts.map +1 -1
- package/dist/agentChat/AgentChat.js +337 -94
- package/dist/agentChat/AgentChat.js.map +1 -1
- package/dist/agentChat/OptionsDrawer.d.ts.map +1 -1
- package/dist/agentChat/OptionsDrawer.js +5 -2
- package/dist/agentChat/OptionsDrawer.js.map +1 -1
- package/dist/agentChat/controller.d.ts +3 -0
- package/dist/agentChat/controller.d.ts.map +1 -1
- package/dist/agentChat/controller.js.map +1 -1
- package/dist/credentialLines.d.ts +10 -0
- package/dist/credentialLines.d.ts.map +1 -0
- package/dist/credentialLines.js +46 -0
- package/dist/credentialLines.js.map +1 -0
- package/dist/sendLater/SendLater.d.ts +8 -1
- package/dist/sendLater/SendLater.d.ts.map +1 -1
- package/dist/sendLater/SendLater.js +16 -8
- package/dist/sendLater/SendLater.js.map +1 -1
- package/dist/toolRender.d.ts.map +1 -1
- package/dist/toolRender.js +7 -2
- package/dist/toolRender.js.map +1 -1
- package/package.json +5 -5
package/dist/Composer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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,179 @@ 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))) })),
|
|
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) })] })), (
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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) => {
|
|
331
|
+
const text = t.chipLabel ?? t.label;
|
|
332
|
+
return (_jsxs("span", { style: S.toggleChip, "data-testid": `composer-toggle-${t.id}`, title: t.title ?? `${t.label} is on`, children: [t.icon, t.onOpen ? (_jsx("button", { type: "button", style: S.toggleChipOpen, onClick: t.onOpen, children: text })) : _jsx("span", { style: S.chipLabel, children: text }), _jsx("button", { style: S.toggleChipX, onClick: t.onRemove, "aria-label": `Turn off ${t.label}`, children: _jsx(IcX, { size: 10 }) })] }, `toggle:${t.id}`));
|
|
333
|
+
}), 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) => {
|
|
334
|
+
setSlashHidden(false);
|
|
335
|
+
setMentionHidden(false);
|
|
336
|
+
// Typing leaves the history: what is in the box is now a draft, and a
|
|
337
|
+
// later Down should not jump to an entry the user has since edited
|
|
338
|
+
// away from.
|
|
339
|
+
histIdx.current = -1;
|
|
340
|
+
onChange(e.target.value);
|
|
341
|
+
}, onPaste: onPaste, onKeyDown: (e) => {
|
|
342
|
+
// While the "/" menu is open it owns the arrows + Enter/Tab/Escape, so a
|
|
343
|
+
// command can be navigated and picked without leaving the keyboard.
|
|
344
|
+
if (mentionHits.length > 0) {
|
|
345
|
+
if (e.key === 'ArrowDown') {
|
|
346
|
+
e.preventDefault();
|
|
347
|
+
setMentionActive((a) => Math.min(a + 1, mentionHits.length - 1));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (e.key === 'ArrowUp') {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
setMentionActive((a) => Math.max(a - 1, 0));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (e.key === 'Escape') {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
setMentionHidden(true);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if ((e.key === 'Enter' || e.key === 'Tab') && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
pickMention(mentionHits[Math.min(mentionActive, mentionHits.length - 1)].name);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (showSlash && filtered.length > 0) {
|
|
367
|
+
if (e.key === 'ArrowDown') {
|
|
368
|
+
e.preventDefault();
|
|
369
|
+
setSlashActive((a) => Math.min(a + 1, filtered.length - 1));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (e.key === 'ArrowUp') {
|
|
373
|
+
e.preventDefault();
|
|
374
|
+
setSlashActive((a) => Math.max(a - 1, 0));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (e.key === 'Escape') {
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
setSlashHidden(true);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if ((e.key === 'Enter' || e.key === 'Tab') && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
|
|
383
|
+
e.preventDefault();
|
|
384
|
+
onPickSlash?.(filtered[Math.min(slashActive, filtered.length - 1)].name);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/* Up/Down recall previous messages, the way a shell does.
|
|
389
|
+
*
|
|
390
|
+
* ONLY FROM THE EDGES OF THE DRAFT. Up recalls when the caret is on
|
|
391
|
+
* the first line and Down when it is on the last, because otherwise
|
|
392
|
+
* the arrows could not move through a multi-line message you are
|
|
393
|
+
* still writing - and a composer that will not let you navigate your
|
|
394
|
+
* own text is a worse trade than no history at all. A selection is
|
|
395
|
+
* left alone for the same reason.
|
|
396
|
+
*
|
|
397
|
+
* The slash menu above claims the arrows first while it is open, so
|
|
398
|
+
* this cannot steal them from command navigation.
|
|
399
|
+
*/
|
|
400
|
+
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && history && history.length > 0
|
|
401
|
+
&& !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
|
402
|
+
const el = e.currentTarget;
|
|
403
|
+
const at = el.selectionStart;
|
|
404
|
+
const noSelection = at === el.selectionEnd;
|
|
405
|
+
const onFirstLine = !value.slice(0, at).includes(NEWLINE);
|
|
406
|
+
const onLastLine = !value.slice(at).includes(NEWLINE);
|
|
407
|
+
const stepBack = e.key === 'ArrowUp' && noSelection && onFirstLine;
|
|
408
|
+
const stepForward = e.key === 'ArrowDown' && noSelection && onLastLine && histIdx.current >= 0;
|
|
409
|
+
if (stepBack || stepForward) {
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
// Stash the live draft on the way in, so stepping back past the
|
|
412
|
+
// newest entry returns what you were writing.
|
|
413
|
+
if (histIdx.current < 0)
|
|
414
|
+
histDraft.current = value;
|
|
415
|
+
const next = stepBack
|
|
416
|
+
? Math.min(histIdx.current + 1, history.length - 1)
|
|
417
|
+
: histIdx.current - 1;
|
|
418
|
+
histIdx.current = next;
|
|
419
|
+
const text = next < 0 ? histDraft.current : history[history.length - 1 - next];
|
|
420
|
+
onChange(text);
|
|
421
|
+
setSlashHidden(true);
|
|
422
|
+
// Caret to the end, as a shell does: the common case is a
|
|
423
|
+
// single-line entry you want to edit or resend immediately, and
|
|
424
|
+
// end-of-text leaves the next Up still on the first line.
|
|
425
|
+
requestAnimationFrame(() => {
|
|
426
|
+
const t = textareaRef.current;
|
|
427
|
+
if (t)
|
|
428
|
+
t.setSelectionRange(text.length, text.length);
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (e.key !== 'Enter')
|
|
434
|
+
return;
|
|
435
|
+
// Cmd/Ctrl+Enter: don't handle it -- let it bubble to the editor's
|
|
436
|
+
// global handler so it runs the prompt. Shift+Enter: newline.
|
|
437
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey)
|
|
438
|
+
return;
|
|
439
|
+
// Alt+Enter pushes into the turn already running.
|
|
440
|
+
if (e.altKey) {
|
|
441
|
+
if (!running || !onInject)
|
|
442
|
+
return;
|
|
443
|
+
// An empty composer normally has nothing to push -- EXCEPT when a
|
|
444
|
+
// message is already in flight, where the second Alt+Enter is the
|
|
445
|
+
// ladder's escalation to a hard inject.
|
|
446
|
+
if (!canSend && !pending?.some((p) => p.state === 'injecting'))
|
|
447
|
+
return;
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
onInject();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// Touch keyboards have no separate newline gesture within easy reach,
|
|
453
|
+
// so on a coarse pointer plain Enter behaves like Shift+Enter (a
|
|
454
|
+
// newline) instead of committing the message -- returning here without
|
|
455
|
+
// preventDefault lets the browser insert it natively, same as the
|
|
456
|
+
// shiftKey branch above. Only the two explicit chords (Alt+Enter,
|
|
457
|
+
// Cmd/Ctrl+Enter) still act on Enter; the mention/slash acceptance
|
|
458
|
+
// above already returned before this point.
|
|
459
|
+
if (isCoarsePointer())
|
|
460
|
+
return;
|
|
461
|
+
// Only take the queue path when the host actually wired one. A host
|
|
462
|
+
// that renders `running` but has not adopted the queue must get
|
|
463
|
+
// today's behavior unchanged -- Enter falls through to onSend()
|
|
464
|
+
// below exactly as it always has.
|
|
465
|
+
if (running && onQueue) {
|
|
466
|
+
if (!canSend)
|
|
467
|
+
return;
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
onQueue();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (!canSend)
|
|
473
|
+
return;
|
|
355
474
|
e.preventDefault();
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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'] })] })) : (
|
|
475
|
+
histIdx.current = -1;
|
|
476
|
+
histDraft.current = '';
|
|
477
|
+
onSend();
|
|
478
|
+
}, placeholder: (() => {
|
|
479
|
+
/* Listening beats every other invitation: the box is empty because
|
|
480
|
+
nothing has been heard yet, and the send/newline hints describe keys
|
|
481
|
+
that are disarmed right now anyway. */
|
|
482
|
+
if (dictating)
|
|
483
|
+
return LEAD_LISTENING;
|
|
484
|
+
// The invitation is the host's; the key hints are this component's.
|
|
485
|
+
const lead = placeholder ?? (onAttach ? LEAD_WITH_PASTE : LEAD_PLAIN);
|
|
486
|
+
if (COARSE_POINTER)
|
|
487
|
+
return lead;
|
|
488
|
+
if (pending && onQueue && running) {
|
|
489
|
+
return `${lead} · ↵ queue · ⌥↵ push into run · ⇧↵ newline`;
|
|
490
|
+
}
|
|
491
|
+
const base = `${lead} · ↵ send · ⇧↵ newline`;
|
|
492
|
+
return runHint ? `${base} · ⌘↵ run` : base;
|
|
493
|
+
})(),
|
|
494
|
+
// Rests at one line and grows with the text, up to the cap.
|
|
495
|
+
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
496
|
// One primary per app: a host that declares --grad-send (corenel.ai)
|
|
442
497
|
// paints Send like every other primary button, and mode colour stays
|
|
443
498
|
// on the dot. Without it (prompd.app) this falls back to the mode
|
|
@@ -445,7 +500,9 @@ export function Composer({ value, onChange, onSend, onStop, running, placeholder
|
|
|
445
500
|
_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
501
|
}
|
|
447
502
|
const S = {
|
|
448
|
-
wrap: { position: 'relative', borderTop: '1px solid var(--border-subtle)', background: 'var(--surface-1)', padding:
|
|
503
|
+
wrap: { position: 'relative', borderTop: '1px solid var(--border-subtle)', background: 'var(--surface-1)', padding: '8px 10px 10px', display: 'flex', flexDirection: 'column', gap: 8 },
|
|
504
|
+
// S.wrap minus the divider line, on no surface band of its own.
|
|
505
|
+
wrapFloating: { position: 'relative', background: 'transparent', padding: '8px 10px 10px', display: 'flex', flexDirection: 'column', gap: 8 },
|
|
449
506
|
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
507
|
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
508
|
slashIcon: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 16, color: 'var(--text-tertiary)', flexShrink: 0 },
|
|
@@ -454,13 +511,15 @@ const S = {
|
|
|
454
511
|
slashSection: { padding: '6px 9px 3px', fontSize: 10.5, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.07em', color: 'var(--text-tertiary)' },
|
|
455
512
|
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
513
|
chips: { display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' },
|
|
457
|
-
|
|
458
|
-
// truncate rather than shoving the context chips off the row.
|
|
459
|
-
chipsRight: { marginLeft: 'auto', minWidth: 0, maxWidth: '60%', display: 'flex', justifyContent: 'flex-end' },
|
|
514
|
+
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
515
|
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
516
|
chipLabel: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
462
517
|
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
|
-
|
|
518
|
+
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 },
|
|
519
|
+
toggleChipOpen: { background: 'none', border: 'none', padding: 0, margin: 0, font: 'inherit', color: 'inherit', cursor: 'pointer', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
520
|
+
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 },
|
|
521
|
+
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 },
|
|
522
|
+
hostSlot: { display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 },
|
|
464
523
|
thumbs: { display: 'flex', flexWrap: 'wrap', gap: 8 },
|
|
465
524
|
thumbWrap: { position: 'relative', width: 60, height: 60 },
|
|
466
525
|
thumb: { width: 60, height: 60, objectFit: 'cover', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-subtle)', cursor: 'zoom-in', display: 'block' },
|
|
@@ -471,24 +530,27 @@ const S = {
|
|
|
471
530
|
// it rests at one line (rows={1}) and grows with the text.
|
|
472
531
|
// One bordered box holds the chip row, this textarea and the send row (see
|
|
473
532
|
// the JSX), so the textarea itself is borderless and transparent.
|
|
474
|
-
boxTitle: { display: 'flex', alignItems: 'center', gap: 8, margin: '-
|
|
533
|
+
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
534
|
boxTitleState: { marginLeft: 'auto', color: 'var(--success)' },
|
|
476
|
-
box: { display: 'flex', flexDirection: 'column', gap: 4, padding:
|
|
535
|
+
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
536
|
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
|
|
479
|
-
//
|
|
480
|
-
// lead-slot/queue can still fill this row on a narrow screen, so it must be
|
|
481
|
-
// able to wrap rather than clip.
|
|
537
|
+
// flexWrap + minWidth:0: the textarea, "+", mic, host icons and stance share
|
|
538
|
+
// this row, so on a narrow screen it must be able to wrap rather than clip.
|
|
482
539
|
bottom: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 },
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
540
|
+
// Basis 0 and a small minWidth: the textarea takes whatever the controls leave,
|
|
541
|
+
// so the resting row stays ONE line down to a ~350px box (a phone). A 160px
|
|
542
|
+
// basis wrapped the empty composer onto two rows at 390px.
|
|
543
|
+
textareaCompact: { flex: '1 1 0', minWidth: 48, order: 0 },
|
|
544
|
+
textareaStacked: { flex: '1 1 100%', order: -1 },
|
|
545
|
+
// Sits in the bottom row right before the stance; marginLeft:auto pushes both
|
|
546
|
+
// to the right. minWidth:0 + overflow/ellipsis make it the FIRST thing to
|
|
547
|
+
// truncate when the row is crowded, since it is purely informational.
|
|
486
548
|
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
549
|
modeDot: { width: 8, height: 8, borderRadius: 999, flexShrink: 0 },
|
|
488
550
|
// Fused stance + Send: one segmented control on the right of the bar.
|
|
489
551
|
// flexShrink:0 -- Send must never be the thing that gets clipped when the
|
|
490
552
|
// bottom row is crowded; the mode label truncates instead (S.stanceName).
|
|
491
|
-
stance: { marginLeft: 'auto', flexShrink: 0, display: 'inline-flex', alignItems: 'stretch', height:
|
|
553
|
+
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
554
|
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
555
|
// maxWidth caps a long mode label so it truncates instead of widening the
|
|
494
556
|
// fused control and pushing Send further toward (or off) the edge.
|