@corenel/web-ui 0.6.0 → 0.8.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 (108) hide show
  1. package/dist/Composer.d.ts.map +1 -1
  2. package/dist/Composer.js +184 -172
  3. package/dist/Composer.js.map +1 -1
  4. package/dist/ContextBar.d.ts +1 -4
  5. package/dist/ContextBar.d.ts.map +1 -1
  6. package/dist/ContextBar.js +2 -2
  7. package/dist/ContextBar.js.map +1 -1
  8. package/dist/ContextManager.d.ts +3 -6
  9. package/dist/ContextManager.d.ts.map +1 -1
  10. package/dist/ContextManager.js +58 -10
  11. package/dist/ContextManager.js.map +1 -1
  12. package/dist/SummaryTranscript.d.ts +11 -1
  13. package/dist/SummaryTranscript.d.ts.map +1 -1
  14. package/dist/SummaryTranscript.js +47 -10
  15. package/dist/SummaryTranscript.js.map +1 -1
  16. package/dist/ToolFold.d.ts +9 -0
  17. package/dist/ToolFold.d.ts.map +1 -0
  18. package/dist/ToolFold.js +32 -0
  19. package/dist/ToolFold.js.map +1 -0
  20. package/dist/Transcript.d.ts +30 -1
  21. package/dist/Transcript.d.ts.map +1 -1
  22. package/dist/Transcript.js +132 -23
  23. package/dist/Transcript.js.map +1 -1
  24. package/dist/agentChat/AgentChat.d.ts +11 -3
  25. package/dist/agentChat/AgentChat.d.ts.map +1 -1
  26. package/dist/agentChat/AgentChat.js +174 -86
  27. package/dist/agentChat/AgentChat.js.map +1 -1
  28. package/dist/agentChat/OptionsDrawer.d.ts +0 -23
  29. package/dist/agentChat/OptionsDrawer.d.ts.map +1 -1
  30. package/dist/agentChat/OptionsDrawer.js +10 -45
  31. package/dist/agentChat/OptionsDrawer.js.map +1 -1
  32. package/dist/agentChat/PlanReview.d.ts +24 -0
  33. package/dist/agentChat/PlanReview.d.ts.map +1 -0
  34. package/dist/agentChat/PlanReview.js +103 -0
  35. package/dist/agentChat/PlanReview.js.map +1 -0
  36. package/dist/agentChat/SkillsSection.js +4 -2
  37. package/dist/agentChat/SkillsSection.js.map +1 -1
  38. package/dist/agentChat/controller.d.ts +20 -2
  39. package/dist/agentChat/controller.d.ts.map +1 -1
  40. package/dist/agentChat/controller.js.map +1 -1
  41. package/dist/arrivalEffect.d.ts +28 -0
  42. package/dist/arrivalEffect.d.ts.map +1 -0
  43. package/dist/arrivalEffect.js +114 -0
  44. package/dist/arrivalEffect.js.map +1 -0
  45. package/dist/commandTurn.d.ts.map +1 -1
  46. package/dist/commandTurn.js +3 -2
  47. package/dist/commandTurn.js.map +1 -1
  48. package/dist/fx/AsteroidsOverlay.d.ts +6 -0
  49. package/dist/fx/AsteroidsOverlay.d.ts.map +1 -0
  50. package/dist/fx/AsteroidsOverlay.js +166 -0
  51. package/dist/fx/AsteroidsOverlay.js.map +1 -0
  52. package/dist/fx/FlapRow.d.ts +8 -0
  53. package/dist/fx/FlapRow.d.ts.map +1 -0
  54. package/dist/fx/FlapRow.js +12 -0
  55. package/dist/fx/FlapRow.js.map +1 -0
  56. package/dist/fx/FxText.d.ts +11 -0
  57. package/dist/fx/FxText.d.ts.map +1 -0
  58. package/dist/fx/FxText.js +50 -0
  59. package/dist/fx/FxText.js.map +1 -0
  60. package/dist/fx/LiveAnswer.d.ts +17 -0
  61. package/dist/fx/LiveAnswer.d.ts.map +1 -0
  62. package/dist/fx/LiveAnswer.js +298 -0
  63. package/dist/fx/LiveAnswer.js.map +1 -0
  64. package/dist/fx/MatrixRain.d.ts +5 -0
  65. package/dist/fx/MatrixRain.d.ts.map +1 -0
  66. package/dist/fx/MatrixRain.js +73 -0
  67. package/dist/fx/MatrixRain.js.map +1 -0
  68. package/dist/fx/NetsplitJoin.d.ts +3 -0
  69. package/dist/fx/NetsplitJoin.d.ts.map +1 -0
  70. package/dist/fx/NetsplitJoin.js +13 -0
  71. package/dist/fx/NetsplitJoin.js.map +1 -0
  72. package/dist/fx/arrivalModes.d.ts +27 -0
  73. package/dist/fx/arrivalModes.d.ts.map +1 -0
  74. package/dist/fx/arrivalModes.js +107 -0
  75. package/dist/fx/arrivalModes.js.map +1 -0
  76. package/dist/fx/tail.d.ts +16 -0
  77. package/dist/fx/tail.d.ts.map +1 -0
  78. package/dist/fx/tail.js +54 -0
  79. package/dist/fx/tail.js.map +1 -0
  80. package/dist/fx/useReveal.d.ts +2 -0
  81. package/dist/fx/useReveal.d.ts.map +1 -0
  82. package/dist/fx/useReveal.js +59 -0
  83. package/dist/fx/useReveal.js.map +1 -0
  84. package/dist/observed.d.ts +16 -0
  85. package/dist/observed.d.ts.map +1 -0
  86. package/dist/observed.js +33 -0
  87. package/dist/observed.js.map +1 -0
  88. package/dist/toolRender.d.ts +6 -2
  89. package/dist/toolRender.d.ts.map +1 -1
  90. package/dist/toolRender.js +27 -17
  91. package/dist/toolRender.js.map +1 -1
  92. package/dist/transcriptSkin.d.ts +23 -0
  93. package/dist/transcriptSkin.d.ts.map +1 -0
  94. package/dist/transcriptSkin.js +255 -0
  95. package/dist/transcriptSkin.js.map +1 -0
  96. package/dist/transcriptSkinCss.d.ts +4 -0
  97. package/dist/transcriptSkinCss.d.ts.map +1 -0
  98. package/dist/transcriptSkinCss.js +136 -0
  99. package/dist/transcriptSkinCss.js.map +1 -0
  100. package/dist/usageSummary.d.ts +31 -0
  101. package/dist/usageSummary.d.ts.map +1 -0
  102. package/dist/usageSummary.js +39 -0
  103. package/dist/usageSummary.js.map +1 -0
  104. package/dist/verificationView.d.ts +3 -0
  105. package/dist/verificationView.d.ts.map +1 -0
  106. package/dist/verificationView.js +37 -0
  107. package/dist/verificationView.js.map +1 -0
  108. package/package.json +7 -6
