@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.
Files changed (46) hide show
  1. package/dist/Composer.d.ts +32 -9
  2. package/dist/Composer.d.ts.map +1 -1
  3. package/dist/Composer.js +292 -230
  4. package/dist/Composer.js.map +1 -1
  5. package/dist/ContextBar.d.ts +4 -1
  6. package/dist/ContextBar.d.ts.map +1 -1
  7. package/dist/ContextBar.js +15 -2
  8. package/dist/ContextBar.js.map +1 -1
  9. package/dist/DictateButton.d.ts +26 -1
  10. package/dist/DictateButton.d.ts.map +1 -1
  11. package/dist/DictateButton.js +26 -10
  12. package/dist/DictateButton.js.map +1 -1
  13. package/dist/DockStatus.d.ts +11 -0
  14. package/dist/DockStatus.d.ts.map +1 -0
  15. package/dist/DockStatus.js +41 -0
  16. package/dist/DockStatus.js.map +1 -0
  17. package/dist/StarterCards.d.ts +20 -0
  18. package/dist/StarterCards.d.ts.map +1 -0
  19. package/dist/StarterCards.js +30 -0
  20. package/dist/StarterCards.js.map +1 -0
  21. package/dist/Transcript.d.ts +9 -1
  22. package/dist/Transcript.d.ts.map +1 -1
  23. package/dist/Transcript.js +11 -2
  24. package/dist/Transcript.js.map +1 -1
  25. package/dist/agentChat/AgentChat.d.ts +53 -7
  26. package/dist/agentChat/AgentChat.d.ts.map +1 -1
  27. package/dist/agentChat/AgentChat.js +337 -94
  28. package/dist/agentChat/AgentChat.js.map +1 -1
  29. package/dist/agentChat/OptionsDrawer.d.ts.map +1 -1
  30. package/dist/agentChat/OptionsDrawer.js +5 -2
  31. package/dist/agentChat/OptionsDrawer.js.map +1 -1
  32. package/dist/agentChat/controller.d.ts +3 -0
  33. package/dist/agentChat/controller.d.ts.map +1 -1
  34. package/dist/agentChat/controller.js.map +1 -1
  35. package/dist/credentialLines.d.ts +10 -0
  36. package/dist/credentialLines.d.ts.map +1 -0
  37. package/dist/credentialLines.js +46 -0
  38. package/dist/credentialLines.js.map +1 -0
  39. package/dist/sendLater/SendLater.d.ts +8 -1
  40. package/dist/sendLater/SendLater.d.ts.map +1 -1
  41. package/dist/sendLater/SendLater.js +16 -8
  42. package/dist/sendLater/SendLater.js.map +1 -1
  43. package/dist/toolRender.d.ts.map +1 -1
  44. package/dist/toolRender.js +7 -2
  45. package/dist/toolRender.js.map +1 -1
  46. package/package.json +5 -5
@@ -7,7 +7,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
7
7
  * persona/policy selection and the system-parts toggles are owned here (harness
8
8
  * get/set); everything editor-specific (context source, tips lifecycle, budget
9
9
  * form, host slash commands) comes in via props. */
10
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
+ import { useHostSheet } from '@corenel/ui-kit/sheetHost';
11
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
11
12
  import { Transcript, ToolLine, DiffProposal } from '../Transcript';
12
13
  import { ToolResultCard } from '../toolRender';
13
14
  import { Composer } from '../Composer';
@@ -37,9 +38,12 @@ import { isDeferredTool } from '@corenel/harness/tools/toolSearch';
37
38
  import { addLoadedTools } from '@corenel/tools-web/sessionLoadedTools';
38
39
  import { useContextDocs, addDoc, removeDoc, classifyUpload, looksBinary, ContextDocError, UPLOAD_ACCEPT } from '@corenel/tools-web/contextDocs';
39
40
  import { DictateButton } from '../DictateButton';
41
+ import { resolveDictation } from '@corenel/tools-web/speech/route';
40
42
  import { browserModelHost } from '@corenel/tools-web/speech/model';
41
43
  import { appendDictated } from './appendDictated';
42
44
  import { TodoBar } from '../TodoBar';
45
+ import { DockStatus } from '../DockStatus';
46
+ import { useTodos } from '@corenel/harness/todos';
43
47
  import { CommandTray, RunningCommand } from '../CommandTray';
44
48
  import { trayCommand, transcriptTurns } from '../commandTurn';
45
49
  import { composerHistory } from '../composerHistory';
@@ -48,7 +52,7 @@ import { fmtTok } from '@corenel/ui-kit/usage';
48
52
  import { isCheckpointTool } from '@corenel/protocol';
49
53
  import { PlanApprovalCard, SUBMIT_PLAN, planTextOf } from './PlanReview';
50
54
  import { useBrand } from '@corenel/ui-kit/host';
51
- import { IcPlus, IcZap, IcTrash, IcChevron, IcX, IcRefresh, IcKey, IcCheck, IcSliders, IcUser, IcFile, IcHelp, IcEdit, IcLayout, Spinner, LiveCursor } from '@corenel/ui-kit/icons';
55
+ import { IcPlus, IcZap, IcTrash, IcChevron, IcX, IcRefresh, IcKey, IcCheck, IcSliders, IcUser, IcFile, IcHelp, IcEdit, IcLayout, IcCode, IcMic, IcClock, Spinner, LiveCursor } from '@corenel/ui-kit/icons';
52
56
  /* No NOOP default for onApplyEdit, deliberately. Transcript treats the prop's
53
57
  * ABSENCE as "this surface has nothing to apply an edit to" and renders the
54
58
  * review row as a statement instead of dead buttons. Substituting a no-op here
@@ -79,7 +83,7 @@ const PS = {
79
83
  textDone: { fontSize: 13, color: 'var(--text-tertiary)', textDecoration: 'line-through' },
80
84
  };
81
85
  export function AgentChat(props) {
82
- const { controller, sessionId, onSessionChange, sessions, aboveComposer, contextWindow, systemSource, offloaded, inPrice, modes, currentSrc, onApplyEdit, renderFence, renderToolCard, emptyState, headerLead, headerExtras, composerRightSlot, title, features, embedded = false, onClose, chips, onRemoveChip, renderContextMenu, capabilities, slashExtra, onSlashAction, interceptSend, onUploadFile, sendOptsExtra, onAfterSend, tip, budgetSection, toolBadge, policyEditor, settingsExtras, LiveTimeline, fixedIdentity, readOnly, planAgents, } = props;
86
+ const { controller, sessionId, onSessionChange, sessions, aboveComposer, contextWindow, systemSource, offloaded, inPrice, modes, currentSrc, onApplyEdit, renderFence, renderToolCard, emptyState, headerLead, headerExtras, composerToggles, dockStatus, title, features, embedded = false, onClose, chips, onRemoveChip, renderContextMenu, capabilities, slashExtra, onSlashAction, interceptSend, onUploadFile, sendOptsExtra, onAfterSend, tip, budgetSection, toolBadge, policyEditor, settingsExtras, LiveTimeline, fixedIdentity, readOnly, planAgents, contentMaxWidth, startLayout = 'docked', startFooter, } = props;
83
87
  const { name: brandName } = useBrand(); // host-injected product name (CorenelHost seam)
84
88
  // The engine owns the conversation, run loop, worker, and persistence — this
85
89
  // column is a view over ONE controller session. The active session id is
@@ -157,7 +161,15 @@ export function AgentChat(props) {
157
161
  // eslint-disable-next-line react-hooks/exhaustive-deps
158
162
  }, [capabilities?.skills, capabilities?.tools]);
159
163
  const [ctxMenuOpen, setCtxMenuOpen] = useState(false);
164
+ /* The mic and send-later live in the "+" menu; these drive them from there.
165
+ * The mic's own Stop shows in the composer whenever it is busy. */
166
+ const dictControl = useRef(null);
167
+ const [laterOpen, setLaterOpen] = useState(false);
168
+ const todosNow = useTodos(sessionId ?? null);
169
+ const [dockEmpty, setDockEmpty] = useState(true);
170
+ const onDockEmpty = useCallback((e) => setDockEmpty(e), []);
160
171
  const fileInputRef = useRef(null);
172
+ const panelRef = useRef(null);
161
173
  const [uploadNotice, setUploadNotice] = useState(null);
