@corenel/web-ui 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/AskUser.d.ts.map +1 -1
  2. package/dist/AskUser.js +3 -1
  3. package/dist/AskUser.js.map +1 -1
  4. package/dist/CommandTray.d.ts.map +1 -1
  5. package/dist/CommandTray.js +2 -1
  6. package/dist/CommandTray.js.map +1 -1
  7. package/dist/Composer.d.ts +30 -9
  8. package/dist/Composer.d.ts.map +1 -1
  9. package/dist/Composer.js +297 -231
  10. package/dist/Composer.js.map +1 -1
  11. package/dist/ContextBar.d.ts +4 -1
  12. package/dist/ContextBar.d.ts.map +1 -1
  13. package/dist/ContextBar.js +38 -2
  14. package/dist/ContextBar.js.map +1 -1
  15. package/dist/ContextManager.d.ts +10 -1
  16. package/dist/ContextManager.d.ts.map +1 -1
  17. package/dist/ContextManager.js +63 -26
  18. package/dist/ContextManager.js.map +1 -1
  19. package/dist/ContextStrip.d.ts +5 -1
  20. package/dist/ContextStrip.d.ts.map +1 -1
  21. package/dist/ContextStrip.js +4 -1
  22. package/dist/ContextStrip.js.map +1 -1
  23. package/dist/DictateButton.d.ts +26 -1
  24. package/dist/DictateButton.d.ts.map +1 -1
  25. package/dist/DictateButton.js +26 -10
  26. package/dist/DictateButton.js.map +1 -1
  27. package/dist/DockStatus.d.ts +11 -0
  28. package/dist/DockStatus.d.ts.map +1 -0
  29. package/dist/DockStatus.js +41 -0
  30. package/dist/DockStatus.js.map +1 -0
  31. package/dist/ExclusionsModal.d.ts +3 -0
  32. package/dist/ExclusionsModal.d.ts.map +1 -1
  33. package/dist/ExclusionsModal.js +28 -7
  34. package/dist/ExclusionsModal.js.map +1 -1
  35. package/dist/RichContent.d.ts.map +1 -1
  36. package/dist/RichContent.js +3 -1
  37. package/dist/RichContent.js.map +1 -1
  38. package/dist/StarterCards.d.ts +20 -0
  39. package/dist/StarterCards.d.ts.map +1 -0
  40. package/dist/StarterCards.js +30 -0
  41. package/dist/StarterCards.js.map +1 -0
  42. package/dist/SummaryTranscript.d.ts.map +1 -1
  43. package/dist/SummaryTranscript.js +4 -3
  44. package/dist/SummaryTranscript.js.map +1 -1
  45. package/dist/TodoBar.d.ts.map +1 -1
  46. package/dist/TodoBar.js +12 -6
  47. package/dist/TodoBar.js.map +1 -1
  48. package/dist/ToolFold.d.ts.map +1 -1
  49. package/dist/ToolFold.js +2 -1
  50. package/dist/ToolFold.js.map +1 -1
  51. package/dist/Transcript.d.ts +9 -1
  52. package/dist/Transcript.d.ts.map +1 -1
  53. package/dist/Transcript.js +41 -22
  54. package/dist/Transcript.js.map +1 -1
  55. package/dist/agentChat/AgentChat.d.ts +43 -7
  56. package/dist/agentChat/AgentChat.d.ts.map +1 -1
  57. package/dist/agentChat/AgentChat.js +282 -96
  58. package/dist/agentChat/AgentChat.js.map +1 -1
  59. package/dist/agentChat/OptionsDrawer.d.ts.map +1 -1
  60. package/dist/agentChat/OptionsDrawer.js +6 -3
  61. package/dist/agentChat/OptionsDrawer.js.map +1 -1
  62. package/dist/agentChat/PlanReview.d.ts.map +1 -1
  63. package/dist/agentChat/PlanReview.js +5 -3
  64. package/dist/agentChat/PlanReview.js.map +1 -1
  65. package/dist/agentChat/controller.d.ts +17 -0
  66. package/dist/agentChat/controller.d.ts.map +1 -1
  67. package/dist/agentChat/controller.js.map +1 -1
  68. package/dist/chatSkin.d.ts +20 -0
  69. package/dist/chatSkin.d.ts.map +1 -0
  70. package/dist/chatSkin.js +220 -0
  71. package/dist/chatSkin.js.map +1 -0
  72. package/dist/chatSkinContext.d.ts +55 -0
  73. package/dist/chatSkinContext.d.ts.map +1 -0
  74. package/dist/chatSkinContext.js +16 -0
  75. package/dist/chatSkinContext.js.map +1 -0
  76. package/dist/chatSkinCss.d.ts +3 -0
  77. package/dist/chatSkinCss.d.ts.map +1 -0
  78. package/dist/chatSkinCss.js +202 -0
  79. package/dist/chatSkinCss.js.map +1 -0
  80. package/dist/commandTurn.d.ts +4 -1
  81. package/dist/commandTurn.d.ts.map +1 -1
  82. package/dist/commandTurn.js +13 -1
  83. package/dist/commandTurn.js.map +1 -1
  84. package/dist/contextCopy.d.ts +13 -0
  85. package/dist/contextCopy.d.ts.map +1 -0
  86. package/dist/contextCopy.js +20 -0
  87. package/dist/contextCopy.js.map +1 -0
  88. package/dist/credentialLines.d.ts +10 -0
  89. package/dist/credentialLines.d.ts.map +1 -0
  90. package/dist/credentialLines.js +46 -0
  91. package/dist/credentialLines.js.map +1 -0
  92. package/dist/sendLater/SendLater.d.ts +8 -1
  93. package/dist/sendLater/SendLater.d.ts.map +1 -1
  94. package/dist/sendLater/SendLater.js +16 -8
  95. package/dist/sendLater/SendLater.js.map +1 -1
  96. package/dist/toolRender.d.ts.map +1 -1
  97. package/dist/toolRender.js +14 -8
  98. package/dist/toolRender.js.map +1 -1
  99. package/dist/transcriptSkin.d.ts +23 -1
  100. package/dist/transcriptSkin.d.ts.map +1 -1
  101. package/dist/transcriptSkin.js +134 -9
  102. package/dist/transcriptSkin.js.map +1 -1
  103. package/dist/transcriptSkinCss.d.ts.map +1 -1
  104. package/dist/transcriptSkinCss.js +17 -10
  105. package/dist/transcriptSkinCss.js.map +1 -1
  106. package/package.json +5 -5
