@spzxg/cli-ui 0.6.6 → 0.6.8
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/CHANGELOG.md +40 -1
- package/README.md +90 -21
- package/dist/cli-ui-embed-Be9_d9jB.js +49691 -0
- package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-CzNfiynu.js} +2 -2
- package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-BL0mSekN.js} +2 -2
- package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-DjCIAfse.js} +1 -1
- package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-CscI4xk0.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +259 -132
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -76
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +10 -8
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +14 -39
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +38 -6
- package/src/chat/CliChat.tsx +121 -51
- package/src/chat/Composer.tsx +54 -22
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +143 -46
- package/src/chat/draft.ts +12 -4
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +1 -1
- package/src/core/blocks.ts +2 -1
- package/src/core/client.ts +65 -61
- package/src/core/config.ts +6 -2
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/store.ts +81 -20
- package/src/core/types.ts +3 -1
- package/src/index.ts +4 -0
- package/src/msglist/CliMsgList.tsx +9 -2
- package/src/operator/node/Chat.tsx +11 -9
- package/src/operator/node/Composer.tsx +103 -75
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +31 -28
- package/src/operator/node/Transcript.tsx +21 -9
- package/src/operator/node/boardBackend.ts +0 -19
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/useTaskStream.ts +3 -1
- package/src/operator.base.css +150 -43
- package/src/operator.css +150 -43
- package/src/styles.css +253 -39
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +25 -12
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +20 -10
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
|
@@ -5,7 +5,6 @@ import type { Status, Priority, AskQuestion, AskAnswers } from "./protocol/types
|
|
|
5
5
|
import { OperatorBoardContext, OperatorBoardQuoteContext, type OperatorBoardOptions, type OperatorBoardRequest, type OperatorBoardTarget } from "./boardInteraction";
|
|
6
6
|
import { readDraft, writeDraft, type Quote } from "./composerDraft";
|
|
7
7
|
import { clientFeatures } from "./protocol/features";
|
|
8
|
-
import { toolDataOf } from "./transcriptModel";
|
|
9
8
|
import { Icon } from "./icons";
|
|
10
9
|
import { type ResolveResult } from "./TaskChanges";
|
|
11
10
|
import { fmtTokens, fmtCost, fmtJobCost, isAwaiting, buildSessions, usageSplit, costDisplay } from "./format";
|
|
@@ -27,8 +26,11 @@ import { registerOperatorBoard } from "./boardInteraction";
|
|
|
27
26
|
import { useConfigurationBusy } from "../../chat/configuration";
|
|
28
27
|
import type { SessionRail as NodeSessionRail } from "./SessionRail";
|
|
29
28
|
const SessionRail=(props:ComponentProps<typeof NodeSessionRail>)=><CliBoard operator={props}/>;
|
|
30
|
-
import { ColResize
|
|
29
|
+
import { ColResize } from "./Layout";
|
|
30
|
+
import { WorkspaceTabs } from "../../ui/WorkspaceTabs";
|
|
31
|
+
import { WorkspaceDrawer, useWorkspaceDrawer } from "../../ui/WorkspaceDrawer";
|
|
31
32
|
import { jget, downloadExport } from "./api";
|
|
33
|
+
import { resolveChatConfig, type CliChatConfig } from "../../core/config";
|
|
32
34
|
|
|
33
35
|
// Non-blocking banner shown when a reopened task's worktree is behind its base
|
|
34
36
|
// branch. Computed (read-only) on open; the actual git op fires only when the user
|
|
@@ -91,8 +93,9 @@ function SyncBanner({ taskId, running, pinned, onResolveWithAI, onSwitchToChat }
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
export function SessionView(props: Parameters<typeof SessionViewInner>[0]) { return <SessionViewInner key={props.task.id} {...props}/>; }
|
|
94
|
-
function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents, messages, running, blockedBy, transcriptLoading, older=0, onLoadOlder, onSend, onStart, onStop, onClear, clearConfirming, onConfirmClear, onCancelClear, onEdit, onReconnect, onSetStatus, onSetPriority, onSetModel, onSetAgent, onSetReasoning, onSetPermission, onSetSendContext, onResolveWithAI, onMerged, onPrCreated, onAnswer, onCancelQueued, onBack, mobile, railW, onRailWidth, onRailReset, railCollapsed, onRailCollapse, onRailExpand, board }: {
|
|
96
|
+
function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents, messages, running, blockedBy, transcriptLoading, older=0, onLoadOlder, onSend, onStart, onStop, onClear, clearConfirming, onConfirmClear, onCancelClear, onEdit, onReconnect, onSetStatus, onSetPriority, onSetModel, onSetAgent, onSetReasoning, onSetPermission, onSetSendContext, onResolveWithAI, onMerged, onPrCreated, onAnswer, onCancelQueued, onBack, mobile, railW, onRailWidth, onRailReset, railCollapsed, onRailCollapse, onRailExpand, board, composer }: {
|
|
95
97
|
board?: OperatorBoardOptions;
|
|
98
|
+
composer?: CliChatConfig["composer"];
|
|
96
99
|
tasks: TaskRow[]; onSelectTask: (id: string) => void; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
|
|
97
100
|
project: ProjectRow; task: TaskRow; agents: AgentsBundle; messages: Msg[]; running: boolean; blockedBy?: string[]; transcriptLoading?: boolean;
|
|
98
101
|
older?: number; onLoadOlder?: (beforeApply?: () => void) => Promise<boolean>;
|
|
@@ -134,21 +137,24 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
134
137
|
return () => observer.disconnect();
|
|
135
138
|
}, [railW]);
|
|
136
139
|
const singlePane = mobile || compact;
|
|
140
|
+
const drawerMode = useWorkspaceDrawer(sessionRef, !!mobile);
|
|
141
|
+
const tabMode = !drawerMode && (singlePane || railCollapsed);
|
|
142
|
+
const [tabsHost, setTabsHost] = useState<HTMLDivElement|null>(null);
|
|
137
143
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
138
144
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
139
145
|
const [view, setView] = useState<"chat" | "changes">("chat");
|
|
140
146
|
const [boardRequest,setBoardRequest]=useState<OperatorBoardRequest|null>(null);
|
|
141
147
|
const boardSequence=useRef(0);
|
|
142
148
|
const acknowledgeBoardRequest=useCallback((sequence:number)=>setBoardRequest(previous=>previous?.sequence===sequence?null:previous),[]);
|
|
143
|
-
const routing=useRef({
|
|
144
|
-
routing.current={
|
|
149
|
+
const routing=useRef({tabMode,drawerMode,task,tasks,board});
|
|
150
|
+
routing.current={tabMode,drawerMode,task,tasks,board};
|
|
145
151
|
const openBoard=useCallback((target:OperatorBoardTarget)=>{
|
|
146
152
|
const context=routing.current;
|
|
147
153
|
if(target.kind==='browser'&&(target.taskId!==context.task.id||!clientFeatures().browser))return false;
|
|
148
154
|
if(target.kind==='file'&&(!target.path||![context.task,...context.tasks].some(t=>t.id===target.taskId&&t.project_id===context.task.project_id)))return false;
|
|
149
155
|
if(context.board?.availableTabs&&!context.board.availableTabs.includes(target.kind==='file'?'files':'browser'))return false;
|
|
150
156
|
setBoardRequest({sequence:++boardSequence.current,target});
|
|
151
|
-
if(context.
|
|
157
|
+
if(context.tabMode||context.drawerMode)setView('changes');
|
|
152
158
|
return true;
|
|
153
159
|
},[]);
|
|
154
160
|
useEffect(()=>registerOperatorBoard(target=>target.taskId===task.id?openBoard(target):false),[task.id,openBoard]);
|
|
@@ -159,10 +165,9 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
159
165
|
const quoteFromBoard=useCallback((quote:Quote)=>{
|
|
160
166
|
const context=routing.current;
|
|
161
167
|
writeDraft(context.task.id,{...readDraft(context.task.id),quote});
|
|
162
|
-
if(context.
|
|
168
|
+
if(context.tabMode||context.drawerMode)setView('chat');
|
|
163
169
|
requestAnimationFrame(()=>sessionRef.current?.querySelector<HTMLTextAreaElement>('.composer textarea')?.focus({preventScroll:true}));
|
|
164
170
|
},[]);
|
|
165
|
-
const activity=useMemo(()=>{const tool=[...messages].reverse().find(m=>m.role==='tool');return `${messages.length}:${tool?.id??''}:${tool?toolDataOf(tool).result!==undefined:false}`;},[messages]);
|
|
166
171
|
const [clearEstimate, setClearEstimate] = useState<InternalUsageEstimate | null>(null);
|
|
167
172
|
const sessions = useMemo(() => buildSessions(messages), [messages]);
|
|
168
173
|
const hasSession = task.started === 1 || messages.length > 0 || !!task.parent_task_id;
|
|
@@ -178,7 +183,8 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
178
183
|
// Run-control pickers + feature gates come from this task's agent capabilities,
|
|
179
184
|
// never a hardcoded list — so the options always match the agent it runs under.
|
|
180
185
|
const caps = capsFor(agents, task.agent);
|
|
181
|
-
const
|
|
186
|
+
const composerConfig = resolveChatConfig({ composer }).composer;
|
|
187
|
+
const controls = { task, caps, agents:agents.agents.map(a=>({id:a.id,label:a.label,disabled:!a.authenticated||a.installed===false})), onSetAgent, defaults: findAgent(agents, task.agent)?.defaults, disabled: running, onSetModel, onSetReasoning, onSetPermission, showAgent: composerConfig.agentSelector, showPermission: composerConfig.executionMode };
|
|
182
188
|
// Usage chip: tokens split into fresh work vs re-read cache (the raw total is
|
|
183
189
|
// mostly cache reads and wildly overstates what ran), and a dollar figure whose
|
|
184
190
|
// presentation follows how this agent is signed in — a subscription login's
|
|
@@ -193,7 +199,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
193
199
|
<div className={`session${singlePane ? " compact-session" : ""}`} ref={sessionRef}>
|
|
194
200
|
<div className="sess-head">
|
|
195
201
|
{onBack && <button className="mobile-back" onClick={onBack} title="返回任务列表" aria-label="返回任务列表">{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>}
|
|
196
|
-
<div className="sh-main">
|
|
202
|
+
{hasSession&&tabMode ? <WorkspaceTabs title={task.title} subtitle={project.name} chatActive={view==='chat'} onChat={()=>setView('chat')} onTabsHost={setTabsHost}/> : <div className="sh-main">
|
|
197
203
|
<div className="crumb">
|
|
198
204
|
<span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: project.color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{project.name[0]}</span>
|
|
199
205
|
<span className="session-project-name" title={project.name}>{project.name}</span>
|
|
@@ -203,8 +209,9 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
203
209
|
<span className="sht-text" title={task.title}>{task.title}</span>
|
|
204
210
|
<button className="icon-btn sh-edit" title="查看并编辑任务详情" aria-label="查看并编辑任务详情" onClick={onEdit}>{Icon.edit()}</button>
|
|
205
211
|
</div>
|
|
206
|
-
</div>
|
|
212
|
+
</div>}
|
|
207
213
|
<div className="sh-tools">
|
|
214
|
+
{hasSession&&drawerMode&&<button className="icon-btn workspace-open" title="打开工作区" aria-label="打开工作区" aria-haspopup="dialog" aria-expanded={view==='changes'} onClick={()=>setView('changes')}>{Icon.folder()}</button>}
|
|
208
215
|
<div style={{ position: "relative" }}>
|
|
209
216
|
<button className={`status-ctl ${awaiting ? "awaiting" : ""}`} onClick={(e) => { e.stopPropagation(); setStatusOpen((o) => !o); setMoreOpen(false); }}>
|
|
210
217
|
<StatusDot status={task.status} running={running} awaiting={awaiting} />
|
|
@@ -227,6 +234,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
227
234
|
<button className="icon-btn session-more" aria-label="更多任务操作" aria-expanded={moreOpen} onClick={(e) => { e.stopPropagation(); setMoreOpen(!moreOpen); setStatusOpen(false); }}>{Icon.dots()}</button>
|
|
228
235
|
{moreOpen && <Popover onClose={() => setMoreOpen(false)}>
|
|
229
236
|
<div className="pop-sec">{agentLabel(agents, task.agent)} · {fmtTokens(usage.fresh)} tokens{cost.show && task.cost_usd > 0 ? ` · ${cost.approx ? "~" : ""}${fmtCost(task.cost_usd)}` : ""}</div>
|
|
237
|
+
<div className="pop-note session-origin" title={task.work_branch || ""}>{Icon.git()}<span>{task.parent_task_id ? `来自 ${task.fork_source_title || "来源任务"}` : "独立任务分支"}</span></div>
|
|
230
238
|
<button className="pop-item" onClick={() => { setMoreOpen(false); onEdit(); }}>{Icon.edit()} 编辑任务详情</button>
|
|
231
239
|
<button className="pop-item" onClick={() => { setMoreOpen(false); downloadExport(`/api/tasks/${task.id}/export`); }}>{Icon.download()} 导出执行过程</button>
|
|
232
240
|
{task.pr_url && <a className="pop-item" href={task.pr_url} target="_blank" rel="noreferrer">{Icon.github()} 打开 PR{prNum ? ` #${prNum}` : ""}</a>}
|
|
@@ -239,14 +247,6 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
239
247
|
</div>
|
|
240
248
|
</div>
|
|
241
249
|
|
|
242
|
-
{hasSession && <div className="session-viewbar">
|
|
243
|
-
{singlePane ? <div className="viewseg">
|
|
244
|
-
<button className={`viewseg-btn ${view === "chat" ? "on" : ""}`} onClick={() => setView("chat")}>对话</button>
|
|
245
|
-
<button className={`viewseg-btn ${view === "changes" ? "on" : ""}`} onClick={() => setView("changes")}>看板</button>
|
|
246
|
-
</div> : <span className="session-agent-label">{agentLabel(agents, task.agent)}</span>}
|
|
247
|
-
<span className="session-branch" title={task.work_branch || ""}>{Icon.git()} {task.parent_task_id ? `来自 ${task.fork_source_title}` : "独立任务分支"}</span>
|
|
248
|
-
</div>}
|
|
249
|
-
|
|
250
250
|
{hasSession && (
|
|
251
251
|
<SyncBanner taskId={task.id} running={running} pinned={!!task.fork_commit} onResolveWithAI={onResolveWithAI} onSwitchToChat={() => setView("chat")} />
|
|
252
252
|
)}
|
|
@@ -262,24 +262,27 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
262
262
|
)}
|
|
263
263
|
|
|
264
264
|
{!hasSession ? (
|
|
265
|
-
|
|
265
|
+
<>{composerConfig.show && <div className="hero-controls"><RunControls {...controls} /></div>}<TaskHero task={task} project={project} onStart={onStart} onEdit={onEdit} onSetSendContext={onSetSendContext} running={running || configBusy} blockedBy={blockedBy} /></>
|
|
266
266
|
) : (
|
|
267
267
|
// Keep the chat at the same React position across all widths. Moving
|
|
268
268
|
// it between different parents remounted the composer during resize
|
|
269
269
|
// and could drop a keystroke, an upload, or an in-flight setting save.
|
|
270
|
-
<div className={
|
|
271
|
-
<div className={`sess-main${
|
|
270
|
+
<div className={tabMode||drawerMode ? "sess-responsive" : "sess-split"} style={tabMode||drawerMode ? undefined : { gridTemplateColumns: `minmax(0,1fr) 0px ${railW}px` }}>
|
|
271
|
+
<div className={`sess-main${tabMode||drawerMode ? " compact-chat" : ""}`} data-chat-layout={tabMode||drawerMode?"centered":"wide"} hidden={tabMode && view !== "chat"}><Chat project={project} task={task} tasks={tasks} agents={agents} messages={messages}
|
|
272
272
|
running={running} blockedBy={blockedBy} transcriptLoading={transcriptLoading} older={older} onLoadOlder={onLoadOlder}
|
|
273
|
-
controls={controls} configBusy={configBusy} active={!
|
|
273
|
+
controls={controls} configBusy={configBusy} composer={composerConfig} active={!tabMode&&!drawerMode || view === "chat"}
|
|
274
274
|
onSelectTask={onSelectTask} onFork={onFork} onSend={onSend} onStop={onStop}
|
|
275
275
|
onStart={onStart} onEdit={onEdit} onClear={onClear} onSetSendContext={onSetSendContext}
|
|
276
276
|
onReconnect={onReconnect} onAnswer={onAnswer} onCancelQueued={onCancelQueued} /></div>
|
|
277
|
-
{!
|
|
278
|
-
|
|
279
|
-
<SessionRail {...board} openRequest={boardRequest} onRequestHandled={acknowledgeBoardRequest}
|
|
277
|
+
{!tabMode&&!drawerMode && <ColResize side="right" min={RAIL_W.min} max={RAIL_W.max} onWidth={onRailWidth} onReset={onRailReset} />}
|
|
278
|
+
<WorkspaceDrawer enabled={drawerMode} open={view==='changes'} onClose={()=>setView('chat')}>
|
|
279
|
+
<SessionRail {...board} openRequest={boardRequest} onRequestHandled={acknowledgeBoardRequest} project={project} task={task} sessions={sessions} running={running}
|
|
280
280
|
onResolveWithAI={onResolveWithAI} onMerged={onMerged} onPrCreated={onPrCreated} onClear={onClear}
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
inlineLayout={tabMode?{tabsHost,active:view==='changes',onActivate:()=>setView('changes'),onChat:()=>setView('chat')}:undefined}
|
|
282
|
+
visible={!drawerMode||view==='changes'}
|
|
283
|
+
onExpand={tabMode&&!singlePane?onRailExpand:undefined}
|
|
284
|
+
onCollapse={tabMode||drawerMode ? () => setView("chat") : onRailCollapse} onSwitchToChat={() => setView("chat")} />
|
|
285
|
+
</WorkspaceDrawer>
|
|
283
286
|
</div>
|
|
284
287
|
)}
|
|
285
288
|
</div>
|
|
@@ -3,7 +3,7 @@ import { useContext } from "react";
|
|
|
3
3
|
import { OperatorBoardContext } from "./boardInteraction";
|
|
4
4
|
import { FileLinkContext } from "./fileLinks";
|
|
5
5
|
|
|
6
|
-
import { memo, useState } from "react";
|
|
6
|
+
import { memo, useId, useState } from "react";
|
|
7
7
|
import type { ToolData, ToolPeek, AskQuestion, AskAnswers, InputRequest } from "./protocol/types";
|
|
8
8
|
import { Icon } from "./icons";
|
|
9
9
|
import { Markdown } from "./Markdown";
|
|
@@ -18,6 +18,8 @@ import type { Msg } from "./types";
|
|
|
18
18
|
import { clientFeatures } from "./protocol/features";
|
|
19
19
|
import { Avatar } from "./shared";
|
|
20
20
|
import { toolDataOf } from "./transcriptModel";
|
|
21
|
+
import { ReferenceCard } from "../../ui/ReferenceCard";
|
|
22
|
+
import { splitQuotedMessage } from "../../ui/reference";
|
|
21
23
|
|
|
22
24
|
// The always-visible "peek" tier — Claude Code's `⎿` line. Counts show no
|
|
23
25
|
// content; diffs/snippets show a capped hunk with a clickable "+N more" that
|
|
@@ -64,21 +66,22 @@ function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable:
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
function ToolView({ data }: { data: ToolData }) {
|
|
67
|
-
const [open, setOpen] = useState(
|
|
69
|
+
const [open, setOpen] = useState<boolean | null>(null);
|
|
70
|
+
const bodyId = useId();
|
|
68
71
|
const hasDiff = !!data.diff?.length;
|
|
69
72
|
const expandable = !!(data.detail || hasDiff || data.result !== undefined);
|
|
70
73
|
// Failures surface their output automatically, like Claude Code.
|
|
71
|
-
const showBody = open
|
|
74
|
+
const showBody = open ?? (!!data.isError && data.result !== undefined);
|
|
72
75
|
return (
|
|
73
76
|
<div className="tool">
|
|
74
|
-
<button className="tool-h" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen(
|
|
77
|
+
<button className="tool-h" title={data.title} aria-expanded={expandable ? showBody : undefined} aria-controls={expandable ? bodyId : undefined} style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen(!showBody)}>
|
|
75
78
|
{expandable && <span className={`tchev ${showBody ? "open" : ""}`}>{Icon.chevRight()}</span>}
|
|
76
79
|
<span className="tg">{data.title}</span>
|
|
77
80
|
{data.result !== undefined && <span className={data.isError ? "tx" : "tcheck"}>{data.isError ? Icon.x() : Icon.check()}</span>}
|
|
78
81
|
</button>
|
|
79
82
|
{data.peek && !showBody && <PeekView peek={data.peek} expandable={expandable} onExpand={() => setOpen(true)} />}
|
|
80
83
|
{showBody && (
|
|
81
|
-
<div className="tool-body">
|
|
84
|
+
<div className="tool-body" id={bodyId}>
|
|
82
85
|
{data.detail && <pre className="tool-pre">{data.detail}</pre>}
|
|
83
86
|
{hasDiff && (
|
|
84
87
|
<pre className="tool-pre diff">{data.diff!.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
|
|
@@ -280,6 +283,7 @@ function HandoffLine({ text, onBrowser, onShowBrowser, readOnly }: { readOnly?:b
|
|
|
280
283
|
// (click opens full size) and text-file chips (a big paste diverted to a file;
|
|
281
284
|
// click opens it). Both are served from the task's uploads dir.
|
|
282
285
|
function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
|
|
286
|
+
const open = useContext(OperatorBoardContext), scope = useContext(FileLinkContext);
|
|
283
287
|
if (!items.length) return null;
|
|
284
288
|
return (
|
|
285
289
|
<div className="msg-attachments">
|
|
@@ -290,8 +294,11 @@ function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
|
|
|
290
294
|
<img src={a.url} alt="附件图片" loading="lazy" />
|
|
291
295
|
</a>
|
|
292
296
|
) : (
|
|
293
|
-
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`}
|
|
294
|
-
|
|
297
|
+
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`} onClick={event => {
|
|
298
|
+
const taskId = /\/api\/tasks\/([^/]+)\/uploads\//.exec(a.url)?.[1] ?? scope?.taskId;
|
|
299
|
+
if (taskId && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && open?.({ kind: "file", taskId: decodeURIComponent(taskId), path: a.path })) event.preventDefault();
|
|
300
|
+
}}>
|
|
301
|
+
{Icon.clip()} <span>{a.name}</span>
|
|
295
302
|
</a>
|
|
296
303
|
)
|
|
297
304
|
)}
|
|
@@ -310,6 +317,11 @@ function AutomaticBoardBackground({content}:{content:string}) {
|
|
|
310
317
|
return <details className="cli-auto-background"><summary>自动看板背景</summary>{blocks.map((part,index)=>part.kind==='block'?<pre key={index}>{part.block.text}</pre>:null)}</details>;
|
|
311
318
|
}
|
|
312
319
|
|
|
320
|
+
function QuotedUserText({ text }: { text: string }) {
|
|
321
|
+
const quoted = splitQuotedMessage(text);
|
|
322
|
+
return quoted ? <><ReferenceCard title={quoted.quote.author} text={quoted.quote.text} renderContent={value => <Markdown>{value}</Markdown>} />{quoted.text && <Markdown>{quoted.text}</Markdown>}</> : <Markdown>{text}</Markdown>;
|
|
323
|
+
}
|
|
324
|
+
|
|
313
325
|
export const MessageView = memo(function MessageView({ m, initial, hideWho, running, agent, agentLabel = "智能体", onAnswer, onInput, onBrowser, onCancelQueued, onClear, onReconnect, onRetry, onQuote, readOnly }: { readOnly?: boolean; onQuote?: (m: Msg, selected?: string) => void; m: Msg; initial: boolean; hideWho: boolean; running?: boolean; agent?: string | null; agentLabel?: string; onAnswer?: (askId: string, questions: AskQuestion[], answers: AskAnswers) => void; onInput?: (inputId: string, values: string[]) => Promise<string | null>; onBrowser?: (action: "handback") => Promise<string | null>; onCancelQueued?: (pendingId: string) => void; onClear?: () => void; onReconnect?: () => void; onRetry?: (msgId: string) => void }) {
|
|
314
326
|
const openBoard=useContext(OperatorBoardContext);
|
|
315
327
|
const fileScope=useContext(FileLinkContext);
|
|
@@ -323,7 +335,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
323
335
|
<div className="msg user queued">
|
|
324
336
|
<div className="who"><Avatar who="user" /> 你<span className="badge queued-badge">排队中</span>{m.ts != null && <span className="msg-time">{clockTime(m.ts)}</span>}</div>
|
|
325
337
|
<div className="msg-body">
|
|
326
|
-
{text && <
|
|
338
|
+
{text && <QuotedUserText text={text} />}
|
|
327
339
|
<AutomaticBoardBackground content={m.content}/>
|
|
328
340
|
<AttachmentStrip items={attachments} />
|
|
329
341
|
{onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => onCancelQueued(m.id)}>{Icon.x()}</button>}
|
|
@@ -440,7 +452,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
440
452
|
)}
|
|
441
453
|
<div className="msg-body">
|
|
442
454
|
{initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
|
|
443
|
-
{text && <Markdown>{text}</Markdown>}
|
|
455
|
+
{text && (isUser ? <QuotedUserText text={text} /> : <Markdown>{text}</Markdown>)}
|
|
444
456
|
{isUser&&<AutomaticBoardBackground content={m.content}/>}
|
|
445
457
|
<AttachmentStrip items={attachments} />
|
|
446
458
|
</div>
|
|
@@ -24,25 +24,6 @@ export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) =>
|
|
|
24
24
|
},
|
|
25
25
|
fileUrl(id, path, options) { const url = taskFileApiUrl(id, path); return options?.download ? `${url}${url.includes('?') ? '&' : '?'}download=1` : url; },
|
|
26
26
|
fileLinkBase: async id => `/api/tasks/${encodeURIComponent(id)}/files`,
|
|
27
|
-
watchFiles(id,handlers) {
|
|
28
|
-
let stopped=false,ended=false;
|
|
29
|
-
const disrupted=(reason:string)=>{if(!stopped&&!ended)handlers.onEvent({type:'resync',reason,complete:false});};
|
|
30
|
-
const subscription=openLive(`/api/tasks/${encodeURIComponent(id)}/files/events`,{
|
|
31
|
-
onMessage:data=>{
|
|
32
|
-
if(stopped||ended)return;
|
|
33
|
-
try{const event=JSON.parse(data);if(['snapshot','changes','resync'].includes(event.type))handlers.onEvent(event);}catch{/* Ignore invalid event. */}
|
|
34
|
-
},
|
|
35
|
-
onEnd:code=>{
|
|
36
|
-
if(stopped||ended)return;
|
|
37
|
-
if(code===0||code===408||code===429||(code>=500&&code!==501)){disrupted(code===429?'文件监听暂时限流,正在等待恢复':'文件监听连接中断,正在重连');return;}
|
|
38
|
-
ended=true;
|
|
39
|
-
const message=code===401?'文件监听登录已过期,请重新登录':code===403?'文件监听被拒绝,请检查任务权限或登录':code===404?'未找到任务或节点不支持文件事件接口':`文件监听请求失败(HTTP ${code})`;
|
|
40
|
-
handlers.onClose(new CenterError(message,`HTTP_${code}`,code));
|
|
41
|
-
},
|
|
42
|
-
onError:error=>disrupted(error.message),
|
|
43
|
-
});
|
|
44
|
-
return()=>{stopped=true;subscription.close();};
|
|
45
|
-
},
|
|
46
27
|
browserEvents(id,handlers) {
|
|
47
28
|
let stopped=false,ended=false;
|
|
48
29
|
const disrupted=(error:CenterError)=>{if(!stopped&&!ended)handlers.onError?.(error);};
|
|
@@ -4,16 +4,34 @@ import type { WorkspaceHandlers } from '../../core/provider';
|
|
|
4
4
|
import type { WorkspaceChangeEvent } from '../../core/types';
|
|
5
5
|
import { cloneBoardView } from '../../board/context';
|
|
6
6
|
import type { CliEvent } from '../../core/store';
|
|
7
|
-
import { cloneWorkspaceChange, subscribeWorkspaceFiles } from '../../board/fileWatch';
|
|
7
|
+
import { cloneWorkspaceChange, subscribeWorkspaceFiles, retryWorkspaceFiles, type WorkspaceRefreshOptions } from '../../board/fileWatch';
|
|
8
8
|
import { getOperatorBoardBackend } from './boardBackend';
|
|
9
|
-
import { readBoardState, subscribeBoardState } from './boardState';
|
|
9
|
+
import { readBoardState, readVisibleBoardSources, subscribeBoardState } from './boardState';
|
|
10
|
+
import { observeLiveMessages } from './liveStream';
|
|
11
|
+
export function refreshOperatorWorkspaceFiles(taskId:string,options?:WorkspaceRefreshOptions):void {
|
|
12
|
+
const backend=getOperatorBoardBackend(),sources=new Set([taskId,...readVisibleBoardSources(taskId)]);
|
|
13
|
+
for(const source of sources)options?retryWorkspaceFiles(backend,source,options):retryWorkspaceFiles(backend,source);
|
|
14
|
+
}
|
|
15
|
+
const observedTasks=new Map<string,{refs:number;stop:()=>void}>();
|
|
16
|
+
function observeTaskFiles(taskId:string):()=>void {
|
|
17
|
+
let entry=observedTasks.get(taskId);
|
|
18
|
+
if(!entry){
|
|
19
|
+
entry={refs:0,stop:observeLiveMessages(`/api/tasks/${taskId}/messages`,data=>{
|
|
20
|
+
try{const event=JSON.parse(data);if(event.type==='turn_end'||event.type==='snapshot')refreshOperatorWorkspaceFiles(taskId);}catch{/* Ignore unrelated/malformed stream payloads. */}
|
|
21
|
+
})};observedTasks.set(taskId,entry);
|
|
22
|
+
}
|
|
23
|
+
const owned=entry;owned.refs++;let active=true;
|
|
24
|
+
return()=>{if(!active)return;active=false;if(--owned.refs===0){observedTasks.delete(taskId);owned.stop();}};
|
|
25
|
+
}
|
|
10
26
|
export type OperatorBoardHandlers=WorkspaceHandlers&{onEvent?:(event:CliEvent)=>void};
|
|
11
27
|
export function useOperatorFileWatch(taskId:string,enabled:boolean,handlers:OperatorBoardHandlers={},onChange?:(event:WorkspaceChangeEvent)=>void,intervalMs?:number){
|
|
12
28
|
const current=useRef({handlers,onChange});current.current={handlers,onChange};
|
|
13
29
|
useEffect(()=>{
|
|
14
30
|
if(!enabled)return;
|
|
15
|
-
|
|
31
|
+
const unobserve=observeTaskFiles(taskId);
|
|
32
|
+
const unsubscribe=subscribeWorkspaceFiles(getOperatorBoardBackend(),taskId,event=>{current.current.onChange?.(event);notifyCallback(current.current.handlers.onFilesChange,cloneWorkspaceChange(event));notifyCallback(current.current.handlers.onEvent,cloneWorkspaceChange(event));},
|
|
16
33
|
{intervalMs,onStatus:status=>{notifyCallback(current.current.handlers.onFilesWatch,{...status});notifyCallback(current.current.handlers.onEvent,{...status});}});
|
|
34
|
+
return()=>{unobserve();unsubscribe();};
|
|
17
35
|
},[taskId,enabled,intervalMs]);
|
|
18
36
|
}
|
|
19
37
|
export function useOperatorContextEvents(taskId:string,enabled:boolean,handlers:OperatorBoardHandlers={}){
|
|
@@ -5,6 +5,8 @@ import { notifyCallback } from '../../core/callbacks';
|
|
|
5
5
|
const cache=new Map<string,{stamp:string;view:BoardView|null}>();
|
|
6
6
|
const listeners=new Map<string,Set<()=>void>>();
|
|
7
7
|
const owners=new Map<string,Map<string,BoardView>>();
|
|
8
|
+
// Mounted directory identity is transient; it must not become retained browsing history.
|
|
9
|
+
const directorySources=new Map<string,Map<string,string>>();
|
|
8
10
|
const key=(id:string)=>`cli-ui:operator-board:${id}`;
|
|
9
11
|
const stamp=(id:string)=>{try{return sessionStorage.getItem(key(id))??'';}catch{return 'blocked';}};
|
|
10
12
|
const validFile=(value:unknown):value is BoardFile=>!!value&&typeof value==='object'&&typeof (value as BoardFile).path==='string'&&!!(value as BoardFile).path&&((value as BoardFile).sourceId===undefined||typeof (value as BoardFile).sourceId==='string');
|
|
@@ -20,6 +22,18 @@ export function readBoardState(taskId:string):BoardView|null {
|
|
|
20
22
|
}catch{/* Broken preferences cannot stop the workspace. */}
|
|
21
23
|
cache.set(taskId,{stamp:saved,view});return view;
|
|
22
24
|
}
|
|
25
|
+
/** Internal: background refresh covers every visible owner, not just the selected board. */
|
|
26
|
+
export function readVisibleBoardSources(taskId:string):string[] {
|
|
27
|
+
const active=owners.get(taskId);
|
|
28
|
+
const views=active?.size?[...active]:[['',readBoardState(taskId)] as const];
|
|
29
|
+
const sources=new Set<string>();
|
|
30
|
+
for(const [owner,view] of views){
|
|
31
|
+
if(!view||view.visible===false)continue;
|
|
32
|
+
for(const file of view.opened??[])sources.add(file.sourceId??taskId);
|
|
33
|
+
const directory=directorySources.get(taskId)?.get(owner);if(directory)sources.add(directory);
|
|
34
|
+
}
|
|
35
|
+
return [...sources];
|
|
36
|
+
}
|
|
23
37
|
export function subscribeBoardState(taskId:string,listener:()=>void):()=>void {
|
|
24
38
|
const set=listeners.get(taskId)??new Set<()=>void>();set.add(listener);listeners.set(taskId,set);return()=>{set.delete(listener);if(!set.size)listeners.delete(taskId);};
|
|
25
39
|
}
|
|
@@ -36,8 +50,10 @@ export function visitBoardFile(taskId:string,file:BoardFile):void {
|
|
|
36
50
|
const current=readBoardState(taskId)??{tab:'files',dir:'',file:null,files:[]};
|
|
37
51
|
publishBoardState(taskId,{...current,history:recent([...(current.history??[]),file])});
|
|
38
52
|
}
|
|
39
|
-
export function reportBoardState(taskId:string,owner:string,view:BoardView):void {
|
|
53
|
+
export function reportBoardState(taskId:string,owner:string,view:BoardView,directorySource?:string):void {
|
|
40
54
|
const sources=owners.get(taskId)??new Map<string,BoardView>();
|
|
55
|
+
if(directorySource){const directories=directorySources.get(taskId)??new Map<string,string>();directories.set(owner,directorySource);directorySources.set(taskId,directories);}
|
|
56
|
+
else{const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);}
|
|
41
57
|
const history=readBoardState(taskId)?.history??view.history??[];
|
|
42
58
|
const selected=reportBoardOwner(sources,owner,{...view,history});owners.set(taskId,sources);
|
|
43
59
|
if(selected)publishBoardState(taskId,{...selected[1],history});
|
|
@@ -51,6 +67,7 @@ export function activateBoardState(taskId:string,owner:string):void {
|
|
|
51
67
|
}
|
|
52
68
|
export function releaseBoardState(taskId:string,owner:string):void {
|
|
53
69
|
const sources=owners.get(taskId);sources?.delete(owner);if(!sources?.size)owners.delete(taskId);
|
|
70
|
+
const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);
|
|
54
71
|
const previous=readBoardState(taskId);if(!previous)return;
|
|
55
72
|
const remaining=sources?.size?selectedBoardOwner(sources)?.[1]:null;
|
|
56
73
|
publishBoardState(taskId,remaining?{...remaining,history:previous.history,visible:remaining.visible!==false}:{...previous,visible:false});
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import type { WorkspaceRefreshOptions } from '../../board/fileWatch';
|
|
1
2
|
import { createWorkspace } from '../../board/workspace';
|
|
2
3
|
import { cloneBoardView } from '../../board/context';
|
|
3
4
|
import { getOperatorBoardBackend } from './boardBackend';
|
|
4
5
|
import { readBoardState } from './boardState';
|
|
5
6
|
import { openOperatorBoard } from './boardInteraction';
|
|
7
|
+
import { refreshOperatorWorkspaceFiles } from './boardEvents';
|
|
6
8
|
/** Host API for the node workspace. Uses the same cloud-facing file API as the board. */
|
|
7
9
|
export function createOperatorWorkspace(taskId:string){
|
|
8
|
-
|
|
10
|
+
const workspace=createWorkspace({backend:getOperatorBoardBackend(),key:taskId,
|
|
9
11
|
view:()=>{const view=readBoardState(taskId);return view?.visible?cloneBoardView(view):undefined;},
|
|
10
12
|
context:()=>{const view=readBoardState(taskId);return view?cloneBoardView(view):null;},
|
|
11
13
|
open:path=>openOperatorBoard({kind:'file',taskId,path})});
|
|
14
|
+
return {...workspace,refresh:(options?:WorkspaceRefreshOptions)=>refreshOperatorWorkspaceFiles(taskId,options)};
|
|
12
15
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import type { UploadPhase } from "../../core/documentUpload";
|
|
2
|
+
|
|
3
|
+
export type Quote = { id: string; author: string; text: string; sourceTaskId?: string };
|
|
2
4
|
export type Attachment = {
|
|
5
|
+
progress?: number;
|
|
6
|
+
phase?: UploadPhase; bytes?: number; size?: number;
|
|
3
7
|
key: string; kind: "image" | "file"; name: string; preview: string; path: string;
|
|
4
8
|
status: "uploading" | "ready" | "error"; error?: string;
|
|
5
9
|
};
|
|
@@ -15,7 +19,7 @@ export function readDraft(id: string): ComposerDraft {
|
|
|
15
19
|
const raw = localStorage.getItem(key(id));
|
|
16
20
|
if (raw) {
|
|
17
21
|
const d = JSON.parse(raw) as ComposerDraft;
|
|
18
|
-
if (typeof d.text === "string" && Array.isArray(d.attachments)) return { ...d, attachments: d.attachments.map((a) => a.status === "uploading" ? { ...a, preview: "", status: "error", error: "
|
|
22
|
+
if (typeof d.text === "string" && Array.isArray(d.attachments)) return { ...d, attachments: d.attachments.map((a) => a.status === "uploading" ? { ...a, preview: "", status: "error", error: "上传已中断,请重新选择文件。" } : a) };
|
|
19
23
|
}
|
|
20
24
|
return { ...emptyDraft(), text: localStorage.getItem(`orch:draft:${id}`) ?? "" };
|
|
21
25
|
} catch { return emptyDraft(); }
|
|
@@ -279,9 +279,11 @@ export function splitAttachments(content: string): { text: string; attachments:
|
|
|
279
279
|
for (const line of content.split("\n")) {
|
|
280
280
|
const m = ATTACHMENT_RE.exec(line.trim());
|
|
281
281
|
const parts = m ? m[2].split(/[\\/]/).filter(Boolean) : [];
|
|
282
|
-
|
|
282
|
+
const upload = parts.length >= 3 && parts[parts.length - 3] === "uploads";
|
|
283
|
+
const managed = parts.length >= 4 && parts[parts.length - 4] === ".workspace" && parts[parts.length - 3] === "attachments";
|
|
284
|
+
if (m && (upload || managed) && parts.slice(-2).every(part => part !== "." && part !== "..")) {
|
|
283
285
|
const [taskId, file] = parts.slice(-2);
|
|
284
|
-
attachments.push({ path: m[2], url: `/api/tasks/${taskId}/uploads/${file}`, kind: m[1] === "image" ? "image" : "file", name: file });
|
|
286
|
+
attachments.push({ path: m[2], url: `/api/tasks/${encodeURIComponent(taskId)}/uploads/${encodeURIComponent(file)}`, kind: m[1] === "image" ? "image" : "file", name: file });
|
|
285
287
|
} else {
|
|
286
288
|
kept.push(line);
|
|
287
289
|
}
|
|
@@ -45,6 +45,18 @@ const BACKOFF_MS = [500, 1000, 2000, 4000, 8000];
|
|
|
45
45
|
const PROBE_MS = 30_000;
|
|
46
46
|
|
|
47
47
|
const subs = new Map<number, Sub>();
|
|
48
|
+
const observers = new Map<string, Set<(data: string) => void>>();
|
|
49
|
+
/** Observe an existing route without opening a connection or server subscription. */
|
|
50
|
+
export function observeLiveMessages(path: string, listener: (data: string) => void): () => void {
|
|
51
|
+
const key = path.split("?")[0];
|
|
52
|
+
const listeners = observers.get(key) ?? new Set(); observers.set(key, listeners); listeners.add(listener);
|
|
53
|
+
return () => { listeners.delete(listener); if (!listeners.size) observers.delete(key); };
|
|
54
|
+
}
|
|
55
|
+
function deliver(s: Sub, data: string) {
|
|
56
|
+
if (subs.get(s.id) !== s) return;
|
|
57
|
+
for (const listener of [...observers.get(s.url.split("?")[0]) ?? []]) notifyCallback(listener, data);
|
|
58
|
+
if (subs.get(s.id) === s) s.h.onMessage(data);
|
|
59
|
+
}
|
|
48
60
|
let nextId = 1;
|
|
49
61
|
let mode: "ws" | "sse" | null = null;
|
|
50
62
|
let ws: WebSocket | null = null;
|
|
@@ -91,7 +103,7 @@ function openEventSource(s: Sub) {
|
|
|
91
103
|
const es = new EventSource(s.url);
|
|
92
104
|
const active = () => subs.get(s.id) === s && s.es === es;
|
|
93
105
|
es.onopen = () => { if (!active()) return; if (s.retry) { clearTimeout(s.retry); s.retry = undefined; } s.fails = 0; s.h.onOpen?.(); };
|
|
94
|
-
es.onmessage = (event) => { if (active()) s
|
|
106
|
+
es.onmessage = (event) => { if (active()) deliver(s, event.data); };
|
|
95
107
|
es.onerror = () => {
|
|
96
108
|
if (!active()) return;
|
|
97
109
|
notifyCallback(s.h.onError, new Error("实时事件连接中断,正在等待恢复"));
|
|
@@ -203,7 +215,7 @@ function onFrame(text: string) {
|
|
|
203
215
|
const nl = text.indexOf("\n");
|
|
204
216
|
if (nl <= 0) return;
|
|
205
217
|
const s = subs.get(Number(text.slice(0, nl)));
|
|
206
|
-
if (s) s
|
|
218
|
+
if (s) deliver(s, text.slice(nl + 1));
|
|
207
219
|
}
|
|
208
220
|
|
|
209
221
|
function connect() {
|
|
@@ -232,12 +232,14 @@ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning,
|
|
|
232
232
|
setOlderByTask(drop);
|
|
233
233
|
for (const t of evicted) { delete openAsksRef.current[t]; delete loadingOlderRef.current[t]; }
|
|
234
234
|
}
|
|
235
|
+
let active = true;
|
|
235
236
|
const sub = openLive(`/api/tasks/${id}/messages?tail=${SNAPSHOT_TAIL}`, {
|
|
236
237
|
onMessage: (data) => {
|
|
238
|
+
if (!active) return;
|
|
237
239
|
try { handleStreamEventRef.current(id, JSON.parse(data) as TaskStreamEvent); } catch {}
|
|
238
240
|
},
|
|
239
241
|
});
|
|
240
|
-
return () => sub.close();
|
|
242
|
+
return () => { active = false; sub.close(); };
|
|
241
243
|
}, [selTask]);
|
|
242
244
|
|
|
243
245
|
// Page in the next batch of earlier messages for a task (the transcript calls
|