162
174
  /* THE DRAFT AS IT STOOD WHEN THE MIC OPENED, and the whole trick behind live
163
175
  * dictation writing into the composer.
@@ -319,6 +331,21 @@ export function AgentChat(props) {
319
331
  if (pinnedRef.current)
320
332
  bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
321
333
  }, [turns, live]);
334
+ /* A decision the run is waiting on (a permission or plan card, an
335
+ * always-allow offer) renders at the bottom of the transcript but changes
336
+ * neither `turns` nor `live`, so the effect above never moved it into view:
337
+ * the card sat below the fold and the agent looked stalled. A new decision
338
+ * re-pins and scrolls even if the reader had scrolled up, because nothing
339
+ * moves until they answer it. */
340
+ const decisionKey = pendingPerm
341
+ ? `perm:${pendingPerm.tool}:${pendingPerm.summary}`
342
+ : approvalSuggestion ? `offer:${approvalSuggestion.tool}` : '';
343
+ useEffect(() => {
344
+ if (!decisionKey)
345
+ return;
346
+ pinnedRef.current = true;
347
+ bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
348
+ }, [decisionKey]);
322
349
  /* A `>pd` command lands out of context (see AgentRuntime.recordCommand), so it
323
350
  * shows in the tray above the composer rather than the transcript until Keep
324
351
  * promotes it. `dismissed` is local on purpose: whether the turn exists and
@@ -380,7 +407,8 @@ export function AgentChat(props) {
380
407
  return () => clearInterval(t);
381
408
  }, [running]);
382
409
  const newSession = async () => {
383
- const id = await controller.newSession();
410
+ // A draft: nothing is saved, or listed, until the first send.
411
+ const id = await (controller.newDraft ? controller.newDraft() : controller.newSession());
384
412
  onSessionChange(id);
385
413
  refreshSessions();
386
414
  setMenuOpen(false);
@@ -525,7 +553,7 @@ export function AgentChat(props) {
525
553
  void (async () => {
526
554
  if (!text || running)
527
555
  return;
528
- if (interceptSend && (await interceptSend(text)))
556
+ if (interceptSend && (await interceptSend(text, [])))
529
557
  return;
530
558
  await sendMessage(text, []);
531
559
  })();
@@ -633,12 +661,19 @@ export function AgentChat(props) {
633
661
  if (interceptSend) {
634
662
  const prior = input;
635
663
  setInput('');
636
- if (await interceptSend(text))
664
+ // The attachments go with the text, both ways: cleared now, restored if
665
+ // declined. Left behind, an image-mode send's screenshot sat in the
666
+ // composer after the image it was attached to had been made.
667
+ if (images.length)
668
+ setAttachments(() => []);
669
+ if (await interceptSend(text, images))
637
670
  return;
638
671
  // Declined: it was never ours to take. Restoring matters most for the
639
672
  // slash path below, where a 'prefilled' template is left in the composer
640
673
  // for review and an optimistic clear would have wiped it.
641
674
  setInput(prior);
675
+ if (images.length)
676
+ setAttachments(() => images);
642
677
  }
643
678
  if (text.startsWith('/') && images.length === 0) {
644
679
  const [cmd, ...rest] = text.slice(1).split(' ');
@@ -852,6 +887,11 @@ export function AgentChat(props) {
852
887
  // eslint-disable-next-line react-hooks/exhaustive-deps
853
888
  }), [sessionId, session.turns]);
854
889
  const menuEnabled = features?.sessionMenu !== false && !readOnly;
890
+ // Full-bleed surface, capped content: the panel and skin paint fill the stage
891
+ // while the header row, body content and dock share one centred column.
892
+ const cap = contentMaxWidth
893
+ ? { width: '100%', maxWidth: contentMaxWidth, marginInline: 'auto', boxSizing: 'border-box' }
894
+ : null;
855
895
  // Defaults true: every existing surface runs our loop, and only a proxied
856
896
  // engine opts out.
857
897
  const ownLoop = features?.ownLoop !== false;
@@ -860,9 +900,10 @@ export function AgentChat(props) {
860
900
  // A host card per live tool, built from the same call shape the settled
861
901
  // transcript hands renderToolCard, so a card does not change on settle. A
862
902
  // non-null card wins in every skin and effect: never folded, never a board row.
903
+ // The result goes along once the tool is done, as the settled transcript does.
863
904
  const liveCards = new Map();
864
905
  for (const t of live?.tools ?? []) {
865
- const card = renderToolCard?.({ id: t.id, type: 'function', function: { name: t.name, arguments: JSON.stringify(t.args || {}) } });
906
+ const card = renderToolCard?.({ id: t.id, type: 'function', function: { name: t.name, arguments: JSON.stringify(t.args || {}) } }, t.done && t.result !== undefined ? { text: t.result, isError: !!t.isError } : undefined);
866
907
  if (card)
867
908
  liveCards.set(t.id, card);
868
909
  }
@@ -877,108 +918,310 @@ export function AgentChat(props) {
877
918
  const skinReach = SKIN_REACH[transcriptSkin];
878
919
  const headerOutside = skinReach.chat && !skinReach.header;
879
920
  const chatWords = skinReach.chat ? CHAT_WORDS[transcriptSkin] ?? {} : {};
880
- const headerEl = (_jsxs("header", { style: S.head, "data-chat-part": "header", "data-chat-running": running ? '' : undefined, children: [headerLead, !menuEnabled ? (_jsx("span", { style: S.sessionName, children: title ?? brandName })) : renaming && renaming.id === sessionId ? (_jsx("input", { autoFocus: true, style: S.renameInput, value: renaming.title, onChange: (e) => setRenaming({ id: renaming.id, title: e.target.value }), onKeyDown: renameKey, onBlur: commitRename, placeholder: "Session name", "aria-label": "Rename session" })) : (_jsxs("div", { style: S.sessionBtn, onClick: () => { setMenuOpen((o) => !o); }, onDoubleClick: (e) => { if (sessionId) {
921
+ const HeadRow = cap ? 'div' : 'header';
922
+ const headerRow = (_jsxs(HeadRow, { style: cap ? { ...S.head, ...cap } : S.head, "data-chat-part": "header", "data-chat-running": running ? '' : undefined, ...(cap ? { 'data-content-cap': '' } : null), children: [headerLead, !menuEnabled ? (_jsx("span", { style: S.sessionName, children: title ?? brandName })) : renaming && renaming.id === sessionId ? (_jsx("input", { autoFocus: true, style: S.renameInput, value: renaming.title, onChange: (e) => setRenaming({ id: renaming.id, title: e.target.value }), onKeyDown: renameKey, onBlur: commitRename, placeholder: "Session name", "aria-label": "Rename session" })) : (_jsxs("div", { style: S.sessionBtn, onClick: () => { setMenuOpen((o) => !o); }, onDoubleClick: (e) => { if (sessionId) {
881
923
  e.stopPropagation();
882
924
  startRename(sessionId);
883
- } }, title: sessionId ? 'Double-click to rename' : undefined, children: [_jsx(IcZap, { size: 14 }), _jsx("span", { style: S.sessionName, children: sessionTitle }), _jsx(IcChevron, { size: 12 })] })), running && (_jsxs("span", { style: S.runChip, title: "The model is working \u2014 press Stop to cancel", children: [_jsx(Spinner, { size: 12 }), _jsx("span", { children: runElapsed > 0 ? `${runElapsed}s` : 'working…' })] })), _jsx("div", { style: { flex: 1, minWidth: 8 } }), headerExtras, !readOnly && _jsx("button", { style: S.iconBtn, onClick: newSession, title: "New chat", "aria-label": "New chat", children: _jsx(IcPlus, { size: 16 }) }), _jsx("button", { style: { ...S.iconBtn, ...(drawerOpen ? S.iconBtnActive : null) }, onClick: () => setDrawerOpen((o) => !o), title: fixedIdentity ? 'Display & capabilities' : `Persona, display & capabilities — ${personaLabel}`, "aria-label": "Panel options", children: _jsx(IcSliders, { size: 16 }) }), !embedded && onClose && _jsx("button", { style: S.iconBtn, onClick: onClose, title: "Close (\u2318K to reopen)", "aria-label": "Close", children: _jsx(IcX, { size: 16 }) }), menuEnabled && menuOpen && (_jsxs("div", { style: S.menu, onMouseLeave: () => setMenuOpen(false), children: [_jsxs("button", { style: S.menuItem, onClick: newSession, children: [_jsx(IcPlus, { size: 13 }), " New chat"] }), _jsx("div", { style: S.menuLabel, children: "Recent" }), sessionList.length === 0 && _jsx("div", { style: S.menuEmpty, children: "No saved chats" }), sessionList.slice(0, 12).map((s) => (_jsx("div", { style: S.menuRow, children: renaming && renaming.id === s.id ? (_jsx("input", { autoFocus: true, style: S.renameInput, value: renaming.title, onChange: (e) => setRenaming({ id: s.id, title: e.target.value }), onKeyDown: renameKey, onBlur: commitRename, placeholder: "Session name", "aria-label": "Rename session", onClick: (e) => e.stopPropagation() })) : (_jsxs(_Fragment, { children: [_jsx("button", { style: menuSession(s.id === sessionId), onClick: () => switchSession(s.id), children: s.title || 'Untitled' }), _jsx("button", { style: S.menuDel, onClick: () => startRename(s.id), title: "Rename", children: _jsx(IcEdit, { size: 12 }) }), _jsx("button", { style: S.menuDel, onClick: () => deleteSession(s.id), title: "Delete", children: _jsx(IcTrash, { size: 12 }) })] })) }, s.id)))] }))] }));
884
- return (_jsxs("div", { style: S.panel, children: [headerOutside ? headerEl : null, _jsxs(ChatSkinRoot, { skin: transcriptSkin, style: S.skinBody, children: [headerOutside ? null : headerEl, ownLoop && _jsx(OptionsDrawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), personaName: personaName, onPersona: pickPersona, policyName: policyName, onPolicy: selectPolicy, hideIdentity: !!fixedIdentity, budgetSection: budgetSection?.({ override, setOverride }), settingsExtras: settingsExtras, toolBadge: toolBadge, policyEditor: policyEditor, registryTick: registryTick, capabilities: capabilities, sessionId: sessionId ?? '' }), ownLoop && !readOnly && _jsx(ContextBar, { contextWindow: contextWindow, systemSource: resolveSystemSource(), offloaded: offloaded, conversation: conversation, sessionId: sessionId ?? '' }), _jsxs("div", { style: S.chatRegion, children: [_jsxs("div", { style: { ...S.body, ...(timelineScrubbed ? { display: 'none' } : null) }, ref: bodyRef, "data-testid": "chat-body", onScroll: onBodyScroll, children: [turns.length === 0 && !live && (loaded !== false
885
- ? (readOnly ? null : emptyState?.({ setInput }))
886
- : _jsxs("div", { style: S.loadingTranscript, children: [_jsx(Spinner, { size: 14, title: "Loading conversation" }), " Loading conversation\u2026"] })), _jsxs("div", { style: S.transcriptWrap, children: [_jsx(Transcript, { turns: shownTurns, currentSrc: currentSrc ?? '', view: transcriptView, skin: transcriptSkin, handoff: handoff ?? undefined, onHandoffDone: () => setHandoff(null), onApplyEdit: onApplyEdit, onToggleInclude: readOnly ? undefined : toggleInclude, onRetry: readOnly ? undefined : onRetry, onImageClick: setLightboxSrc, renderFence: renderFence, renderToolCard: renderToolCard, footer: live ? (_jsxs("div", { style: fx === 'matrix' ? { ...S.live, position: 'relative', isolation: 'isolate' } : S.live, children: [fx === 'matrix' && _jsx(MatrixRain, { active: running }), recovery && _jsxs("div", { style: S.recovery, children: [_jsx(Spinner, { size: 13, title: "Recovering" }), " ", recovery] }), fx === 'splitflap' && live.tools.filter((t) => t.name !== 'propose_edit' && !liveCards.has(t.id)).map((t) => (_jsx("div", { "data-fx-id": t.id, children: _jsx(FlapRow, { status: t.done ? (t.isError ? 'failed' : 'done') : 'running', name: t.name.replace(/_/g, ' '), detail: Object.values(t.args ?? {}).find((v) => typeof v === 'string') ?? '' }) }, t.id))), fx !== 'splitflap' && transcriptSkin === 'minimal' && (_jsx(ToolFold, { invs: live.tools.filter((t) => t.name !== 'propose_edit' && !liveCards.has(t.id)).map((t) => ({
887
- name: t.name, args: t.args || {}, result: t.result ?? '', isError: t.isError, running: !t.done, ms: t.ms,
888
- })), view: transcriptView })), live.tools.filter((t) => (fx !== 'splitflap' && transcriptSkin !== 'minimal') || t.name === 'propose_edit' || liveCards.has(t.id)).map((t) => liveCards.has(t.id) ? (
889
- // A host card replaces the default card mid-run too.
890
- _jsx(React.Fragment, { children: liveCards.get(t.id) }, t.id)) : t.name === 'propose_edit' ? (
891
- // propose_edit BLOCKS its run on the review, so the decidable card
892
- // must render here, mid-run — not only in the settled transcript.
893
- _jsx(DiffProposal, { call: { id: t.id, type: 'function', function: { name: 'propose_edit', arguments: JSON.stringify(t.args || {}) } }, currentSrc: currentSrc ?? '', onApplyEdit: onApplyEdit, view: transcriptView }, t.id)) : t.done ? (
894
- // A tool that finished mid-run gets the SAME expandable card as the
895
- // settled transcript, so its args/result are viewable before the
896
- // turn ends (not just a bare summary line).
897
- // data-fx-id stays the same across running -> done, so an effect
898
- // treats the swap as the same tool line (one rock, one animation).
899
- _jsx("div", { "data-fx-id": t.id, children: _jsx(ToolResultCard, { inv: { name: t.name, args: t.args || {}, result: t.result ?? '', isError: t.isError, running: false, ms: t.ms } }) }, t.id)) : (
900
- // The name animates once, here, when the tool appears.
901
- _jsx("div", { "data-fx-id": t.id, style: S.liveToolRow, children: _jsx(ToolLine, { name: t.name, args: t.args || {}, result: t.result, isError: t.isError, running: true, ms: t.ms, nameFx: fx }) }, t.id))), live.text && fx === 'netsplit' && !hasAnswer && _jsx(NetsplitJoin, {}), live.text ? _jsxs(_Fragment, { children: [_jsx("div", { "data-tx-part": "assistant", children: _jsx(LiveAnswer, { text: live.text, effect: fx, complete: !running, onProgress: (n) => { liveShownRef.current = n; } }) }), running && _jsx(LiveCursor, {})] }) : (!recovery && live.tools.length === 0 && (_jsxs("div", { style: S.thinkingWrap, children: [_jsxs("div", { style: S.thinking, children: [_jsx(Spinner, { size: 14, title: "Thinking" }), " thinking\u2026"] }), tip && _jsx("div", { style: S.tipGhost, children: tip })] })))] })) : undefined }), plan && plan.length > 0 && _jsx(PlanCard, { entries: plan })] }), pendingPerm && pendingPerm.tool === SUBMIT_PLAN && (_jsx(PlanApprovalCard, { agent: pendingPerm.agent ?? 'This agent', plan: planTextOf(undefined, pendingPerm.summary), ...(planAgents ? { agents: planAgents } : {}), onApprove: () => resolvePerm(true), onDeny: (note) => resolvePerm(false, false, note) })), pendingPerm && pendingPerm.tool !== SUBMIT_PLAN && (_jsxs("div", { style: S.perm, "data-chat-part": "perm", ...((pendingPerm.tool === 'budget' ? chatWords.budgetTitle : chatWords.permTitle)
902
- ? { role: 'group', 'aria-label': pendingPerm.tool === 'budget' ? 'Budget limit reached' : 'Permission needed' }
903
- : {}), children: [_jsx("div", { style: S.permHead, "data-chat-part": "perm-head", children: pendingPerm.tool === 'budget'
904
- ? (chatWords.budgetTitle ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Budget limit reached"] }))
905
- : (chatWords.permTitle?.(pendingPerm.tool) ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Permission needed"] })) }), _jsx("div", { style: S.permBody, children: pendingPerm.tool === 'budget'
906
- ? pendingPerm.summary
907
- : (_jsxs(_Fragment, { children: [pendingPerm.requester === 'you'
908
- ? 'Your command needs'
909
- : pendingPerm.agent
910
- ? _jsxs(_Fragment, { children: [_jsx("strong", { children: pendingPerm.agent }), " (called by ", pendingPerm.caller ?? 'this agent', ") wants to use"] })
911
- : 'The agent wants to use', ' ', _jsx("strong", { children: pendingPerm.tool }), ". ", pendingPerm.summary] })) }), _jsx("div", { style: S.permActions, children: pendingPerm.tool === 'budget' ? (_jsxs(_Fragment, { children: [_jsx("button", { style: S.permDeny, "data-chat-part": "perm-deny", ...plainName(chatWords.budgetStop, 'Stop'), onClick: () => resolvePerm(false), children: chatWords.budgetStop ?? 'Stop' }), _jsxs("button", { style: S.permAllow, "data-chat-part": "perm-go", ...plainName(chatWords.budgetContinue, 'Continue'), onClick: () => resolvePerm(true), children: [_jsx(IcCheck, { size: 12 }), " ", chatWords.budgetContinue ?? 'Continue'] })] })) : (_jsxs(_Fragment, { children: [_jsx("button", { style: S.permDeny, "data-chat-part": "perm-deny", ...plainName(chatWords.deny, 'Deny'), onClick: () => resolvePerm(false), children: chatWords.deny ?? 'Deny' }), !isCheckpointTool(pendingPerm.tool) && (_jsx("button", { style: S.permAllowSession, "data-chat-part": "perm-session", ...plainName(chatWords.allowSession, 'Allow for session'), onClick: () => resolvePerm(true, true), children: chatWords.allowSession ?? 'Allow for session' })), _jsxs("button", { style: S.permAllow, "data-chat-part": "perm-go", ...plainName(chatWords.allowOnce, 'Allow once'), onClick: () => resolvePerm(true), children: [_jsx(IcCheck, { size: 12 }), " ", chatWords.allowOnce ?? 'Allow once'] })] })) })] })), approvalSuggestion && !pendingPerm && (_jsxs("div", { style: S.approvalSuggest, role: "status", "aria-live": "polite", "data-chat-part": "suggest", children: [_jsxs("div", { children: ["You've allowed ", _jsx("strong", { children: approvalSuggestion.tool }), " ", approvalSuggestion.count, " times", approvalSuggestion.scope === 'workspace' ? ' in this workspace' : '', ". Always allow it?"] }), _jsxs("div", { style: S.permActions, children: [approvalSuggestion.scopeChoice ? (_jsxs(_Fragment, { children: [_jsx("button", { style: S.permAllowSession, onClick: () => respondSuggestion('workspace'), children: "This workspace" }), _jsx("button", { style: S.permAllowSession, onClick: () => respondSuggestion('global'), children: "Everywhere" })] })) : (_jsx("button", { style: S.permAllowSession, onClick: () => respondSuggestion('global'), children: "Always allow" })), _jsx("button", { style: S.permDeny, onClick: () => respondSuggestion('not-now'), children: "Not now" }), _jsx("button", { style: S.permDeny, onClick: () => respondSuggestion('never'), children: "Never" })] })] })), policyNotes.map((n, i) => (_jsxs("div", { style: n.action === 'stop' ? S.policyStop : S.policyWarn, children: [_jsx(IcKey, { size: 12 }), " Policy (", policyName, "): ", n.message] }, i))), error && _jsx("div", { style: S.error, children: error }), !readOnly && !running && !error && suggestions.length > 0 && (_jsxs("div", { style: S.followRow, "data-chat-part": "followups", children: [chatWords.followups && _jsx("span", { style: S.followLabel, children: chatWords.followups }), suggestions.map((sg) => (_jsx("button", { style: S.followChip, onClick: () => setInput(sg), title: "Use this follow-up", children: sg }, sg)))] }))] }), LiveTimeline && _jsx(LiveTimeline, { events: recEvents, running: running, onScrubbedChange: setTimelineScrubbed })] }), ctxMenuOpen && (_jsxs("div", { style: S.ctxMenu, onMouseLeave: () => setCtxMenuOpen(false), children: [renderContextMenu?.(() => setCtxMenuOpen(false)), _jsxs("button", { style: S.menuItem, disabled: !sessionId, onClick: () => fileInputRef.current?.click(), children: [_jsx(IcFile, { size: 13 }), " Upload document\u2026"] })] })), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: UPLOAD_ACCEPT, style: { display: 'none' }, onChange: (e) => {
925
+ } }, title: sessionId ? 'Double-click to rename' : undefined, children: [_jsx(IcZap, { size: 14 }), _jsx("span", { style: S.sessionName, children: sessionTitle }), _jsx(IcChevron, { size: 12 })] })), _jsx("div", { style: { flex: 1, minWidth: 8 } }), headerExtras, ownLoop && !readOnly && _jsx(ContextBar, { variant: "inline", contextWindow: contextWindow, systemSource: resolveSystemSource(), offloaded: offloaded, conversation: conversation, sessionId: sessionId ?? '' }), !readOnly && _jsx("button", { style: S.iconBtn, onClick: newSession, title: "New chat", "aria-label": "New chat", children: _jsx(IcPlus, { size: 16 }) }), _jsx("button", { style: { ...S.iconBtn, ...(drawerOpen ? S.iconBtnActive : null) }, onClick: () => setDrawerOpen((o) => !o), title: fixedIdentity ? 'Display & capabilities' : `Persona, display & capabilities — ${personaLabel}`, "aria-label": "Panel options", children: _jsx(IcSliders, { size: 16 }) }), !embedded && onClose && _jsx("button", { style: S.iconBtn, onClick: onClose, title: "Close (\u2318K to reopen)", "aria-label": "Close", children: _jsx(IcX, { size: 16 }) }), menuEnabled && menuOpen && (_jsxs("div", { style: S.menu, onMouseLeave: () => setMenuOpen(false), children: [_jsxs("button", { style: S.menuItem, onClick: newSession, children: [_jsx(IcPlus, { size: 13 }), " New chat"] }), _jsx("div", { style: S.menuLabel, children: "Recent" }), sessionList.length === 0 && _jsx("div", { style: S.menuEmpty, children: "No saved chats" }), sessionList.slice(0, 12).map((s) => (_jsx("div", { style: S.menuRow, children: renaming && renaming.id === s.id ? (_jsx("input", { autoFocus: true, style: S.renameInput, value: renaming.title, onChange: (e) => setRenaming({ id: s.id, title: e.target.value }), onKeyDown: renameKey, onBlur: commitRename, placeholder: "Session name", "aria-label": "Rename session", onClick: (e) => e.stopPropagation() })) : (_jsxs(_Fragment, { children: [_jsx("button", { style: menuSession(s.id === sessionId), onClick: () => switchSession(s.id), children: s.title || 'Untitled' }), _jsx("button", { style: S.menuDel, onClick: () => startRename(s.id), title: "Rename", children: _jsx(IcEdit, { size: 12 }) }), _jsx("button", { style: S.menuDel, onClick: () => deleteSession(s.id), title: "Delete", children: _jsx(IcTrash, { size: 12 }) })] })) }, s.id)))] }))] }));
926
+ const isPhone = useMatchMedia('(max-width: 640px)');
927
+ const switchRef = useRef(switchSession);
928
+ switchRef.current = switchSession;
929
+ const lastSession = useMemo(() => {
930
+ const best = sessionList
931
+ .filter((s) => s.id !== sessionId && typeof s.updatedAt === 'number' && (s.messages === undefined || s.messages.length > 0))
932
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
933
+ return best ? { title: best.title || 'Untitled', updatedAt: best.updatedAt ?? 0, open: () => switchRef.current(best.id) } : undefined;
934
+ }, [sessionList, sessionId]);
935
+ const showEmpty = turns.length === 0 && !live && loaded !== false && !readOnly;
936
+ const centered = startLayout === 'centered' && showEmpty && !isPhone;
937
+ const wasCentered = useRef(centered);
938
+ /* Moving the dock between the centred start and the panel foot REMOUNTS the
939
+ * composer (a new parent), so whatever focus it held is gone by the time any
940
+ * effect runs. Whether it had focus is read here, during the render that
941
+ * flips `centered` and before React touches the DOM; the old textarea is
942
+ * still the one in the document. */
943
+ const composerHadFocus = useRef(false);
944
+ if (centered !== wasCentered.current && typeof document !== 'undefined') {
945
+ const old = panelRef.current?.querySelector('[data-chat-part="composer"] textarea');
946
+ composerHadFocus.current = !!old && old === document.activeElement;
947
+ }
948
+ useEffect(() => {
949
+ const focusComposer = () => requestAnimationFrame(() => panelRef.current?.querySelector('[data-chat-part="composer"] textarea')?.focus());
950
+ // Centred -> docked once the chat has turns: the first send, and also a
951
+ // session switch into a chat that already has turns. Both put the caret in
952
+ // the box, intentionally -- the next thing you do is type.
953
+ if (wasCentered.current && !centered && (turns.length > 0 || live))
954
+ focusComposer();
955
+ // Docked -> centred (a new or emptied chat): keep the caret only if it was
956
+ // already in the composer; never steal focus from elsewhere on the page.
957
+ else if (!wasCentered.current && centered && composerHadFocus.current)
958
+ focusComposer();
959
+ wasCentered.current = centered;
960
+ composerHadFocus.current = false;
961
+ // turns/live are read only on the centred -> docked transition itself.
962
+ // eslint-disable-next-line react-hooks/exhaustive-deps
963
+ }, [centered]);
964
+ /* Escape closes the "+" menu and hands focus back to the "+" button that
965
+ * opened it, the way any menu behaves. Listening only while it is open. */
966
+ useEffect(() => {
967
+ if (!ctxMenuOpen)
968
+ return;
969
+ const onKey = (e) => {
970
+ if (e.key !== 'Escape')
971
+ return;
972
+ setCtxMenuOpen(false);
973
+ panelRef.current?.querySelector('[data-testid="composer-add"]')?.focus();
974
+ };
975
+ document.addEventListener('keydown', onKey);
976
+ return () => document.removeEventListener('keydown', onKey);
977
+ }, [ctxMenuOpen]);
978
+ /* A press anywhere else closes it too. Hover-out alone is not enough once a
979
+ * toggle's options show: their lists float past the menu's edge, and on a
980
+ * phone there is no hover at all. The "+" button and toggle chips open and
981
+ * close it themselves; anything marked data-keeps-menu (a phone sheet
982
+ * portalled out of the menu) is part of it. */
983
+ const ctxMenuRef = useRef(null);
984
+ useEffect(() => {
985
+ if (!ctxMenuOpen)
986
+ return;
987
+ const onDown = (e) => {
988
+ const t = e.target;
989
+ if (!(t instanceof Element))
990
+ return;
991
+ if (ctxMenuRef.current?.contains(t))
992
+ return;
993
+ if (t.closest('[data-testid="composer-add"], [data-testid^="composer-toggle-"], [data-keeps-menu], .chat-plus-sheet'))
994
+ return;
995
+ setCtxMenuOpen(false);
996
+ };
997
+ document.addEventListener('pointerdown', onDown);
998
+ return () => document.removeEventListener('pointerdown', onDown);
999
+ }, [ctxMenuOpen]);
1000
+ /* Options under a switched-on toggle (the image settings) hold dropdowns;
1001
+ * leaving the menu's box to reach one must not close it. */
1002
+ const optionsShown = (composerToggles ?? []).some((t) => t.on && t.options);
1003
+ /* The host's bottom sheet, when it supplies one and this is a phone. Held in
1004
+ * an object so the component reads as a JSX tag. */
1005
+ const HostSheet = useHostSheet();
1006
+ const phoneSheet = isPhone && HostSheet ? { Sheet: HostSheet } : null;
1007
+ /* Where the menu fits inside the panel, which hides overflow. It opens up
1008
+ * from the composer. With the composer centred (a new chat) and a toggle's
1009
+ * options showing, that can run under the chat header and cut off the first
1010
+ * items. Then it opens down if that side has room; failing that it slides up
1011
+ * only as far as the top of the chat area and overlaps the composer, which
1012
+ * keeps every item visible without a scrollbar; only a menu taller than the
1013
+ * whole chat area scrolls. Measured after every render while open, because
1014
+ * switching a toggle on changes its height; state changes only on a change. */
1015
+ const [menuFit, setMenuFit] = useState({});
1016
+ useLayoutEffect(() => {
1017
+ const menu = ctxMenuRef.current;
1018
+ const dock = menu?.parentElement;
1019
+ const panel = panelRef.current;
1020
+ if (!ctxMenuOpen || phoneSheet || !menu || !dock || !panel)
1021
+ return;
1022
+ const p = panel.getBoundingClientRect();
1023
+ const head = panel.querySelector('[data-chat-part="header"]')?.getBoundingClientRect();
1024
+ const areaTop = Math.max(head && head.bottom > p.top ? head.bottom : p.top, 0) + 8;
1025
+ const areaBottom = Math.min(p.bottom, window.innerHeight) - 8;
1026
+ const d = dock.getBoundingClientRect();
1027
+ const h = menu.scrollHeight;
1028
+ const next = h <= d.top - areaTop ? {}
1029
+ : h <= areaBottom - d.bottom ? { down: true }
1030
+ : { top: areaTop - d.top, ...(h > areaBottom - areaTop ? { maxHeight: areaBottom - areaTop } : {}) };
1031
+ setMenuFit((cur) => (cur.top === next.top && cur.down === next.down && cur.maxHeight === next.maxHeight ? cur : next));
1032
+ });
1033
+ /* A resize moves the composer (it is centred) without a render here, so
1034
+ * measure again then too. */
1035
+ const [, setResizeTick] = useState(0);
1036
+ useEffect(() => {
1037
+ if (!ctxMenuOpen)
1038
+ return;
1039
+ const onResize = () => setResizeTick((n) => n + 1);
1040
+ window.addEventListener('resize', onResize);
1041
+ return () => window.removeEventListener('resize', onResize);
1042
+ }, [ctxMenuOpen]);
1043
+ const menuItemStyle = isPhone ? { ...S.menuItem, minHeight: 44, fontSize: 14 } : S.menuItem;
1044
+ const headerEl = cap ? _jsx("header", { style: S.headOuter, children: headerRow }) : headerRow;
1045
+ const composerEl = (_jsx(Composer, { value: input, onChange: setInput, docked: !centered,
1046
+ // The mic goes in the bottom row's FIXED first position, not the chip
1047
+ // row. It was in the chip row first, and TodoBar shoved it sideways
1048
+ // every time the agent's current task changed -- a control that moves
1049
+ // while the agent works is a control people misclick, and this one's
1050
+ // worst misclick is "I thought I stopped recording". It stays in the
1051
+ // composer either way, which is what keeps it reachable on a phone
1052
+ // (the chat header is not -- see composerSlot.test.ts).
1053
+ //
1054
+ // Rendered here rather than passed in by each host: dictation belongs to
1055
+ // the agent chat, not to any one surface, and four separate wirings
1056
+ // would mean four chances to ship a surface that is silently mute.
1057
+ dictating: dictating, leadSlot: _jsx(DictateButton, { host: browserModelHost(),
1058
+ // Started from the "+" menu; shows itself (Stop, progress, consent,
1059
+ // errors) only while busy, always in this same first slot.
1060
+ hideWhenIdle: true, controlRef: dictControl,
1061
+ // Browser or paired sidecar, chosen when a dictation starts.
1062
+ backend: () => { const r = resolveDictation(); return { host: r.host, createDictation: r.createDictation, supported: r.where !== 'none' }; },
1063
+ // Capture the draft the moment the mic opens; every guess below joins
1064
+ // onto THIS, never onto the previous guess.
1065
+ onListeningChange: (on) => { if (on)
1066
+ dictationBase.current = inputRef.current; setDictating(on); },
1067
+ /* Live guesses go straight into the draft -- that is the feature.
1068
+ EMPTY IS IGNORED, and that is load-bearing: DictateButton clears the
1069
+ guess with onInterim('') AFTER stop() resolves, which is after the
1070
+ final transcript has already landed. Honouring it would recompute
1071
+ `base + ''` and wipe the committed text a beat after showing it. */
1072
+ onInterim: (t) => { if (t)
1073
+ setInput(appendDictated(dictationBase.current, t)); }, onFinal: (t) => setInput(appendDictated(dictationBase.current, t)) }), onSend: send, onStop: () => { stoppedRef.current = true; if (sessionId)
1074
+ controller.stop(sessionId); }, running: running, mode: mode, onModeChange: setMode, modes: modeEntries, chips: [
1075
+ ...(chips ?? []),
1076
+ ...contextDocs.map((d) => ({ id: `doc:${d.id}`, label: `${d.name} · ${fmtTok(d.tokens)} tok`, removable: true })),
1077
+ ], onRemoveChip: (cid) => {
1078
+ if (cid.startsWith('doc:')) {
1079
+ if (sessionId)
1080
+ removeDoc(sessionId, cid.slice(4));
1081
+ }
1082
+ else
1083
+ onRemoveChip?.(cid);
1084
+ }, onAddContext: () => setCtxMenuOpen((o) => !o),
1085
+ // The agent's plan, above the composer. prompd.app also has a dock panel
1086
+ // for it; on corenel.ai this is the ONLY place it is visible. Renders
1087
+ // nothing when there are no tasks.
1088
+ // The host's own controls sit BEFORE the task list, so their position
1089
+ // stays put: TodoBar renders nothing when there are no tasks, and a
1090
+ // control that moves as the agent starts and finishes work is a
1091
+ // control people misclick.
1092
+ toggleChips: (composerToggles ?? []).filter((t) => t.on).map((t) => ({
1093
+ id: t.id, label: t.label, icon: t.icon, onRemove: t.onToggle,
1094
+ ...(t.title ? { title: t.title } : {}),
1095
+ ...(t.chipLabel ? { chipLabel: t.chipLabel } : {}),
1096
+ /* Opens the menu ON the options: focus goes to the toggle's row,
1097
+ * so a keyboard or screen reader lands where the chip pointed. */
1098
+ ...(t.options ? {
1099
+ onOpen: () => {
1100
+ setCtxMenuOpen(true);
1101
+ requestAnimationFrame(() => panelRef.current?.querySelector(`[data-composer-toggle="${t.id}"]`)?.focus());
1102
+ },
1103
+ } : {}),
1104
+ })), status: (running || todosNow.length > 0 || degradedModel || dockStatus != null) ? (_jsx(DockStatus, { running: running, elapsed: runElapsed, todos: _jsx(TodoBar, { sessionId: sessionId }), hasTodos: todosNow.length > 0, degradedModel: degradedModel, host: dockStatus, onEmptyChange: onDockEmpty })) : undefined, statusHidden: dockEmpty && !running && todosNow.length === 0 && !degradedModel && pending.length === 0, slashCommands: slashCommands, onPickSlash: (name) => { if (runSlash(name) !== 'prefilled')
1105
+ setInput(''); }, attachments: attachments, onAttach: (urls) => setAttachments((a) => [...a, ...urls]), onRemoveAttachment: (i) => setAttachments((a) => a.filter((_, j) => j !== i)), pending: pending, onQueue: queueCurrent, onInject: injectPending, onRemovePending: (id) => { if (sessionId)
1106
+ removePending(sessionId, id); }, onEditPending: (id, text) => { if (sessionId)
1107
+ editPending(sessionId, id, text); }, onImageClick: setLightboxSrc, inPrice: inPrice, onDropFiles: (files) => void processFiles(files), history: history, sendLater: (_jsx(SendLater, { collapsed: true, open: laterOpen, onOpenChange: setLaterOpen, text: input, onQueued: () => setInput(''), onSendNow: (t) => { void sendMessage(t, []); }, items: laterItems, now: laterNow })) }));
1108
+ /* Everything that sits directly above the dock: notices, a pending question,
1109
+ * the host's aboveComposer, a command in flight and the console tray. It
1110
+ * travels WITH the dock -- into the centred start when the chat is empty,
1111
+ * at the panel foot otherwise -- so it is never stranded far from the box
1112
+ * it belongs to. The read-only bar is not part of it (centred never happens
1113
+ * in read-only). */
1114
+ const noticesEl = (_jsxs(_Fragment, { children: [saveBlocked === 'load-failed' && (_jsxs("div", { style: S.uploadNotice, role: "alert", "data-chat-part": "unsaved", children: [_jsx("span", { style: { flex: 1 }, children: "New messages in this chat are not being saved." }), controller.retryLoad && (_jsx("button", { type: "button", style: S.unsavedRetry, disabled: retrying, onClick: () => { void retryLoad(); }, children: retrying ? 'Retrying...' : 'Retry' }))] })), mergedNotice && (_jsxs("div", { style: S.uploadNotice, role: "status", "data-chat-part": "merged", children: [_jsx("span", { style: { flex: 1 }, children: "Another tab changed this chat; its turns were merged in." }), _jsx("button", { type: "button", style: S.uploadNoticeX, onClick: () => setMergedNotice(false), "aria-label": "Dismiss", children: _jsx(IcX, { size: 11 }) })] })), uploadNotice && (_jsxs("div", { style: S.uploadNotice, children: [_jsx("span", { style: { flex: 1, whiteSpace: 'pre-line' }, children: uploadNotice }), _jsx("button", { style: S.uploadNoticeX, onClick: () => setUploadNotice(null), "aria-label": "Dismiss", children: _jsx(IcX, { size: 11 }) })] })), pendingAsk && (_jsx(AskUser, { questions: pendingAsk.questions, onSubmit: (answers) => { if (sessionId)
1115
+ controller.resolveAsk(sessionId, answers); }, onCancel: () => { if (sessionId)
1116
+ controller.resolveAsk(sessionId, null); } })), readOnly ? null : (_jsxs(_Fragment, { children: [aboveComposer, pendingCommand && _jsx(RunningCommand, { command: pendingCommand.command, startedAt: pendingCommand.startedAt }), _jsx(CommandTray, { command: tray, onKeep: (turnId) => { if (sessionId)
1117
+ controller.toggleInclude(sessionId, turnId); }, onDismiss: (turnId) => setDismissed((d) => new Set(d).add(turnId)) })] }))] }));
1118
+ const menuItems = (_jsxs(_Fragment, { children: [renderContextMenu?.(() => setCtxMenuOpen(false)), _jsxs("button", { style: menuItemStyle, disabled: !sessionId, onClick: () => fileInputRef.current?.click(), children: [_jsx(IcFile, { size: 13 }), " Upload document\u2026"] }), slashCommands.length > 0 && (_jsxs("button", { style: menuItemStyle, onClick: () => {
1119
+ setCtxMenuOpen(false);
1120
+ setInput('/');
1121
+ requestAnimationFrame(() => panelRef.current?.querySelector('[data-chat-part="composer"] textarea')?.focus());
1122
+ }, children: [_jsx(IcCode, { size: 13 }), " Commands"] })), _jsx("div", { style: S.menuLabel, children: "Tools" }), (() => {
1123
+ const route = resolveDictation();
1124
+ const label = dictating ? 'Stop dictating'
1125
+ : route.where === 'sidecar' ? (route.tunnel ? 'Dictate (on your sidecar, via tunnel)' : 'Dictate (on your sidecar)')
1126
+ : 'Dictate';
1127
+ const unsupported = dictControl.current?.supported === false;
1128
+ return (_jsxs("button", { style: menuItemStyle, disabled: !dictating && (route.where === 'none' || unsupported), title: dictating ? undefined : route.where === 'none' ? route.reason
1129
+ : unsupported ? 'Voice input needs a microphone and a browser that supports one.' : undefined, onClick: () => { setCtxMenuOpen(false); void dictControl.current?.toggle(); }, children: [_jsx(IcMic, { size: 13 }), " ", label] }));
1130
+ })(), _jsxs("button", { style: menuItemStyle, disabled: running, title: running ? 'Available when this run finishes' : undefined, onClick: () => { setCtxMenuOpen(false); setLaterOpen(true); }, children: [_jsx(IcClock, { size: 13 }), " Send later\u2026"] }), (composerToggles ?? []).map((t) => t.options ? (
1131
+ /* A toggle with settings is a switch: flipping it keeps the
1132
+ * menu open so its settings appear right here, under it. */
1133
+ _jsxs(React.Fragment, { children: [_jsxs("button", { role: "menuitemcheckbox", "aria-checked": t.on, style: isPhone ? { ...S.menuItemTall, minHeight: 48, fontSize: 14 } : S.menuItemTall, "data-composer-toggle": t.id, title: t.title, onClick: t.onToggle, children: [t.icon, _jsxs("span", { style: S.menuItemText, children: [_jsx("span", { children: t.label }), t.on && t.summary && _jsx("span", { style: S.menuItemSub, children: t.summary })] }), _jsx("span", { "aria-hidden": "true", style: { ...S.switchTrack, ...(t.on ? S.switchTrackOn : null) }, children: _jsx("span", { style: { ...S.switchKnob, ...(t.on ? S.switchKnobOn : null) } }) })] }), t.on && _jsx("div", { style: S.menuOptions, "data-testid": `composer-toggle-options-${t.id}`, children: t.options() })] }, t.id)) : (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": t.on, style: menuItemStyle, title: t.title, onClick: () => { setCtxMenuOpen(false); t.onToggle(); }, children: [t.icon, " ", _jsx("span", { style: { flex: 1, textAlign: 'left' }, children: t.label }), t.on && _jsx(IcCheck, { size: 13 })] }, t.id)))] }));
1134
+ const dockEl = (_jsxs("div", { style: S.dockWrap, children: [ctxMenuOpen && (phoneSheet ? (
1135
+ /* On a phone the menu is a bottom sheet: anchored where the thumb
1136
+ * is, the conversation dimmed behind it, and its body scrolls the
1137
+ * way every phone sheet does once the image settings are open. */
1138
+ _jsx(phoneSheet.Sheet, { label: "Add to message", onClose: () => setCtxMenuOpen(false), className: "chat-plus-sheet", children: _jsx("div", { ref: ctxMenuRef, style: S.ctxSheetBody, children: menuItems }) })) : (_jsx("div", { ref: ctxMenuRef, style: {
1139
+ ...S.ctxMenu,
1140
+ ...(menuFit.down ? { bottom: 'auto', top: 'calc(100% + 4px)' } : null),
1141
+ ...(menuFit.top !== undefined ? { bottom: 'auto', top: menuFit.top } : null),
1142
+ ...(menuFit.maxHeight !== undefined ? { maxHeight: menuFit.maxHeight, overflowY: 'auto' } : null),
1143
+ }, onMouseLeave: optionsShown ? undefined : () => setCtxMenuOpen(false), children: menuItems }))), composerEl] }));
1144
+ return (_jsxs("div", { style: S.panel, ref: panelRef, children: [headerOutside ? headerEl : null, _jsxs(ChatSkinRoot, { skin: transcriptSkin, style: S.skinBody, children: [headerOutside ? null : headerEl, ownLoop && _jsx(OptionsDrawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), personaName: personaName, onPersona: pickPersona, policyName: policyName, onPolicy: selectPolicy, hideIdentity: !!fixedIdentity, budgetSection: budgetSection?.({ override, setOverride }), settingsExtras: settingsExtras, toolBadge: toolBadge, policyEditor: policyEditor, registryTick: registryTick, capabilities: capabilities, sessionId: sessionId ?? '' }), _jsxs("div", { style: S.chatRegion, children: [_jsx("div", { style: { ...S.body, ...(timelineScrubbed ? { display: 'none' } : null) }, ref: bodyRef, "data-testid": "chat-body", onScroll: onBodyScroll, children: _jsxs(CapBox, { cap: cap, extra: S.bodyInner, children: [turns.length === 0 && !live && (loaded !== false
1145
+ ? (readOnly ? null : (centered ? (_jsxs("div", { style: S.centerStart, "data-testid": "center-start", children: [emptyState?.({ setInput, lastSession }), noticesEl, dockEl, startFooter?.({ setInput, lastSession })] })) : (_jsxs(_Fragment, { children: [emptyState?.({ setInput, lastSession }), startFooter?.({ setInput, lastSession })] }))))
1146
+ : _jsxs("div", { style: S.loadingTranscript, children: [_jsx(Spinner, { size: 14, title: "Loading conversation" }), " Loading conversation\u2026"] })), _jsxs("div", { style: S.transcriptWrap, children: [_jsx(Transcript, { turns: shownTurns, currentSrc: currentSrc ?? '', view: transcriptView, skin: transcriptSkin, handoff: handoff ?? undefined, onHandoffDone: () => setHandoff(null), onApplyEdit: onApplyEdit, onToggleInclude: readOnly ? undefined : toggleInclude, onRetry: readOnly ? undefined : onRetry, onImageClick: setLightboxSrc, renderFence: renderFence, renderToolCard: renderToolCard, footer: live ? (_jsxs("div", { style: fx === 'matrix' ? { ...S.live, position: 'relative', isolation: 'isolate' } : S.live, children: [fx === 'matrix' && _jsx(MatrixRain, { active: running }), recovery && _jsxs("div", { style: S.recovery, children: [_jsx(Spinner, { size: 13, title: "Recovering" }), " ", recovery] }), fx === 'splitflap' && live.tools.filter((t) => t.name !== 'propose_edit' && !liveCards.has(t.id)).map((t) => (_jsx("div", { "data-fx-id": t.id, children: _jsx(FlapRow, { status: t.done ? (t.isError ? 'failed' : 'done') : 'running', name: t.name.replace(/_/g, ' '), detail: Object.values(t.args ?? {}).find((v) => typeof v === 'string') ?? '' }) }, t.id))), fx !== 'splitflap' && transcriptSkin === 'minimal' && (_jsx(ToolFold, { invs: live.tools.filter((t) => t.name !== 'propose_edit' && !liveCards.has(t.id)).map((t) => ({
1147
+ name: t.name, args: t.args || {}, result: t.result ?? '', isError: t.isError, running: !t.done, ms: t.ms,
1148
+ })), view: transcriptView })), live.tools.filter((t) => (fx !== 'splitflap' && transcriptSkin !== 'minimal') || t.name === 'propose_edit' || liveCards.has(t.id)).map((t) => liveCards.has(t.id) ? (
1149
+ // A host card replaces the default card mid-run too.
1150
+ _jsx(React.Fragment, { children: liveCards.get(t.id) }, t.id)) : t.name === 'propose_edit' ? (
1151
+ // propose_edit BLOCKS its run on the review, so the decidable card
1152
+ // must render here, mid-run — not only in the settled transcript.
1153
+ _jsx(DiffProposal, { call: { id: t.id, type: 'function', function: { name: 'propose_edit', arguments: JSON.stringify(t.args || {}) } }, currentSrc: currentSrc ?? '', onApplyEdit: onApplyEdit, view: transcriptView }, t.id)) : t.done ? (
1154
+ // A tool that finished mid-run gets the SAME expandable card as the
1155
+ // settled transcript, so its args/result are viewable before the
1156
+ // turn ends (not just a bare summary line).
1157
+ // data-fx-id stays the same across running -> done, so an effect
1158
+ // treats the swap as the same tool line (one rock, one animation).
1159
+ _jsx("div", { "data-fx-id": t.id, children: _jsx(ToolResultCard, { inv: { name: t.name, args: t.args || {}, result: t.result ?? '', isError: t.isError, running: false, ms: t.ms } }) }, t.id)) : (
1160
+ // The name animates once, here, when the tool appears.
1161
+ _jsx("div", { "data-fx-id": t.id, style: S.liveToolRow, children: _jsx(ToolLine, { name: t.name, args: t.args || {}, result: t.result, isError: t.isError, running: true, ms: t.ms, nameFx: fx }) }, t.id))), live.text && fx === 'netsplit' && !hasAnswer && _jsx(NetsplitJoin, {}), live.text ? _jsxs(_Fragment, { children: [_jsx("div", { "data-tx-part": "assistant", children: _jsx(LiveAnswer, { text: live.text, effect: fx, complete: !running, onProgress: (n) => { liveShownRef.current = n; } }) }), running && _jsx(LiveCursor, {})] }) : (!recovery && live.tools.length === 0 && (_jsxs("div", { style: S.thinkingWrap, children: [_jsxs("div", { style: S.thinking, children: [_jsx(Spinner, { size: 14, title: "Thinking" }), " thinking\u2026"] }), tip && _jsx("div", { style: S.tipGhost, children: tip })] })))] })) : undefined }), plan && plan.length > 0 && _jsx(PlanCard, { entries: plan })] }), pendingPerm && pendingPerm.tool === SUBMIT_PLAN && (_jsx(PlanApprovalCard, { agent: pendingPerm.agent ?? 'This agent', plan: planTextOf(undefined, pendingPerm.summary), ...(planAgents ? { agents: planAgents } : {}), onApprove: () => resolvePerm(true), onDeny: (note) => resolvePerm(false, false, note) })), pendingPerm && pendingPerm.tool !== SUBMIT_PLAN && (_jsxs("div", { style: S.perm, "data-chat-part": "perm", ...((pendingPerm.tool === 'budget' ? chatWords.budgetTitle : chatWords.permTitle)
1162
+ ? { role: 'group', 'aria-label': pendingPerm.tool === 'budget' ? 'Budget limit reached' : 'Permission needed' }
1163
+ : {}), children: [_jsx("div", { style: S.permHead, "data-chat-part": "perm-head", children: pendingPerm.tool === 'budget'
1164
+ ? (chatWords.budgetTitle ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Budget limit reached"] }))
1165
+ : (chatWords.permTitle?.(pendingPerm.tool) ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Permission needed"] })) }), _jsx("div", { style: S.permBody, children: pendingPerm.tool === 'budget'
1166
+ ? pendingPerm.summary
1167
+ : (_jsxs(_Fragment, { children: [pendingPerm.requester === 'you'
1168
+ ? 'Your command needs'
1169
+ : pendingPerm.agent
1170
+ ? _jsxs(_Fragment, { children: [_jsx("strong", { children: pendingPerm.agent }), " (called by ", pendingPerm.caller ?? 'this agent', ") wants to use"] })
1171
+ : 'The agent wants to use', ' ', _jsx("strong", { children: pendingPerm.tool }), ". ", pendingPerm.summary] })) }), _jsx("div", { style: S.permActions, children: pendingPerm.tool === 'budget' ? (_jsxs(_Fragment, { children: [_jsx("button", { style: S.permDeny, "data-chat-part": "perm-deny", ...plainName(chatWords.budgetStop, 'Stop'), onClick: () => resolvePerm(false), children: chatWords.budgetStop ?? 'Stop' }), _jsxs("button", { style: S.permAllow, "data-chat-part": "perm-go", ...plainName(chatWords.budgetContinue, 'Continue'), onClick: () => resolvePerm(true), children: [_jsx(IcCheck, { size: 12 }), " ", chatWords.budgetContinue ?? 'Continue'] })] })) : (_jsxs(_Fragment, { children: [_jsx("button", { style: S.permDeny, "data-chat-part": "perm-deny", ...plainName(chatWords.deny, 'Deny'), onClick: () => resolvePerm(false), children: chatWords.deny ?? 'Deny' }), !isCheckpointTool(pendingPerm.tool) && (_jsx("button", { style: S.permAllowSession, "data-chat-part": "perm-session", ...plainName(chatWords.allowSession, 'Allow for session'), onClick: () => resolvePerm(true, true), children: chatWords.allowSession ?? 'Allow for session' })), _jsxs("button", { style: S.permAllow, "data-chat-part": "perm-go", ...plainName(chatWords.allowOnce, 'Allow once'), onClick: () => resolvePerm(true), children: [_jsx(IcCheck, { size: 12 }), " ", chatWords.allowOnce ?? 'Allow once'] })] })) })] })), approvalSuggestion && !pendingPerm && (_jsxs("div", { style: S.approvalSuggest, role: "status", "aria-live": "polite", "data-chat-part": "suggest", children: [_jsxs("div", { children: ["You've allowed ", _jsx("strong", { children: approvalSuggestion.tool }), " ", approvalSuggestion.count, " times", approvalSuggestion.scope === 'workspace' ? ' in this workspace' : '', ". Always allow it?"] }), _jsxs("div", { style: S.permActions, children: [approvalSuggestion.scopeChoice ? (_jsxs(_Fragment, { children: [_jsx("button", { style: S.permAllowSession, onClick: () => respondSuggestion('workspace'), children: "This workspace" }), _jsx("button", { style: S.permAllowSession, onClick: () => respondSuggestion('global'), children: "Everywhere" })] })) : (_jsx("button", { style: S.permAllowSession, onClick: () => respondSuggestion('global'), children: "Always allow" })), _jsx("button", { style: S.permDeny, onClick: () => respondSuggestion('not-now'), children: "Not now" }), _jsx("button", { style: S.permDeny, onClick: () => respondSuggestion('never'), children: "Never" })] })] })), policyNotes.map((n, i) => (_jsxs("div", { style: n.action === 'stop' ? S.policyStop : S.policyWarn, children: [_jsx(IcKey, { size: 12 }), " Policy (", policyName, "): ", n.message] }, i))), error && _jsx("div", { style: S.error, children: error }), !readOnly && !running && !error && suggestions.length > 0 && (_jsxs("div", { style: S.followRow, "data-chat-part": "followups", children: [chatWords.followups && _jsx("span", { style: S.followLabel, children: chatWords.followups }), suggestions.map((sg) => (_jsx("button", { style: S.followChip, onClick: () => setInput(sg), title: "Use this follow-up", children: sg }, sg)))] }))] }) }), LiveTimeline && _jsx(LiveTimeline, { events: recEvents, running: running, onScrubbedChange: setTimelineScrubbed })] }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: UPLOAD_ACCEPT, style: { display: 'none' }, onChange: (e) => {
912
1172
  const files = Array.from(e.target.files ?? []);
913
1173
  e.target.value = '';
914
1174
  setCtxMenuOpen(false);
915
1175
  void processFiles(files);
916
- } }), saveBlocked === 'load-failed' && (_jsxs("div", { style: S.uploadNotice, role: "alert", "data-chat-part": "unsaved", children: [_jsx("span", { style: { flex: 1 }, children: "New messages in this chat are not being saved." }), controller.retryLoad && (_jsx("button", { type: "button", style: S.unsavedRetry, disabled: retrying, onClick: () => { void retryLoad(); }, children: retrying ? 'Retrying...' : 'Retry' }))] })), mergedNotice && (_jsxs("div", { style: S.uploadNotice, role: "status", "data-chat-part": "merged", children: [_jsx("span", { style: { flex: 1 }, children: "Another tab changed this chat; its turns were merged in." }), _jsx("button", { type: "button", style: S.uploadNoticeX, onClick: () => setMergedNotice(false), "aria-label": "Dismiss", children: _jsx(IcX, { size: 11 }) })] })), uploadNotice && (_jsxs("div", { style: S.uploadNotice, children: [_jsx("span", { style: { flex: 1, whiteSpace: 'pre-line' }, children: uploadNotice }), _jsx("button", { style: S.uploadNoticeX, onClick: () => setUploadNotice(null), "aria-label": "Dismiss", children: _jsx(IcX, { size: 11 }) })] })), pendingAsk && (_jsx(AskUser, { questions: pendingAsk.questions, onSubmit: (answers) => { if (sessionId)
917
- controller.resolveAsk(sessionId, answers); }, onCancel: () => { if (sessionId)
918
- controller.resolveAsk(sessionId, null); } })), degradedModel && (_jsx("div", { style: S.budgetWrap, children: _jsxs("span", { style: S.budgetDegraded, children: ["Degraded to ", degradedModel, " (budget)"] }) })), readOnly ? (
919
- /* WATCH MODE: the whole input region is replaced, not disabled piecemeal.
920
- A disabled box with live chips around it still reads as "type here". */
921
- _jsxs("div", { style: S.readOnlyBar, role: "region", "aria-label": "Watching this run", children: [_jsx("div", { style: S.readOnlyBanner, children: readOnly.banner }), readOnly.action] })) : (_jsxs(_Fragment, { children: [aboveComposer, pendingCommand && _jsx(RunningCommand, { command: pendingCommand.command, startedAt: pendingCommand.startedAt }), _jsx(CommandTray, { command: tray, onKeep: (turnId) => { if (sessionId)
922
- controller.toggleInclude(sessionId, turnId); }, onDismiss: (turnId) => setDismissed((d) => new Set(d).add(turnId)) }), _jsx(Composer, { value: input, onChange: setInput,
923
- // The mic goes in the bottom row's FIXED first position, not the chip
924
- // row. It was in the chip row first, and TodoBar shoved it sideways
925
- // every time the agent's current task changed -- a control that moves
926
- // while the agent works is a control people misclick, and this one's
927
- // worst misclick is "I thought I stopped recording". It stays in the
928
- // composer either way, which is what keeps it reachable on a phone
929
- // (the chat header is not -- see composerSlot.test.ts).
930
- //
931
- // Rendered here rather than passed in by each host: dictation belongs to
932
- // the agent chat, not to any one surface, and four separate wirings
933
- // would mean four chances to ship a surface that is silently mute.
934
- dictating: dictating, leadSlot: _jsx(DictateButton, { host: browserModelHost(),
935
- // Capture the draft the moment the mic opens; every guess below joins
936
- // onto THIS, never onto the previous guess.
937
- onListeningChange: (on) => { if (on)
938
- dictationBase.current = inputRef.current; setDictating(on); },
939
- /* Live guesses go straight into the draft -- that is the feature.
940
- EMPTY IS IGNORED, and that is load-bearing: DictateButton clears the
941
- guess with onInterim('') AFTER stop() resolves, which is after the
942
- final transcript has already landed. Honouring it would recompute
943
- `base + ''` and wipe the committed text a beat after showing it. */
944
- onInterim: (t) => { if (t)
945
- setInput(appendDictated(dictationBase.current, t)); }, onFinal: (t) => setInput(appendDictated(dictationBase.current, t)) }), onSend: send, onStop: () => { stoppedRef.current = true; if (sessionId)
946
- controller.stop(sessionId); }, running: running, mode: mode, onModeChange: setMode, modes: modeEntries, chips: [
947
- ...(chips ?? []),
948
- ...contextDocs.map((d) => ({ id: `doc:${d.id}`, label: `${d.name} · ${fmtTok(d.tokens)} tok`, removable: true })),
949
- ], onRemoveChip: (cid) => {
950
- if (cid.startsWith('doc:')) {
951
- if (sessionId)
952
- removeDoc(sessionId, cid.slice(4));
953
- }
954
- else
955
- onRemoveChip?.(cid);
956
- }, onAddContext: () => setCtxMenuOpen((o) => !o),
957
- // The agent's plan, above the composer. prompd.app also has a dock panel
958
- // for it; on corenel.ai this is the ONLY place it is visible. Renders
959
- // nothing when there are no tasks.
960
- // The host's own controls sit BEFORE the task list, so their position
961
- // stays put: TodoBar renders nothing when there are no tasks, and a
962
- // control that moves as the agent starts and finishes work is a
963
- // control people misclick.
964
- rightSlot: _jsxs(_Fragment, { children: [composerRightSlot, _jsx(TodoBar, { sessionId: sessionId })] }), slashCommands: slashCommands, onPickSlash: (name) => { if (runSlash(name) !== 'prefilled')
965
- setInput(''); }, attachments: attachments, onAttach: (urls) => setAttachments((a) => [...a, ...urls]), onRemoveAttachment: (i) => setAttachments((a) => a.filter((_, j) => j !== i)), pending: pending, onQueue: queueCurrent, onInject: injectPending, onRemovePending: (id) => { if (sessionId)
966
- removePending(sessionId, id); }, onEditPending: (id, text) => { if (sessionId)
967
- editPending(sessionId, id, text); }, onImageClick: setLightboxSrc, inPrice: inPrice, onDropFiles: (files) => void processFiles(files), history: history, sendLater: (_jsx(SendLater, { text: input, onQueued: () => setInput(''), onSendNow: (t) => { void sendMessage(t, []); }, items: laterItems, now: laterNow })) })] }))] }), lightboxSrc && _jsx(Lightbox, { src: lightboxSrc, onClose: () => setLightboxSrc(null) })] }));
1176
+ } }), _jsxs(CapBox, { cap: cap, children: [centered ? null : noticesEl, readOnly ? (
1177
+ /* WATCH MODE: the whole input region is replaced, not disabled piecemeal.
1178
+ A disabled box with live chips around it still reads as "type here". */
1179
+ _jsxs("div", { style: S.readOnlyBar, role: "region", "aria-label": "Watching this run", children: [_jsx("div", { style: S.readOnlyBanner, children: readOnly.banner }), readOnly.action] })) : (centered ? null : dockEl)] })] }), lightboxSrc && _jsx(Lightbox, { src: lightboxSrc, onClose: () => setLightboxSrc(null) })] }));
1180
+ }
1181
+ function useMatchMedia(q) {
1182
+ const get = () => typeof matchMedia === 'function' && matchMedia(q).matches;
1183
+ const [m, setM] = useState(get);
1184
+ useEffect(() => {
1185
+ if (typeof matchMedia !== 'function')
1186
+ return;
1187
+ const mq = matchMedia(q);
1188
+ const on = () => setM(mq.matches);
1189
+ on();
1190
+ mq.addEventListener?.('change', on);
1191
+ return () => mq.removeEventListener?.('change', on);
1192
+ }, [q]);
1193
+ return m;
968
1194
  }