@@ -16,7 +16,9 @@ import { AskUser } from '../AskUser';
16
16
  import { Lightbox } from '../Lightbox';
17
17
  import { ContextBar } from '../ContextBar';
18
18
  import { useTranscriptView } from '../transcriptView';
19
- import { useTranscriptSkin, skinCommand } from '../transcriptSkin';
19
+ import { useTranscriptSkin, skinCommand, SKIN_REACH } from '../transcriptSkin';
20
+ import { ChatSkinRoot, CHAT_WORDS } from '../chatSkin';
21
+ import { plainName } from '../chatSkinContext';
20
22
  import { ToolFold } from '../ToolFold';
21
23
  import { useArrivalEffect, effectCommand, prefersReducedMotion, resolveArrivalEffect } from '../arrivalEffect';
22
24
  import { LiveAnswer } from '../fx/LiveAnswer';
@@ -35,9 +37,12 @@ import { isDeferredTool } from '@corenel/harness/tools/toolSearch';
35
37
  import { addLoadedTools } from '@corenel/tools-web/sessionLoadedTools';
36
38
  import { useContextDocs, addDoc, removeDoc, classifyUpload, looksBinary, ContextDocError, UPLOAD_ACCEPT } from '@corenel/tools-web/contextDocs';
37
39
  import { DictateButton } from '../DictateButton';
40
+ import { resolveDictation } from '@corenel/tools-web/speech/route';
38
41
  import { browserModelHost } from '@corenel/tools-web/speech/model';
39
42
  import { appendDictated } from './appendDictated';
40
43
  import { TodoBar } from '../TodoBar';
44
+ import { DockStatus } from '../DockStatus';
45
+ import { useTodos } from '@corenel/harness/todos';
41
46
  import { CommandTray, RunningCommand } from '../CommandTray';
42
47
  import { trayCommand, transcriptTurns } from '../commandTurn';
43
48
  import { composerHistory } from '../composerHistory';
@@ -46,7 +51,7 @@ import { fmtTok } from '@corenel/ui-kit/usage';
46
51
  import { isCheckpointTool } from '@corenel/protocol';
47
52
  import { PlanApprovalCard, SUBMIT_PLAN, planTextOf } from './PlanReview';
48
53
  import { useBrand } from '@corenel/ui-kit/host';
49
- import { IcPlus, IcZap, IcTrash, IcChevron, IcX, IcRefresh, IcKey, IcCheck, IcSliders, IcUser, IcFile, IcHelp, IcEdit, IcLayout, Spinner, LiveCursor } from '@corenel/ui-kit/icons';
54
+ 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';
50
55
  /* No NOOP default for onApplyEdit, deliberately. Transcript treats the prop's
51
56
  * ABSENCE as "this surface has nothing to apply an edit to" and renders the
52
57
  * review row as a statement instead of dead buttons. Substituting a no-op here
@@ -77,14 +82,14 @@ const PS = {
77
82
  textDone: { fontSize: 13, color: 'var(--text-tertiary)', textDecoration: 'line-through' },
78
83
  };
79
84
  export function AgentChat(props) {
80
- 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;
85
+ 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;
81
86
  const { name: brandName } = useBrand(); // host-injected product name (CorenelHost seam)
82
87
  // The engine owns the conversation, run loop, worker, and persistence — this
83
88
  // column is a view over ONE controller session. The active session id is
84
89
  // CONTROLLED by the host (sessionId/onSessionChange).
85
90
  const session = useControllerSession(controller, sessionId ?? '');
86
91
  const globalTick = useControllerGlobal(controller); // cross-session changes (titles, run badges)
87
- const { turns, input, mode, running, live, recovery, error, suggestions, attachments, pendingPerm, pendingAsk, approvalSuggestion, policyNotes, degradedModel, recEvents, plan, pendingCommand, loaded } = session;
92
+ const { turns, input, mode, running, live, recovery, error, suggestions, attachments, pendingPerm, pendingAsk, approvalSuggestion, policyNotes, degradedModel, recEvents, plan, pendingCommand, loaded, saveBlocked, mergeTick } = session;
88
93
  // Thin engine-routed setters so the render below reads like the old local-state
89
94
  // version (same call sites, engine-backed).
90
95
  const setInput = useCallback((v) => { if (sessionId)
@@ -155,7 +160,15 @@ export function AgentChat(props) {
155
160
  // eslint-disable-next-line react-hooks/exhaustive-deps
156
161
  }, [capabilities?.skills, capabilities?.tools]);
157
162
  const [ctxMenuOpen, setCtxMenuOpen] = useState(false);
163
+ /* The mic and send-later live in the "+" menu; these drive them from there.
164
+ * The mic's own Stop shows in the composer whenever it is busy. */
165
+ const dictControl = useRef(null);
166
+ const [laterOpen, setLaterOpen] = useState(false);
167
+ const todosNow = useTodos(sessionId ?? null);
168
+ const [dockEmpty, setDockEmpty] = useState(true);
169
+ const onDockEmpty = useCallback((e) => setDockEmpty(e), []);
158
170
  const fileInputRef = useRef(null);