@@ -7,17 +7,23 @@ 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
11
  import { Transcript, ToolLine, DiffProposal } from '../Transcript';
12
12
  import { ToolResultCard } from '../toolRender';
13
- import { Markdown } from '../RichContent';
14
13
  import { Composer } from '../Composer';
15
14
  import { SendLater, useSendLater } from '../sendLater/SendLater';
16
15
  import { AskUser } from '../AskUser';
17
16
  import { Lightbox } from '../Lightbox';
18
17
  import { ContextBar } from '../ContextBar';
19
18
  import { useTranscriptView } from '../transcriptView';
20
- import { OptionsDrawer, useMcpTools } from './OptionsDrawer';
19
+ import { useTranscriptSkin, skinCommand } from '../transcriptSkin';
20
+ import { ToolFold } from '../ToolFold';
21
+ import { useArrivalEffect, effectCommand, prefersReducedMotion, resolveArrivalEffect } from '../arrivalEffect';
22
+ import { LiveAnswer } from '../fx/LiveAnswer';
23
+ import { FlapRow } from '../fx/FlapRow';
24
+ import { MatrixRain } from '../fx/MatrixRain';
25
+ import { NetsplitJoin } from '../fx/NetsplitJoin';
26
+ import { OptionsDrawer } from './OptionsDrawer';
21
27
  import { useControllerSession, useControllerGlobal } from './controller';
22
28
  import { getSelectedPolicyName, setSelectedPolicyName } from '@corenel/harness/guardrail';
23
29
  import { loadPersonas, getSelectedPersona, setSelectedPersona } from '@corenel/harness/prompts/config';
@@ -37,8 +43,10 @@ import { trayCommand, transcriptTurns } from '../commandTurn';
37
43
  import { composerHistory } from '../composerHistory';
38
44
  import { usePendingQueue, addPending, removePending, editPending, setPendingState, getPending, setPendingNote } from './pendingQueue';
39
45
  import { fmtTok } from '@corenel/ui-kit/usage';
46
+ import { isCheckpointTool } from '@corenel/protocol';
47
+ import { PlanApprovalCard, SUBMIT_PLAN, planTextOf } from './PlanReview';
40
48
  import { useBrand } from '@corenel/ui-kit/host';
41
- import { IcPlus, IcZap, IcTrash, IcChevron, IcX, IcRefresh, IcKey, IcCheck, IcSliders, IcUser, IcFile, IcCode, IcHelp, IcEdit, Spinner, LiveCursor } from '@corenel/ui-kit/icons';
49
+ import { IcPlus, IcZap, IcTrash, IcChevron, IcX, IcRefresh, IcKey, IcCheck, IcSliders, IcUser, IcFile, IcHelp, IcEdit, IcLayout, Spinner, LiveCursor } from '@corenel/ui-kit/icons';
42
50
  /* No NOOP default for onApplyEdit, deliberately. Transcript treats the prop's
43
51
  * ABSENCE as "this surface has nothing to apply an edit to" and renders the
44
52
  * review row as a statement instead of dead buttons. Substituting a no-op here
@@ -69,14 +77,14 @@ const PS = {
69
77
  textDone: { fontSize: 13, color: 'var(--text-tertiary)', textDecoration: 'line-through' },
70
78
  };
71
79
  export function AgentChat(props) {
72
- const { controller, sessionId, onSessionChange, sessions, aboveComposer, contextWindow, systemSource, offloaded, usageSurface, inPrice, modes, currentSrc, onApplyEdit, renderFence, emptyState, headerLead, headerExtras, composerRightSlot, title, features, embedded = false, onClose, chips, onRemoveChip, renderContextMenu, capabilities, slashExtra, onSlashAction, interceptSend, onUploadFile, sendOptsExtra, onAfterSend, tip, budgetSection, connections, toolBadge, policyEditor, settingsExtras, LiveTimeline, fixedIdentity, } = 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, } = props;
73
81
  const { name: brandName } = useBrand(); // host-injected product name (CorenelHost seam)
74
82
  // The engine owns the conversation, run loop, worker, and persistence — this
75
83
  // column is a view over ONE controller session. The active session id is
76
84
  // CONTROLLED by the host (sessionId/onSessionChange).
77
85
  const session = useControllerSession(controller, sessionId ?? '');
78
86
  const globalTick = useControllerGlobal(controller); // cross-session changes (titles, run badges)
79
- const { turns, input, mode, running, live, recovery, error, suggestions, attachments, pendingPerm, pendingAsk, policyNotes, degradedModel, recEvents, plan, pendingCommand } = session;
87
+ const { turns, input, mode, running, live, recovery, error, suggestions, attachments, pendingPerm, pendingAsk, approvalSuggestion, policyNotes, degradedModel, recEvents, plan, pendingCommand, loaded } = session;
80
88
  // Thin engine-routed setters so the render below reads like the old local-state
81
89
  // version (same call sites, engine-backed).
82
90
  const setInput = useCallback((v) => { if (sessionId)
@@ -113,6 +121,11 @@ export function AgentChat(props) {
113
121
  // so the scrubber's in-flow folded view reclaims its space.
114
122
  const [timelineScrubbed, setTimelineScrubbed] = useState(false);
115
123
  const [transcriptView] = useTranscriptView();
124
+ const [transcriptSkin, setTranscriptSkin] = useTranscriptSkin();
125
+ const [arrivalEffect, setArrivalEffect] = useArrivalEffect();
126
+ // The effect that plays: the setting ('match' follows the skin's pairing),
127
+ // and always off when the system asks for reduced motion.
128
+ const fx = resolveArrivalEffect(arrivalEffect, transcriptSkin, prefersReducedMotion());
116
129
  // Installed skills, for the "/" command menu (the drawer's SkillsSection loads its
117
130
  // own copy). Subscribe so the menu re-renders when a skill is installed/removed;
118
131
  // the same signal bumps registryTick so the drawer's tool groups recompute.
@@ -141,15 +154,6 @@ export function AgentChat(props) {
141
154
  return subscribeSkills(reload);
142
155
  // eslint-disable-next-line react-hooks/exhaustive-deps
143
156
  }, [capabilities?.skills, capabilities?.tools]);
144
- // Discovered MCP tools for the "/" command menu — mount-load from cache, then a
145
- // forced refresh each time the drawer opens (mirrors the old AgentPanel's
146
- // mount-load + drawer-open refresh of its shared list), plus after a
147
- // drawer-driven MCP mutation (onConnectionsChanged, Fix 4) so the slash menu
148
- // doesn't go stale between drawer opens.
149
- const [mcpKey, setMcpKey] = useState(0);
150
- useEffect(() => { if (drawerOpen)
151
- setMcpKey((k) => k + 1); }, [drawerOpen]);
152
- const mcpTools = useMcpTools(connections, mcpKey);
153
157
  const [ctxMenuOpen, setCtxMenuOpen] = useState(false);
154
158
  const fileInputRef = useRef(null);
155
159
  const [uploadNotice, setUploadNotice] = useState(null);
@@ -289,6 +293,27 @@ export function AgentChat(props) {
289
293
  return c && !dismissed.has(c.turnId) ? c : null;
290
294
  }, [turns, dismissed]);
291
295
  const shownTurns = useMemo(() => transcriptTurns(turns), [turns]);
296
+ // Whether the conversation already has an answer (Netsplit joins only once).
297
+ const hasAnswer = useMemo(
298
+ // Multimodal (array) content counts too: any non-empty assistant content.
299
+ () => shownTurns.some((t) => t.messages.some((m) => m.role === 'assistant' && (typeof m.content === 'string' ? m.content.trim() !== '' : Array.isArray(m.content) && m.content.length > 0))), [shownTurns]);
300
+ /* Arrival-effect hand-off. Many models deliver the whole answer in one burst
301
+ * a second or two before the turn settles, so the in-progress area vanishes
302
+ * mid-reveal. When it does, the newest settled answer carries on from where
303
+ * the reveal was, then shows plain (Transcript clears it via onHandoffDone).
304
+ * Decided during render, not in an effect, so the full text never flashes
305
+ * for a frame first. In memory only: a reload or another chat shows plain. */
306
+ const liveShownRef = useRef(0);
307
+ const [handoff, setHandoff] = useState(null);
308
+ const [handoffWatch, setHandoffWatch] = useState({ live: !!live, sessionId });
309
+ if (handoffWatch.live !== !!live || handoffWatch.sessionId !== sessionId) {
310
+ setHandoffWatch({ live: !!live, sessionId });
311
+ const settled = handoffWatch.live && !live && handoffWatch.sessionId === sessionId;
312
+ const last = shownTurns[shownTurns.length - 1];
313
+ setHandoff(settled && fx !== 'none' && last ? { turnId: last.id, effect: fx, from: liveShownRef.current } : null);
314
+ if (live)
315
+ liveShownRef.current = 0;
316
+ }
292
317
  /* What Up/Down recall. Derived from the turns rather than kept as you type, so
293
318
  * it survives a reload and always matches what is on screen. Includes typed
294
319
  * commands, prefix restored, because re-running the last one is the single
@@ -374,17 +399,23 @@ export function AgentChat(props) {
374
399
  if (sessionId)
375
400
  controller.resolvePerm(sessionId, allow, always);
376
401
  };
402
+ const respondSuggestion = (c) => {
403
+ if (sessionId)
404
+ controller.respondSuggestion(sessionId, c);
405
+ };
377
406
  // The "/" command menu = the generic commands, the host's extras, then every
378
- // available tool, skill, and MCP tool as an `insert` command that prefills an
407
+ // available tool (registry MCP tools included) and skill as an `insert` command that prefills an
379
408
  // editable request. Rebuilt when the registries change. `context` appears only
380
409
  // when the host supplies an add-context popup to open.
381
410
  const slashCommands = useMemo(() => {
382
411
  // Order matches the old AgentPanel: Session, Workspace(+host extras), Prompt,
383
- // Help, then Tools/Skills/MCP inserts. `help` is generic but lands AFTER the
412
+ // Help, then Tools/Skills inserts. `help` is generic but lands AFTER the
384
413
  // host's slashExtra merge so a host's Workspace/Prompt entries stay ahead of it.
385
414
  const beforeExtra = [
386
415
  { name: 'new', hint: 'start a fresh chat', section: 'Session', icon: _jsx(IcPlus, { size: 13 }), kind: 'action' },
387
416
  { 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' },
418
+ { name: 'effect', hint: 'Change how new text arrives', section: 'Session', icon: _jsx(IcZap, { size: 13 }), kind: 'action' },
388
419
  ...(renderContextMenu ? [{ name: 'context', hint: 'attach files or selection', section: 'Workspace', icon: _jsx(IcFile, { size: 13 }), kind: 'action' }] : []),
389
420
  { name: 'persona', hint: 'switch the agent persona', section: 'Workspace', icon: _jsx(IcUser, { size: 13 }), kind: 'action' },
390
421
  ];
@@ -405,12 +436,9 @@ export function AgentChat(props) {
405
436
  const skillCmds = skills
406
437
  .filter((s) => s.enabled && !staticNames.has(s.name))
407
438
  .map((s) => ({ name: s.name, hint: trunc(s.description), section: 'Skills', icon: _jsx(IcZap, { size: 13 }), kind: 'insert', template: `Use the ${s.name} skill to ` }));
408
- const mcpCmds = mcpTools
409
- .filter((t) => !!t.name && !t.error && !staticNames.has(t.name))
410
- .map((t) => ({ name: t.name, hint: trunc(t.description || t.serverLabel), section: 'MCP', icon: _jsx(IcCode, { size: 13 }), kind: 'insert', template: `Use the ${t.name} MCP tool (${t.serverLabel}) to ` }));
411
- return [...staticCmds, ...toolCmds, ...skillCmds, ...mcpCmds];
439
+ return [...staticCmds, ...toolCmds, ...skillCmds];
412
440
  // eslint-disable-next-line react-hooks/exhaustive-deps
413
- }, [renderContextMenu, slashExtra, capabilities, skills, mcpTools, registryTick]);
441
+ }, [renderContextMenu, slashExtra, capabilities, skills, registryTick]);
414
442
  const slashMap = useMemo(() => Object.fromEntries(slashCommands.map((c) => [c.name, c])), [slashCommands]);
415
443
  // Per-send options for the engine. Rebuilt per call so persona/policy/budget are
416
444
  // read fresh; `partsOverride` reads through the ref so a toggle right before send
@@ -467,11 +495,11 @@ export function AgentChat(props) {
467
495
  };
468
496
  // /help renders from the CURRENT resolved slash set, not a static blurb — so
469
497
  // every surface (editor, /agent, /craft) lists exactly its own commands. Tools/
470
- // Skills/MCP are excluded (those are insert-a-request shortcuts, not "commands"
498
+ // Skills are excluded (those are insert-a-request shortcuts, not "commands"
471
499
  // in the help sense); only the Session/Workspace/Prompt/Help-style entries
472
500
  // (the generic set + a host's slashExtra) are listed.
473
501
  const buildHelpText = () => {
474
- const cmds = slashCommands.filter((c) => c.section !== 'Tools' && c.section !== 'Skills' && c.section !== 'MCP');
502
+ const cmds = slashCommands.filter((c) => c.section !== 'Tools' && c.section !== 'Skills');
475
503
  const lines = cmds.map((c) => `- \`/${c.name}\` — ${c.hint}`).join('\n');
476
504
  return `**${brandName}** — type \`/\` for commands:\n\n${lines}\n\nPick a mode (auto/edit/plan/brainstorm) by the composer.`;
477
505
  };
@@ -487,7 +515,7 @@ export function AgentChat(props) {
487
515
  else
488
516
  pendingLoadsRef.current.push(name);
489
517
  };
490
- const runSlash = (name) => {
518
+ const runSlash = (name, arg = '') => {
491
519
  const def = slashMap[name];
492
520
  if (!def)
493
521
  return 'none';
@@ -517,6 +545,24 @@ export function AgentChat(props) {
517
545
  if (sessionId)
518
546
  controller.note(sessionId, buildHelpText());
519
547
  break;
548
+ case 'skin': {
549
+ // Local only: a note turn is never sent to the model.
550
+ const r = skinCommand(arg, transcriptSkin);
551
+ if (r.next)
552
+ setTranscriptSkin(r.next);
553
+ if (sessionId)
554
+ controller.note(sessionId, r.note);
555
+ break;
556
+ }
557
+ case 'effect': {
558
+ // Local only, like /skin: a note turn is never sent to the model.
559
+ const r = effectCommand(arg, arrivalEffect);
560
+ if (r.next)
561
+ setArrivalEffect(r.next);
562
+ if (sessionId)
563
+ controller.note(sessionId, r.note);
564
+ break;
565
+ }
520
566
  default: return 'none';
521
567
  }
522
568
  return 'handled';
@@ -558,7 +604,8 @@ export function AgentChat(props) {
558
604
  setInput(prior);
559
605
  }
560
606
  if (text.startsWith('/') && images.length === 0) {
561
- const r = runSlash(text.slice(1).split(' ')[0]);
607
+ const [cmd, ...rest] = text.slice(1).split(' ');
608
+ const r = runSlash(cmd, rest.join(' '));
562
609
  if (r === 'handled') {
563
610
  setInput('');
564
611
  return;
@@ -574,8 +621,14 @@ export function AgentChat(props) {
574
621
  * does -- same guards, same transcript, same everything. It deliberately does
575
622
  * NOT go through `send` above, which reads the composer and would dispatch
576
623
  * slash commands a second time; the text was already resolved when it was
577
- * parked. `running` holds firing until the current turn finishes. */
578
- const { items: laterItems, now: laterNow } = useSendLater((text) => { void sendMessage(text, []); }, running);
624
+ * parked. `running` holds firing until the current turn finishes.
625
+ *
626
+ * WATCHING COUNTS AS BUSY. The queue is global, not per chat, so a due item
627
+ * would otherwise fire into whatever this column shows: into a finished
628
+ * browser run it continues that run as a chat nobody asked for, and into a
629
+ * daemon watch the send is a no-op -- after takeDue has already removed the
630
+ * item, so it is lost. Busy leaves it queued for a chat that can take it. */
631
+ const { items: laterItems, now: laterNow } = useSendLater((text) => { void sendMessage(text, []); }, running || !!readOnly);
579
632
  /* The pending queue: what you typed while a turn was running.
580
633
  *
581
634
  * Flush is deliberately ONE AT A TIME. Concatenating the queue into a single
@@ -761,78 +814,104 @@ export function AgentChat(props) {
761
814
  restoreToolResult: (turnId, blobId, content) => (sessionId ? controller.restoreToolResult(sessionId, turnId, blobId, content) : false),
762
815
  // eslint-disable-next-line react-hooks/exhaustive-deps
763
816
  }), [sessionId, session.turns]);
764
- const menuEnabled = features?.sessionMenu !== false;
817
+ const menuEnabled = features?.sessionMenu !== false && !readOnly;
765
818
  // Defaults true: every existing surface runs our loop, and only a proxied
766
819
  // engine opts out.
767
820
  const ownLoop = features?.ownLoop !== false;
768
821
  const sessionTitle = sessionList.find((s) => s.id === sessionId)?.title || (title ?? brandName);
769
822
  const modeEntries = useMemo(() => modes.map((m) => ({ id: m.id, label: m.label, description: m.description ?? '', accent: m.accent })), [modes]);
823
+ // A host card per live tool, built from the same call shape the settled
824
+ // transcript hands renderToolCard, so a card does not change on settle. A
825
+ // non-null card wins in every skin and effect: never folded, never a board row.
826
+ const liveCards = new Map();
827
+ for (const t of live?.tools ?? []) {
828
+ const card = renderToolCard?.({ id: t.id, type: 'function', function: { name: t.name, arguments: JSON.stringify(t.args || {}) } });
829
+ if (card)
830
+ liveCards.set(t.id, card);
831
+ }
770
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) {
771
833
  e.stopPropagation();
772
834
  startRename(sessionId);
773
- } }, 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, _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, connections: connections, toolBadge: toolBadge, policyEditor: policyEditor, registryTick: registryTick, capabilities: capabilities, sessionId: sessionId ?? '' }), ownLoop && _jsx(ContextBar, { contextWindow: contextWindow, systemSource: resolveSystemSource(), offloaded: offloaded, conversation: conversation, usageSurface: usageSurface, 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 && emptyState?.({ setInput }), _jsxs("div", { style: S.transcriptWrap, children: [_jsx(Transcript, { turns: shownTurns, currentSrc: currentSrc ?? '', view: transcriptView, onApplyEdit: onApplyEdit, onToggleInclude: toggleInclude, onRetry: onRetry, onImageClick: setLightboxSrc, renderFence: renderFence }), live && (_jsxs("div", { style: S.live, children: [recovery && _jsxs("div", { style: S.recovery, children: [_jsx(Spinner, { size: 13, title: "Recovering" }), " ", recovery] }), live.tools.map((t) => t.name === 'propose_edit' ? (
774
- // propose_edit BLOCKS its run on the review, so the decidable card
775
- // must render here, mid-run — not only in the settled transcript.
776
- _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 ? (
777
- // A tool that finished mid-run gets the SAME expandable card as the
778
- // settled transcript, so its args/result are viewable before the
779
- // turn ends (not just a bare summary line).
780
- _jsx(ToolResultCard, { inv: { name: t.name, args: t.args || {}, result: t.result ?? '', isError: t.isError, running: false, ms: t.ms } }, t.id)) : (_jsx("div", { style: S.liveToolRow, children: _jsx(ToolLine, { name: t.name, args: t.args || {}, result: t.result, isError: t.isError, running: true, ms: t.ms }) }, t.id))), live.text ? _jsxs(_Fragment, { children: [_jsx(Markdown, { text: live.text }), 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 })] })))] })), plan && plan.length > 0 && _jsx(PlanCard, { entries: plan })] }), pendingPerm && (_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'
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), onApprove: () => resolvePerm(true), onDeny: () => resolvePerm(false) })), 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'
781
853
  ? pendingPerm.summary
782
- : (_jsxs(_Fragment, { children: [pendingPerm.requester === 'you' ? 'Your command needs' : '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" }), _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"] })] })) })] })), 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 }), !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) => {
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) => {
783
859
  const files = Array.from(e.target.files ?? []);
784
860
  e.target.value = '';
785
861
  setCtxMenuOpen(false);
786
862
  void processFiles(files);
787
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)
788
864
  controller.resolveAsk(sessionId, answers); }, onCancel: () => { if (sessionId)
789
- controller.resolveAsk(sessionId, null); } })), degradedModel && (_jsx("div", { style: S.budgetWrap, children: _jsxs("span", { style: S.budgetDegraded, children: ["Degraded to ", degradedModel, " (budget)"] }) })), aboveComposer, pendingCommand && _jsx(RunningCommand, { command: pendingCommand.command, startedAt: pendingCommand.startedAt }), _jsx(CommandTray, { command: tray, onKeep: (turnId) => { if (sessionId)
790
- controller.toggleInclude(sessionId, turnId); }, onDismiss: (turnId) => setDismissed((d) => new Set(d).add(turnId)) }), _jsx(Composer, { value: input, onChange: setInput,
791
- // The mic goes in the bottom row's FIXED first position, not the chip
792
- // row. It was in the chip row first, and TodoBar shoved it sideways
793
- // every time the agent's current task changed -- a control that moves
794
- // while the agent works is a control people misclick, and this one's
795
- // worst misclick is "I thought I stopped recording". It stays in the
796
- // composer either way, which is what keeps it reachable on a phone
797
- // (the chat header is not -- see composerSlot.test.ts).
798
- //
799
- // Rendered here rather than passed in by each host: dictation belongs to
800
- // the agent chat, not to any one surface, and four separate wirings
801
- // would mean four chances to ship a surface that is silently mute.
802
- dictating: dictating, leadSlot: _jsx(DictateButton, { host: browserModelHost(),
803
- // Capture the draft the moment the mic opens; every guess below joins
804
- // onto THIS, never onto the previous guess.
805
- onListeningChange: (on) => { if (on)
806
- dictationBase.current = inputRef.current; setDictating(on); },
807
- /* Live guesses go straight into the draft -- that is the feature.
808
- EMPTY IS IGNORED, and that is load-bearing: DictateButton clears the
809
- guess with onInterim('') AFTER stop() resolves, which is after the
810
- final transcript has already landed. Honouring it would recompute
811
- `base + ''` and wipe the committed text a beat after showing it. */
812
- onInterim: (t) => { if (t)
813
- setInput(appendDictated(dictationBase.current, t)); }, onFinal: (t) => setInput(appendDictated(dictationBase.current, t)) }), onSend: send, onStop: () => { stoppedRef.current = true; if (sessionId)
814
- controller.stop(sessionId); }, running: running, mode: mode, onModeChange: setMode, modes: modeEntries, chips: [
815
- ...(chips ?? []),
816
- ...contextDocs.map((d) => ({ id: `doc:${d.id}`, label: `${d.name} · ${fmtTok(d.tokens)} tok`, removable: true })),
817
- ], onRemoveChip: (cid) => {
818
- if (cid.startsWith('doc:')) {
819
- if (sessionId)
820
- removeDoc(sessionId, cid.slice(4));
821
- }
822
- else
823
- onRemoveChip?.(cid);
824
- }, onAddContext: () => setCtxMenuOpen((o) => !o),
825
- // The agent's plan, above the composer. prompd.app also has a dock panel
826
- // for it; on corenel.ai this is the ONLY place it is visible. Renders
827
- // nothing when there are no tasks.
828
- // The host's own controls sit BEFORE the task list, so their position
829
- // stays put: TodoBar renders nothing when there are no tasks, and a
830
- // control that moves as the agent starts and finishes work is a
831
- // control people misclick.
832
- rightSlot: _jsxs(_Fragment, { children: [composerRightSlot, _jsx(TodoBar, { sessionId: sessionId })] }), slashCommands: slashCommands, onPickSlash: (name) => { if (runSlash(name) !== 'prefilled')
833
- 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)
834
- removePending(sessionId, id); }, onEditPending: (id, text) => { if (sessionId)
835
- 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) })] }));
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) })] }));
836
915
  }
