@spzxg/cli-ui 0.6.10 → 0.6.12

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/cli-ui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-CbxWozLO.js";
1
+ import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-B0HDalUb.js";
2
2
  export {
3
3
  a as CliBoard,
4
4
  i as CliBrowserView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzxg/cli-ui",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
4
4
  "type": "module",
5
5
  "description": "相互联动的 CliMsgList、CliChat、CliBoard,支持任务中枢和完整 Operator 工作区。",
6
6
  "main": "src/index.ts",
@@ -1,6 +1,8 @@
1
1
  // cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
2
2
  // 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
3
3
  // 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
4
+ import { SubagentChatScope } from "../ui/SubagentChat";
5
+ import { parentMessages } from "../core/subagents";
4
6
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties } from "react";
5
7
  import { CliErrorBoundary } from "../ui/ErrorBoundary";
6
8
  import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
@@ -198,11 +200,18 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
198
200
 
199
201
  const allMessages = useMemo(() => (conv ? viewMessages(conv) : []), [conv]);
200
202
  // config.transcript.tools=false:去掉工具卡片,只留文字往来(提问 / 输入卡片要用户操作,照常显示)。
201
- const messages = useMemo(() => (cfg.transcript.tools ? allMessages : allMessages.filter((m) => {
203
+ const parent = useMemo(() => parentMessages(allMessages), [allMessages]);
204
+ const messages = useMemo(() => (cfg.transcript.tools ? parent : parent.filter((m) => {
202
205
  if (m.role !== "tool") return true;
203
206
  const data = toolDataOf(m);
204
207
  return !!(data.ask || data.input);
205
- })), [allMessages, cfg.transcript.tools]);
208
+ })), [parent, cfg.transcript.tools]);
209
+ const remoteTaskId = conv?.remoteTaskId ?? conv?.task?.remote_task_id ?? null;
210
+ const loadSubagent = useCallback(async (id: string, generation: number, before?: string) => {
211
+ if (!key || !remoteTaskId || !store.client.subagent) throw new Error("当前接入未提供子代理历史接口");
212
+ return store.client.subagent(key, remoteTaskId, id, generation, before);
213
+ }, [store, key, remoteTaskId]);
214
+ const renderSubagentMessage = useCallback((message: Msg, name: string) => <MessageView m={message} initial={false} hideWho={false} handlers={{}} agentLabel={name} view={{ timestamps: true, initialBadge: false, userLabel: "收到的任务", agentName: name }} />, []);
206
215
  const running = conv ? isRunning(conv) : false;
207
216
  const job = entry?.job;
208
217
  // 一次性任务没有轮次:补充消息通过 continueTask 接在当前子任务的对话后面。
@@ -691,6 +700,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
691
700
  <FileLinkContext.Provider value={resolveFile}>
692
701
  <FileOpenContext.Provider value={openFile}>
693
702
  <div ref={chatRef} className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
703
+ <SubagentChatScope scopeKey={`${key ?? "new"}:${remoteTaskId ?? ""}`} messages={allMessages} load={store.client.subagent ? loadSubagent : undefined} renderMessage={renderSubagentMessage}>
694
704
  <div className="session">