171
+ const panelRef = useRef(null);
159
172
  const [uploadNotice, setUploadNotice] = useState(null);
160
173
  /* THE DRAFT AS IT STOOD WHEN THE MIC OPENED, and the whole trick behind live
161
174
  * dictation writing into the composer.
@@ -253,6 +266,41 @@ export function AgentChat(props) {
253
266
  // A rejection notice is about THIS session's upload attempt; don't let it
254
267
  // linger onto another chat.
255
268
  useEffect(() => { setUploadNotice(null); }, [sessionId]);
269
+ /* MERGED IN. A save found another writer (a second tab, the daemon) had
270
+ * changed this chat and merged its turns in, so the transcript just grew
271
+ * above where the person was reading. Shown when the tick goes UP while this
272
+ * chat is on screen -- never for a merge from before it was opened -- and
273
+ * dismissible, like the upload notice beside it. */
274
+ const [mergedNotice, setMergedNotice] = useState(false);
275
+ const mergeSeen = useRef(null);
276
+ useEffect(() => {
277
+ const tick = mergeTick ?? 0;
278
+ const prev = mergeSeen.current;
279
+ mergeSeen.current = { id: sessionId ?? '', tick };
280
+ if (prev && prev.id === (sessionId ?? '')) {
281
+ if (tick > prev.tick)
282
+ setMergedNotice(true);
283
+ }
284
+ else {
285
+ setMergedNotice(false);
286
+ }
287
+ }, [sessionId, mergeTick]);
288
+ /* The unsaved-chat notice's Retry. Pending while the read and save run, so
289
+ * a second tap cannot start a second retry; a rejection is swallowed here
290
+ * because the notice itself is the report -- it stays until a save lands. */
291
+ const [retrying, setRetrying] = useState(false);
292
+ const retryLoad = useCallback(async () => {
293
+ if (!sessionId || !controller.retryLoad)
294
+ return;
295
+ setRetrying(true);
296
+ try {
297
+ await controller.retryLoad(sessionId);
298
+ }
299
+ catch { /* the notice stays */ }
300
+ finally {
301
+ setRetrying(false);
302
+ }
303
+ }, [controller, sessionId]);
256
304
  const [selPolicy, setSelPolicy] = useState(getSelectedPolicyName);
257
305
  const selectPolicy = useCallback((name) => { setSelPolicy(name); setSelectedPolicyName(name); }, []);
258
306
  const policyName = fixedIdentity?.policyName ?? selPolicy;
@@ -343,7 +391,8 @@ export function AgentChat(props) {
343
391
  return () => clearInterval(t);
344
392
  }, [running]);