969
1195
  // Session-row style is keyed by "active" — kept out of S (which is a flat style
970
1196
  // map) and typed as its own helper.
1197
+ function CapBox({ cap, extra, children }) {
1198
+ return cap ? _jsx("div", { "data-content-cap": "", style: { ...cap, ...extra }, children: children }) : _jsx(_Fragment, { children: children });
1199
+ }
971
1200
  const menuSession = (active) => ({ flex: 1, textAlign: 'left', padding: '7px 9px', border: 0, borderRadius: 'var(--radius-sm)', background: active ? 'var(--accent-soft)' : 'transparent', color: active ? 'var(--accent)' : 'var(--text-secondary)', fontSize: 13, cursor: 'pointer', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' });
972
1201
  const S = {
973
1202
  panel: { position: 'relative', display: 'flex', flexDirection: 'column', flex: 1, width: '100%', minWidth: 0, height: '100%', background: 'var(--surface-1)', overflow: 'hidden' },
974
- head: { position: 'relative', display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, rowGap: 6, minHeight: 48, padding: '7px 10px', borderBottom: '1px solid var(--border-subtle)', flexShrink: 0 },
1203
+ // Full-width shell for a capped header row; the rule stays on the capped row so it follows the column.
1204
+ headOuter: { position: 'relative', flexShrink: 0, borderBottom: 'none' },
1205
+ // Same flow as S.body's own, for the capped inner wrapper.
1206
+ bodyInner: { display: 'flex', flexDirection: 'column', gap: 16, flex: '1 0 auto' },
1207
+ head: { position: 'relative', display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, rowGap: 6, minHeight: 40, padding: '4px 10px', borderBottom: '1px solid var(--border-subtle)', flexShrink: 0 },
975
1208
  sessionBtn: { display: 'inline-flex', alignItems: 'center', gap: 7, height: 30, padding: '0 10px', borderRadius: 'var(--radius-sm)', background: 'var(--surface-2)', border: '1px solid var(--border-subtle)', color: 'var(--text-secondary)', cursor: 'pointer', maxWidth: 220, minWidth: 0, flexShrink: 1 },
976
- runChip: { display: 'inline-flex', alignItems: 'center', gap: 6, marginLeft: 8, height: 22, padding: '0 9px', borderRadius: 'var(--radius-pill, 999px)', background: 'var(--accent-soft)', border: '1px solid var(--border-accent)', color: 'var(--accent)', fontSize: 11.5, fontWeight: 600, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' },
977
1209
  sessionName: { fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
978
1210
  iconBtn: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, border: 0, borderRadius: 'var(--radius-sm)', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer' },
979
1211
  iconBtnActive: { background: 'var(--accent-soft)', color: 'var(--accent)' },
980
1212
  menu: { position: 'absolute', top: 'calc(100% + 4px)', left: 10, width: 280, maxWidth: 'calc(100vw - 20px)', background: 'var(--surface-1)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-4)', padding: 6, zIndex: 200 },
981
1213
  menuItem: { display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 9px', border: 0, borderRadius: 'var(--radius-sm)', background: 'transparent', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13, fontWeight: 500 },
1214
+ /* The menu's items inside the phone sheet: the sheet supplies the surface,
1215
+ * the edges and the scrolling. */
1216
+ ctxSheetBody: { display: 'flex', flexDirection: 'column', gap: 2, padding: '0 12px 12px' },
1217
+ menuItemTall: { display: 'flex', alignItems: 'center', gap: 8, width: '100%', minHeight: 40, padding: '6px 9px', border: 0, borderRadius: 'var(--radius-sm)', background: 'transparent', color: 'var(--text-primary)', cursor: 'pointer', fontSize: 13, fontWeight: 500 },
1218
+ menuItemText: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2, textAlign: 'left' },
1219
+ menuItemSub: { fontSize: 12, fontWeight: 400, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
1220
+ switchTrack: { flexShrink: 0, position: 'relative', width: 32, height: 18, borderRadius: 'var(--radius-pill)', background: 'var(--border-default)', transition: 'background var(--motion-fast, 150ms ease-out)' },
1221
+ switchTrackOn: { background: 'var(--accent)' },
1222
+ switchKnob: { position: 'absolute', top: 2, left: 2, width: 14, height: 14, borderRadius: 'var(--radius-pill)', background: 'var(--surface-1)', boxShadow: 'var(--shadow-1)', transition: 'transform var(--motion-base, 200ms cubic-bezier(0.2, 0.8, 0.2, 1))' },
1223
+ switchKnobOn: { transform: 'translateX(14px)' },
1224
+ menuOptions: { margin: '2px 2px 4px', padding: '10px 8px 4px', borderTop: '1px solid var(--border-subtle)' },
982
1225
  menuLabel: { fontSize: 'var(--fs-eyebrow)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)', padding: '8px 9px 4px' },
983
1226
  menuEmpty: { padding: '4px 9px 8px', fontSize: 12.5, color: 'var(--text-disabled)' },
984
1227
  menuRow: { display: 'flex', alignItems: 'center', gap: 4 },
@@ -1009,7 +1252,9 @@ const S = {
1009
1252
  followChip: { display: 'inline-flex', alignItems: 'center', maxWidth: '100%', padding: '5px 10px', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-pill)', background: 'transparent', color: 'var(--text-secondary)', fontSize: 12.5, lineHeight: 1.3, textAlign: 'left', cursor: 'pointer' },
1010
1253
  policyWarn: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12.5, color: 'var(--warning)', background: 'var(--warning-soft)', border: '1px solid var(--warning)', borderRadius: 'var(--radius-sm)', padding: '8px 10px' },
1011
1254
  policyStop: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12.5, color: 'var(--danger)', background: 'var(--danger-soft)', border: '1px solid var(--danger)', borderRadius: 'var(--radius-sm)', padding: '8px 10px' },
1012
- ctxMenu: { margin: '0 10px', padding: 8, background: 'var(--surface-2)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', display: 'flex', flexDirection: 'column', gap: 4 },
1255
+ dockWrap: { position: 'relative' },
1256
+ centerStart: { margin: 'auto 0', display: 'flex', flexDirection: 'column', gap: 20, padding: '24px 0' },
1257
+ ctxMenu: { position: 'absolute', left: 10, bottom: 'calc(100% - 4px)', zIndex: 200, minWidth: 240, boxShadow: 'var(--shadow-3)', padding: 8, background: 'var(--surface-2)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', display: 'flex', flexDirection: 'column', gap: 4 },
1013
1258
  perm: { border: '1px solid var(--warning)', borderRadius: 'var(--radius-md)', background: 'var(--warning-soft)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 },
1014
1259
  permHead: { display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' },
1015
1260
  // pre-wrap: a submit_plan summary is the whole multi-line plan being approved.
@@ -1023,8 +1268,6 @@ const S = {
1023
1268
  approvalSuggest: { border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', background: 'var(--surface-2)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 },
1024
1269
  // Per-run budget control — the run chip's soft note when a degrade breach
1025
1270
  // switched the in-flight model.
1026
- budgetWrap: { position: 'relative', display: 'flex', alignItems: 'center', gap: 8, padding: '0 10px 6px', flexShrink: 0 },
1027
- budgetDegraded: { fontSize: 11, color: 'var(--warning)' },
1028
1271
  uploadNotice: { display: 'flex', alignItems: 'center', gap: 8, margin: '0 10px 6px', padding: '7px 10px', border: '1px solid var(--warning)', borderRadius: 'var(--radius-sm)', background: 'var(--warning-soft)', color: 'var(--warning)', fontSize: 12.5 },
1029
1272
  // 44px tall: the one way out of an unsaved chat has to be easy to hit on a phone.
1030
1273
  unsavedRetry: { minHeight: 44, padding: '0 14px', border: '1px solid var(--warning)', borderRadius: 'var(--radius-sm)', background: 'transparent', color: 'inherit', fontSize: 12.5, fontWeight: 600, cursor: 'pointer', flexShrink: 0 },