695
705
  {h.show && (workspaceAvailable || externalBoardVisible || h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
696
706
  {workspaceTabs ? <WorkspaceTabs title={convTitle} subtitle={h.crumb ? title : undefined} chatActive={!workspaceActive} onChat={showChat} onTabsHost={setTabsHost} /> : (h.crumb || showCurrentTitle) && <div className="sh-main">
@@ -883,6 +893,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
883
893
  inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
884
894
  </WorkspaceDrawer>
885
895
  </div>
896
+ </SubagentChatScope>
886
897
  {popup && key && board && (
887
898
  <div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
888
899
  <div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
@@ -1,3 +1,4 @@
1
+ import type { SubagentTranscript } from "./subagents";
1
2
  import { createSseParser } from "./sse";
2
3
  import { NEW_TASK, parseConvKey } from "./conversation";
3
4
  import type {
@@ -107,6 +108,8 @@ export interface ConversationBackend {
107
108
  /** taskId:只要对话任务里这一个对话的轮次。 */
108
109
  listTurns(jobId: string, query?: { afterSeq?: number; limit?: number; taskId?: string }): Promise<{ job_id: string; turns: CenterTurn[] }>;
109
110
  listTasks(): Promise<{ tasks: CenterTask[] }>;
111
+ /** 原生子代理的公开运行记录;before 用于读取更早的一页。 */
112
+ subagent?(jobId: string, remote: string, childId: string, generation: number, before?: string): Promise<SubagentTranscript>;
110
113
  /**
111
114
  * 更早的节点转录(往上翻):remote 是流里给的节点会话,before 是手里最早那条消息的 id。
112
115
  * 返回这一页(旧的在前)和再往前还剩几条。旧版中枢没有这个接口(实时流也就给的是全量,用不到)。
@@ -344,6 +347,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
344
347
  return error instanceof Error ? error.message : String(error);
345
348
  }
346
349
  },
350
+ subagent: (jobId, remote, childId, generation, before) => request<SubagentTranscript>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/subagents/${id(childId)}?remote=${id(remote)}&generation=${generation}&limit=200${before ? `&before=${id(before)}` : ""}`),
347
351
  history: (jobId: string, remote: string, before: string, limit = 200) =>
348
352
  request<{ messages: Message[]; older: number }>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/history?remote=${id(remote)}&before=${id(before)}&limit=${limit}`),
349
353
  createConversation: (firstMessage: string, opts: NewConversationOptions & { businessId?: string } = {}) => {
@@ -229,19 +229,25 @@ export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
229
229
  case "assistant":
230
230
  return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("a"), role: "assistant", content: e.content, generation: gen, ts: e.ts }));
231
231
  case "tool": {
232
- const data: ToolData = { title: e.title, detail: e.detail, peek: e.peek, diff: e.diff };
233
- return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("t"), role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id }));
232
+ const data: ToolData = { title: e.title, detail: e.detail, peek: e.peek, diff: e.diff, subagent: e.subagent, subagentMessage: e.subagentMessage };
233
+ return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("t"), role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id, ts: e.ts }));
234
234
  }
235
235
  case "tool_result": {
236
236
  // 先按消息 id 找(快照加载的消息也能对上),找不到再按 tool_use id。结果多在末尾,从后往前找。
237
237
  const arr = conv.msgs;
238
238
  let i = arr.length - 1;
239
239
  while (i >= 0 && (arr[i].role !== "tool" || (arr[i].id !== e.msgId && arr[i].toolId !== e.id))) i--;
240
- if (i < 0) return conv;
240
+ if (i < 0) {
241
+ if (!e.msgId || (!e.subagent && !e.subagentMessage)) return conv;
242
+ const data: ToolData = { title: e.subagent?.title ?? e.subagentMessage?.tool?.title ?? "子代理消息", subagent: e.subagent, subagentMessage: e.subagentMessage };
243
+ return set(upsertMsg(arr, { id: e.msgId, role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id, ts: e.ts }));
244
+ }
241
245
  try {
242
246
  const d = JSON.parse(arr[i].content) as ToolData;
243
247
  d.result = e.content;
244
248
  d.isError = e.isError;
249
+ if (e.subagent) d.subagent = e.subagent;
250
+ if (e.subagentMessage) d.subagentMessage = e.subagentMessage;
245
251
  if (e.peek) d.peek = e.peek;
246
252
  const next = arr.slice();
247
253
  next[i] = { ...arr[i], content: JSON.stringify(d) };
@@ -535,6 +541,7 @@ export function turnCallback(conv: ConvState, turn: CenterTurn, continuedFrom: s
535
541
  context_reset: turn.context_reset,
536
542
  merged: !!turn.merged,
537
543
  merge_error: turn.merge_error ?? null,
544
+ ...(turn.saved !== undefined ? { saved: turn.saved, saved_at: turn.saved_at ?? null, save_error: turn.save_error ?? null } : {}),
538
545
  agent: turn.agent,
539
546
  resolved_model: turn.resolved_model,
540
547
  total_tokens: turn.total_tokens, input_tokens: turn.input_tokens, output_tokens: turn.output_tokens,
@@ -0,0 +1,78 @@
1
+ /** Shared wire format for native CLI subagents. These are not Center tasks. */
2
+ export interface SubagentInfo {
3
+ id: string;
4
+ parentId?: string;
5
+ title: string;
6
+ task?: string;
7
+ status: "running" | "completed" | "failed" | "cancelled" | "unknown";
8
+ detailAvailability: "full" | "partial" | "unavailable";
9
+ detailReason?: string;
10
+ updatedAt?: number;
11
+ }
12
+
13
+ export interface SubagentMessage {
14
+ subagentId: string;
15
+ role: "user" | "assistant" | "tool" | "system";
16
+ content: string;
17
+ tool?: { id: string; title: string; detail?: string; result?: string; isError?: boolean };
18
+ eventId: string;
19
+ updatedAt?: number;
20
+ }
21
+
22
+ export interface SubagentFields {
23
+ subagent?: SubagentInfo;
24
+ subagentMessage?: SubagentMessage;
25
+ }
26
+
27
+ type Row = { id: string; role: string; content: string; generation: number; ts?: number; created_at?: number };
28
+ export interface SubagentTranscript { subagent: SubagentInfo; messages: Row[]; refreshable?: boolean; older?: number }
29
+ export type LoadSubagent = (id: string, generation: number, before?: string) => Promise<SubagentTranscript>;
30
+
31
+ const cache = new WeakMap<Row, SubagentFields>();
32
+ export function subagentFields(row: Row): SubagentFields {
33
+ const known = cache.get(row);
34
+ if (known) return known;
35
+ const fields: SubagentFields = {};
36
+ if (row.role === "tool") {
37
+ try {
38
+ const value = JSON.parse(row.content);
39
+ const info = value?.subagent;
40
+ if (info && typeof info.id === "string" && typeof info.title === "string") fields.subagent = {
41
+ id: info.id, title: info.title,
42
+ parentId: typeof info.parentId === "string" ? info.parentId : undefined,
43
+ task: typeof info.task === "string" ? info.task : undefined,
44
+ status: ["running", "completed", "failed", "cancelled", "unknown"].includes(info.status) ? info.status : "unknown",
45
+ detailAvailability: ["full", "partial", "unavailable"].includes(info.detailAvailability) ? info.detailAvailability : "partial",
46
+ detailReason: typeof info.detailReason === "string" ? info.detailReason : undefined,
47
+ updatedAt: typeof info.updatedAt === "number" && Number.isFinite(info.updatedAt) ? info.updatedAt : undefined,
48
+ };
49
+ if (value?.subagentMessage && typeof value.subagentMessage.subagentId === "string" && typeof value.subagentMessage.content === "string"
50
+ && ["user", "assistant", "tool", "system"].includes(value.subagentMessage.role)) fields.subagentMessage = value.subagentMessage;
51
+ } catch { /* Ordinary or malformed tool output is handled by the existing renderer. */ }
52
+ }
53
+ cache.set(row, fields);
54
+ return fields;
55
+ }
56
+
57
+ /** Keep child activity out of the parent timeline, but retain an entry if its card is outside the loaded tail. */
58
+ export function parentMessages<T extends Row>(rows: T[]): T[] {
59
+ const cardKeys = new Set(rows.flatMap(row => {
60
+ const info = subagentFields(row).subagent;
61
+ return info ? [`${row.generation}:${info.id}`] : [];
62
+ }));
63
+ const emitted = new Set<string>();
64
+ return rows.flatMap(row => {
65
+ const { subagent, subagentMessage } = subagentFields(row);
66
+ if (subagent?.parentId && cardKeys.has(`${row.generation}:${subagent.parentId}`)) return [];
67
+ if (!subagentMessage) return [row];
68
+ const key = `${row.generation}:${subagentMessage.subagentId}`;
69
+ if (cardKeys.has(key) || emitted.has(key)) return [];
70
+ emitted.add(key);
71
+ const info: SubagentInfo = { id: subagentMessage.subagentId, title: "子代理", status: "unknown", detailAvailability: "partial", detailReason: "正在读取子代理详情" };
72
+ return [{ ...row, id: `subagent-entry:${key}`, role: "tool", content: JSON.stringify({ title: info.title, subagent: info }) }];
73
+ });
74
+ }
75
+
76
+ export function childMessages(rows: Row[], id: string, generation: number) {
77
+ return rows.filter(row => row.generation === generation && (subagentFields(row).subagentMessage?.subagentId === id || subagentFields(row).subagent?.parentId === id));
78
+ }
package/src/core/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SubagentInfo, SubagentMessage } from "./subagents";
1
2
  /**
2
3
  * 两套数据形状:
3
4
  * · center 服务(cli-task-center)对话任务接口的返回(snake_case,原样保留,便于对照 API 文档);
@@ -37,6 +38,10 @@ export interface CenterTurn {
37
38
  /** 这一轮的改动是否已合并回项目基线。 */
38
39
  merged?: boolean;
39
40
  merge_error?: string | null;
41
+ /** 保存本任务分支与合并项目主分支分别记录;旧版中枢不返回。 */
42
+ saved?: boolean;
43
+ saved_at?: string | null;
44
+ save_error?: string | null;
40
45
  created_at: string;
41
46
  started_at: string | null;
42
47
  ended_at: string | null;
@@ -203,6 +208,8 @@ export type ToolPeek =
203
208
  | { kind: "todos"; items: { text: string; status: string }[] };
204
209
 
205
210
  export interface ToolData {
211
+ subagent?: SubagentInfo;
212
+ subagentMessage?: SubagentMessage;
206
213
  title: string;
207
214
  detail?: string;
208
215
  result?: string;
@@ -228,8 +235,8 @@ export type NodeEvent =
228
235
  | { type: "dequeued"; msgId: string }
229
236
  | { type: "model"; model: string }
230
237
  | { type: "assistant"; content: string; msgId?: string; generation?: number; ts?: number }
231
- | { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; msgId?: string; generation?: number }
232
- | { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; msgId?: string }
238
+ | { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; subagent?: SubagentInfo; subagentMessage?: SubagentMessage; msgId?: string; generation?: number; ts?: number }
239
+ | { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; subagent?: SubagentInfo; subagentMessage?: SubagentMessage; msgId?: string; generation?: number; ts?: number }
233
240
  | { type: "ask"; id: string; questions: AskQuestion[]; msgId?: string; generation?: number }
234
241
  | { type: "ask_answered"; id: string; answers: AskAnswers; msgId?: string; awaiting_input?: 0 | 1 }
235
242
  | { type: "input"; input: InputRequest; msgId?: string; generation?: number; awaiting_input?: 0 | 1 }
@@ -338,6 +345,9 @@ export interface ConversationTurnCallback {
338
345
  context_reset: boolean;
339
346
  merged: boolean;
340
347
  merge_error: string | null;
348
+ saved?: boolean;
349
+ saved_at?: string | null;
350
+ save_error?: string | null;
341
351
  agent: string | null;
342
352
  resolved_model: string | null;
343
353
  total_tokens: number;
@@ -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(messages, older > 0), [messages, older]);
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
- </></FileLinkContext.Provider>
522
+ </SubagentChatScope></FileLinkContext.Provider>
517
523
  );
518
524
 
519
525
  }
@@ -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 = () => can && 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 });
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()} 创建任务</button>
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) return prev;
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 };
@@ -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;
@@ -1068,7 +1097,8 @@ button.tpeek-more:hover{color:var(--accent-ink);}
1068
1097
  .compact-session>.sess-rail{flex:1;}