345
393
  const newSession = async () => {
346
- const id = await controller.newSession();
394
+ // A draft: nothing is saved, or listed, until the first send.
395
+ const id = await (controller.newDraft ? controller.newDraft() : controller.newSession());
347
396
  onSessionChange(id);
348
397
  refreshSessions();
349
398
  setMenuOpen(false);
@@ -414,7 +463,7 @@ export function AgentChat(props) {
414
463
  const beforeExtra = [
415
464
  { name: 'new', hint: 'start a fresh chat', section: 'Session', icon: _jsx(IcPlus, { size: 13 }), kind: 'action' },
416
465
  { name: 'clear', hint: 'clear this conversation', section: 'Session', icon: _jsx(IcRefresh, { size: 13 }), kind: 'action' },
417
- { name: 'skin', hint: 'Change how the transcript looks', section: 'Session', icon: _jsx(IcLayout, { size: 13 }), kind: 'action' },
466
+ { name: 'skin', hint: 'Change how the chat looks', section: 'Session', icon: _jsx(IcLayout, { size: 13 }), kind: 'action' },
418
467
  { name: 'effect', hint: 'Change how new text arrives', section: 'Session', icon: _jsx(IcZap, { size: 13 }), kind: 'action' },
419
468
  ...(renderContextMenu ? [{ name: 'context', hint: 'attach files or selection', section: 'Workspace', icon: _jsx(IcFile, { size: 13 }), kind: 'action' }] : []),
420
469
  { name: 'persona', hint: 'switch the agent persona', section: 'Workspace', icon: _jsx(IcUser, { size: 13 }), kind: 'action' },
@@ -488,7 +537,7 @@ export function AgentChat(props) {
488
537
  void (async () => {
489
538
  if (!text || running)
490
539
  return;
491
- if (interceptSend && (await interceptSend(text)))
540
+ if (interceptSend && (await interceptSend(text, [])))
492
541
  return;
493
542
  await sendMessage(text, []);
494
543
  })();
@@ -596,12 +645,19 @@ export function AgentChat(props) {
596
645
  if (interceptSend) {
597
646
  const prior = input;
598
647
  setInput('');
599
- if (await interceptSend(text))
648
+ // The attachments go with the text, both ways: cleared now, restored if
649
+ // declined. Left behind, an image-mode send's screenshot sat in the
650
+ // composer after the image it was attached to had been made.
651
+ if (images.length)
652
+ setAttachments(() => []);
653
+ if (await interceptSend(text, images))
600
654
  return;
601
655
  // Declined: it was never ours to take. Restoring matters most for the
602
656
  // slash path below, where a 'prefilled' template is left in the composer
603
657
  // for review and an optimistic clear would have wiped it.
604
658
  setInput(prior);
659
+ if (images.length)
660
+ setAttachments(() => images);
605
661
  }
606
662
  if (text.startsWith('/') && images.length === 0) {
607
663
  const [cmd, ...rest] = text.slice(1).split(' ');
@@ -815,6 +871,11 @@ export function AgentChat(props) {
815
871
  // eslint-disable-next-line react-hooks/exhaustive-deps
816
872
  }), [sessionId, session.turns]);
817
873
  const menuEnabled = features?.sessionMenu !== false && !readOnly;
874
+ // Full-bleed surface, capped content: the panel and skin paint fill the stage
875
+ // while the header row, body content and dock share one centred column.
876
+ const cap = contentMaxWidth
877
+ ? { width: '100%', maxWidth: contentMaxWidth, marginInline: 'auto', boxSizing: 'border-box' }
878
+ : null;
818
879
  // Defaults true: every existing surface runs our loop, and only a proxied
819
880
  // engine opts out.
820
881
  const ownLoop = features?.ownLoop !== false;
@@ -823,104 +884,223 @@ export function AgentChat(props) {
823
884
  // A host card per live tool, built from the same call shape the settled
824
885
  // transcript hands renderToolCard, so a card does not change on settle. A
825
886
  // non-null card wins in every skin and effect: never folded, never a board row.
887
+ // The result goes along once the tool is done, as the settled transcript does.
826
888
  const liveCards = new Map();
827
889
  for (const t of live?.tools ?? []) {
828
- const card = renderToolCard?.({ id: t.id, type: 'function', function: { name: t.name, arguments: JSON.stringify(t.args || {}) } });
890
+ 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);
829
891
  if (card)
830
892
  liveCards.set(t.id, card);
831
893
  }
832
- return (_jsxs("div", { style: S.panel, children: [_jsxs("header", { style: S.head, 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) {
833
- e.stopPropagation();
834
- startRename(sessionId);
835
- } }, 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)))] }))] }), 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
836
- ? (readOnly ? null : emptyState?.({ setInput }))
837
- : _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) => ({
838
- name: t.name, args: t.args || {}, result: t.result ?? '', isError: t.isError, running: !t.done, ms: t.ms,
839
- })), view: transcriptView })), live.tools.filter((t) => (fx !== 'splitflap' && transcriptSkin !== 'minimal') || t.name === 'propose_edit' || liveCards.has(t.id)).map((t) => liveCards.has(t.id) ? (
840
- // A host card replaces the default card mid-run too.
841
- _jsx(React.Fragment, { children: liveCards.get(t.id) }, t.id)) : t.name === 'propose_edit' ? (
842
- // propose_edit BLOCKS its run on the review, so the decidable card
843
- // must render here, mid-run — not only in the settled transcript.
844
- _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 ? (
845
- // A tool that finished mid-run gets the SAME expandable card as the
846
- // settled transcript, so its args/result are viewable before the
847
- // turn ends (not just a bare summary line).
848
- // data-fx-id stays the same across running -> done, so an effect
849
- // treats the swap as the same tool line (one rock, one animation).
850
- _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)) : (
851
- // The name animates once, here, when the tool appears.
852
- _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, children: [_jsxs("div", { style: S.permHead, children: [_jsx(IcKey, { size: 14 }), " ", pendingPerm.tool === 'budget' ? 'Budget limit reached' : 'Permission needed'] }), _jsx("div", { style: S.permBody, children: pendingPerm.tool === 'budget'
853
- ? pendingPerm.summary
854
- : (_jsxs(_Fragment, { children: [pendingPerm.requester === 'you'
855
- ? 'Your command needs'
856
- : pendingPerm.agent
857
- ? _jsxs(_Fragment, { children: [_jsx("strong", { children: pendingPerm.agent }), " (called by ", pendingPerm.caller ?? 'this agent', ") wants to use"] })
858
- : '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, onClick: () => resolvePerm(false), children: "Stop" }), _jsxs("button", { style: S.permAllow, onClick: () => resolvePerm(true), children: [_jsx(IcCheck, { size: 12 }), " Continue"] })] })) : (_jsxs(_Fragment, { children: [_jsx("button", { style: S.permDeny, onClick: () => resolvePerm(false), children: "Deny" }), !isCheckpointTool(pendingPerm.tool) && (_jsx("button", { style: S.permAllowSession, onClick: () => resolvePerm(true, true), children: "Allow for session" })), _jsxs("button", { style: S.permAllow, onClick: () => resolvePerm(true), children: [_jsx(IcCheck, { size: 12 }), " Allow once"] })] })) })] })), approvalSuggestion && !pendingPerm && (_jsxs("div", { style: S.approvalSuggest, role: "status", "aria-live": "polite", 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 && (_jsx("div", { style: S.followRow, children: 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) => {
859
- const files = Array.from(e.target.files ?? []);
860
- e.target.value = '';
861
- setCtxMenuOpen(false);
862
- void processFiles(files);
863
- } }), 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)
894
+ /* The skin reaches past the transcript only for skins whose SKIN_REACH says
895
+ * so. The header and the options drawer sit inside the root, in their
896
+ * original order, for every skin: an unskinned chat's DOM (and so its tab
897
+ * order) is as before, a skin change restyles in place rather than
898
+ * remounting, and a drawer opened from a skinned header looks like it. The
899
+ * one exception is a chat-reach skin that leaves its header out: the header
900
+ * then renders above the root. The lightbox, a neutral image viewer, stays
901
+ * outside and keeps the app theme. */
902
+ const skinReach = SKIN_REACH[transcriptSkin];
903
+ const headerOutside = skinReach.chat && !skinReach.header;
904
+ const chatWords = skinReach.chat ? CHAT_WORDS[transcriptSkin] ?? {} : {};
905
+ const HeadRow = cap ? 'div' : 'header';
906
+ 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) {
907
+ e.stopPropagation();
908
+ startRename(sessionId);
909
+ } }, 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)))] }))] }));
910
+ const isPhone = useMatchMedia('(max-width: 640px)');
911
+ const switchRef = useRef(switchSession);
912
+ switchRef.current = switchSession;
913
+ const lastSession = useMemo(() => {
914
+ const best = sessionList
915
+ .filter((s) => s.id !== sessionId && typeof s.updatedAt === 'number' && (s.messages === undefined || s.messages.length > 0))
916
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
917
+ return best ? { title: best.title || 'Untitled', updatedAt: best.updatedAt ?? 0, open: () => switchRef.current(best.id) } : undefined;
918
+ }, [sessionList, sessionId]);
919
+ const showEmpty = turns.length === 0 && !live && loaded !== false && !readOnly;
920
+ const centered = startLayout === 'centered' && showEmpty && !isPhone;
921
+ const wasCentered = useRef(centered);
922
+ /* Moving the dock between the centred start and the panel foot REMOUNTS the
923
+ * composer (a new parent), so whatever focus it held is gone by the time any
924
+ * effect runs. Whether it had focus is read here, during the render that
925
+ * flips `centered` and before React touches the DOM; the old textarea is
926
+ * still the one in the document. */
927
+ const composerHadFocus = useRef(false);
928
+ if (centered !== wasCentered.current && typeof document !== 'undefined') {
929
+ const old = panelRef.current?.querySelector('[data-chat-part="composer"] textarea');
930
+ composerHadFocus.current = !!old && old === document.activeElement;
931
+ }
932
+ useEffect(() => {
933
+ const focusComposer = () => requestAnimationFrame(() => panelRef.current?.querySelector('[data-chat-part="composer"] textarea')?.focus());
934
+ // Centred -> docked once the chat has turns: the first send, and also a
935
+ // session switch into a chat that already has turns. Both put the caret in
936
+ // the box, intentionally -- the next thing you do is type.
937
+ if (wasCentered.current && !centered && (turns.length > 0 || live))
938
+ focusComposer();
939
+ // Docked -> centred (a new or emptied chat): keep the caret only if it was
940
+ // already in the composer; never steal focus from elsewhere on the page.
941
+ else if (!wasCentered.current && centered && composerHadFocus.current)
942
+ focusComposer();
943
+ wasCentered.current = centered;
944
+ composerHadFocus.current = false;
945
+ // turns/live are read only on the centred -> docked transition itself.
946
+ // eslint-disable-next-line react-hooks/exhaustive-deps
947
+ }, [centered]);
948
+ /* Escape closes the "+" menu and hands focus back to the "+" button that
949
+ * opened it, the way any menu behaves. Listening only while it is open. */
950
+ useEffect(() => {
951
+ if (!ctxMenuOpen)
952
+ return;
953
+ const onKey = (e) => {
954
+ if (e.key !== 'Escape')
955
+ return;
956
+ setCtxMenuOpen(false);
957
+ panelRef.current?.querySelector('[data-testid="composer-add"]')?.focus();
958
+ };
959
+ document.addEventListener('keydown', onKey);
960
+ return () => document.removeEventListener('keydown', onKey);
961
+ }, [ctxMenuOpen]);
962
+ const headerEl = cap ? _jsx("header", { style: S.headOuter, children: headerRow }) : headerRow;
963
+ const composerEl = (_jsx(Composer, { value: input, onChange: setInput, docked: !centered,
964
+ // The mic goes in the bottom row's FIXED first position, not the chip
965
+ // row. It was in the chip row first, and TodoBar shoved it sideways
966
+ // every time the agent's current task changed -- a control that moves
967
+ // while the agent works is a control people misclick, and this one's
968
+ // worst misclick is "I thought I stopped recording". It stays in the
969
+ // composer either way, which is what keeps it reachable on a phone
970
+ // (the chat header is not -- see composerSlot.test.ts).
971
+ //
972
+ // Rendered here rather than passed in by each host: dictation belongs to
973
+ // the agent chat, not to any one surface, and four separate wirings
974
+ // would mean four chances to ship a surface that is silently mute.
975
+ dictating: dictating, leadSlot: _jsx(DictateButton, { host: browserModelHost(),
976
+ // Started from the "+" menu; shows itself (Stop, progress, consent,
977
+ // errors) only while busy, always in this same first slot.
978
+ hideWhenIdle: true, controlRef: dictControl,
979
+ // Browser or paired sidecar, chosen when a dictation starts.
980
+ backend: () => { const r = resolveDictation(); return { host: r.host, createDictation: r.createDictation, supported: r.where !== 'none' }; },
981
+ // Capture the draft the moment the mic opens; every guess below joins
982
+ // onto THIS, never onto the previous guess.
983
+ onListeningChange: (on) => { if (on)
984
+ dictationBase.current = inputRef.current; setDictating(on); },
985
+ /* Live guesses go straight into the draft -- that is the feature.
986
+ EMPTY IS IGNORED, and that is load-bearing: DictateButton clears the
987
+ guess with onInterim('') AFTER stop() resolves, which is after the
988
+ final transcript has already landed. Honouring it would recompute
989
+ `base + ''` and wipe the committed text a beat after showing it. */
990
+ onInterim: (t) => { if (t)
991
+ setInput(appendDictated(dictationBase.current, t)); }, onFinal: (t) => setInput(appendDictated(dictationBase.current, t)) }), onSend: send, onStop: () => { stoppedRef.current = true; if (sessionId)
992
+ controller.stop(sessionId); }, running: running, mode: mode, onModeChange: setMode, modes: modeEntries, chips: [
993
+ ...(chips ?? []),
994
+ ...contextDocs.map((d) => ({ id: `doc:${d.id}`, label: `${d.name} · ${fmtTok(d.tokens)} tok`, removable: true })),
995
+ ], onRemoveChip: (cid) => {
996
+ if (cid.startsWith('doc:')) {
997
+ if (sessionId)
998
+ removeDoc(sessionId, cid.slice(4));
999
+ }
1000
+ else
1001
+ onRemoveChip?.(cid);
1002
+ }, onAddContext: () => setCtxMenuOpen((o) => !o),
1003
+ // The agent's plan, above the composer. prompd.app also has a dock panel
1004
+ // for it; on corenel.ai this is the ONLY place it is visible. Renders
1005
+ // nothing when there are no tasks.
1006
+ // The host's own controls sit BEFORE the task list, so their position
1007
+ // stays put: TodoBar renders nothing when there are no tasks, and a
1008
+ // control that moves as the agent starts and finishes work is a
1009
+ // control people misclick.
1010
+ toggleChips: (composerToggles ?? []).filter((t) => t.on).map((t) => ({
1011
+ id: t.id, label: t.label, icon: t.icon, ...(t.title ? { title: t.title } : {}), onRemove: t.onToggle,
1012
+ })), 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')
1013
+ 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)
1014
+ removePending(sessionId, id); }, onEditPending: (id, text) => { if (sessionId)
1015
+ 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 })) }));
1016
+ /* Everything that sits directly above the dock: notices, a pending question,
1017
+ * the host's aboveComposer, a command in flight and the console tray. It
1018
+ * travels WITH the dock -- into the centred start when the chat is empty,
1019
+ * at the panel foot otherwise -- so it is never stranded far from the box
1020
+ * it belongs to. The read-only bar is not part of it (centred never happens
1021
+ * in read-only). */
1022
+ 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)
864
1023
  controller.resolveAsk(sessionId, answers); }, onCancel: () => { if (sessionId)
865
- controller.resolveAsk(sessionId, null); } })), degradedModel && (_jsx("div", { style: S.budgetWrap, children: _jsxs("span", { style: S.budgetDegraded, children: ["Degraded to ", degradedModel, " (budget)"] }) })), readOnly ? (
866
- /* WATCH MODE: the whole input region is replaced, not disabled piecemeal.
867
- A disabled box with live chips around it still reads as "type here". */
868
- _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)
869
- controller.toggleInclude(sessionId, turnId); }, onDismiss: (turnId) => setDismissed((d) => new Set(d).add(turnId)) }), _jsx(Composer, { value: input, onChange: setInput,
870
- // The mic goes in the bottom row's FIXED first position, not the chip
871
- // row. It was in the chip row first, and TodoBar shoved it sideways
872
- // every time the agent's current task changed -- a control that moves
873
- // while the agent works is a control people misclick, and this one's
874
- // worst misclick is "I thought I stopped recording". It stays in the
875
- // composer either way, which is what keeps it reachable on a phone
876
- // (the chat header is not -- see composerSlot.test.ts).
877
- //
878
- // Rendered here rather than passed in by each host: dictation belongs to
879
- // the agent chat, not to any one surface, and four separate wirings
880
- // would mean four chances to ship a surface that is silently mute.
881
- dictating: dictating, leadSlot: _jsx(DictateButton, { host: browserModelHost(),
882
- // Capture the draft the moment the mic opens; every guess below joins
883
- // onto THIS, never onto the previous guess.
884
- onListeningChange: (on) => { if (on)
885
- dictationBase.current = inputRef.current; setDictating(on); },
886
- /* Live guesses go straight into the draft -- that is the feature.
887
- EMPTY IS IGNORED, and that is load-bearing: DictateButton clears the
888
- guess with onInterim('') AFTER stop() resolves, which is after the
889
- final transcript has already landed. Honouring it would recompute
890
- `base + ''` and wipe the committed text a beat after showing it. */
891
- onInterim: (t) => { if (t)
892
- setInput(appendDictated(dictationBase.current, t)); }, onFinal: (t) => setInput(appendDictated(dictationBase.current, t)) }), onSend: send, onStop: () => { stoppedRef.current = true; if (sessionId)
893
- controller.stop(sessionId); }, running: running, mode: mode, onModeChange: setMode, modes: modeEntries, chips: [
894
- ...(chips ?? []),
895
- ...contextDocs.map((d) => ({ id: `doc:${d.id}`, label: `${d.name} · ${fmtTok(d.tokens)} tok`, removable: true })),
896
- ], onRemoveChip: (cid) => {
897
- if (cid.startsWith('doc:')) {
898
- if (sessionId)
899
- removeDoc(sessionId, cid.slice(4));
900
- }
901
- else
902
- onRemoveChip?.(cid);
903
- }, onAddContext: () => setCtxMenuOpen((o) => !o),
904
- // The agent's plan, above the composer. prompd.app also has a dock panel
905
- // for it; on corenel.ai this is the ONLY place it is visible. Renders
906
- // nothing when there are no tasks.
907
- // The host's own controls sit BEFORE the task list, so their position
908
- // stays put: TodoBar renders nothing when there are no tasks, and a
909
- // control that moves as the agent starts and finishes work is a
910
- // control people misclick.
911
- rightSlot: _jsxs(_Fragment, { children: [composerRightSlot, _jsx(TodoBar, { sessionId: sessionId })] }), slashCommands: slashCommands, onPickSlash: (name) => { if (runSlash(name) !== 'prefilled')
912
- 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)
913
- removePending(sessionId, id); }, onEditPending: (id, text) => { if (sessionId)
914
- 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) })] }));
1024
+ 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)
1025
+ controller.toggleInclude(sessionId, turnId); }, onDismiss: (turnId) => setDismissed((d) => new Set(d).add(turnId)) })] }))] }));
1026
+ const dockEl = (_jsxs("div", { style: S.dockWrap, children: [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"] }), slashCommands.length > 0 && (_jsxs("button", { style: S.menuItem, onClick: () => {
1027
+ setCtxMenuOpen(false);
1028
+ setInput('/');
1029
+ requestAnimationFrame(() => panelRef.current?.querySelector('[data-chat-part="composer"] textarea')?.focus());
1030
+ }, children: [_jsx(IcCode, { size: 13 }), " Commands"] })), _jsx("div", { style: S.menuLabel, children: "Tools" }), (() => {
1031
+ const route = resolveDictation();
1032
+ const label = dictating ? 'Stop dictating'
1033
+ : route.where === 'sidecar' ? (route.tunnel ? 'Dictate (on your sidecar, via tunnel)' : 'Dictate (on your sidecar)')
1034
+ : 'Dictate';
1035
+ const unsupported = dictControl.current?.supported === false;
1036
+ return (_jsxs("button", { style: S.menuItem, disabled: !dictating && (route.where === 'none' || unsupported), title: dictating ? undefined : route.where === 'none' ? route.reason
1037
+ : 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] }));
1038
+ })(), _jsxs("button", { style: S.menuItem, 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) => (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": t.on, style: S.menuItem, 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)))] })), composerEl] }));
1039
+ 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
1040
+ ? (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 })] }))))
1041
+ : _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) => ({
1042
+ name: t.name, args: t.args || {}, result: t.result ?? '', isError: t.isError, running: !t.done, ms: t.ms,
1043
+ })), view: transcriptView })), live.tools.filter((t) => (fx !== 'splitflap' && transcriptSkin !== 'minimal') || t.name === 'propose_edit' || liveCards.has(t.id)).map((t) => liveCards.has(t.id) ? (
1044
+ // A host card replaces the default card mid-run too.
1045
+ _jsx(React.Fragment, { children: liveCards.get(t.id) }, t.id)) : t.name === 'propose_edit' ? (
1046
+ // propose_edit BLOCKS its run on the review, so the decidable card
1047
+ // must render here, mid-run — not only in the settled transcript.
1048
+ _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 ? (
1049
+ // A tool that finished mid-run gets the SAME expandable card as the
1050
+ // settled transcript, so its args/result are viewable before the
1051
+ // turn ends (not just a bare summary line).
1052
+ // data-fx-id stays the same across running -> done, so an effect
1053
+ // treats the swap as the same tool line (one rock, one animation).
1054
+ _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)) : (
1055
+ // The name animates once, here, when the tool appears.
1056
+ _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)
1057
+ ? { role: 'group', 'aria-label': pendingPerm.tool === 'budget' ? 'Budget limit reached' : 'Permission needed' }
1058
+ : {}), children: [_jsx("div", { style: S.permHead, "data-chat-part": "perm-head", children: pendingPerm.tool === 'budget'
1059
+ ? (chatWords.budgetTitle ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Budget limit reached"] }))
1060
+ : (chatWords.permTitle?.(pendingPerm.tool) ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Permission needed"] })) }), _jsx("div", { style: S.permBody, children: pendingPerm.tool === 'budget'
1061
+ ? pendingPerm.summary
1062
+ : (_jsxs(_Fragment, { children: [pendingPerm.requester === 'you'
1063
+ ? 'Your command needs'
1064
+ : pendingPerm.agent
1065
+ ? _jsxs(_Fragment, { children: [_jsx("strong", { children: pendingPerm.agent }), " (called by ", pendingPerm.caller ?? 'this agent', ") wants to use"] })
1066
+ : '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) => {
1067
+ const files = Array.from(e.target.files ?? []);
1068
+ e.target.value = '';
1069
+ setCtxMenuOpen(false);
1070
+ void processFiles(files);
1071
+ } }), _jsxs(CapBox, { cap: cap, children: [centered ? null : noticesEl, readOnly ? (
1072
+ /* WATCH MODE: the whole input region is replaced, not disabled piecemeal.
1073
+ A disabled box with live chips around it still reads as "type here". */
1074
+ _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) })] }));
1075
+ }
1076
+ function useMatchMedia(q) {
1077
+ const get = () => typeof matchMedia === 'function' && matchMedia(q).matches;
1078
+ const [m, setM] = useState(get);
1079
+ useEffect(() => {
1080
+ if (typeof matchMedia !== 'function')
1081
+ return;
1082
+ const mq = matchMedia(q);
1083
+ const on = () => setM(mq.matches);
1084
+ on();
1085
+ mq.addEventListener?.('change', on);
1086
+ return () => mq.removeEventListener?.('change', on);
1087
+ }, [q]);
1088
+ return m;
915
1089
  }
916
1090
  // Session-row style is keyed by "active" — kept out of S (which is a flat style
917
1091
  // map) and typed as its own helper.
1092
+ function CapBox({ cap, extra, children }) {
1093
+ return cap ? _jsx("div", { "data-content-cap": "", style: { ...cap, ...extra }, children: children }) : _jsx(_Fragment, { children: children });
1094
+ }
918
1095
  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' });
919
1096
  const S = {
920
1097
  panel: { position: 'relative', display: 'flex', flexDirection: 'column', flex: 1, width: '100%', minWidth: 0, height: '100%', background: 'var(--surface-1)', overflow: 'hidden' },
921
- 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 },
1098
+ // Full-width shell for a capped header row; the rule stays on the capped row so it follows the column.
1099
+ headOuter: { position: 'relative', flexShrink: 0, borderBottom: 'none' },
1100
+ // Same flow as S.body's own, for the capped inner wrapper.
1101
+ bodyInner: { display: 'flex', flexDirection: 'column', gap: 16, flex: '1 0 auto' },
1102
+ 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 },
922
1103
  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 },
