@spzxg/cli-ui 0.6.11 → 0.6.13
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 +20 -1
- package/README.md +9 -3
- package/dist/{cli-ui-embed-CbxWozLO.js → cli-ui-embed-CXaGT9e4.js} +14911 -14636
- package/dist/{cli-ui-index-ImgEunSf.js → cli-ui-index-bMQJjEBW.js} +2 -2
- package/dist/{cli-ui-jszip.min-DGdGLbxr.js → cli-ui-jszip.min-AMeWMDT5.js} +2 -2
- package/dist/{cli-ui-jszip.min-DWmufeZi.js → cli-ui-jszip.min-D_w3a6AF.js} +1 -1
- package/dist/{cli-ui-xlsx-s2TcknA1.js → cli-ui-xlsx-DMzew4IO.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/board/CliBoard.tsx +2 -3
- package/src/board/FileViewer.tsx +3 -3
- package/src/board/FilesPane.tsx +1 -1
- package/src/board/fileScope.ts +6 -3
- package/src/board/fileSync.ts +12 -4
- package/src/board/fileSyncProcessor.ts +4 -3
- package/src/board/fileSyncTransport.ts +2 -2
- package/src/board/fileWatch.ts +36 -20
- package/src/chat/CliChat.tsx +13 -2
- package/src/core/client.ts +4 -0
- package/src/core/conversation.ts +10 -3
- package/src/core/identity.ts +2 -14
- package/src/core/sha256.ts +21 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/types.ts +12 -2
- package/src/operator/node/Chat.tsx +10 -4
- package/src/operator/node/Layout.tsx +3 -1
- package/src/operator/node/SessionRail.tsx +2 -2
- package/src/operator/node/Transcript.tsx +7 -2
- package/src/operator/node/modals.tsx +13 -2
- package/src/operator/node/protocol/types.ts +5 -2
- package/src/operator/node/useTaskStream.ts +8 -4
- package/src/operator.base.css +35 -0
- package/src/operator.css +35 -0
- package/src/ui/SubagentChat.tsx +197 -0
- package/src/ui/Transcript.tsx +5 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { SubagentChatScope, SubagentHistoryBoundary } from "../../ui/SubagentChat";
|
|
3
|
+
import { parentMessages, type SubagentTranscript } from "../../core/subagents";
|
|
4
|
+
import { jget } from "./api";
|
|
2
5
|
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
6
|
import type { AskQuestion, AskAnswers } from "./protocol/types";
|
|
4
7
|
import { Icon } from "./icons";
|
|
@@ -79,6 +82,9 @@ export type ChatProps = {
|
|
|
79
82
|
export function Chat(props: ChatProps) { return <ChatSession key={props.task.id} {...props} />; }
|
|
80
83
|
function ChatSession({ project, task, tasks, agents, messages, running, transcriptLoading, older=0, onLoadOlder,
|
|
81
84
|
controls, configBusy, composer, active=true, onSelectTask, onFork, onSend, onStop, onClear, onReconnect, onAnswer, onCancelQueued }: ChatProps) {
|
|
85
|
+
const parent = useMemo(() => parentMessages(messages), [messages]);
|
|
86
|
+
const loadSubagent = useCallback((id: string, generation: number, before?: string) => jget<SubagentTranscript>(`/api/tasks/${encodeURIComponent(task.id)}/subagents/${encodeURIComponent(id)}?generation=${generation}&limit=200${before ? `&before=${encodeURIComponent(before)}` : ""}`), [task.id]);
|
|
87
|
+
const renderSubagentMessage = useCallback((message: Msg, name: string) => <MessageView m={message} initial={false} hideWho={false} agent={task.agent} agentLabel={name} userLabel="收到的任务" speakerLabel={name} readOnly />, [task.agent]);
|
|
82
88
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
83
89
|
useChatScrollbar(scrollRef, task.id);
|
|
84
90
|
const [quoteRequest, setQuoteRequest] = useState<Quote|null>(null);
|
|
@@ -182,7 +188,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
182
188
|
// reveals RENDER_STEP more above at a time (paging older messages in from the
|
|
183
189
|
// server once the loaded ones run out) and drops rows far below; scrolling down
|
|
184
190
|
// brings those back. "Jump to latest" returns to following.
|
|
185
|
-
const items = useMemo(() => buildTranscriptItems(
|
|
191
|
+
const items = useMemo(() => buildTranscriptItems(parent, older > 0), [parent, older]);
|
|
186
192
|
// First / last rendered row by key; null = derived (newest rows).
|
|
187
193
|
const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
188
194
|
const { startIdx, endIdx } = useMemo(() => {
|
|
@@ -443,11 +449,11 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
443
449
|
useEffect(()=>{if(!task.parent_task_id && active) scrollToBottom("auto");},[active,task.id]);
|
|
444
450
|
const queued = <QueuedMessages scopeKey={task.id} messages={messages.filter(message => message.role === "queued")} onCancel={message => stableCancelQueued(message.id)} attachmentUrl={attachment => `/api/tasks/${encodeURIComponent(attachment.taskId)}/uploads/${encodeURIComponent(attachment.name)}`} renderMessage={message => <MessageView m={message} initial={false} hideWho readOnly />} />;
|
|
445
451
|
return (<FileLinkContext.Provider value={fileScope}>
|
|
446
|
-
|
|
452
|
+
<SubagentChatScope scopeKey={task.id} messages={messages} load={loadSubagent} renderMessage={renderSubagentMessage}>
|
|
447
453
|
<div className="transcript-wrap">
|
|
448
454
|
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
|
|
449
455
|
<div className="tw">
|
|
450
|
-
<BranchHistory entries={flow.entries} tasks={tasks} agents={agents} onSelect={onSelectTask} onRetry={flow.retry} onQuote={(m,source,selected)=>quote(m,source,selected)}
|
|
456
|
+
<SubagentHistoryBoundary><BranchHistory entries={flow.entries} tasks={tasks} agents={agents} onSelect={onSelectTask} onRetry={flow.retry} onQuote={(m,source,selected)=>quote(m,source,selected)} /></SubagentHistoryBoundary>
|
|
451
457
|
{task.parent_task_id && <div className="flow-task-heading current"><span>沿此分支继续</span><strong>{task.title}</strong></div>}
|
|
452
458
|
{transcriptLoading && messages.length === 0 && (
|
|
453
459
|
// Snapshot hasn't streamed in yet — sketch a user turn and an agent
|
|
@@ -513,7 +519,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
513
519
|
</div>
|
|
514
520
|
{composer?.show !== false ? <Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={async text => { const sent = await onSend(text); if (sent && !running) scrollToBottom("auto"); return sent; }} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls}
|
|
515
521
|
fileUpload={composer?.fileUpload} queued={queued} /> : queued}
|
|
516
|
-
|
|
522
|
+
</SubagentChatScope></FileLinkContext.Provider>
|
|
517
523
|
);
|
|
518
524
|
|
|
519
525
|
}
|
|
@@ -88,7 +88,9 @@ export function ColResize({ min, max, onWidth, onReset, side = "left" }: { min:
|
|
|
88
88
|
const move = (e: MouseEvent) => {
|
|
89
89
|
if (!dragging.current) return;
|
|
90
90
|
if (side === "right") {
|
|
91
|
-
|
|
91
|
+
// WorkspaceDrawer keeps display:contents wrappers on desktop; measure the visible rail.
|
|
92
|
+
const next = ref.current?.nextElementSibling as HTMLElement | null;
|
|
93
|
+
const col = next?.querySelector<HTMLElement>(".sess-rail") ?? next;
|
|
92
94
|
if (!col) return;
|
|
93
95
|
const right = col.getBoundingClientRect().right;
|
|
94
96
|
onWidthRef.current(Math.max(min, Math.min(max, Math.round(right - e.clientX))));
|
|
@@ -122,7 +122,7 @@ function ContextPane({ task, sessions, running, onClear }: { task: TaskRow; sess
|
|
|
122
122
|
);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
export function SessionRail({ project, task, sessions, running, onResolveWithAI, onMerged, onPrCreated, onClear,
|
|
125
|
+
export function SessionRail({ project, task, sessions, running, onResolveWithAI, onMerged, onPrCreated, onClear, onSwitchToChat,
|
|
126
126
|
tabs, availableTabs, defaultTab, autoContext, watchFiles = true, fileWatchIntervalMs, onFilesChange, onFilesWatch, onBoardContextChange, onEvent, openRequest, onRequestHandled, onQuote, inlineLayout, onExpand, visible = true }: OperatorBoardOptions & {
|
|
127
127
|
project: ProjectRow; task: TaskRow; sessions: Session[]; running: boolean;
|
|
128
128
|
onResolveWithAI: (taskId: string) => Promise<ResolveResult>; onMerged?: () => void;
|
|
@@ -268,7 +268,7 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
|
|
|
268
268
|
</div>
|
|
269
269
|
<PanelControls toolbarRef={setToolbarHost} available={available} open={panels.open} label={label} onShow={id=>{panels.show(id);setTab(id);inlineLayout?.onActivate();}} onClose={close}
|
|
270
270
|
upload={available.includes('files')?{onChoose:upload.choose,disabled:upload.busy||!uploader,hint:canUpload?undefined:'启动会话并创建工作区后可上传文件'}:undefined}/>
|
|
271
|
-
{inlineLayout
|
|
271
|
+
{inlineLayout && onExpand && <button className="rail-collapse" onClick={onExpand} title="展开右侧看板" aria-label="展开看板">{Icon.chevRight()}</button>}
|
|
272
272
|
</div>;
|
|
273
273
|
return <aside className={`sess-rail${inlineLayout?' sess-rail-inline':''}`} aria-label="任务看板" hidden={!contentVisible} style={!contentVisible?{display:'none'}:undefined} onPointerDown={()=>activateBoardState(task.id,owner)} onClickCapture={()=>activateBoardState(task.id,owner)} onFocusCapture={()=>activateBoardState(task.id,owner)}>
|
|
274
274
|
{inlineLayout ? inlineLayout.tabsHost&&createPortal(railTabs,inlineLayout.tabsHost) : railTabs}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { subagentFields } from "../../core/subagents";
|
|
3
|
+
import { SubagentEntry } from "../../ui/SubagentChat";
|
|
2
4
|
import { useContext } from "react";
|
|
3
5
|
import { OperatorBoardContext } from "./boardInteraction";
|
|
4
6
|
import { FileLinkContext } from "./fileLinks";
|
|
@@ -322,7 +324,7 @@ function QuotedUserText({ text }: { text: string }) {
|
|
|
322
324
|
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
325
|
}
|
|
324
326
|
|
|
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 }) {
|
|
327
|
+
export const MessageView = memo(function MessageView({ m, initial, hideWho, running, agent, agentLabel = "智能体", userLabel = "你", speakerLabel = "智能体", onAnswer, onInput, onBrowser, onCancelQueued, onClear, onReconnect, onRetry, onQuote, readOnly }: { readOnly?: boolean; userLabel?: string; speakerLabel?: string; 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 }) {
|
|
326
328
|
const openBoard=useContext(OperatorBoardContext);
|
|
327
329
|
const fileScope=useContext(FileLinkContext);
|
|
328
330
|
const onShowBrowser=()=>{if(fileScope&&openBoard)openBoard({kind:'browser',taskId:fileScope.taskId});else showTaskBrowser(fileScope?.taskId);};
|
|
@@ -345,6 +347,9 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
345
347
|
}
|
|
346
348
|
if (m.role === "tool") {
|
|
347
349
|
const data = toolDataOf(m);
|
|
350
|
+
const child = subagentFields(m);
|
|
351
|
+
if (child.subagentMessage) return null;
|
|
352
|
+
if (child.subagent) return <SubagentEntry info={child.subagent} generation={m.generation} />;
|
|
348
353
|
if (data.input) {
|
|
349
354
|
return <div className="msg msg-tool" inert={readOnly||undefined}><InputView input={data.input} agentLabel={agentLabel} onSubmit={onInput} onShowBrowser={onShowBrowser} /></div>;
|
|
350
355
|
}
|
|
@@ -445,7 +450,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
445
450
|
{!hideWho && (
|
|
446
451
|
<div className="who">
|
|
447
452
|
<Avatar who={isUser ? "user" : "cc"} agent={agent} />
|
|
448
|
-
{isUser ?
|
|
453
|
+
{isUser ? userLabel : speakerLabel}
|
|
449
454
|
{initial && <span className="badge">初始提示</span>}
|
|
450
455
|
{m.ts != null && <span className="msg-time">{clockTime(m.ts)}</span>}
|
|
451
456
|
</div>
|
|
@@ -200,6 +200,9 @@ export function NewTaskModal({ project, agents, tasks, onClose, onCreate, onOpen
|
|
|
200
200
|
const [sendContext, setSendContext] = useState(project.send_context !== 0);
|
|
201
201
|
const [deps, setDeps] = useState<string[]>([]);
|
|
202
202
|
const [autoStart, setAutoStart] = useState(false);
|
|
203
|
+
const submitting = useRef(false);
|
|
204
|
+
const [creating, setCreating] = useState(false);
|
|
205
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
203
206
|
// The task's real browser (docs/BROWSER.md): offered only while the feature is on.
|
|
204
207
|
const showBrowser = clientFeatures().browser;
|
|
205
208
|
const [browser, setBrowser] = useState(false);
|
|
@@ -219,7 +222,14 @@ export function NewTaskModal({ project, agents, tasks, onClose, onCreate, onOpen
|
|
|
219
222
|
// launch otherwise, so offering "start now" here would just produce a 400).
|
|
220
223
|
const agentReady = selAgent ? !agentStateHint(selAgent) : true;
|
|
221
224
|
const canStart = !blocked && agentReady;
|
|
222
|
-
const create = () =>
|
|
225
|
+
const create = async () => {
|
|
226
|
+
if (!can || submitting.current) return;
|
|
227
|
+
submitting.current = true; setCreating(true); setCreateError(null);
|
|
228
|
+
try {
|
|
229
|
+
await onCreate({ title: title.trim(), desc: desc.trim(), priority, agent, startNow: startNow && canStart, sendContext, depends_on: deps, auto_start: autoStart && deps.length > 0, browser: showBrowser && browser });
|
|
230
|
+
} catch (error) { setCreateError(error instanceof Error ? error.message : String(error)); }
|
|
231
|
+
finally { submitting.current = false; setCreating(false); }
|
|
232
|
+
};
|
|
223
233
|
return (
|
|
224
234
|
<Modal title="新建任务" sub={`${project.name} · 标题与描述构成 ${agentLabel(agents, agent)} 的任务上下文`} onClose={onClose}
|
|
225
235
|
footer={<>
|
|
@@ -227,9 +237,10 @@ export function NewTaskModal({ project, agents, tasks, onClose, onCreate, onOpen
|
|
|
227
237
|
title={blocked ? "暂时无法开始 —— 该任务被尚未完成的任务阻塞" : !agentReady ? `${selAgent?.label} ${agentStateHint(selAgent) === "未安装" ? "的命令行工具未安装在服务器上" : "尚未连接"},无法启动会话` : undefined}>
|
|
228
238
|
<input type="checkbox" checked={startNow && canStart} disabled={!canStart} onChange={(e) => setStartNow(e.target.checked)} /> 立即开始会话
|
|
229
239
|
</label>
|
|
240
|
+
{createError && <span className="hlp" role="alert" style={{ color: "var(--red)", margin: 0 }}>创建未完成:{createError}</span>}
|
|
230
241
|
<span className="spacer" />
|
|
231
242
|
<button className="btn btn-ghost" onClick={onClose}>取消</button>
|
|
232
|
-
<button className="btn btn-accent" disabled={!can} onClick={create}>{Icon.plus()}
|
|
243
|
+
<button className="btn btn-accent" disabled={!can || creating} onClick={create}>{Icon.plus()} {creating ? "创建任务中…" : "创建任务"}</button>
|
|
233
244
|
</>}>
|
|
234
245
|
<div className="field">
|
|
235
246
|
<div className="lab">标题</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SubagentInfo, SubagentMessage } from "../../../core/subagents";
|
|
1
2
|
interface AgentProfile { base_url: string; token: string; model: string; wire_api?: string | null; api?: string | null; reasoning?: string | null }
|
|
2
3
|
|
|
3
4
|
export type Priority = "hi" | "med" | "lo";
|
|
@@ -236,8 +237,8 @@ export type StreamEvent =
|
|
|
236
237
|
| { type: "session"; sessionId: string }
|
|
237
238
|
| { type: "model"; model: string }
|
|
238
239
|
| { type: "assistant"; content: string }
|
|
239
|
-
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[] }
|
|
240
|
-
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek }
|
|
240
|
+
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; subagent?: SubagentInfo; subagentMessage?: SubagentMessage }
|
|
241
|
+
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; subagent?: SubagentInfo; subagentMessage?: SubagentMessage }
|
|
241
242
|
| { type: "ask"; id: string; questions: AskQuestion[] }
|
|
242
243
|
| { type: "ask_answered"; id: string; answers: AskAnswers; awaiting_input?: 0 | 1 }
|
|
243
244
|
// An input card was raised or settled (the whole card each time; upsert by msgId).
|
|
@@ -311,6 +312,8 @@ export type GlobalEvent = GlobalTaskEvent | AgentAuthEvent;
|
|
|
311
312
|
|
|
312
313
|
// How a tool call is stored (JSON) in a "tool" message's content.
|
|
313
314
|
export interface ToolData {
|
|
315
|
+
subagent?: SubagentInfo;
|
|
316
|
+
subagentMessage?: SubagentMessage;
|
|
314
317
|
title: string;
|
|
315
318
|
detail?: string;
|
|
316
319
|
result?: string;
|
|
@@ -119,8 +119,8 @@ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning,
|
|
|
119
119
|
else if (ev.type === "model") setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, resolved_model: ev.model } : x)));
|
|
120
120
|
else if (ev.type === "assistant") upsertMsg(taskId, { id: ev.msgId ?? `a-${Date.now()}-${Math.random()}`, role: "assistant", content: ev.content, generation: gen, ts: ev.ts });
|
|
121
121
|
else if (ev.type === "tool") {
|
|
122
|
-
const data: ToolData = { title: ev.title, detail: ev.detail, peek: ev.peek, diff: ev.diff };
|
|
123
|
-
upsertMsg(taskId, { id: ev.msgId ?? `t-${Date.now()}-${Math.random()}`, role: "tool", content: JSON.stringify(data), generation: gen, toolId: ev.id });
|
|
122
|
+
const data: ToolData = { title: ev.title, detail: ev.detail, peek: ev.peek, diff: ev.diff, subagent: ev.subagent, subagentMessage: ev.subagentMessage };
|
|
123
|
+
upsertMsg(taskId, { id: ev.msgId ?? `t-${Date.now()}-${Math.random()}`, role: "tool", content: JSON.stringify(data), generation: gen, toolId: ev.id, ts: ev.ts });
|
|
124
124
|
} else if (ev.type === "tool_result") {
|
|
125
125
|
// Match by DB message id (works on snapshot-loaded messages too), falling
|
|
126
126
|
// back to the in-memory tool_use id. Newest first: results land near the end.
|
|
@@ -128,10 +128,14 @@ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning,
|
|
|
128
128
|
const arr = prev[taskId] ?? [];
|
|
129
129
|
let i = arr.length - 1;
|
|
130
130
|
while (i >= 0 && (arr[i].role !== "tool" || (arr[i].id !== ev.msgId && arr[i].toolId !== ev.id))) i--;
|
|
131
|
-
if (i < 0)
|
|
131
|
+
if (i < 0) {
|
|
132
|
+
if (!ev.msgId || (!ev.subagent && !ev.subagentMessage)) return prev;
|
|
133
|
+
const data: ToolData = { title: ev.subagent?.title ?? ev.subagentMessage?.tool?.title ?? "子代理消息", subagent: ev.subagent, subagentMessage: ev.subagentMessage };
|
|
134
|
+
return { ...prev, [taskId]: [...arr, { id: ev.msgId, role: "tool", content: JSON.stringify(data), generation: gen, toolId: ev.id, ts: ev.ts }] };
|
|
135
|
+
}
|
|
132
136
|
const m = arr[i];
|
|
133
137
|
let content: string;
|
|
134
|
-
try { const d = JSON.parse(m.content) as ToolData; d.result = ev.content; d.isError = ev.isError; if (ev.peek) d.peek = ev.peek; content = JSON.stringify(d); } catch { return prev; }
|
|
138
|
+
try { const d = JSON.parse(m.content) as ToolData; d.result = ev.content; d.isError = ev.isError; if (ev.subagent) d.subagent = ev.subagent; if (ev.subagentMessage) d.subagentMessage = ev.subagentMessage; if (ev.peek) d.peek = ev.peek; content = JSON.stringify(d); } catch { return prev; }
|
|
135
139
|
const next = arr.slice();
|
|
136
140
|
next[i] = { ...m, content };
|
|
137
141
|
return { ...prev, [taskId]: next };
|
package/src/operator.base.css
CHANGED
|
@@ -4,6 +4,35 @@
|
|
|
4
4
|
(ported from the Claude Design hi-fi prototype)
|
|
5
5
|
========================================================================== */
|
|
6
6
|
|
|
7
|
+
/* Native CLI subagents share the transcript renderer and keep their own viewer. */
|
|
8
|
+
.subagent-chat-scope{display:flex;position:relative;flex:1;min-height:0;min-width:0;overflow:hidden;}
|
|
9
|
+
.subagent-chat-main{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;}
|
|
10
|
+
.subagent-chat-main>.session{flex:1;min-height:0;}
|
|
11
|
+
.subagent-entry{display:flex;align-items:center;gap:9px;width:100%;padding:11px 13px;border:1px solid var(--line-strong);border-radius:var(--r);background:var(--surface);color:var(--ink);text-align:left;}
|
|
12
|
+
.subagent-entry:not(:disabled):hover{border-color:var(--accent);background:var(--accent-soft);}
|
|
13
|
+
.subagent-entry:disabled{cursor:default;}
|
|
14
|
+
.subagent-entry-icon{display:flex;color:var(--accent);}
|
|
15
|
+
.subagent-entry-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;}
|
|
16
|
+
.subagent-entry svg{flex:none;width:16px;height:16px;}
|
|
17
|
+
.subagent-status{font-size:11px;color:var(--ink-3);white-space:nowrap;}
|
|
18
|
+
.subagent-status.running{color:var(--accent);}
|
|
19
|
+
.subagent-status.completed{color:var(--green);}
|
|
20
|
+
.subagent-status.failed{color:var(--red);}
|
|
21
|
+
.subagent-chat-panel{display:flex;flex-direction:column;position:relative;flex:0 0 46%;width:46%;min-width:0;max-width:560px;min-height:0;border-left:1px solid var(--line-strong);background:var(--panel);z-index:30;}
|
|
22
|
+
.subagent-chat-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line);}
|
|
23
|
+
.subagent-chat-header>div{display:flex;flex:1;min-width:0;flex-direction:column;gap:3px;}
|
|
24
|
+
.subagent-chat-header strong{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
25
|
+
.subagent-chat-caption{font-size:10px;color:var(--ink-3);}
|
|
26
|
+
.subagent-chat-header .icon-btn{flex:none;}
|
|
27
|
+
.subagent-chat-transcript{min-height:0;flex:1;overflow:auto;overscroll-behavior:contain;}
|
|
28
|
+
.subagent-chat-transcript .tw{width:100%;max-width:none;padding:16px;}
|
|
29
|
+
.subagent-chat-transcript .msg{max-width:100%;}
|
|
30
|
+
.subagent-chat-note{font-size:12px;color:var(--ink-3);line-height:1.6;margin-bottom:14px;overflow-wrap:anywhere;}
|
|
31
|
+
.subagent-chat-note.error{color:var(--red);}
|
|
32
|
+
.subagent-chat-latest{align-self:center;margin:6px 0;}
|
|
33
|
+
.subagent-chat-scrim{position:absolute;inset:0;background:rgba(0,0,0,.25);z-index:29;}
|
|
34
|
+
.subagent-chat-scope.narrow>.subagent-chat-panel{position:absolute;inset:0 0 0 auto;width:min(100%,560px);max-width:100%;box-shadow:-8px 0 24px rgba(0,0,0,.16);}
|
|
35
|
+
|
|
7
36
|
:root{
|
|
8
37
|
/* ---- cool near-black "mission control" palette (dark = default) ---- */
|
|
9
38
|
--bg:#080a0e;
|
|
@@ -1842,6 +1871,12 @@ button.pop-item:hover{background:var(--surface-2);}
|
|
|
1842
1871
|
[data-chat-layout]>.cli-refs,[data-chat-layout]>.cli-queue{width:calc(100% - 2 * var(--cli-chat-gutter) - var(--cli-chat-scrollbar,0px));max-width:var(--cli-chat-content-max);margin-inline:auto;margin-inline-start:max(var(--cli-chat-gutter),calc((100% - var(--cli-chat-scrollbar,0px) - var(--cli-chat-content-max)) / 2));flex-shrink:0;}
|
|
1843
1872
|
@container chat-column (max-width:900px){[data-chat-layout]>*{--cli-chat-content-max:100%;}}
|
|
1844
1873
|
@container chat-column (max-width:640px){[data-chat-layout]>*{--cli-chat-gutter:12px;}.session [data-chat-layout]>.composer{padding-inline-end:var(--cli-chat-gutter);}}
|
|
1874
|
+
@media (min-width:761px){
|
|
1875
|
+
[data-chat-layout]{--cli-chat-gutter:32px;}
|
|
1876
|
+
.session [data-chat-layout] .composer{padding-inline:var(--cli-chat-gutter);padding-inline-end:calc(var(--cli-chat-gutter) + var(--cli-chat-scrollbar,0px));}
|
|
1877
|
+
[data-chat-layout] .composer>.composer-inner{width:100%;max-width:var(--cli-chat-content-max);margin-inline:auto;}
|
|
1878
|
+
@container chat-column (max-width:640px){[data-chat-layout]>*{--cli-chat-gutter:24px;}.session [data-chat-layout] .composer{padding-inline-end:var(--cli-chat-gutter);}}
|
|
1879
|
+
}
|
|
1845
1880
|
.task-origin{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--ink-3);padding-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
1846
1881
|
.fork-sources{display:flex;flex-wrap:wrap;gap:6px;padding:12px 14px 0;}
|
|
1847
1882
|
.fork-sources>span{max-width:100%;display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--line);background:var(--surface-2);border-radius:8px;font-size:11px;color:var(--ink-2);overflow-wrap:anywhere;}
|
package/src/operator.css
CHANGED
|
@@ -6,6 +6,35 @@
|
|
|
6
6
|
(ported from the Claude Design hi-fi prototype)
|
|
7
7
|
========================================================================== */
|
|
8
8
|
|
|
9
|
+
/* Native CLI subagents share the transcript renderer and keep their own viewer. */
|
|
10
|
+
.cli-ui .subagent-chat-scope{display:flex;position:relative;flex:1;min-height:0;min-width:0;overflow:hidden;}
|
|
11
|
+
.cli-ui .subagent-chat-main{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;}
|
|
12
|
+
.cli-ui .subagent-chat-main>.session{flex:1;min-height:0;}
|
|
13
|
+
.cli-ui .subagent-entry{display:flex;align-items:center;gap:9px;width:100%;padding:11px 13px;border:1px solid var(--line-strong);border-radius:var(--r);background:var(--surface);color:var(--ink);text-align:left;}
|
|
14
|
+
.cli-ui .subagent-entry:not(:disabled):hover{border-color:var(--accent);background:var(--accent-soft);}
|
|
15
|
+
.cli-ui .subagent-entry:disabled{cursor:default;}
|
|
16
|
+
.cli-ui .subagent-entry-icon{display:flex;color:var(--accent);}
|
|
17
|
+
.cli-ui .subagent-entry-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;}
|
|
18
|
+
.cli-ui .subagent-entry svg{flex:none;width:16px;height:16px;}
|
|
19
|
+
.cli-ui .subagent-status{font-size:11px;color:var(--ink-3);white-space:nowrap;}
|
|
20
|
+
.cli-ui .subagent-status.running{color:var(--accent);}
|
|
21
|
+
.cli-ui .subagent-status.completed{color:var(--green);}
|
|
22
|
+
.cli-ui .subagent-status.failed{color:var(--red);}
|
|
23
|
+
.cli-ui .subagent-chat-panel{display:flex;flex-direction:column;position:relative;flex:0 0 46%;width:46%;min-width:0;max-width:560px;min-height:0;border-left:1px solid var(--line-strong);background:var(--panel);z-index:30;}
|
|
24
|
+
.cli-ui .subagent-chat-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line);}
|
|
25
|
+
.cli-ui .subagent-chat-header>div{display:flex;flex:1;min-width:0;flex-direction:column;gap:3px;}
|
|
26
|
+
.cli-ui .subagent-chat-header strong{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
27
|
+
.cli-ui .subagent-chat-caption{font-size:10px;color:var(--ink-3);}
|
|
28
|
+
.cli-ui .subagent-chat-header .icon-btn{flex:none;}
|
|
29
|
+
.cli-ui .subagent-chat-transcript{min-height:0;flex:1;overflow:auto;overscroll-behavior:contain;}
|
|
30
|
+
.cli-ui .subagent-chat-transcript .tw{width:100%;max-width:none;padding:16px;}
|
|
31
|
+
.cli-ui .subagent-chat-transcript .msg{max-width:100%;}
|
|
32
|
+
.cli-ui .subagent-chat-note{font-size:12px;color:var(--ink-3);line-height:1.6;margin-bottom:14px;overflow-wrap:anywhere;}
|
|
33
|
+
.cli-ui .subagent-chat-note.error{color:var(--red);}
|
|
34
|
+
.cli-ui .subagent-chat-latest{align-self:center;margin:6px 0;}
|
|
35
|
+
.cli-ui .subagent-chat-scrim{position:absolute;inset:0;background:rgba(0,0,0,.25);z-index:29;}
|
|
36
|
+
.cli-ui .subagent-chat-scope.narrow>.subagent-chat-panel{position:absolute;inset:0 0 0 auto;width:min(100%,560px);max-width:100%;box-shadow:-8px 0 24px rgba(0,0,0,.16);}
|
|
37
|
+
|
|
9
38
|
.cli-ui{
|
|
10
39
|
/* ---- cool near-black "mission control" palette (dark = default) ---- */
|
|
11
40
|
--bg:#080a0e;
|
|
@@ -1842,6 +1871,12 @@
|
|
|
1842
1871
|
.cli-ui [data-chat-layout]>.cli-refs,.cli-ui [data-chat-layout]>.cli-queue{width:calc(100% - 2 * var(--cli-chat-gutter) - var(--cli-chat-scrollbar,0px));max-width:var(--cli-chat-content-max);margin-inline:auto;margin-inline-start:max(var(--cli-chat-gutter),calc((100% - var(--cli-chat-scrollbar,0px) - var(--cli-chat-content-max)) / 2));flex-shrink:0;}
|
|
1843
1872
|
@container chat-column (max-width:900px){.cli-ui [data-chat-layout]>*{--cli-chat-content-max:100%;}}
|
|
1844
1873
|
@container chat-column (max-width:640px){.cli-ui [data-chat-layout]>*{--cli-chat-gutter:12px;}.cli-ui .session [data-chat-layout]>.composer{padding-inline-end:var(--cli-chat-gutter);}}
|
|
1874
|
+
@media (min-width:761px){
|
|
1875
|
+
.cli-ui [data-chat-layout]{--cli-chat-gutter:32px;}
|
|
1876
|
+
.cli-ui .session [data-chat-layout] .composer{padding-inline:var(--cli-chat-gutter);padding-inline-end:calc(var(--cli-chat-gutter) + var(--cli-chat-scrollbar,0px));}
|
|
1877
|
+
.cli-ui [data-chat-layout] .composer>.composer-inner{width:100%;max-width:var(--cli-chat-content-max);margin-inline:auto;}
|
|
1878
|
+
@container chat-column (max-width:640px){.cli-ui [data-chat-layout]>*{--cli-chat-gutter:24px;}.cli-ui .session [data-chat-layout] .composer{padding-inline-end:var(--cli-chat-gutter);}}
|
|
1879
|
+
}
|
|
1845
1880
|
.cli-ui .task-origin{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--ink-3);padding-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
1846
1881
|
.cli-ui .fork-sources{display:flex;flex-wrap:wrap;gap:6px;padding:12px 14px 0;}
|
|
1847
1882
|
.cli-ui .fork-sources>span{max-width:100%;display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--line);background:var(--surface-2);border-radius:8px;font-size:11px;color:var(--ink-2);overflow-wrap:anywhere;}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import type { Msg } from "../core/types";
|
|
3
|
+
import { childMessages, subagentFields, type LoadSubagent, type SubagentInfo, type SubagentTranscript } from "../core/subagents";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
|
|
6
|
+
const STATUS = { running: "运行中", completed: "已完成", failed: "失败", cancelled: "已取消", unknown: "状态未知" };
|
|
7
|
+
type Selection = { scope: string; generation: number; info: SubagentInfo };
|
|
8
|
+
const SubagentContext = createContext<((info: SubagentInfo, generation: number) => void) | null>(null);
|
|
9
|
+
|
|
10
|
+
export function SubagentHistoryBoundary({ children }: { children: ReactNode }) { return <SubagentContext.Provider value={null}>{children}</SubagentContext.Provider>; }
|
|
11
|
+
|
|
12
|
+
export function SubagentEntry({ info, generation }: { info: SubagentInfo; generation: number }) {
|
|
13
|
+
const open = useContext(SubagentContext);
|
|
14
|
+
return <div className="msg msg-tool">
|
|
15
|
+
<button type="button" className="subagent-entry" disabled={!open} onClick={() => open?.(info, generation)} aria-label={`查看子代理:${info.title}`}>
|
|
16
|
+
<span className="subagent-entry-icon">{Icon.spark()}</span>
|
|
17
|
+
<span className="subagent-entry-name">{info.title}</span>
|
|
18
|
+
<span className={`subagent-status ${info.status}`}>{STATUS[info.status] ?? STATUS.unknown}</span>
|
|
19
|
+
{Icon.chevRight()}
|
|
20
|
+
</button>
|
|
21
|
+
</div>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One shared viewer for embedded Chat and the native Operator chat. It never controls execution. */
|
|
25
|
+
export function SubagentChatScope({ scopeKey, messages, load, renderMessage, children }: {
|
|
26
|
+
scopeKey: string; messages: Msg[]; load?: LoadSubagent;
|
|
27
|
+
renderMessage: (message: Msg, agentName: string) => ReactNode; children: ReactNode;
|
|
28
|
+
}) {
|
|
29
|
+
const [selection, setSelection] = useState<Selection | null>(null);
|
|
30
|
+
const selected = selection?.scope === scopeKey ? selection : null;
|
|
31
|
+
const open = useCallback((info: SubagentInfo, generation: number) => setSelection({ scope: scopeKey, generation, info }), [scopeKey]);
|
|
32
|
+
const close = useCallback(() => setSelection(null), []);
|
|
33
|
+
const root = useRef<HTMLDivElement>(null);
|
|
34
|
+
const [narrow, setNarrow] = useState(true);
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
const element = root.current;
|
|
37
|
+
if (!element) return;
|
|
38
|
+
const measure = () => { const width = element.getBoundingClientRect().width; if (width > 0) setNarrow(width < 900); };
|
|
39
|
+
measure();
|
|
40
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
41
|
+
observer?.observe(element);
|
|
42
|
+
return () => observer?.disconnect();
|
|
43
|
+
}, []);
|
|
44
|
+
return <SubagentContext.Provider value={open}>
|
|
45
|
+
<div ref={root} className={`subagent-chat-scope${selected ? " has-subagent" : ""}${narrow ? " narrow" : ""}`}>
|
|
46
|
+
<div className="subagent-chat-main" inert={!!selected && narrow || undefined}>{children}</div>
|
|
47
|
+
{selected && <SubagentPanel key={`${scopeKey}:${selected.generation}:${selected.info.id}`} selection={selected} messages={messages} load={load} renderMessage={renderMessage} onClose={close} modal={narrow} />}
|
|
48
|
+
</div>
|
|
49
|
+
</SubagentContext.Provider>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function SubagentPanel({ selection, messages, load, renderMessage, onClose, modal }: {
|
|
53
|
+
selection: Selection; messages: Msg[]; load?: LoadSubagent;
|
|
54
|
+
renderMessage: (message: Msg, agentName: string) => ReactNode; onClose: () => void; modal: boolean;
|
|
55
|
+
}) {
|
|
56
|
+
const [snapshot, setSnapshot] = useState<(SubagentTranscript & { before?: string }) | null>(null);
|
|
57
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
58
|
+
const [loading, setLoading] = useState(!!load);
|
|
59
|
+
const [error, setError] = useState<string | null>(null);
|
|
60
|
+
const [attempt, setAttempt] = useState(0);
|
|
61
|
+
const panel = useRef<HTMLElement>(null);
|
|
62
|
+
const closeButton = useRef<HTMLButtonElement>(null);
|
|
63
|
+
const scroll = useRef<HTMLDivElement>(null);
|
|
64
|
+
const follow = useRef(true);
|
|
65
|
+
const [following, setFollowing] = useState(true);
|
|
66
|
+
const [shownCount, setShownCount] = useState(120);
|
|
67
|
+
const { id } = selection.info;
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
let disposed = false;
|
|
71
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
72
|
+
if (!load) { setLoading(false); return; }
|
|
73
|
+
setLoading(true); setError(null);
|
|
74
|
+
const refresh = async (initial = false) => {
|
|
75
|
+
if (disposed) return;
|
|
76
|
+
if (!initial && document.visibilityState === "hidden") { timer = setTimeout(() => void refresh(), 5000); return; }
|
|
77
|
+
try {
|
|
78
|
+
const value = await load(id, selection.generation);
|
|
79
|
+
if (value.subagent?.id !== id || !Array.isArray(value.messages)) throw new Error("子代理记录与当前选择不匹配");
|
|
80
|
+
if (disposed) return;
|
|
81
|
+
setSnapshot(previous => {
|
|
82
|
+
const overlap = previous?.messages.some(old => value.messages.some(row => row.id === old.id));
|
|
83
|
+
const byId = new Map((previous?.messages ?? []).map(row => [row.id, row]));
|
|
84
|
+
for (const row of value.messages) byId.set(row.id, row);
|
|
85
|
+
return { ...value, messages: [...byId.values()],
|
|
86
|
+
older: overlap ? previous?.older ?? value.older : value.older,
|
|
87
|
+
before: overlap ? previous?.before : value.messages[0]?.id };
|
|
88
|
+
}); setError(null);
|
|
89
|
+
if (value.refreshable && ["running", "unknown"].includes(value.subagent.status)) timer = setTimeout(() => void refresh(), 5000);
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
if (!disposed) setError(cause instanceof Error ? cause.message : String(cause));
|
|
92
|
+
} finally { if (!disposed) setLoading(false); }
|
|
93
|
+
};
|
|
94
|
+
void refresh(true);
|
|
95
|
+
return () => { disposed = true; if (timer) clearTimeout(timer); };
|
|
96
|
+
}, [load, id, selection.generation, attempt]);
|
|
97
|
+
|
|
98
|
+
const info = useMemo(() => {
|
|
99
|
+
let latest = snapshot?.subagent ?? selection.info;
|
|
100
|
+
for (const message of messages) {
|
|
101
|
+
if (message.generation !== selection.generation) continue;
|
|
102
|
+
const candidate = subagentFields(message).subagent;
|
|
103
|
+
if (candidate?.id === id && (candidate.updatedAt ?? 0) >= (latest.updatedAt ?? 0)) latest = candidate;
|
|
104
|
+
}
|
|
105
|
+
return latest;
|
|
106
|
+
}, [messages, snapshot, selection, id]);
|
|
107
|
+
const rows = useMemo(() => {
|
|
108
|
+
const merged = new Map((snapshot?.messages ?? []).map(row => [row.id, row]));
|
|
109
|
+
for (const row of childMessages(messages, id, selection.generation)) {
|
|
110
|
+
const previous = merged.get(row.id);
|
|
111
|
+
const updated = (value: typeof row) => {
|
|
112
|
+
const fields = subagentFields(value);
|
|
113
|
+
return fields.subagentMessage?.updatedAt ?? fields.subagent?.updatedAt ?? 0;
|
|
114
|
+
};
|
|
115
|
+
if (!previous || updated(row) >= updated(previous)) merged.set(row.id, row);
|
|
116
|
+
}
|
|
117
|
+
return childMessages([...merged.values()], id, selection.generation).sort((a, b) => (a.ts ?? a.created_at ?? 0) - (b.ts ?? b.created_at ?? 0)).map(row => {
|
|
118
|
+
const child = subagentFields(row).subagentMessage;
|
|
119
|
+
return { id: row.id, generation: row.generation, ts: row.ts ?? row.created_at,
|
|
120
|
+
role: child?.role ?? "tool", content: child?.tool ? JSON.stringify(child.tool) : child?.content ?? row.content } as Msg;
|
|
121
|
+
});
|
|
122
|
+
}, [snapshot, messages, id, selection.generation]);
|
|
123
|
+
const shown = rows.slice(-shownCount);
|
|
124
|
+
const promptAlreadyShown = rows.some(row => row.role === "user" && row.content === info.task);
|
|
125
|
+
const loadOlder = async () => {
|
|
126
|
+
if (rows.length > shownCount) { setShownCount(value => value + 120); return; }
|
|
127
|
+
if (!load || !snapshot?.before || loadingOlder) return;
|
|
128
|
+
const before = snapshot.before;
|
|
129
|
+
setLoadingOlder(true); setError(null);
|
|
130
|
+
try {
|
|
131
|
+
const value = await load(id, selection.generation, before);
|
|
132
|
+
if (value.subagent?.id !== id || !Array.isArray(value.messages)) throw new Error("子代理记录与当前选择不匹配");
|
|
133
|
+
setSnapshot(previous => {
|
|
134
|
+
const byId = new Map(value.messages.map(row => [row.id, row]));
|
|
135
|
+
for (const row of previous?.messages ?? []) byId.set(row.id, row);
|
|
136
|
+
return { ...(previous ?? value), messages: [...byId.values()],
|
|
137
|
+
...(previous?.before === before ? { older: value.older, before: value.messages[0]?.id ?? before } : {}) };
|
|
138
|
+
});
|
|
139
|
+
setShownCount(count => count + 200);
|
|
140
|
+
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
|
141
|
+
finally { setLoadingOlder(false); }
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
146
|
+
closeButton.current?.focus({ preventScroll: true });
|
|
147
|
+
return () => {
|
|
148
|
+
if ((document.activeElement === document.body || panel.current?.contains(document.activeElement)) && previous?.isConnected && !previous.closest("[inert],[hidden]")) previous.focus({ preventScroll: true });
|
|
149
|
+
};
|
|
150
|
+
}, []);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const onKey = (event: KeyboardEvent) => {
|
|
153
|
+
if (event.defaultPrevented) return;
|
|
154
|
+
const other = (document.activeElement as HTMLElement | null)?.closest('[role="dialog"]');
|
|
155
|
+
if (other && other !== panel.current) return;
|
|
156
|
+
if (event.key === "Escape") { event.preventDefault(); onClose(); return; }
|
|
157
|
+
if (!modal || event.key !== "Tab" || !panel.current) return;
|
|
158
|
+
const items = [...panel.current.querySelectorAll<HTMLElement>('button:not(:disabled),a[href],input:not(:disabled),textarea:not(:disabled),[tabindex="0"]')]
|
|
159
|
+
.filter(item => !item.closest("[hidden],[inert]") && getComputedStyle(item).display !== "none");
|
|
160
|
+
const first = items[0], last = items.at(-1);
|
|
161
|
+
if (!first || !last) return;
|
|
162
|
+
if (!panel.current.contains(document.activeElement) || (event.shiftKey && document.activeElement === first)) { event.preventDefault(); (event.shiftKey ? last : first).focus(); }
|
|
163
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
164
|
+
};
|
|
165
|
+
window.addEventListener("keydown", onKey);
|
|
166
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
167
|
+
}, [modal, onClose]);
|
|
168
|
+
useLayoutEffect(() => { if (follow.current && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; }, [rows, loading]);
|
|
169
|
+
|
|
170
|
+
return <>
|
|
171
|
+
{modal && <button className="subagent-chat-scrim" type="button" tabIndex={-1} aria-label="收起子代理详情" onClick={onClose} />}
|
|
172
|
+
<aside ref={panel} className="subagent-chat-panel" role="dialog" aria-modal={modal || undefined} aria-label={`子代理:${info.title}`}>
|
|
173
|
+
<div className="subagent-chat-header">
|
|
174
|
+
<div><span className="subagent-chat-caption">子代理</span><strong>{info.title}</strong></div>
|
|
175
|
+
<span className={`subagent-status ${info.status}`}>{STATUS[info.status] ?? STATUS.unknown}</span>
|
|
176
|
+
<button ref={closeButton} type="button" className="icon-btn" title="关闭子代理详情" aria-label="关闭子代理详情" onClick={onClose}>{Icon.x()}</button>
|
|
177
|
+
</div>
|
|
178
|
+
<div ref={scroll} className="subagent-chat-transcript transcript" onScroll={() => {
|
|
179
|
+
const element = scroll.current;
|
|
180
|
+
follow.current = !!element && element.scrollHeight - element.scrollTop - element.clientHeight < 60;
|
|
181
|
+
setFollowing(follow.current);
|
|
182
|
+
}}>
|
|
183
|
+
<div className="tw">
|
|
184
|
+
{loading && <div className="subagent-chat-note" role="status">正在读取子代理记录…</div>}
|
|
185
|
+
{error && <div className="subagent-chat-note error" role="alert">读取历史失败:{error} <button className="btn btn-ghost btn-sm" onClick={() => setAttempt(value => value + 1)}>重试</button></div>}
|
|
186
|
+
{!load && <div className="subagent-chat-note">当前接入未提供子代理历史接口,以下为已加载的记录。</div>}
|
|
187
|
+
{info.detailAvailability !== "full" && <div className="subagent-chat-note">{info.detailReason || (info.detailAvailability === "unavailable" ? "此智能体暂未提供子代理运行过程。" : "此智能体仅提供了部分运行记录。")}</div>}
|
|
188
|
+
{info.task && !promptAlreadyShown && renderMessage({ id: `prompt:${id}`, role: "user", content: info.task, generation: selection.generation }, info.title)}
|
|
189
|
+
{(rows.length > shownCount || (snapshot?.older ?? 0) > 0) && <div className="load-older"><button type="button" className="btn btn-ghost btn-sm" disabled={loadingOlder} onClick={() => { follow.current = false; setFollowing(false); void loadOlder(); }}>{loadingOlder ? "正在读取更早的记录…" : "加载更早的子代理记录"}</button></div>}
|
|
190
|
+
{shown.map(row => <div key={row.id}>{renderMessage(row, info.title)}</div>)}
|
|
191
|
+
{!loading && !error && !rows.length && <div className="subagent-chat-note">尚无可展示的运行记录。</div>}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
{!following && <button type="button" className="btn btn-ghost btn-sm subagent-chat-latest" onClick={() => { follow.current = true; setFollowing(true); if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; }}>跳到最新</button>}
|
|
195
|
+
</aside>
|
|
196
|
+
</>;
|
|
197
|
+
}
|
package/src/ui/Transcript.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// 转录里的一行。移植自 operator-oss/app/orchestrator/Transcript.tsx,类名、结构、文案保持一致;
|
|
2
2
|
// 去掉了对话任务用不到的部分(/clear、重新登录、浏览器交还……这些动作 center 不提供)。
|
|
3
|
+
import { subagentFields } from "../core/subagents";
|
|
4
|
+
import { SubagentEntry } from "./SubagentChat";
|
|
3
5
|
import { memo, useContext, useId, useState } from "react";
|
|
4
6
|
import { FileOpenContext, FileLinkContext } from "./fileLinks";
|
|
5
7
|
import type { AskAnswers, AskQuestion, InputRequest, Msg, ToolData, ToolPeek } from "../core/types";
|
|
@@ -370,6 +372,9 @@ export const MessageView = memo(function MessageView({ m, initial: isInitial, hi
|
|
|
370
372
|
}
|
|
371
373
|
if (m.role === "tool") {
|
|
372
374
|
const data = toolDataOf(m);
|
|
375
|
+
const child = subagentFields(m);
|
|
376
|
+
if (child.subagentMessage) return null;
|
|
377
|
+
if (child.subagent) return <SubagentEntry info={child.subagent} generation={m.generation} />;
|
|
373
378
|
if (data.input) return <div className="msg msg-tool"><InputView input={data.input} agentLabel={agentLabel} onSubmit={handlers.onInput} onBrowser={handlers.onShowBrowser} /></div>;
|
|
374
379
|
if (data.ask) return <div className="msg msg-tool"><AskView data={data} agentLabel={agentLabel} onAnswer={handlers.onAnswer ? (answers) => handlers.onAnswer!(data.ask?.id || m.toolId || "", data.ask?.questions ?? [], answers) : undefined} /></div>;
|
|
375
380
|
return <div className="msg msg-tool"><ToolView data={data} /></div>;
|