1069
1098
  .compact-session .sess-head{flex-wrap:wrap;}
1070
1099
  .compact-session .sh-tools{flex-wrap:wrap;}
1071
- .msg-actions{display:flex;margin-top:6px;}
1100
+ /* 保留消息引用能力;Chat 消息下方不再显示操作入口。 */
1101
+ .msg-actions{display:none;}
1072
1102
  .msg-actions button{display:flex;align-items:center;gap:5px;min-height:32px;padding:5px 8px;border-radius:6px;background:transparent;font-size:12px;color:var(--ink-3);}
1073
1103
  .msg-actions button svg{width:14px;height:14px;}
1074
1104
  .msg-actions button:hover,.msg-actions button:focus-visible{background:var(--surface-2);color:var(--ink);}
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;
@@ -1068,7 +1097,8 @@
1068
1097
  .cli-ui .compact-session>.sess-rail{flex:1;}
1069
1098
  .cli-ui .compact-session .sess-head{flex-wrap:wrap;}
1070
1099
  .cli-ui .compact-session .sh-tools{flex-wrap:wrap;}
1071
- .cli-ui .msg-actions{display:flex;margin-top:6px;}
1100
+ /* 保留消息引用能力;Chat 消息下方不再显示操作入口。 */
1101
+ .cli-ui .msg-actions{display:none;}
1072
1102
  .cli-ui .msg-actions button{display:flex;align-items:center;gap:5px;min-height:32px;padding:5px 8px;border-radius:6px;background:transparent;font-size:12px;color:var(--ink-3);}
1073
1103
  .cli-ui .msg-actions button svg{width:14px;height:14px;}
1074
1104
  .cli-ui .msg-actions button:hover,.cli-ui .msg-actions button:focus-visible{background:var(--surface-2);color:var(--ink);}