923
- 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' },
924
1104
  sessionName: { fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
925
1105
  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' },
926
1106
  iconBtnActive: { background: 'var(--accent-soft)', color: 'var(--accent)' },
@@ -931,6 +1111,9 @@ const S = {
931
1111
  menuRow: { display: 'flex', alignItems: 'center', gap: 4 },
932
1112
  menuDel: { display: 'inline-flex', width: 26, height: 26, alignItems: 'center', justifyContent: 'center', border: 0, borderRadius: 'var(--radius-sm)', background: 'transparent', color: 'var(--text-disabled)', cursor: 'pointer' },
933
1113
  renameInput: { flex: 1, minWidth: 0, maxWidth: 240, height: 28, padding: '0 9px', boxSizing: 'border-box', borderRadius: 'var(--radius-sm)', background: 'var(--surface-1)', border: '1px solid var(--border-accent)', color: 'var(--text-primary)', fontSize: 13, fontWeight: 600, fontFamily: 'inherit', outline: 'none' },
1114
+ // Holds everything the chat skin can reach. Same flex role the panel's own
1115
+ // children had, so an unskinned chat lays out exactly as before.
1116
+ skinBody: { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' },
934
1117
  chatRegion: { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' },
935
1118
  body: { flex: 1, minHeight: 0, overflowY: 'auto', padding: 14, display: 'flex', flexDirection: 'column', gap: 16 },
936
1119
  // Wraps the settled Transcript + live-run block so a host-injected LiveTimeline
@@ -949,10 +1132,13 @@ const S = {
949
1132
  error: { fontSize: 12.5, color: 'var(--danger)', background: 'var(--danger-soft)', border: '1px solid var(--danger)', borderRadius: 'var(--radius-sm)', padding: '8px 10px' },
950
1133
  // Follow-up chips under the latest reply — click to prefill the composer.
951
1134
  followRow: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 },
1135
+ followLabel: { alignSelf: 'center', fontSize: 10.5, letterSpacing: '0.14em', color: 'var(--text-tertiary)' },
952
1136
  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' },
953
1137
  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' },
954
1138
  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' },
955
- 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 },
1139
+ dockWrap: { position: 'relative' },
1140
+ centerStart: { margin: 'auto 0', display: 'flex', flexDirection: 'column', gap: 20, padding: '24px 0' },
1141
+ 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 },
956
1142
  perm: { border: '1px solid var(--warning)', borderRadius: 'var(--radius-md)', background: 'var(--warning-soft)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 },
957
1143
  permHead: { display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' },
958
1144
  // pre-wrap: a submit_plan summary is the whole multi-line plan being approved.
@@ -966,9 +1152,9 @@ const S = {
966
1152
  approvalSuggest: { border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', background: 'var(--surface-2)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 },
967
1153
  // Per-run budget control — the run chip's soft note when a degrade breach
968
1154
  // switched the in-flight model.
969
- budgetWrap: { position: 'relative', display: 'flex', alignItems: 'center', gap: 8, padding: '0 10px 6px', flexShrink: 0 },
970
- budgetDegraded: { fontSize: 11, color: 'var(--warning)' },
971
1155
  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 },
1156
+ // 44px tall: the one way out of an unsaved chat has to be easy to hit on a phone.
1157
+ 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 },
972
1158
  uploadNoticeX: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, border: 0, borderRadius: 'var(--radius-sm)', background: 'transparent', color: 'inherit', cursor: 'pointer', flexShrink: 0 },
973
1159
  readOnlyBar: { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderTop: '1px solid var(--border-subtle)', background: 'var(--surface-2)', flexShrink: 0 },
974
1160
  readOnlyBanner: { flex: 1, minWidth: 0, fontSize: 13, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },