@corenel/web-ui 0.13.2 → 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.
- package/dist/Composer.d.ts +30 -1
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +111 -19
- package/dist/Composer.js.map +1 -1
- package/dist/ContextBar.d.ts +9 -3
- package/dist/ContextBar.d.ts.map +1 -1
- package/dist/ContextBar.js +14 -7
- package/dist/ContextBar.js.map +1 -1
- package/dist/ContextManager.d.ts +10 -2
- package/dist/ContextManager.d.ts.map +1 -1
- package/dist/ContextManager.js +14 -10
- package/dist/ContextManager.js.map +1 -1
- package/dist/DictateButton.d.ts +21 -1
- package/dist/DictateButton.d.ts.map +1 -1
- package/dist/DictateButton.js +73 -10
- package/dist/DictateButton.js.map +1 -1
- package/dist/PickChips.d.ts +6 -0
- package/dist/PickChips.d.ts.map +1 -0
- package/dist/PickChips.js +55 -0
- package/dist/PickChips.js.map +1 -0
- package/dist/ProcessDock.d.ts +10 -0
- package/dist/ProcessDock.d.ts.map +1 -1
- package/dist/ProcessDock.js +24 -1
- package/dist/ProcessDock.js.map +1 -1
- package/dist/RichContent.d.ts.map +1 -1
- package/dist/RichContent.js +102 -24
- package/dist/RichContent.js.map +1 -1
- package/dist/SummaryTranscript.d.ts +5 -1
- package/dist/SummaryTranscript.d.ts.map +1 -1
- package/dist/SummaryTranscript.js +5 -3
- package/dist/SummaryTranscript.js.map +1 -1
- package/dist/TodoBar.d.ts +2 -0
- package/dist/TodoBar.d.ts.map +1 -1
- package/dist/TodoBar.js +8 -0
- package/dist/TodoBar.js.map +1 -1
- package/dist/TodoLine.d.ts +11 -0
- package/dist/TodoLine.d.ts.map +1 -0
- package/dist/TodoLine.js +55 -0
- package/dist/TodoLine.js.map +1 -0
- package/dist/ToolFold.js +1 -1
- package/dist/ToolFold.js.map +1 -1
- package/dist/Transcript.d.ts +19 -1
- package/dist/Transcript.d.ts.map +1 -1
- package/dist/Transcript.js +127 -58
- package/dist/Transcript.js.map +1 -1
- package/dist/agentChat/AgentChat.d.ts +42 -0
- package/dist/agentChat/AgentChat.d.ts.map +1 -1
- package/dist/agentChat/AgentChat.js +573 -100
- package/dist/agentChat/AgentChat.js.map +1 -1
- package/dist/agentChat/ChatSettingsSheet.d.ts +9 -0
- package/dist/agentChat/ChatSettingsSheet.d.ts.map +1 -0
- package/dist/agentChat/ChatSettingsSheet.js +103 -0
- package/dist/agentChat/ChatSettingsSheet.js.map +1 -0
- package/dist/agentChat/OptionsDrawer.d.ts +8 -0
- package/dist/agentChat/OptionsDrawer.d.ts.map +1 -1
- package/dist/agentChat/OptionsDrawer.js +24 -215
- package/dist/agentChat/OptionsDrawer.js.map +1 -1
- package/dist/agentChat/PlanReview.d.ts +1 -1
- package/dist/agentChat/PlanReview.d.ts.map +1 -1
- package/dist/agentChat/PlanReview.js +1 -1
- package/dist/agentChat/PlanReview.js.map +1 -1
- package/dist/agentChat/SourceGroups.d.ts +24 -0
- package/dist/agentChat/SourceGroups.d.ts.map +1 -0
- package/dist/agentChat/SourceGroups.js +117 -0
- package/dist/agentChat/SourceGroups.js.map +1 -0
- package/dist/agentChat/controller.d.ts +30 -1
- package/dist/agentChat/controller.d.ts.map +1 -1
- package/dist/agentChat/controller.js.map +1 -1
- package/dist/agentChat/pendingQueue.d.ts +4 -0
- package/dist/agentChat/pendingQueue.d.ts.map +1 -1
- package/dist/agentChat/pendingQueue.js +7 -0
- package/dist/agentChat/pendingQueue.js.map +1 -1
- package/dist/agentChat/planTool.d.ts +4 -0
- package/dist/agentChat/planTool.d.ts.map +1 -0
- package/dist/agentChat/planTool.js +4 -0
- package/dist/agentChat/planTool.js.map +1 -0
- package/dist/agentChat/testSheetHost.d.ts +3 -0
- package/dist/agentChat/testSheetHost.d.ts.map +1 -0
- package/dist/agentChat/testSheetHost.js +9 -0
- package/dist/agentChat/testSheetHost.js.map +1 -0
- package/dist/agentChat/useChatOptions.d.ts +63 -0
- package/dist/agentChat/useChatOptions.d.ts.map +1 -0
- package/dist/agentChat/useChatOptions.js +156 -0
- package/dist/agentChat/useChatOptions.js.map +1 -0
- package/dist/chatRefreshContext.d.ts +8 -0
- package/dist/chatRefreshContext.d.ts.map +1 -0
- package/dist/chatRefreshContext.js +14 -0
- package/dist/chatRefreshContext.js.map +1 -0
- package/dist/chatSkinContext.d.ts +13 -6
- package/dist/chatSkinContext.d.ts.map +1 -1
- package/dist/chatSkinContext.js +18 -4
- package/dist/chatSkinContext.js.map +1 -1
- package/dist/chatSkinCss.d.ts.map +1 -1
- package/dist/chatSkinCss.js +108 -22
- package/dist/chatSkinCss.js.map +1 -1
- package/dist/composerAction.d.ts +16 -0
- package/dist/composerAction.d.ts.map +1 -0
- package/dist/composerAction.js +20 -0
- package/dist/composerAction.js.map +1 -0
- package/dist/composerHistory.d.ts.map +1 -1
- package/dist/composerHistory.js +4 -1
- package/dist/composerHistory.js.map +1 -1
- package/dist/fx/LiveAnswer.d.ts.map +1 -1
- package/dist/fx/LiveAnswer.js +324 -59
- package/dist/fx/LiveAnswer.js.map +1 -1
- package/dist/fx/arrivalModes.d.ts +17 -2
- package/dist/fx/arrivalModes.d.ts.map +1 -1
- package/dist/fx/arrivalModes.js +46 -4
- package/dist/fx/arrivalModes.js.map +1 -1
- package/dist/fx/pacer.d.ts +41 -0
- package/dist/fx/pacer.d.ts.map +1 -0
- package/dist/fx/pacer.js +66 -0
- package/dist/fx/pacer.js.map +1 -0
- package/dist/fx/useReveal.d.ts +16 -1
- package/dist/fx/useReveal.d.ts.map +1 -1
- package/dist/fx/useReveal.js +101 -41
- package/dist/fx/useReveal.js.map +1 -1
- package/dist/localAddress.d.ts +2 -0
- package/dist/localAddress.d.ts.map +1 -0
- package/dist/localAddress.js +56 -0
- package/dist/localAddress.js.map +1 -0
- package/dist/steps/AfterReply.d.ts +67 -0
- package/dist/steps/AfterReply.d.ts.map +1 -0
- package/dist/steps/AfterReply.js +217 -0
- package/dist/steps/AfterReply.js.map +1 -0
- package/dist/steps/ChatApproval.d.ts +18 -0
- package/dist/steps/ChatApproval.d.ts.map +1 -0
- package/dist/steps/ChatApproval.js +35 -0
- package/dist/steps/ChatApproval.js.map +1 -0
- package/dist/steps/Delegation.d.ts +77 -0
- package/dist/steps/Delegation.d.ts.map +1 -0
- package/dist/steps/Delegation.js +188 -0
- package/dist/steps/Delegation.js.map +1 -0
- package/dist/steps/LiveRail.d.ts +14 -0
- package/dist/steps/LiveRail.d.ts.map +1 -0
- package/dist/steps/LiveRail.js +16 -0
- package/dist/steps/LiveRail.js.map +1 -0
- package/dist/steps/StepDetail.d.ts +23 -0
- package/dist/steps/StepDetail.d.ts.map +1 -0
- package/dist/steps/StepDetail.js +195 -0
- package/dist/steps/StepDetail.js.map +1 -0
- package/dist/steps/StepDetailHost.d.ts +54 -0
- package/dist/steps/StepDetailHost.d.ts.map +1 -0
- package/dist/steps/StepDetailHost.js +175 -0
- package/dist/steps/StepDetailHost.js.map +1 -0
- package/dist/steps/StepRail.d.ts +38 -0
- package/dist/steps/StepRail.d.ts.map +1 -0
- package/dist/steps/StepRail.js +238 -0
- package/dist/steps/StepRail.js.map +1 -0
- package/dist/steps/approval.d.ts +73 -0
- package/dist/steps/approval.d.ts.map +1 -0
- package/dist/steps/approval.js +81 -0
- package/dist/steps/approval.js.map +1 -0
- package/dist/steps/model.d.ts +140 -0
- package/dist/steps/model.d.ts.map +1 -0
- package/dist/steps/model.js +433 -0
- package/dist/steps/model.js.map +1 -0
- package/dist/steps/outcome.d.ts +5 -0
- package/dist/steps/outcome.d.ts.map +1 -0
- package/dist/steps/outcome.js +34 -0
- package/dist/steps/outcome.js.map +1 -0
- package/dist/steps/peek.d.ts +35 -0
- package/dist/steps/peek.d.ts.map +1 -0
- package/dist/steps/peek.js +219 -0
- package/dist/steps/peek.js.map +1 -0
- package/dist/steps/plan.d.ts +50 -0
- package/dist/steps/plan.d.ts.map +1 -0
- package/dist/steps/plan.js +242 -0
- package/dist/steps/plan.js.map +1 -0
- package/dist/steps/useRailDetail.d.ts +25 -0
- package/dist/steps/useRailDetail.d.ts.map +1 -0
- package/dist/steps/useRailDetail.js +108 -0
- package/dist/steps/useRailDetail.js.map +1 -0
- package/dist/steps/words.d.ts +40 -0
- package/dist/steps/words.d.ts.map +1 -0
- package/dist/steps/words.js +247 -0
- package/dist/steps/words.js.map +1 -0
- package/dist/toolRender.d.ts +27 -1
- package/dist/toolRender.d.ts.map +1 -1
- package/dist/toolRender.js +72 -4
- package/dist/toolRender.js.map +1 -1
- package/dist/transcriptSkin.d.ts.map +1 -1
- package/dist/transcriptSkin.js +66 -0
- package/dist/transcriptSkin.js.map +1 -1
- package/dist/transcriptSkinCss.d.ts +35 -0
- package/dist/transcriptSkinCss.d.ts.map +1 -1
- package/dist/transcriptSkinCss.js +360 -19
- package/dist/transcriptSkinCss.js.map +1 -1
- package/dist/useHoldPress.d.ts +24 -0
- package/dist/useHoldPress.d.ts.map +1 -0
- package/dist/useHoldPress.js +85 -0
- package/dist/useHoldPress.js.map +1 -0
- package/package.json +5 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 [
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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: () =>
|
|
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.
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
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)) }),
|
|
1079
|
-
|
|
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
|
|
1098
|
-
|
|
1099
|
-
|
|
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,
|
|
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 && (
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
: (
|
|
1171
|
-
?
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
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 },
|