@corenel/web-ui 0.13.3 → 0.14.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 (193) hide show
  1. package/dist/Composer.d.ts +30 -1
  2. package/dist/Composer.d.ts.map +1 -1
  3. package/dist/Composer.js +111 -19
  4. package/dist/Composer.js.map +1 -1
  5. package/dist/ContextBar.d.ts +9 -3
  6. package/dist/ContextBar.d.ts.map +1 -1
  7. package/dist/ContextBar.js +14 -7
  8. package/dist/ContextBar.js.map +1 -1
  9. package/dist/ContextManager.d.ts +10 -2
  10. package/dist/ContextManager.d.ts.map +1 -1
  11. package/dist/ContextManager.js +14 -10
  12. package/dist/ContextManager.js.map +1 -1
  13. package/dist/DictateButton.d.ts +21 -1
  14. package/dist/DictateButton.d.ts.map +1 -1
  15. package/dist/DictateButton.js +73 -10
  16. package/dist/DictateButton.js.map +1 -1
  17. package/dist/PickChips.d.ts +6 -0
  18. package/dist/PickChips.d.ts.map +1 -0
  19. package/dist/PickChips.js +55 -0
  20. package/dist/PickChips.js.map +1 -0
  21. package/dist/ProcessDock.d.ts +10 -0
  22. package/dist/ProcessDock.d.ts.map +1 -1
  23. package/dist/ProcessDock.js +24 -1
  24. package/dist/ProcessDock.js.map +1 -1
  25. package/dist/RichContent.d.ts.map +1 -1
  26. package/dist/RichContent.js +102 -24
  27. package/dist/RichContent.js.map +1 -1
  28. package/dist/SummaryTranscript.d.ts +5 -1
  29. package/dist/SummaryTranscript.d.ts.map +1 -1
  30. package/dist/SummaryTranscript.js +5 -3
  31. package/dist/SummaryTranscript.js.map +1 -1
  32. package/dist/TodoBar.d.ts +2 -0
  33. package/dist/TodoBar.d.ts.map +1 -1
  34. package/dist/TodoBar.js +8 -0
  35. package/dist/TodoBar.js.map +1 -1
  36. package/dist/TodoLine.d.ts +11 -0
  37. package/dist/TodoLine.d.ts.map +1 -0
  38. package/dist/TodoLine.js +55 -0
  39. package/dist/TodoLine.js.map +1 -0
  40. package/dist/ToolFold.js +1 -1
  41. package/dist/ToolFold.js.map +1 -1
  42. package/dist/Transcript.d.ts +19 -1
  43. package/dist/Transcript.d.ts.map +1 -1
  44. package/dist/Transcript.js +127 -58
  45. package/dist/Transcript.js.map +1 -1
  46. package/dist/agentChat/AgentChat.d.ts +42 -0
  47. package/dist/agentChat/AgentChat.d.ts.map +1 -1
  48. package/dist/agentChat/AgentChat.js +573 -100
  49. package/dist/agentChat/AgentChat.js.map +1 -1
  50. package/dist/agentChat/ChatSettingsSheet.d.ts +9 -0
  51. package/dist/agentChat/ChatSettingsSheet.d.ts.map +1 -0
  52. package/dist/agentChat/ChatSettingsSheet.js +103 -0
  53. package/dist/agentChat/ChatSettingsSheet.js.map +1 -0
  54. package/dist/agentChat/OptionsDrawer.d.ts +8 -0
  55. package/dist/agentChat/OptionsDrawer.d.ts.map +1 -1
  56. package/dist/agentChat/OptionsDrawer.js +24 -215
  57. package/dist/agentChat/OptionsDrawer.js.map +1 -1
  58. package/dist/agentChat/PlanReview.d.ts +1 -1
  59. package/dist/agentChat/PlanReview.d.ts.map +1 -1
  60. package/dist/agentChat/PlanReview.js +1 -1
  61. package/dist/agentChat/PlanReview.js.map +1 -1
  62. package/dist/agentChat/SourceGroups.d.ts +24 -0
  63. package/dist/agentChat/SourceGroups.d.ts.map +1 -0
  64. package/dist/agentChat/SourceGroups.js +117 -0
  65. package/dist/agentChat/SourceGroups.js.map +1 -0
  66. package/dist/agentChat/controller.d.ts +30 -1
  67. package/dist/agentChat/controller.d.ts.map +1 -1
  68. package/dist/agentChat/controller.js.map +1 -1
  69. package/dist/agentChat/pendingQueue.d.ts +4 -0
  70. package/dist/agentChat/pendingQueue.d.ts.map +1 -1
  71. package/dist/agentChat/pendingQueue.js +7 -0
  72. package/dist/agentChat/pendingQueue.js.map +1 -1
  73. package/dist/agentChat/planTool.d.ts +4 -0
  74. package/dist/agentChat/planTool.d.ts.map +1 -0
  75. package/dist/agentChat/planTool.js +4 -0
  76. package/dist/agentChat/planTool.js.map +1 -0
  77. package/dist/agentChat/testSheetHost.d.ts +3 -0
  78. package/dist/agentChat/testSheetHost.d.ts.map +1 -0
  79. package/dist/agentChat/testSheetHost.js +9 -0
  80. package/dist/agentChat/testSheetHost.js.map +1 -0
  81. package/dist/agentChat/useChatOptions.d.ts +63 -0
  82. package/dist/agentChat/useChatOptions.d.ts.map +1 -0
  83. package/dist/agentChat/useChatOptions.js +156 -0
  84. package/dist/agentChat/useChatOptions.js.map +1 -0
  85. package/dist/chatRefreshContext.d.ts +8 -0
  86. package/dist/chatRefreshContext.d.ts.map +1 -0
  87. package/dist/chatRefreshContext.js +14 -0
  88. package/dist/chatRefreshContext.js.map +1 -0
  89. package/dist/chatSkinContext.d.ts +13 -6
  90. package/dist/chatSkinContext.d.ts.map +1 -1
  91. package/dist/chatSkinContext.js +18 -4
  92. package/dist/chatSkinContext.js.map +1 -1
  93. package/dist/chatSkinCss.d.ts.map +1 -1
  94. package/dist/chatSkinCss.js +108 -22
  95. package/dist/chatSkinCss.js.map +1 -1
  96. package/dist/composerAction.d.ts +16 -0
  97. package/dist/composerAction.d.ts.map +1 -0
  98. package/dist/composerAction.js +20 -0
  99. package/dist/composerAction.js.map +1 -0
  100. package/dist/composerHistory.d.ts.map +1 -1
  101. package/dist/composerHistory.js +4 -1
  102. package/dist/composerHistory.js.map +1 -1
  103. package/dist/fx/LiveAnswer.d.ts.map +1 -1
  104. package/dist/fx/LiveAnswer.js +324 -59
  105. package/dist/fx/LiveAnswer.js.map +1 -1
  106. package/dist/fx/arrivalModes.d.ts +17 -2
  107. package/dist/fx/arrivalModes.d.ts.map +1 -1
  108. package/dist/fx/arrivalModes.js +46 -4
  109. package/dist/fx/arrivalModes.js.map +1 -1
  110. package/dist/fx/pacer.d.ts +41 -0
  111. package/dist/fx/pacer.d.ts.map +1 -0
  112. package/dist/fx/pacer.js +66 -0
  113. package/dist/fx/pacer.js.map +1 -0
  114. package/dist/fx/useReveal.d.ts +16 -1
  115. package/dist/fx/useReveal.d.ts.map +1 -1
  116. package/dist/fx/useReveal.js +101 -41
  117. package/dist/fx/useReveal.js.map +1 -1
  118. package/dist/localAddress.d.ts +2 -0
  119. package/dist/localAddress.d.ts.map +1 -0
  120. package/dist/localAddress.js +56 -0
  121. package/dist/localAddress.js.map +1 -0
  122. package/dist/steps/AfterReply.d.ts +67 -0
  123. package/dist/steps/AfterReply.d.ts.map +1 -0
  124. package/dist/steps/AfterReply.js +217 -0
  125. package/dist/steps/AfterReply.js.map +1 -0
  126. package/dist/steps/ChatApproval.d.ts +18 -0
  127. package/dist/steps/ChatApproval.d.ts.map +1 -0
  128. package/dist/steps/ChatApproval.js +35 -0
  129. package/dist/steps/ChatApproval.js.map +1 -0
  130. package/dist/steps/Delegation.d.ts +77 -0
  131. package/dist/steps/Delegation.d.ts.map +1 -0
  132. package/dist/steps/Delegation.js +188 -0
  133. package/dist/steps/Delegation.js.map +1 -0
  134. package/dist/steps/LiveRail.d.ts +14 -0
  135. package/dist/steps/LiveRail.d.ts.map +1 -0
  136. package/dist/steps/LiveRail.js +16 -0
  137. package/dist/steps/LiveRail.js.map +1 -0
  138. package/dist/steps/StepDetail.d.ts +23 -0
  139. package/dist/steps/StepDetail.d.ts.map +1 -0
  140. package/dist/steps/StepDetail.js +195 -0
  141. package/dist/steps/StepDetail.js.map +1 -0
  142. package/dist/steps/StepDetailHost.d.ts +54 -0
  143. package/dist/steps/StepDetailHost.d.ts.map +1 -0
  144. package/dist/steps/StepDetailHost.js +175 -0
  145. package/dist/steps/StepDetailHost.js.map +1 -0
  146. package/dist/steps/StepRail.d.ts +38 -0
  147. package/dist/steps/StepRail.d.ts.map +1 -0
  148. package/dist/steps/StepRail.js +238 -0
  149. package/dist/steps/StepRail.js.map +1 -0
  150. package/dist/steps/approval.d.ts +73 -0
  151. package/dist/steps/approval.d.ts.map +1 -0
  152. package/dist/steps/approval.js +81 -0
  153. package/dist/steps/approval.js.map +1 -0
  154. package/dist/steps/model.d.ts +140 -0
  155. package/dist/steps/model.d.ts.map +1 -0
  156. package/dist/steps/model.js +433 -0
  157. package/dist/steps/model.js.map +1 -0
  158. package/dist/steps/outcome.d.ts +5 -0
  159. package/dist/steps/outcome.d.ts.map +1 -0
  160. package/dist/steps/outcome.js +34 -0
  161. package/dist/steps/outcome.js.map +1 -0
  162. package/dist/steps/peek.d.ts +35 -0
  163. package/dist/steps/peek.d.ts.map +1 -0
  164. package/dist/steps/peek.js +219 -0
  165. package/dist/steps/peek.js.map +1 -0
  166. package/dist/steps/plan.d.ts +50 -0
  167. package/dist/steps/plan.d.ts.map +1 -0
  168. package/dist/steps/plan.js +242 -0
  169. package/dist/steps/plan.js.map +1 -0
  170. package/dist/steps/useRailDetail.d.ts +25 -0
  171. package/dist/steps/useRailDetail.d.ts.map +1 -0
  172. package/dist/steps/useRailDetail.js +108 -0
  173. package/dist/steps/useRailDetail.js.map +1 -0
  174. package/dist/steps/words.d.ts +40 -0
  175. package/dist/steps/words.d.ts.map +1 -0
  176. package/dist/steps/words.js +247 -0
  177. package/dist/steps/words.js.map +1 -0
  178. package/dist/toolRender.d.ts +27 -1
  179. package/dist/toolRender.d.ts.map +1 -1
  180. package/dist/toolRender.js +72 -4
  181. package/dist/toolRender.js.map +1 -1
  182. package/dist/transcriptSkin.d.ts.map +1 -1
  183. package/dist/transcriptSkin.js +66 -0
  184. package/dist/transcriptSkin.js.map +1 -1
  185. package/dist/transcriptSkinCss.d.ts +35 -0
  186. package/dist/transcriptSkinCss.d.ts.map +1 -1
  187. package/dist/transcriptSkinCss.js +360 -19
  188. package/dist/transcriptSkinCss.js.map +1 -1
  189. package/dist/useHoldPress.d.ts +24 -0
  190. package/dist/useHoldPress.d.ts.map +1 -0
  191. package/dist/useHoldPress.js +85 -0
  192. package/dist/useHoldPress.js.map +1 -0
  193. package/package.json +5 -5
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /* AgentChat — the one chat column, extracted verbatim from the editor
3
3
  * AgentPanel and re-based on the structural AgentSurfaceController so every
4
4
  * agent surface (editor, /agent, /craft) renders the same header / transcript /
@@ -8,9 +8,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  * get/set); everything editor-specific (context source, tips lifecycle, budget
9
9
  * form, host slash commands) comes in via props. */
10
10
  import { useHostSheet } from '@corenel/ui-kit/sheetHost';
11
+ import { useEscape } from '@corenel/ui-kit/useEscape';
11
12
  import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
12
13
  import { Transcript, ToolLine, DiffProposal } from '../Transcript';
13
- import { ToolResultCard } from '../toolRender';
14
+ import { BlockedResults, ToolResultCard } from '../toolRender';
14
15
  import { Composer } from '../Composer';
15
16
  import { SendLater, useSendLater } from '../sendLater/SendLater';
16
17
  import { AskUser } from '../AskUser';
@@ -19,6 +20,7 @@ import { ContextBar } from '../ContextBar';
19
20
  import { useTranscriptView } from '../transcriptView';
20
21
  import { useTranscriptSkin, skinCommand, SKIN_REACH } from '../transcriptSkin';
21
22
  import { ChatSkinRoot, CHAT_WORDS } from '../chatSkin';
23
+ import { ChatRefreshScope } from '../chatRefreshContext';
22
24
  import { plainName } from '../chatSkinContext';
23
25
  import { ToolFold } from '../ToolFold';
24
26
  import { useArrivalEffect, effectCommand, prefersReducedMotion, resolveArrivalEffect } from '../arrivalEffect';
@@ -27,8 +29,15 @@ import { FlapRow } from '../fx/FlapRow';
27
29
  import { MatrixRain } from '../fx/MatrixRain';
28
30
  import { NetsplitJoin } from '../fx/NetsplitJoin';
29
31
  import { OptionsDrawer } from './OptionsDrawer';
32
+ import { ChatSettingsSheet } from './ChatSettingsSheet';
30
33
  import { useControllerSession, useControllerGlobal } from './controller';
31
- import { getSelectedPolicyName, setSelectedPolicyName } from '@corenel/harness/guardrail';
34
+ import { StepDetailHost, useStepDetail } from '../steps/StepDetailHost';
35
+ import { LiveRail, LIVE_SCOPE } from '../steps/LiveRail';
36
+ import { argsJson, liveBlocks } from '../steps/model';
37
+ import { planAfter } from '../steps/plan';
38
+ import { ApprovalSlotContext, approvalBelongsToCall, chatApproval } from '../steps/approval';
39
+ import { ArmedPlanApproval, ChatApproval } from '../steps/ChatApproval';
40
+ import { getPolicy, getSelectedPolicyName, setSelectedPolicyName, STANDARD_POLICY } from '@corenel/harness/guardrail';
32
41
  import { loadPersonas, getSelectedPersona, setSelectedPersona } from '@corenel/harness/prompts/config';
33
42
  import { getSystemParts } from '@corenel/harness/systemParts';
34
43
  import { sessionSystemConfig } from '@corenel/tools-web/sessionSystemConfig';
@@ -38,7 +47,10 @@ import { allTools } from '@corenel/harness/tools/builtins';
38
47
  import { isDeferredTool } from '@corenel/harness/tools/toolSearch';
39
48
  import { addLoadedTools } from '@corenel/tools-web/sessionLoadedTools';
40
49
  import { useContextDocs, addDoc, removeDoc, classifyUpload, looksBinary, ContextDocError, UPLOAD_ACCEPT } from '@corenel/tools-web/contextDocs';
50
+ import { usePicks, removePick, setPicks, listPicks, withPicks } from '@corenel/tools-web/composerPicks';
41
51
  import { DictateButton } from '../DictateButton';
52
+ import { KitButton, KitChip, KitSheet, ListRow } from '@corenel/ui-kit/kit';
53
+ import { Toggle } from '@corenel/ui-kit/FieldControls';
42
54
  import { resolveDictation } from '@corenel/tools-web/speech/route';
43
55
  import { browserModelHost } from '@corenel/tools-web/speech/model';
44
56
  import { appendDictated } from './appendDictated';
@@ -48,17 +60,39 @@ import { useTodos } from '@corenel/harness/todos';
48
60
  import { CommandTray, RunningCommand } from '../CommandTray';
49
61
  import { trayCommand, transcriptTurns } from '../commandTurn';
50
62
  import { composerHistory } from '../composerHistory';
51
- import { usePendingQueue, addPending, removePending, editPending, setPendingState, getPending, setPendingNote } from './pendingQueue';
63
+ import { usePendingQueue, addPending, removePending, editPending, setPendingState, getPending, setPendingNote, dropQueued } from './pendingQueue';
52
64
  import { fmtTok } from '@corenel/ui-kit/usage';
53
- import { isCheckpointTool } from '@corenel/protocol';
65
+ import { HOOK_ASK_TOOL, isCheckpointTool } from '@corenel/protocol';
54
66
  import { PlanApprovalCard, SUBMIT_PLAN, planTextOf } from './PlanReview';
55
67
  import { useBrand } from '@corenel/ui-kit/host';
56
- 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';
68
+ import { IcPlus, IcZap, IcTrash, IcChevron, IcX, IcRefresh, IcKey, IcCheck, IcSliders, IcUser, IcFile, IcHelp, IcEdit, IcLayout, IcCode, IcMic, IcClock, Spinner, LiveCursor, IcTarget } from '@corenel/ui-kit/icons';
57
69
  /* No NOOP default for onApplyEdit, deliberately. Transcript treats the prop's
58
70
  * ABSENCE as "this surface has nothing to apply an edit to" and renders the
59
71
  * review row as a statement instead of dead buttons. Substituting a no-op here
60
72
  * made that absence unobservable: /craft got a live Apply that silently did
61
73
  * nothing and then reported the edit applied. Forward undefined as undefined. */
74
+ /** Longest the rail's run-end hand-off waits for the live answer's reveal. */
75
+ const HANDOFF_MAX_MS = 3000;
76
+ /** A turn's last answer text, or '' when it has none. */
77
+ function lastAnswerOf(turn) {
78
+ for (let i = turn.messages.length - 1; i >= 0; i--) {
79
+ const m = turn.messages[i];
80
+ if (m.role === 'assistant' && typeof m.content === 'string' && m.content)
81
+ return m.content;
82
+ }
83
+ return '';
84
+ }
85
+ /** Lends the step detail host's api to the chat that renders it (Run again closes it). */
86
+ function DetailApiBridge({ into }) {
87
+ into.current = useStepDetail();
88
+ return null;
89
+ }
90
+ /** The chat's step detail host, when the rail is on; otherwise its children as they are. */
91
+ function ChatDetailHost(p) {
92
+ if (!p.on)
93
+ return _jsx(_Fragment, { children: p.children });
94
+ return (_jsxs(StepDetailHost, { className: "tx-chat-host", follow: p.follow, hidden: p.hidden, children: [_jsx(DetailApiBridge, { into: p.apiRef }), p.children] }));
95
+ }
62
96
  /** An agent-maintained task list.
63
97
  *
64
98
  * Rendered as plain state, not as something to interact with: these steps are
@@ -84,7 +118,7 @@ const PS = {
84
118
  textDone: { fontSize: 13, color: 'var(--text-tertiary)', textDecoration: 'line-through' },
85
119
  };
86
120
  export function AgentChat(props) {
87
- 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', runHint = false, startFooter, } = props;
121
+ const { controller, sessionId, onSessionChange, sessions, aboveComposer, contextWindow, systemSource, offloaded, inPrice, modes, currentSrc, onApplyEdit, renderFence, renderToolCard, emptyState, headerLead, headerExtras, openOptionsRef, 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', runHint = false, startFooter, smartAction = false, phoneKit = false, hideHeader = false, settingsLead, rerunCommand, } = props;
88
122
  const { name: brandName } = useBrand(); // host-injected product name (CorenelHost seam)
89
123
  // The engine owns the conversation, run loop, worker, and persistence — this
90
124
  // column is a view over ONE controller session. The active session id is
@@ -123,7 +157,23 @@ export function AgentChat(props) {
123
157
  // The right-side options drawer: persona, display, policy, and capabilities —
124
158
  // the extracted OptionsDrawer (Task 3) renders it; AgentChat owns only the
125
159
  // open flag and the CONTROLLED persona/policy selections.
126
- const [drawerOpen, setDrawerOpen] = useState(false);
160
+ const [ownDrawerOpen, setDrawerOpen] = useState(false);
161
+ /* Controlled when the host passes `settingsOpen` (the internal flag is then
162
+ * unread). Every open and close goes through setSettings, which also reports
163
+ * to the host when one is listening. */
164
+ const drawerOpen = props.settingsOpen ?? ownDrawerOpen;
165
+ const onSettingsOpenChangeRef = useRef(props.onSettingsOpenChange);
166
+ onSettingsOpenChangeRef.current = props.onSettingsOpenChange;
167
+ const setSettings = useCallback((v) => {
168
+ setDrawerOpen(v);
169
+ onSettingsOpenChangeRef.current?.(v);
170
+ }, []);
171
+ useEffect(() => {
172
+ if (!openOptionsRef)
173
+ return;
174
+ openOptionsRef.current = () => setSettings(true);
175
+ return () => { openOptionsRef.current = null; };
176
+ }, [openOptionsRef]);
127
177
  // True while the injected live scrubber is scrubbed back: hide the live transcript
128
178
  // so the scrubber's in-flow folded view reclaims its space.
129
179
  const [timelineScrubbed, setTimelineScrubbed] = useState(false);
@@ -169,6 +219,9 @@ export function AgentChat(props) {
169
219
  /* The mic and send-later live in the "+" menu; these drive them from there.
170
220
  * The mic's own Stop shows in the composer whenever it is busy. */
171
221
  const dictControl = useRef(null);
222
+ /* smartAction: the mic reports consent, download progress and errors here,
223
+ * shown in the notice row above the composer. */
224
+ const [dictNotice, setDictNotice] = useState(null);
172
225
  const [laterOpen, setLaterOpen] = useState(false);
173
226
  const todosNow = useTodos(sessionId ?? null);
174
227
  const [dockEmpty, setDockEmpty] = useState(true);
@@ -209,6 +262,15 @@ export function AgentChat(props) {
209
262
  * and the DictateButton are siblings; the button reports, this decides. */
210
263
  const [dictating, setDictating] = useState(false);
211
264
  const contextDocs = useContextDocs(sessionId ?? '');
265
+ /* Elements picked in a page preview (composerPicks.ts): one-shot, like a
266
+ * pasted image. They go with the next message as marked text, so every path
267
+ * below that takes the composer's text takes `draftText()`, and they clear
268
+ * wherever the attachments clear. */
269
+ const pickKey = sessionId ?? '';
270
+ const picks = usePicks(pickKey);
271
+ const draftText = () => withPicks(input.trim(), picks);
272
+ const clearPicks = () => { if (picks.length)
273
+ setPicks(pickKey, []); };
212
274
  const readAsDataUrl = (f) => new Promise((res) => {
213
275
  const r = new FileReader();
214
276
  r.onload = () => res(typeof r.result === 'string' ? r.result : '');
@@ -310,6 +372,11 @@ export function AgentChat(props) {
310
372
  const [selPolicy, setSelPolicy] = useState(getSelectedPolicyName);
311
373
  const selectPolicy = useCallback((name) => { setSelPolicy(name); setSelectedPolicyName(name); }, []);
312
374
  const policyName = fixedIdentity?.policyName ?? selPolicy;
375
+ /* The policy a send resolves (runtime.ts: getPolicy(policyName), else the
376
+ * standard one), for the context figure's tool count. A host's policy
377
+ * transform only adds permissions, never a tools gate, so this is the gate
378
+ * the run applies. */
379
+ const runPolicy = useMemo(() => getPolicy(policyName) ?? STANDARD_POLICY, [policyName]);
313
380
  const [selPersona, setSelPersona] = useState(getSelectedPersona);
314
381
  const pickPersona = useCallback((id) => { setSelPersona(id); setSelectedPersona(id); }, []);
315
382
  const personaName = fixedIdentity ? (fixedIdentity.persona ?? '') : selPersona;
@@ -373,16 +440,76 @@ export function AgentChat(props) {
373
440
  * Decided during render, not in an effect, so the full text never flashes
374
441
  * for a frame first. In memory only: a reload or another chat shows plain. */
375
442
  const liveShownRef = useRef(0);
443
+ /* smartAction: the request the chat's own card last answered, so a second
444
+ * tap (or the plan card's Approve after a Deny) cannot answer again. */
445
+ const answeredPermRef = useRef(null);
446
+ /* How many sub-agent rows show the pending approval (ApprovalSlotContext).
447
+ * While none does, the card stays on the rail. */
448
+ const [slotClaims, setSlotClaims] = useState(0);
449
+ const claimSlot = useCallback(() => {
450
+ setSlotClaims((n) => n + 1);
451
+ return () => setSlotClaims((n) => n - 1);
452
+ }, []);
376
453
  const [handoff, setHandoff] = useState(null);
377
454
  const [handoffWatch, setHandoffWatch] = useState({ live: !!live, sessionId });
455
+ /* The rail (smartAction, normal and expanded views) hands off without a
456
+ * snap: the live turn's last view stays in place, its answer still the same
457
+ * LiveAnswer playing its reveal and fades, until that reveal is done; then
458
+ * the settled turn takes over, plain. `railTwin` is the turn the run settled
459
+ * into, so an open live step's detail moves to its settled twin. */
460
+ const railLive = smartAction && transcriptView !== 'summary';
461
+ const lastLiveRef = useRef(null);
462
+ const [frozen, setFrozen] = useState(null);
463
+ const [railTwin, setRailTwin] = useState(null);
378
464
  if (handoffWatch.live !== !!live || handoffWatch.sessionId !== sessionId) {
379
465
  setHandoffWatch({ live: !!live, sessionId });
380
466
  const settled = handoffWatch.live && !live && handoffWatch.sessionId === sessionId;
381
467
  const last = shownTurns[shownTurns.length - 1];
382
- setHandoff(settled && fx !== 'none' && last ? { turnId: last.id, effect: fx, from: liveShownRef.current } : null);
383
- if (live)
468
+ const snap = lastLiveRef.current;
469
+ const freeze = railLive && settled && fx !== 'none' && !!last && !!snap && !!snap.text
470
+ && snap.live.tools.every((t) => t.done) && lastAnswerOf(last) === snap.text;
471
+ setFrozen(freeze && snap ? snap : null);
472
+ setRailTwin(railLive && settled && last ? last.id : null);
473
+ setHandoff(settled && fx !== 'none' && last && !freeze ? { turnId: last.id, effect: fx, from: liveShownRef.current } : null);
474
+ if (live) {
384
475
  liveShownRef.current = 0;
476
+ lastLiveRef.current = null;
477
+ }
478
+ }
479
+ /* The answer the live footer shows. On the rail the runtime's commit of the
480
+ * answer (foldLive's 'assistant' clears the text, the tool list unchanged)
481
+ * does not take it off screen before the run settles. */
482
+ const heldRef = useRef({ text: '', tools: null });
483
+ if (live) {
484
+ if (live.text)
485
+ heldRef.current = { text: live.text, tools: live.tools };
486
+ else if (heldRef.current.tools !== live.tools)
487
+ heldRef.current = { text: '', tools: null };
488
+ }
489
+ else {
490
+ heldRef.current = { text: '', tools: null };
491
+ }
492
+ const liveText = live ? (railLive ? live.text || heldRef.current.text : live.text) : '';
493
+ if (live && railLive)
494
+ lastLiveRef.current = { live, turns: shownTurns, text: liveText, hadAnswer: hasAnswer };
495
+ /* When each running live step was first seen: its time reads from it on the
496
+ * one-second runElapsed tick. Cleared with the run. */
497
+ const firstSeenRef = useRef({ sessionId, at: new Map() });
498
+ if ((!live && firstSeenRef.current.at.size) || firstSeenRef.current.sessionId !== sessionId) {
499
+ firstSeenRef.current = { sessionId, at: new Map() };
385
500
  }
501
+ // Off the rail (the summary view) there is no hand-off view to keep.
502
+ if (frozen && !railLive)
503
+ setFrozen(null);
504
+ // The hand-off waits for the reveal at most HANDOFF_MAX_MS: a hidden tab (no
505
+ // animation frames) must not keep the settled turn off screen.
506
+ const handoffHeld = !live && !!frozen;
507
+ useEffect(() => {
508
+ if (!handoffHeld)
509
+ return undefined;
510
+ const id = setTimeout(() => setFrozen(null), HANDOFF_MAX_MS);
511
+ return () => clearTimeout(id);
512
+ }, [handoffHeld]);
386
513
  /* What Up/Down recall. Derived from the turns rather than kept as you type, so
387
514
  * it survives a reload and always matches what is on screen. Includes typed
388
515
  * commands, prefix restored, because re-running the last one is the single
@@ -465,10 +592,28 @@ export function AgentChat(props) {
465
592
  setRenaming(null);
466
593
  }
467
594
  };
595
+ /* A tool result a System One check or hook blocked, for the tool card's
596
+ * "Show the result": held by the controller in this page's memory only. */
597
+ const blockedLookup = useMemo(() => (sessionId && controller.blockedResult ? (callId) => controller.blockedResult?.(sessionId, callId) : null), [controller, sessionId]);
468
598
  const resolvePerm = (allow, always = false, note) => {
469
599
  if (sessionId)
470
600
  controller.resolvePerm(sessionId, allow, always, note);
471
601
  };
602
+ /* smartAction: an answer resolves the exact request its card was shown for
603
+ * (resolvePermIf with that request's id), never a newer one, and only once.
604
+ * A controller with no request ids (ACP, a remote run) has one ask at a
605
+ * time and answers through resolvePerm. */
606
+ const answerShownPerm = (sid, perm, allow, always, note) => {
607
+ if (answeredPermRef.current === perm)
608
+ return;
609
+ answeredPermRef.current = perm;
610
+ if (typeof perm.id === 'number' && controller.resolvePermIf)
611
+ controller.resolvePermIf(sid, perm.id, allow, always, note);
612
+ else if (note !== undefined)
613
+ controller.resolvePerm(sid, allow, always, note);
614
+ else
615
+ controller.resolvePerm(sid, allow, always);
616
+ };
472
617
  const respondSuggestion = (c) => {
473
618
  if (sessionId)
474
619
  controller.respondSuggestion(sessionId, c);
@@ -535,8 +680,14 @@ export function AgentChat(props) {
535
680
  return id;
536
681
  };
537
682
  /** Returns false when nothing was sent (empty message, or a run in flight) --
538
- * a caller holding the only copy of the text must not drop it on a refusal. */
539
- const sendMessage = async (text, images) => {
683
+ * a caller holding the only copy of the text must not drop it on a refusal.
684
+ *
685
+ * `onSettled` hears, once the controller's send has returned, whether the
686
+ * message LANDED: a new user turn is in the session. That is the one signal
687
+ * every controller gives. A refusal can return quietly with nothing put
688
+ * back (the ACP proxy with no sidecar sets only an error), so a caller that
689
+ * emptied the composer itself uses it to put the draft back. */
690
+ const sendMessage = async (text, images, onSettled) => {
540
691
  if ((!text && images.length === 0) || running)
541
692
  return false;
542
693
  // Sending your own message always snaps the view to the bottom.
@@ -548,10 +699,30 @@ export function AgentChat(props) {
548
699
  addLoadedTools(sid, pendingLoadsRef.current);
549
700
  pendingLoadsRef.current = [];
550
701
  }
551
- await controller.send(sid, text, images, buildSendOpts());
702
+ const before = new Set(controller.getState(sid).turns.map((t) => t.id));
703
+ try {
704
+ await controller.send(sid, text, images, buildSendOpts());
705
+ }
706
+ finally {
707
+ // A send that threw settles too: its turn was taken back, so it did not land.
708
+ onSettled?.(sid, controller.getState(sid).turns.some((t) => !before.has(t.id) && t.messages[0]?.role === 'user'));
709
+ }
552
710
  onAfterSend?.(sid);
553
711
  return true;
554
712
  };
713
+ /** Deliver a message that is not in the composer (a queued flush, a held
714
+ * chip's "Send now", an interrupt). With `smartAction` it goes through
715
+ * `interceptSend` first, like a typed send, so image mode and host commands
716
+ * apply to it (D6). Without it, exactly sendMessage. Refused while a run is
717
+ * in flight BEFORE the intercept: an image generation must not start beside
718
+ * a live run. */
719
+ const deliver = async (text, images) => {
720
+ if ((!text && images.length === 0) || running)
721
+ return false;
722
+ if (smartAction && interceptSend && (await interceptSend(text, images)))
723
+ return true;
724
+ return sendMessage(text, images);
725
+ };
555
726
  /** The full send pipeline minus slash dispatch — the `api.sendText` handed to
556
727
  * host slash actions (the editor's /init sends a composed prompt through it). */
557
728
  const sendText = (text) => {
@@ -609,7 +780,7 @@ export function AgentChat(props) {
609
780
  setCtxMenuOpen(true);
610
781
  break;
611
782
  case 'persona':
612
- setDrawerOpen(true);
783
+ setSettings(true);
613
784
  break;
614
785
  case 'help':
615
786
  if (sessionId)
@@ -638,8 +809,9 @@ export function AgentChat(props) {
638
809
  return 'handled';
639
810
  };
640
811
  const send = async () => {
641
- const text = input.trim();
812
+ const text = draftText();
642
813
  const images = attachments;
814
+ const taken = picks;
643
815
  if ((!text && images.length === 0) || running)
644
816
  return;
645
817
  // Host commands are first-chance (the editor's /strategy + /model) — BEFORE
@@ -671,6 +843,8 @@ export function AgentChat(props) {
671
843
  // composer after the image it was attached to had been made.
672
844
  if (images.length)
673
845
  setAttachments(() => []);
846
+ // Picks too, the same both ways.
847
+ clearPicks();
674
848
  if (await interceptSend(text, images))
675
849
  return;
676
850
  // Declined: it was never ours to take. Restoring matters most for the
@@ -679,8 +853,11 @@ export function AgentChat(props) {
679
853
  setInput(prior);
680
854
  if (images.length)
681
855
  setAttachments(() => images);
856
+ if (taken.length)
857
+ setPicks(pickKey, taken);
682
858
  }
683
- if (text.startsWith('/') && images.length === 0) {
859
+ // A message carrying picks is not a slash command, the same rule as images.
860
+ if (text.startsWith('/') && images.length === 0 && taken.length === 0) {
684
861
  const [cmd, ...rest] = text.slice(1).split(' ');
685
862
  const r = runSlash(cmd, rest.join(' '));
686
863
  if (r === 'handled') {
@@ -692,6 +869,48 @@ export function AgentChat(props) {
692
869
  }
693
870
  // The engine guards + clears the composer itself: a feature-guard denial
694
871
  // surfaces in session.error with the draft and pasted images intact.
872
+ //
873
+ // With picks the sent text is not what the box holds, and the engine only
874
+ // empties a box whose text IS what was sent (runtime startTurn), so the
875
+ // box and the picks are emptied here -- and put back if the message did
876
+ // not land, whatever the controller did or did not restore. The box is
877
+ // restored only when it is empty or holds the merged text a runtime put
878
+ // back; anything typed since is left alone.
879
+ //
880
+ // The restore goes to the session the send USED: in a new chat that is
881
+ // the one ensureSession just made, which is where the composer reads its
882
+ // picks from next, not the '' they were taken from. A send that never
883
+ // reached the controller (a false return, ensureSession failing) puts
884
+ // them back where they were. A send that throws is restored and not
885
+ // rethrown: the controller owns how its failure is shown.
886
+ if (taken.length) {
887
+ const prior = input;
888
+ const from = pickKey;
889
+ clearPicks();
890
+ setInput('');
891
+ const restore = (sid) => {
892
+ setPicks(sid, [...taken, ...listPicks(sid).filter((p) => !taken.includes(p))]);
893
+ const now = controller.getState(sid).input;
894
+ if (!now.trim() || now.trim() === text)
895
+ controller.setInput(sid, prior);
896
+ };
897
+ let settled = false;
898
+ try {
899
+ await sendMessage(text, images, (sid, landed) => {
900
+ settled = true;
901
+ if (!landed)
902
+ restore(sid);
903
+ });
904
+ }
905
+ catch {
906
+ /* restored in onSettled (or below, when the send never reached the controller) */
907
+ }
908
+ finally {
909
+ if (!settled)
910
+ restore(from);
911
+ }
912
+ return;
913
+ }
695
914
  await sendMessage(text, images);
696
915
  };
697
916
  /* Send-later: a parked message goes through sendMessage exactly as a typed one
@@ -719,6 +938,10 @@ export function AgentChat(props) {
719
938
  const flushingRef = useRef(null);
720
939
  const stopNotesRef = useRef(0);
721
940
  const prevRunning = useRef(running);
941
+ /** smartAction: a message that interrupted the run by stopping it (no inject
942
+ * on this controller, or it carries attachments). Sent at the run's end,
943
+ * ahead of anything queued. */
944
+ const interruptRef = useRef(null);
722
945
  useEffect(() => {
723
946
  if (running && !prevRunning.current) {
724
947
  // A fresh run: forget the last one's abnormal markers.
@@ -747,7 +970,26 @@ export function AgentChat(props) {
747
970
  const unconfirmed = getPending(sessionId).filter((p) => p.state === 'injecting').map((p) => p.id);
748
971
  if (unconfirmed.length)
749
972
  setPendingState(sessionId, unconfirmed, 'queued');
750
- if (abnormal) {
973
+ const interrupt = interruptRef.current;
974
+ interruptRef.current = null;
975
+ /* Parked as held on its OWN session, never lost: the composer was
976
+ * already cleared, so this is the only copy. */
977
+ const park = (m) => {
978
+ const p = addPending(m.sid, m.text, m.images);
979
+ setPendingState(m.sid, [p.id], 'held');
980
+ };
981
+ if (interrupt && interrupt.sid !== sessionId) {
982
+ // The chat changed before its run ended: this edge belongs to another
983
+ // session, so the message must not be sent into it.
984
+ park(interrupt);
985
+ }
986
+ if (interrupt && interrupt.sid === sessionId) {
987
+ // The run was stopped to make room for this message: it goes now, and
988
+ // whatever else was queued stays queued, flushing after it (D3).
989
+ void deliver(interrupt.text, interrupt.images).then((sent) => { if (!sent)
990
+ park(interrupt); });
991
+ }
992
+ else if (abnormal) {
751
993
  const ids = getPending(sessionId).map((p) => p.id);
752
994
  if (ids.length)
753
995
  setPendingState(sessionId, ids, 'held');
@@ -761,7 +1003,7 @@ export function AgentChat(props) {
761
1003
  const next = getPending(sessionId).find((p) => p.state === 'queued' && p.id !== flushingRef.current);
762
1004
  if (next) {
763
1005
  flushingRef.current = next.id;
764
- void sendMessage(next.text, next.images).then((sent) => {
1006
+ void deliver(next.text, next.images).then((sent) => {
765
1007
  if (sent)
766
1008
  removePending(sessionId, next.id);
767
1009
  flushingRef.current = null;
@@ -797,12 +1039,13 @@ export function AgentChat(props) {
797
1039
  const queueCurrent = () => {
798
1040
  if (!sessionId)
799
1041
  return;
800
- const text = input.trim();
1042
+ const text = draftText();
801
1043
  if (!text && attachments.length === 0)
802
1044
  return;
803
1045
  addPending(sessionId, text, attachments);
804
1046
  setInput('');
805
1047
  setAttachments(() => []);
1048
+ clearPicks();
806
1049
  };
807
1050
  /* Push a message into the turn already running.
808
1051
  *
@@ -821,11 +1064,12 @@ export function AgentChat(props) {
821
1064
  // time -- so they are reconciled here, at press time, and never re-pushed.
822
1065
  const landed = new Set(session.injectedIds ?? []);
823
1066
  if (!id) {
824
- const text = input.trim();
1067
+ const text = draftText();
825
1068
  if (text || attachments.length > 0) {
826
1069
  const msg = addPending(sid, text, attachments);
827
1070
  setInput('');
828
1071
  setAttachments(() => []);
1072
+ clearPicks();
829
1073
  id = msg.id;
830
1074
  }
831
1075
  else {
@@ -862,7 +1106,7 @@ export function AgentChat(props) {
862
1106
  // Nothing live took it. A held chip's "Send now" then means what it says:
863
1107
  // send as a fresh turn -- and the entry goes only once that send happened.
864
1108
  if (entry.state === 'held') {
865
- void sendMessage(entry.text, entry.images).then((sent) => {
1109
+ void deliver(entry.text, entry.images).then((sent) => {
866
1110
  if (sent)
867
1111
  removePending(sid, entry.id);
868
1112
  else
@@ -873,6 +1117,55 @@ export function AgentChat(props) {
873
1117
  if (!injectable)
874
1118
  setPendingNote(sid, entry.id, INJECT_IMAGES_NOTE);
875
1119
  };
1120
+ /* smartAction: a hold on Queue interrupts the reply with the draft (D3).
1121
+ *
1122
+ * A text-only draft on a controller that can inject is pushed in HARD: it
1123
+ * cuts the reply and lands now. Anything else (no inject, the ACP proxy, or
1124
+ * a draft with attachments the inject wire cannot carry) stops the run and
1125
+ * sends the draft when it ends. The draft leaves the composer either way.
1126
+ *
1127
+ * A host switch that is ON (image mode) also takes the stop-and-send path:
1128
+ * the inject wire goes straight to the chat model, and the message must go
1129
+ * through interceptSend at the run's end like any other send (D6).
1130
+ *
1131
+ * With no run to interrupt (it ended as the hold landed) there is nothing to
1132
+ * cut: the draft is a normal send. Stopping an idle session would leave the
1133
+ * draft waiting on a run-end edge that never comes, until the NEXT run. */
1134
+ const interruptCurrent = () => {
1135
+ const sid = sessionId;
1136
+ if (!sid)
1137
+ return;
1138
+ if (!running) {
1139
+ void send();
1140
+ return;
1141
+ }
1142
+ const text = draftText();
1143
+ const images = attachments;
1144
+ if (!text && images.length === 0)
1145
+ return;
1146
+ setInput('');
1147
+ setAttachments(() => []);
1148
+ clearPicks();
1149
+ const toggleOn = (composerToggles ?? []).some((t) => t.on);
1150
+ if (images.length === 0 && controller.inject && !toggleOn) {
1151
+ const msg = addPending(sid, text, []);
1152
+ if (controller.inject(sid, [{ id: msg.id, text }], true)) {
1153
+ setPendingState(sid, [msg.id], 'injecting');
1154
+ return;
1155
+ }
1156
+ // Refused (no run on the worker to take it): the same as a controller
1157
+ // without inject -- stop, then send it at the run's end.
1158
+ removePending(sid, msg.id);
1159
+ }
1160
+ // A second hold while the first stop is still landing: the earlier message
1161
+ // queues behind this one rather than being overwritten.
1162
+ const earlier = interruptRef.current;
1163
+ if (earlier)
1164
+ addPending(earlier.sid, earlier.text, earlier.images);
1165
+ interruptRef.current = { sid, text, images };
1166
+ stoppedRef.current = true;
1167
+ controller.stop(sid);
1168
+ };
876
1169
  // Re-run a prior user turn (the engine drops it + everything after, then re-sends).
877
1170
  const onRetry = (turnId) => {
878
1171
  if (!sessionId || running)
@@ -902,16 +1195,119 @@ export function AgentChat(props) {
902
1195
  const ownLoop = features?.ownLoop !== false;
903
1196
  const sessionTitle = sessionList.find((s) => s.id === sessionId)?.title || (title ?? brandName);
904
1197
  const modeEntries = useMemo(() => modes.map((m) => ({ id: m.id, label: m.label, description: m.description ?? '', accent: m.accent })), [modes]);
1198
+ // The live turn as drawn: the run's own, or on the rail its last view kept
1199
+ // through the run-end hand-off (see `frozen`).
1200
+ const liveView = live ?? (railLive && frozen ? frozen.live : null);
1201
+ const liveAnswer = live ? liveText : frozen?.text ?? '';
905
1202
  // A host card per live tool, built from the same call shape the settled
906
1203
  // transcript hands renderToolCard, so a card does not change on settle. A
907
1204
  // non-null card wins in every skin and effect: never folded, never a board row.
908
1205
  // The result goes along once the tool is done, as the settled transcript does.
909
- const liveCards = new Map();
910
- for (const t of live?.tools ?? []) {
911
- 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);
912
- if (card)
913
- liveCards.set(t.id, card);
1206
+ // Built when the tools change, not on every text delta (the fold keeps the
1207
+ // tools array while only the answer text grows).
1208
+ const liveTools = liveView?.tools;
1209
+ const liveCards = useMemo(() => {
1210
+ const cards = new Map();
1211
+ if (!renderToolCard)
1212
+ return cards;
1213
+ for (const t of liveTools ?? []) {
1214
+ const card = renderToolCard({ id: t.id, type: 'function', function: { name: t.name, arguments: argsJson(t.args) } }, t.done && t.result !== undefined ? { text: t.result, isError: !!t.isError } : undefined);
1215
+ if (card)
1216
+ cards.set(t.id, card);
1217
+ }
1218
+ return cards;
1219
+ }, [liveTools, renderToolCard]);
1220
+ /* The live turn on the rail. A running step's time counts from when it was
1221
+ * first seen; the one-second runElapsed tick re-renders it. Every effect
1222
+ * draws the rail; split-flap flips the step words in tiles instead of the
1223
+ * departures board (which stays for the summary view and prompd.app). */
1224
+ const railTurns = railLive && frozen && !live ? frozen.turns : shownTurns;
1225
+ const livePlanStart = useMemo(() => planAfter(railTurns), [railTurns]);
1226
+ /* smartAction: the pending permission as a card in the flow. On the live
1227
+ * rail it sits after the step that asked; a delegated callee's ask goes to
1228
+ * the row of the call that asked, once that row shows it (ApprovalSlot).
1229
+ * With no rail in view (no run in progress, the summary view)
1230
+ * it stands alone where the permission card always was. Keyed by request,
1231
+ * so a newer ask is a new card. */
1232
+ const askPerm = smartAction && sessionId ? pendingPerm : null;
1233
+ const skinReach = SKIN_REACH[transcriptSkin];
1234
+ const chatWords = skinReach.chat ? CHAT_WORDS[transcriptSkin] ?? {} : {};
1235
+ // The kit card shows the skin's words (plain words stay its accessible names).
1236
+ // A key is passed only when the skin defines it, so the default skin is unchanged.
1237
+ const approvalLabels = {
1238
+ ...(chatWords.allowOnce ? { allow: chatWords.allowOnce } : {}),
1239
+ ...(chatWords.deny ? { deny: chatWords.deny } : {}),
1240
+ ...(chatWords.allowSession ? { session: chatWords.allowSession } : {}),
1241
+ ...(chatWords.budgetTitle ? { budgetTitle: chatWords.budgetTitle } : {}),
1242
+ ...(chatWords.budgetContinue ? { budgetContinue: chatWords.budgetContinue } : {}),
1243
+ ...(chatWords.budgetStop ? { budgetStop: chatWords.budgetStop } : {}),
1244
+ };
1245
+ let approvalNode = null;
1246
+ if (askPerm && sessionId) {
1247
+ const sid = sessionId;
1248
+ const cardKey = typeof askPerm.id === 'number' ? `chat:${sid}:${askPerm.id}` : `chat:${sid}:${askPerm.tool}:${askPerm.summary}`;
1249
+ approvalNode = askPerm.tool === HOOK_ASK_TOOL ? (_jsx(HookAskCard, { perm: askPerm, onAnswer: (allow) => answerShownPerm(sid, askPerm, allow, false) }, cardKey)) : askPerm.tool === SUBMIT_PLAN ? (_jsx(ArmedPlanApproval, { agent: askPerm.agent ?? 'This agent', plan: planTextOf(undefined, askPerm.summary), ...(planAgents ? { agents: planAgents } : {}), onApprove: () => answerShownPerm(sid, askPerm, true, false), onDeny: (note) => answerShownPerm(sid, askPerm, false, false, note) }, cardKey)) : (_jsx(ChatApproval, { request: chatApproval(sid, askPerm, Date.now(), approvalLabels), onAnswer: (a) => answerShownPerm(sid, askPerm, a.allow, a.always) }, cardKey));
914
1250
  }
1251
+ const approvalCall = askPerm && liveView ? liveView.tools.find((t) => approvalBelongsToCall(askPerm, t)) : undefined;
1252
+ const approvalSlot = approvalCall && approvalNode
1253
+ ? { callId: approvalCall.id, node: approvalNode, claim: claimSlot }
1254
+ : null;
1255
+ const approvalOnRail = !!approvalNode && railLive && !!liveView;
1256
+ const approvalAlone = !!approvalNode && !approvalOnRail;
1257
+ /* The live rail is rebuilt when its inputs change: the tools, the view, the
1258
+ * plan, an approval on the rail, and the one-second tick while a step runs
1259
+ * (its time). A text delta alone does not rebuild it, so the steps keep their
1260
+ * identity and their lines do not redraw. */
1261
+ const railApproval = approvalOnRail && !(approvalSlot && slotClaims > 0) ? approvalNode : null;
1262
+ const liveRunning = !!liveTools?.some((t) => !t.done && typeof t.ms !== 'number');
1263
+ const liveEdits = !!liveTools?.some((t) => t.name === 'propose_edit');
1264
+ const railTick = liveRunning ? runElapsed : 0;
1265
+ const railSrc = liveEdits ? currentSrc ?? '' : '';
1266
+ const railView = transcriptView === 'expanded' ? 'expanded' : 'normal';
1267
+ const liveRail = useMemo(() => {
1268
+ if (!railLive || !liveTools)
1269
+ return [];
1270
+ const now = Date.now();
1271
+ const timed = liveTools.map((t) => {
1272
+ if (t.done || typeof t.ms === 'number')
1273
+ return t;
1274
+ const at = firstSeenRef.current.at;
1275
+ let seen = at.get(t.id);
1276
+ if (seen === undefined) {
1277
+ seen = now;
1278
+ at.set(t.id, seen);
1279
+ }
1280
+ return { ...t, ms: Math.max(0, now - seen) };
1281
+ });
1282
+ return liveBlocks(timed, {
1283
+ live: true,
1284
+ view: railView,
1285
+ custom: (callId, name, args) => liveCards.get(callId) ?? (name === 'propose_edit'
1286
+ // propose_edit BLOCKS its run on the review, so the decidable card renders mid-run.
1287
+ ? _jsx(DiffProposal, { call: { id: callId, type: 'function', function: { name, arguments: args } }, currentSrc: railSrc, onApplyEdit: onApplyEdit, view: transcriptView })
1288
+ : null),
1289
+ planStart: livePlanStart,
1290
+ showUpcoming: true,
1291
+ ...(railApproval ? { approval: railApproval } : {}),
1292
+ });
1293
+ // railTick is here so a running step's time follows the one-second tick.
1294
+ }, [railLive, liveTools, railView, liveCards, railSrc, onApplyEdit, transcriptView, livePlanStart, railApproval, railTick]);
1295
+ /* Run again (rail only): the host's rerun text goes in the composer, the
1296
+ * detail closes and the composer takes focus. Nothing is sent. */
1297
+ const detailApiRef = useRef(null);
1298
+ const onRerun = rerunCommand ? (tool, command, where) => {
1299
+ const text = rerunCommand({ tool, command, ...where });
1300
+ if (text === null)
1301
+ return;
1302
+ setInput(text);
1303
+ detailApiRef.current?.close();
1304
+ requestAnimationFrame(() => panelRef.current?.querySelector('[data-chat-part="composer"] textarea')?.focus());
1305
+ } : undefined;
1306
+ const canRerun = rerunCommand ? (tool, command, where) => rerunCommand({ tool, command, ...where }) !== null : undefined;
1307
+ /* The rail's live footer. Its parts keep their places between the running
1308
+ * turn and its hand-off view, so the answer's LiveAnswer is never remounted. */
1309
+ const handingOff = !live && !!frozen;
1310
+ const railFooterEl = railLive && liveView ? (_jsxs("div", { style: fx === 'matrix' ? { ...S.live, position: 'relative', isolation: 'isolate' } : S.live, children: [fx === 'matrix' && _jsx(MatrixRain, { active: running }), recovery && _jsxs("div", { "data-chat-part": "recovery", style: S.recovery, children: [_jsx(Spinner, { size: 13, title: "Recovering" }), " ", recovery] }), _jsx(ApprovalSlotContext.Provider, { value: approvalSlot, children: _jsx(LiveRail, { blocks: liveRail, nameFx: fx, onRerun: onRerun, canRerun: canRerun }) }), liveAnswer && fx === 'netsplit' && !(handingOff && frozen ? frozen.hadAnswer : hasAnswer) && _jsx(NetsplitJoin, {}), liveAnswer ? (_jsxs(_Fragment, { children: [_jsx("div", { "data-tx-part": "assistant", children: _jsx(LiveAnswer, { text: liveAnswer, effect: fx, complete: !running, onProgress: (n) => { liveShownRef.current = n; }, onDone: handingOff ? () => setFrozen(null) : undefined }) }), running && _jsx(LiveCursor, {})] })) : (!recovery && liveView.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;
915
1311
  /* The skin reaches past the transcript only for skins whose SKIN_REACH says
916
1312
  * so. The header and the options drawer sit inside the root, in their
917
1313
  * original order, for every skin: an unskinned chat's DOM (and so its tab
@@ -920,14 +1316,12 @@ export function AgentChat(props) {
920
1316
  * one exception is a chat-reach skin that leaves its header out: the header
921
1317
  * then renders above the root. The lightbox, a neutral image viewer, stays
922
1318
  * outside and keeps the app theme. */
923
- const skinReach = SKIN_REACH[transcriptSkin];
924
1319
  const headerOutside = skinReach.chat && !skinReach.header;
925
- const chatWords = skinReach.chat ? CHAT_WORDS[transcriptSkin] ?? {} : {};
926
1320
  const HeadRow = cap ? 'div' : 'header';
927
1321
  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) {
928
1322
  e.stopPropagation();
929
1323
  startRename(sessionId);
930
- } }, 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)))] }))] }));
1324
+ } }, 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 ?? '', policy: runPolicy }), !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: () => setSettings(!drawerOpen), 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)))] }))] }));
931
1325
  const isPhone = useMatchMedia('(max-width: 640px)');
932
1326
  const switchRef = useRef(switchSession);
933
1327
  switchRef.current = switchSession;
@@ -967,19 +1361,13 @@ export function AgentChat(props) {
967
1361
  // eslint-disable-next-line react-hooks/exhaustive-deps
968
1362
  }, [centered]);
969
1363
  /* Escape closes the "+" menu and hands focus back to the "+" button that
970
- * opened it, the way any menu behaves. Listening only while it is open. */
971
- useEffect(() => {
972
- if (!ctxMenuOpen)
973
- return;
974
- const onKey = (e) => {
975
- if (e.key !== 'Escape')
976
- return;
977
- setCtxMenuOpen(false);
978
- panelRef.current?.querySelector('[data-testid="composer-add"]')?.focus();
979
- };
980
- document.addEventListener('keydown', onKey);
981
- return () => document.removeEventListener('keydown', onKey);
982
- }, [ctxMenuOpen]);
1364
+ * opened it, the way any menu behaves. On the shared escape stack, so a sheet
1365
+ * opened from the menu closes first; under phoneKit the menu is itself a
1366
+ * sheet, which closes on Escape and returns focus on its own. */
1367
+ useEscape(ctxMenuOpen && !phoneKit, () => {
1368
+ setCtxMenuOpen(false);
1369
+ panelRef.current?.querySelector('[data-testid="composer-add"]')?.focus();
1370
+ });
983
1371
  /* A press anywhere else closes it too. Hover-out alone is not enough once a
984
1372
  * toggle's options show: their lists float past the menu's edge, and on a
985
1373
  * phone there is no hover at all. The "+" button and toggle chips open and
@@ -987,7 +1375,8 @@ export function AgentChat(props) {
987
1375
  * portalled out of the menu) is part of it. */
988
1376
  const ctxMenuRef = useRef(null);
989
1377
  useEffect(() => {
990
- if (!ctxMenuOpen)
1378
+ // The phone Sheet closes itself; Sheets opened from it portal outside it.
1379
+ if (!ctxMenuOpen || phoneKit)
991
1380
  return;
992
1381
  const onDown = (e) => {
993
1382
  const t = e.target;
@@ -1001,7 +1390,7 @@ export function AgentChat(props) {
1001
1390
  };
1002
1391
  document.addEventListener('pointerdown', onDown);
1003
1392
  return () => document.removeEventListener('pointerdown', onDown);
1004
- }, [ctxMenuOpen]);
1393
+ }, [ctxMenuOpen, phoneKit]);
1005
1394
  /* Options under a switched-on toggle (the image settings) hold dropdowns;
1006
1395
  * leaving the menu's box to reach one must not close it. */
1007
1396
  const optionsShown = (composerToggles ?? []).some((t) => t.on && t.options);
@@ -1047,6 +1436,29 @@ export function AgentChat(props) {
1047
1436
  }, [ctxMenuOpen]);
1048
1437
  const menuItemStyle = isPhone ? { ...S.menuItem, minHeight: 44, fontSize: 14 } : S.menuItem;
1049
1438
  const headerEl = cap ? _jsx("header", { style: S.headOuter, children: headerRow }) : headerRow;
1439
+ /* The host's ON toggles as composer chips. */
1440
+ const chipsFor = (toggles) => (toggles ?? []).filter((t) => t.on).map((t) => ({
1441
+ id: t.id, label: t.label, icon: t.icon, onRemove: t.onToggle,
1442
+ ...(t.title ? { title: t.title } : {}),
1443
+ ...(t.chipLabel ? { chipLabel: t.chipLabel } : {}),
1444
+ // smartAction: an ON toggle can rename the action ("Generate").
1445
+ ...(smartAction && t.sendLabel ? { sendLabel: t.sendLabel } : {}),
1446
+ /* Opens the menu ON the options: focus goes to the toggle's row,
1447
+ * so a keyboard or screen reader lands where the chip pointed. */
1448
+ ...(t.options ? {
1449
+ onOpen: () => {
1450
+ setCtxMenuOpen(true);
1451
+ requestAnimationFrame(() => panelRef.current?.querySelector(`[data-composer-toggle="${t.id}"]`)?.focus());
1452
+ },
1453
+ } : {}),
1454
+ }));
1455
+ /* smartAction: the mic IS the composer's action while it has nothing else to
1456
+ * do (or while it is busy), and reports consent, progress and errors to the
1457
+ * notice row instead of beside itself. Same wiring as the leadSlot mic below,
1458
+ * which stays for the legacy composer; Composer renders only one of them. */
1459
+ const smartMic = smartAction ? (_jsx(DictateButton, { host: browserModelHost(), backend: () => { const r = resolveDictation(); return { host: r.host, createDictation: r.createDictation, supported: r.where !== 'none' }; }, onListeningChange: (on) => { if (on)
1460
+ dictationBase.current = inputRef.current; setDictating(on); }, onInterim: (t) => { if (t)
1461
+ setInput(appendDictated(dictationBase.current, t)); }, onFinal: (t) => setInput(appendDictated(dictationBase.current, t)), onNotice: setDictNotice })) : undefined;
1050
1462
  const composerEl = (_jsx(Composer, { value: input, onChange: setInput, runHint: runHint, docked: !centered,
1051
1463
  // The mic goes in the bottom row's FIXED first position, not the chip
1052
1464
  // row. It was in the chip row first, and TodoBar shoved it sideways
@@ -1075,15 +1487,28 @@ export function AgentChat(props) {
1075
1487
  final transcript has already landed. Honouring it would recompute
1076
1488
  `base + ''` and wipe the committed text a beat after showing it. */
1077
1489
  onInterim: (t) => { if (t)
1078
- setInput(appendDictated(dictationBase.current, t)); }, onFinal: (t) => setInput(appendDictated(dictationBase.current, t)) }), onSend: send, onStop: () => { stoppedRef.current = true; if (sessionId)
1079
- controller.stop(sessionId); }, running: running, mode: mode, onModeChange: setMode, modes: modeEntries, chips: [
1490
+ setInput(appendDictated(dictationBase.current, t)); }, onFinal: (t) => setInput(appendDictated(dictationBase.current, t)) }), smartAction: smartAction, phoneLayout: phoneKit, sessionKey: sessionId ?? undefined, dictate: smartMic, onInterrupt: smartAction ? interruptCurrent : undefined, onSend: send,
1491
+ // smartAction: Stop also drops every queued message (held ones stay), and an interrupt
1492
+ // still waiting for this run to end (D4). Without it
1493
+ // they are parked as held by the run-end effect, as before.
1494
+ onStop: () => { stoppedRef.current = true; if (sessionId) {
1495
+ if (smartAction) {
1496
+ dropQueued(sessionId);
1497
+ if (interruptRef.current?.sid === sessionId)
1498
+ interruptRef.current = null;
1499
+ }
1500
+ controller.stop(sessionId);
1501
+ } }, running: running, mode: mode, onModeChange: setMode, modes: modeEntries, chips: [
1080
1502
  ...(chips ?? []),
1081
1503
  ...contextDocs.map((d) => ({ id: `doc:${d.id}`, label: `${d.name} · ${fmtTok(d.tokens)} tok`, removable: true })),
1504
+ ...picks.map((p) => ({ id: `pick:${p.id}`, label: p.label, removable: true, oneShot: true, icon: _jsx(IcTarget, { size: 11 }), removeLabel: 'Remove picked element', detail: p.text })),
1082
1505
  ], onRemoveChip: (cid) => {
1083
1506
  if (cid.startsWith('doc:')) {
1084
1507
  if (sessionId)
1085
1508
  removeDoc(sessionId, cid.slice(4));
1086
1509
  }
1510
+ else if (cid.startsWith('pick:'))
1511
+ removePick(pickKey, cid.slice(5));
1087
1512
  else
1088
1513
  onRemoveChip?.(cid);
1089
1514
  }, onAddContext: () => setCtxMenuOpen((o) => !o),
@@ -1094,29 +1519,21 @@ export function AgentChat(props) {
1094
1519
  // stays put: TodoBar renders nothing when there are no tasks, and a
1095
1520
  // control that moves as the agent starts and finishes work is a
1096
1521
  // control people misclick.
1097
- toggleChips: (composerToggles ?? []).filter((t) => t.on).map((t) => ({
1098
- id: t.id, label: t.label, icon: t.icon, onRemove: t.onToggle,
1099
- ...(t.title ? { title: t.title } : {}),
1100
- ...(t.chipLabel ? { chipLabel: t.chipLabel } : {}),
1101
- /* Opens the menu ON the options: focus goes to the toggle's row,
1102
- * so a keyboard or screen reader lands where the chip pointed. */
1103
- ...(t.options ? {
1104
- onOpen: () => {
1105
- setCtxMenuOpen(true);
1106
- requestAnimationFrame(() => panelRef.current?.querySelector(`[data-composer-toggle="${t.id}"]`)?.focus());
1107
- },
1108
- } : {}),
1109
- })), 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')
1522
+ toggleChips: chipsFor(composerToggles), 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,
1523
+ // smartAction: queued bubbles sit above the box, not in the strip.
1524
+ statusHidden: dockEmpty && !running && todosNow.length === 0 && !degradedModel && (smartAction || pending.length === 0), slashCommands: slashCommands, onPickSlash: (name) => { if (runSlash(name) !== 'prefilled')
1110
1525
  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)
1111
1526
  removePending(sessionId, id); }, onEditPending: (id, text) => { if (sessionId)
1112
- 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 })) }));
1527
+ editPending(sessionId, id, text); }, onImageClick: setLightboxSrc, inPrice: inPrice, onDropFiles: (files) => void processFiles(files), history: history, sendLater: (_jsx(SendLater, { collapsed: true, open: laterOpen, onOpenChange: setLaterOpen,
1528
+ // Picks are parked with the text, like a send, and leave the composer.
1529
+ text: draftText(), onQueued: () => { setInput(''); clearPicks(); }, onSendNow: (t) => { void sendMessage(t, []); }, items: laterItems, now: laterNow })) }));
1113
1530
  /* Everything that sits directly above the dock: notices, a pending question,
1114
1531
  * the host's aboveComposer, a command in flight and the console tray. It
1115
1532
  * travels WITH the dock -- into the centred start when the chat is empty,
1116
1533
  * at the panel foot otherwise -- so it is never stranded far from the box
1117
1534
  * it belongs to. The read-only bar is not part of it (centred never happens
1118
1535
  * in read-only). */