837
916
  // Session-row style is keyed by "active" — kept out of S (which is a flat style
838
917
  // map) and typed as its own helper.
@@ -857,6 +936,9 @@ const S = {
857
936
  // Wraps the settled Transcript + live-run block so a host-injected LiveTimeline
858
937
  // (position: absolute) can overlay them while scrubbed back. `body` already
859
938
  // owns the scroll (overflowY: auto) — this is just the positioning context.
939
+ /* Centred in the column like the opener it replaces, so the two states swap
940
+ * in place instead of the conversation jumping when the load lands. */
941
+ loadingTranscript: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '48px 16px', color: 'var(--text-tertiary)', fontSize: 13 },
860
942
  transcriptWrap: { position: 'relative', display: 'flex', flexDirection: 'column', gap: 16 },
861
943
  live: { display: 'flex', flexDirection: 'column', gap: 8 },
862
944
  liveToolRow: { display: 'flex', alignItems: 'center', padding: '6px 10px', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', background: 'var(--surface-2)' },
@@ -873,16 +955,22 @@ const S = {
873
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 },
874
956
  perm: { border: '1px solid var(--warning)', borderRadius: 'var(--radius-md)', background: 'var(--warning-soft)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 },
875
957
  permHead: { display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' },
876
- permBody: { fontSize: 13, lineHeight: 1.5, color: 'var(--text-secondary)' },
958
+ // pre-wrap: a submit_plan summary is the whole multi-line plan being approved.
959
+ // Wraps a long path or URL at 390px, and a long plan scrolls inside the card
960
+ // so Allow and Deny stay in view.
961
+ permBody: { fontSize: 13, lineHeight: 1.5, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '45vh', overflowY: 'auto' },
877
962
  permActions: { display: 'flex', gap: 8, flexWrap: 'wrap' },
878
963
  permDeny: { height: 30, padding: '0 12px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-sm)', background: 'var(--surface-1)', color: 'var(--text-secondary)', fontSize: 12.5, fontWeight: 600, cursor: 'pointer' },
879
964
  permAllowSession: { height: 30, padding: '0 12px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-sm)', background: 'var(--surface-1)', color: 'var(--text-secondary)', fontSize: 12.5, fontWeight: 600, cursor: 'pointer' },
880
965
  permAllow: { display: 'inline-flex', alignItems: 'center', gap: 5, height: 30, padding: '0 14px', border: 0, borderRadius: 'var(--radius-sm)', background: 'var(--grad-brand)', color: 'var(--text-on-accent, #fff)', fontSize: 12.5, fontWeight: 600, cursor: 'pointer' },
966
+ approvalSuggest: { border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', background: 'var(--surface-2)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 },
881
967
  // Per-run budget control — the run chip's soft note when a degrade breach
882
968
  // switched the in-flight model.
883
969
  budgetWrap: { position: 'relative', display: 'flex', alignItems: 'center', gap: 8, padding: '0 10px 6px', flexShrink: 0 },
884
970
  budgetDegraded: { fontSize: 11, color: 'var(--warning)' },
885
971
  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 },
886
972
  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
+ readOnlyBar: { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderTop: '1px solid var(--border-subtle)', background: 'var(--surface-2)', flexShrink: 0 },
974
+ readOnlyBanner: { flex: 1, minWidth: 0, fontSize: 13, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
887
975
  };
888
976
  //# sourceMappingURL=AgentChat.js.map