1119
- 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)
1536
+ const noticesEl = (_jsxs(_Fragment, { children: [smartAction && dictNotice && (_jsx("div", { style: S.uploadNotice, role: dictNotice.kind === 'error' ? 'alert' : 'status', "data-chat-part": "dictation", children: dictNotice.kind === 'consent' ? (_jsxs(_Fragment, { children: [_jsxs("span", { style: { flex: 1 }, children: ["Voice input runs entirely on your device. It needs a one-time ", dictNotice.size, " download."] }), _jsx(KitButton, { variant: "primary", onClick: dictNotice.accept, children: "Enable" }), _jsx(KitButton, { variant: "secondary", onClick: dictNotice.decline, children: "Not now" })] })) : dictNotice.kind === 'preparing' ? (_jsxs(_Fragment, { children: [_jsxs("span", { style: { flex: 1 }, children: ["Getting voice input ready", dictNotice.progress != null ? ` ${dictNotice.progress}%` : ''] }), _jsx(KitButton, { variant: "secondary", onClick: dictNotice.cancel, children: "Cancel" })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { style: { flex: 1 }, children: dictNotice.message }), _jsx("button", { type: "button", style: S.uploadNoticeX, onClick: dictNotice.dismiss, "aria-label": "Dismiss", children: _jsx(IcX, { size: 11 }) })] })) })), 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)
1120
1537
  controller.resolveAsk(sessionId, answers); }, onCancel: () => { if (sessionId)
1121
1538
  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)
1122
1539
  controller.toggleInclude(sessionId, turnId); }, onDismiss: (turnId) => setDismissed((d) => new Set(d).add(turnId)) })] }))] }));
@@ -1124,7 +1541,7 @@ export function AgentChat(props) {
1124
1541
  setCtxMenuOpen(false);
1125
1542
  setInput('/');
1126
1543
  requestAnimationFrame(() => panelRef.current?.querySelector('[data-chat-part="composer"] textarea')?.focus());
1127
- }, children: [_jsx(IcCode, { size: 13 }), " Commands"] })), _jsx("div", { style: S.menuLabel, children: "Tools" }), (() => {
1544
+ }, children: [_jsx(IcCode, { size: 13 }), " Commands"] })), _jsx("div", { style: S.menuLabel, children: "Tools" }), !smartAction && (() => {
1128
1545
  const route = resolveDictation();
1129
1546
  const label = dictating ? 'Stop dictating'
1130
1547
  : route.where === 'sidecar' ? (route.tunnel ? 'Dictate (on your sidecar, via tunnel)' : 'Dictate (on your sidecar)')
@@ -1136,7 +1553,15 @@ export function AgentChat(props) {
1136
1553
  /* A toggle with settings is a switch: flipping it keeps the
1137
1554
  * menu open so its settings appear right here, under it. */
1138
1555
  _jsxs(React.Fragment, { children: [_jsxs("button", { role: "menuitemcheckbox", "aria-checked": t.on, style: isPhone ? { ...S.menuItemTall, minHeight: 48, fontSize: 14 } : S.menuItemTall, "data-composer-toggle": t.id, title: t.title, onClick: t.onToggle, children: [t.icon, _jsxs("span", { style: S.menuItemText, children: [_jsx("span", { children: t.label }), t.on && t.summary && _jsx("span", { style: S.menuItemSub, children: t.summary })] }), _jsx("span", { "aria-hidden": "true", style: { ...S.switchTrack, ...(t.on ? S.switchTrackOn : null) }, children: _jsx("span", { style: { ...S.switchKnob, ...(t.on ? S.switchKnobOn : null) } }) })] }), t.on && _jsx("div", { style: S.menuOptions, "data-testid": `composer-toggle-options-${t.id}`, children: t.options() })] }, t.id)) : (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": t.on, style: menuItemStyle, title: t.title, onClick: () => { setCtxMenuOpen(false); t.onToggle(); }, children: [t.icon, " ", _jsx("span", { style: { flex: 1, textAlign: 'left' }, children: t.label }), t.on && _jsx(IcCheck, { size: 13 })] }, t.id)))] }));
1139
- const dockEl = (_jsxs("div", { style: S.dockWrap, children: [ctxMenuOpen && (phoneSheet ? (
1556
+ const dockEl = (_jsxs("div", { style: S.dockWrap, children: [ctxMenuOpen && (phoneKit ? (
1557
+ /* The phone shell: attach, then one row per host switch, in a kit
1558
+ * Sheet. Attach clicks the picker FIRST so it opens inside the tap's
1559
+ * gesture, then closes the Sheet. */
1560
+ _jsx(KitSheet, { open: true, label: "Add to message", onClose: () => setCtxMenuOpen(false), children: _jsxs("div", { className: "chat-plus-sheet", children: [_jsx(ListRow, { title: "Attach a file", lead: _jsx(IcFile, { size: 18 }), onClick: () => { fileInputRef.current?.click(); setCtxMenuOpen(false); } }), (composerToggles ?? []).map((t) => {
1561
+ const name = t.sheetLabel ?? t.label;
1562
+ const opts = t.on ? (t.sheetOptions ?? t.options) : undefined;
1563
+ return (_jsxs(React.Fragment, { children: [_jsx(ListRow, { title: name, lead: t.icon, trailing: _jsx(Toggle, { ariaLabel: name, checked: t.on, onChange: () => t.onToggle() }) }), opts?.()] }, t.id));
1564
+ }), renderContextMenu?.(() => setCtxMenuOpen(false))] }) })) : phoneSheet ? (
1140
1565
  /* On a phone the menu is a bottom sheet: anchored where the thumb
1141
1566
  * is, the conversation dimmed behind it, and its body scrolls the
1142
1567
  * way every phone sheet does once the image settings are open. */
@@ -1146,42 +1571,67 @@ export function AgentChat(props) {
1146
1571
  ...(menuFit.top !== undefined ? { bottom: 'auto', top: menuFit.top } : null),
1147
1572
  ...(menuFit.maxHeight !== undefined ? { maxHeight: menuFit.maxHeight, overflowY: 'auto' } : null),
1148
1573
  }, onMouseLeave: optionsShown ? undefined : () => setCtxMenuOpen(false), children: menuItems }))), composerEl] }));
1149
- 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
1150
- ? (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 })] }))))
1151
- : _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) => ({
1152
- name: t.name, args: t.args || {}, result: t.result ?? '', isError: t.isError, running: !t.done, ms: t.ms,
1153
- })), view: transcriptView })), live.tools.filter((t) => (fx !== 'splitflap' && transcriptSkin !== 'minimal') || t.name === 'propose_edit' || liveCards.has(t.id)).map((t) => liveCards.has(t.id) ? (
1154
- // A host card replaces the default card mid-run too.
1155
- _jsx(React.Fragment, { children: liveCards.get(t.id) }, t.id)) : t.name === 'propose_edit' ? (
1156
- // propose_edit BLOCKS its run on the review, so the decidable card
1157
- // must render here, mid-run — not only in the settled transcript.
1158
- _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 ? (
1159
- // A tool that finished mid-run gets the SAME expandable card as the
1160
- // settled transcript, so its args/result are viewable before the
1161
- // turn ends (not just a bare summary line).
1162
- // data-fx-id stays the same across running -> done, so an effect
1163
- // treats the swap as the same tool line (one rock, one animation).
1164
- _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)) : (
1165
- // The name animates once, here, when the tool appears.
1166
- _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)
1167
- ? { role: 'group', 'aria-label': pendingPerm.tool === 'budget' ? 'Budget limit reached' : 'Permission needed' }
1168
- : {}), children: [_jsx("div", { style: S.permHead, "data-chat-part": "perm-head", children: pendingPerm.tool === 'budget'
1169
- ? (chatWords.budgetTitle ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Budget limit reached"] }))
1170
- : (chatWords.permTitle?.(pendingPerm.tool) ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Permission needed"] })) }), _jsx("div", { style: S.permBody, children: pendingPerm.tool === 'budget'
1171
- ? pendingPerm.summary
1172
- : (_jsxs(_Fragment, { children: [pendingPerm.requester === 'you'
1173
- ? 'Your command needs'
1174
- : pendingPerm.agent
1175
- ? _jsxs(_Fragment, { children: [_jsx("strong", { children: pendingPerm.agent }), " (called by ", pendingPerm.caller ?? 'this agent', ") wants to use"] })
1176
- : '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) => {
1177
- const files = Array.from(e.target.files ?? []);
1178
- e.target.value = '';
1179
- setCtxMenuOpen(false);
1180
- void processFiles(files);
1181
- } }), _jsxs(CapBox, { cap: cap, children: [centered ? null : noticesEl, readOnly ? (
1182
- /* WATCH MODE: the whole input region is replaced, not disabled piecemeal.
1183
- A disabled box with live chips around it still reads as "type here". */
1184
- _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) })] }));
1574
+ /* The settings, as the drawer or (phoneKit) the phone Sheet: one prop set. */
1575
+ const settingsProps = {
1576
+ open: drawerOpen,
1577
+ onClose: () => setSettings(false),
1578
+ personaName,
1579
+ onPersona: pickPersona,
1580
+ policyName,
1581
+ onPolicy: selectPolicy,
1582
+ hideIdentity: !!fixedIdentity,
1583
+ budgetSection: budgetSection?.({ override, setOverride }),
1584
+ settingsExtras,
1585
+ toolBadge,
1586
+ policyEditor,
1587
+ registryTick,
1588
+ capabilities,
1589
+ sessionId: sessionId ?? '',
1590
+ // smartAction: the composer has no mode control, so Mode lives here, and
1591
+ // a proxied chat (no own loop) gets the settings as Mode only.
1592
+ ...(smartAction ? { modes: modeEntries, mode, onMode: setMode, modeOnly: !ownLoop } : null),
1593
+ };
1594
+ return (_jsx(BlockedResults.Provider, { value: blockedLookup, children: _jsx(ChatRefreshScope, { on: smartAction, children: _jsxs("div", { style: S.panel, ref: panelRef, children: [headerOutside && !hideHeader ? headerEl : null, _jsxs(ChatSkinRoot, { skin: transcriptSkin, style: S.skinBody, children: [headerOutside || hideHeader ? null : headerEl, (ownLoop || smartAction) && (phoneKit ? _jsx(ChatSettingsSheet, { ...settingsProps, lead: settingsLead, contextMeter: ownLoop && !readOnly ? _jsx(ContextBar, { variant: "flow", contextWindow: contextWindow, systemSource: resolveSystemSource(), offloaded: offloaded, conversation: conversation, sessionId: sessionId ?? '', policy: runPolicy }) : undefined }) : _jsx(OptionsDrawer, { ...settingsProps })), _jsxs("div", { style: S.chatRegion, children: [_jsx(ChatDetailHost, { on: smartAction, hidden: timelineScrubbed, apiRef: detailApiRef, follow: railTwin && !liveView ? { from: LIVE_SCOPE, to: `${railTwin}/` } : undefined, children: _jsx("div", { style: { ...S.body, ...(timelineScrubbed ? { display: 'none' } : null) }, ref: bodyRef, "data-testid": "chat-body", "data-chat-session": sessionId, onScroll: onBodyScroll, children: _jsxs(CapBox, { cap: cap, extra: S.bodyInner, children: [turns.length === 0 && !live && (loaded !== false
1595
+ ? (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 })] }))))
1596
+ : _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: railTurns, 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, chatRefresh: smartAction, liveFooter: !!liveView, onRerun: smartAction ? onRerun : undefined, canRerun: smartAction ? canRerun : undefined, footer: railLive ? railFooterEl : live ? (_jsxs("div", { style: fx === 'matrix' ? { ...S.live, position: 'relative', isolation: 'isolate' } : S.live, children: [fx === 'matrix' && _jsx(MatrixRain, { active: running }), recovery && _jsxs("div", { "data-chat-part": "recovery", 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) => ({
1597
+ name: t.name, args: t.args || {}, result: t.result ?? '', isError: t.isError, running: !t.done, ms: t.ms,
1598
+ })), view: transcriptView })), live.tools.filter((t) => (fx !== 'splitflap' && transcriptSkin !== 'minimal') || t.name === 'propose_edit' || liveCards.has(t.id)).map((t) => liveCards.has(t.id) ? (
1599
+ // A host card replaces the default card mid-run too.
1600
+ _jsx(React.Fragment, { children: liveCards.get(t.id) }, t.id)) : t.name === 'propose_edit' ? (
1601
+ // propose_edit BLOCKS its run on the review, so the decidable card
1602
+ // must render here, mid-run — not only in the settled transcript.
1603
+ _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 ? (
1604
+ // A tool that finished mid-run gets the SAME expandable card as the
1605
+ // settled transcript, so its args/result are viewable before the
1606
+ // turn ends (not just a bare summary line).
1607
+ // data-fx-id stays the same across running -> done, so an effect
1608
+ // treats the swap as the same tool line (one rock, one animation).
1609
+ _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)) : (
1610
+ // The name animates once, here, when the tool appears.
1611
+ _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 })] }), approvalAlone && _jsx("div", { className: "tx-approval", "data-tx-part": "approval", "data-tx-alone": "", children: approvalNode }), !approvalNode && 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) })), !approvalNode && pendingPerm && pendingPerm.tool === HOOK_ASK_TOOL && (
1612
+ // Bound to this request's id: an answer never lands on a newer ask.
1613
+ _jsx(HookAskCard, { perm: pendingPerm, onAnswer: (allow) => { if (sessionId)
1614
+ answerShownPerm(sessionId, pendingPerm, allow, false); } }, pendingPerm.id ?? pendingPerm.summary)), !approvalNode && pendingPerm && pendingPerm.tool !== SUBMIT_PLAN && pendingPerm.tool !== HOOK_ASK_TOOL && (_jsxs("div", { style: S.perm, "data-chat-part": "perm", ...((pendingPerm.tool === 'budget' ? chatWords.budgetTitle : chatWords.permTitle)
1615
+ ? { role: 'group', 'aria-label': pendingPerm.tool === 'budget' ? 'Budget limit reached' : 'Permission needed' }
1616
+ : {}), children: [_jsx("div", { style: S.permHead, "data-chat-part": "perm-head", children: pendingPerm.tool === 'budget'
1617
+ ? (chatWords.budgetTitle ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Budget limit reached"] }))
1618
+ : (chatWords.permTitle?.(pendingPerm.tool) ?? _jsxs(_Fragment, { children: [_jsx(IcKey, { size: 14 }), " Permission needed"] })) }), _jsx("div", { style: S.permBody, children: pendingPerm.tool === 'budget'
1619
+ ? pendingPerm.summary
1620
+ : (_jsxs(_Fragment, { children: [pendingPerm.requester === 'you'
1621
+ ? 'Your command needs'
1622
+ : pendingPerm.agent
1623
+ ? _jsxs(_Fragment, { children: [_jsx("strong", { children: pendingPerm.agent }), " (called by ", pendingPerm.caller ?? 'this agent', ") wants to use"] })
1624
+ : '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) => smartAction
1625
+ ? _jsx(KitChip, { onClick: () => setInput(sg), children: sg }, sg)
1626
+ : _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) => {
1627
+ const files = Array.from(e.target.files ?? []);
1628
+ e.target.value = '';
1629
+ setCtxMenuOpen(false);
1630
+ void processFiles(files);
1631
+ } }), _jsxs(CapBox, { cap: cap, children: [centered ? null : noticesEl, readOnly ? (
1632
+ /* WATCH MODE: the whole input region is replaced, not disabled piecemeal.
1633
+ A disabled box with live chips around it still reads as "type here". */
1634
+ _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) })] }) }) }));
1185
1635
  }
1186
1636
  function useMatchMedia(q) {
1187
1637
  const get = () => typeof matchMedia === 'function' && matchMedia(q).matches;
@@ -1203,6 +1653,23 @@ function CapBox({ cap, extra, children }) {
1203
1653
  return cap ? _jsx("div", { "data-content-cap": "", style: { ...cap, ...extra }, children: children }) : _jsx(_Fragment, { children: children });
1204
1654
  }
1205
1655
  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' });
1656
+ /** A System One check or hook asking before the run goes on (tool
1657
+ * HOOK_ASK_TOOL). It names the tool and every reason, shows the tool's result
1658
+ * on request when the ask carries one, and offers only Block and Allow: the
1659
+ * answer is for this one tool call, so there is no session allow. */
1660
+ function HookAskCard({ perm, onAnswer }) {
1661
+ const [shown, setShown] = useState(false);
1662
+ const answered = useRef(false);
1663
+ const answer = (allow) => {
1664
+ if (answered.current)
1665
+ return;
1666
+ answered.current = true;
1667
+ onAnswer(allow);
1668
+ };
1669
+ const reasons = perm.hook?.reasons.length ? perm.hook.reasons : perm.summary.split('\n').filter(Boolean);
1670
+ const result = perm.hook?.result;
1671
+ return (_jsxs("div", { style: S.perm, role: "group", "aria-label": "System One check", "data-chat-part": "hook-ask", children: [_jsxs("div", { style: S.permHead, "data-chat-part": "perm-head", children: [_jsx(IcKey, { size: 14 }), " System One check"] }), _jsxs("div", { style: S.permBody, children: [perm.hook ? _jsxs("div", { children: ["Tool ", _jsx("strong", { children: perm.hook.tool })] }) : null, _jsx("ul", { style: S.hookReasons, children: reasons.map((r, i) => _jsx("li", { children: r }, i)) }), _jsx("div", { children: "The run waits for your answer." }), result !== undefined ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", style: S.hookShow, "aria-expanded": shown, onClick: () => setShown((v) => !v), children: shown ? 'Hide the result' : 'Show the result' }), shown ? _jsx("pre", { style: S.hookResult, children: result }) : null] })) : null] }), _jsxs("div", { style: S.permActions, children: [_jsx("button", { type: "button", style: S.hookBlock, "data-chat-part": "perm-deny", onClick: () => answer(false), children: "Block" }), _jsxs("button", { type: "button", style: S.hookAllow, "data-chat-part": "perm-go", onClick: () => answer(true), children: [_jsx(IcCheck, { size: 12 }), " Allow"] })] })] }));
1672
+ }
1206
1673
  const S = {
1207
1674
  panel: { position: 'relative', display: 'flex', flexDirection: 'column', flex: 1, width: '100%', minWidth: 0, height: '100%', background: 'var(--surface-1)', overflow: 'hidden' },
1208
1675
  // Full-width shell for a capped header row; the rule stays on the capped row so it follows the column.
@@ -1248,7 +1715,8 @@ const S = {
1248
1715
  liveToolRow: { display: 'flex', alignItems: 'center', padding: '6px 10px', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', background: 'var(--surface-2)' },
1249
1716
  thinkingWrap: { display: 'flex', flexDirection: 'column', gap: 6 },
1250
1717
  thinking: { display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--text-tertiary)', fontSize: 13 },
1251
- recovery: { display: 'inline-flex', alignItems: 'center', gap: 7, color: 'var(--warning)', fontSize: 13, marginBottom: 6 },
1718
+ // A chat skin sets --chat-recovery on the part where its --warning does not read.
1719
+ recovery: { display: 'inline-flex', alignItems: 'center', gap: 7, color: 'var(--chat-recovery, var(--warning))', fontSize: 13, marginBottom: 6 },
1252
1720
  tipGhost: { fontSize: 12, color: 'var(--text-disabled)', fontStyle: 'italic', opacity: 0.7, paddingLeft: 20, maxWidth: 320, lineHeight: 1.4 },
1253
1721
  error: { fontSize: 12.5, color: 'var(--danger)', background: 'var(--danger-soft)', border: '1px solid var(--danger)', borderRadius: 'var(--radius-sm)', padding: '8px 10px' },
1254
1722
  // Follow-up chips under the latest reply — click to prefill the composer.
@@ -1268,6 +1736,11 @@ const S = {
1268
1736
  permBody: { fontSize: 13, lineHeight: 1.5, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '45vh', overflowY: 'auto' },
1269
1737
  permActions: { display: 'flex', gap: 8, flexWrap: 'wrap' },
1270
1738
  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' },
1739
+ hookReasons: { margin: '4px 0', paddingLeft: 18 },
1740
+ hookShow: { minHeight: 44, marginTop: 6, 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' },
1741
+ hookResult: { margin: '6px 0 0', padding: 8, maxHeight: 240, overflow: 'auto', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', fontSize: 12, background: 'var(--surface-1)', borderRadius: 'var(--radius-sm)' },
1742
+ hookBlock: { flex: 1, minHeight: 44, padding: '0 14px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-sm)', background: 'var(--surface-1)', color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600, cursor: 'pointer' },
1743
+ hookAllow: { flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5, minHeight: 44, padding: '0 14px', border: 0, borderRadius: 'var(--radius-sm)', background: 'var(--grad-brand)', color: 'var(--text-on-accent, #fff)', fontSize: 13, fontWeight: 600, cursor: 'pointer' },
1271
1744
  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' },
1272
1745
  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' },
1273
1746
  approvalSuggest: { border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', background: 'var(--surface-2)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 },