feat(agent): 抽屉与会话页统一成一个对话界面,子会话在父会话里实时可见

- 抽屉和会话页渲染同一套 GoalComposer / AgentRunPanel;抽屉标题即最近会话切换,
  头部只有新对话、在页面打开、关闭;去掉右下角悬浮按钮和最小化小窗
- 会话列表合一:对话与巡检混排带类型小标签,搜索 + 一个状态下拉,只列顶层会话
  (GET /agent/runs?roots=true,新增可选参数)
- 子会话卡片:由镜像的 subagent_* 事件折成,显示任务、状态、当前步骤、用时、调用次数、费用,
  可展开看过程、可打开;顶部「N 个子会话运行中」跳到卡片;subagent_completed 加可选 durationMs
- 回答去内部字段:汇报规则禁止照抄键值与字段表;展示层 stripInternalFields 把字段行 / 字段表移进技术信息
- 开场建议按页面给 2 条完整问法、点了直接发;指令只留 /先给方案,/重跑 改成「重新运行」按钮

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 13:56:38 +08:00
1 parent dd0f6e2c09
commit 3692793dff
35 files changed
+1454 -1137

No files matched your search

@@ -1,5 +1,5 @@
import { CAPABILITY_ACTIVITY, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
import type { ApprovalView, OptionsView, StreamItem, ToolCallView } from "./use-agent-stream.ts";
import type { ApprovalView, OptionsView, StreamItem, SubagentView, ToolCallView } from "./use-agent-stream.ts";
// 会话流的排版模型(AGUI-3,纯函数,单测覆盖):
// 用户消息 → 一行进度(这一段里的工具调用、思考和过程中的话) → 结论正文 → 没做的事 → 一行实际结果。
@@ -178,7 +178,7 @@ export type ActivityItem =
export type BodyText = { key: string; text: string; at: string };
export type TurnPart =
| { kind: "segment"; key: string; activity: ActivityItem[]; body: BodyText[] }
| { kind: "segment"; key: string; activity: ActivityItem[]; body: BodyText[]; subagents: SubagentView[] }
| { kind: "options"; key: string; at: string; view: OptionsView }
| { kind: "approval"; key: string; at: string; view: ApprovalView }
| { kind: "compaction"; key: string; at: string; item: Extract<StreamItem, { kind: "compaction" }> }
@@ -217,7 +217,7 @@ export function buildConversation(
const closeSegment = () => {
if (!turn) return;
if (seg || texts.length) {
const s = seg ?? { kind: "segment" as const, key: `s${texts[0]?.key}`, activity: [], body: [] };
const s = seg ?? { kind: "segment" as const, key: `s${texts[0]?.key}`, activity: [], body: [], subagents: [] };
s.body = texts;
turn.parts.push(s);
}
@@ -232,7 +232,7 @@ export function buildConversation(
return turn;
};
const segment = (key: string) => {
if (!seg) seg = { kind: "segment", key: `s${key}`, activity: [], body: [] };
if (!seg) seg = { kind: "segment", key: `s${key}`, activity: [], body: [], subagents: [] };
return seg;
};
@@ -273,6 +273,15 @@ export function buildConversation(
// 这次拒绝已经转成审批卡:不再算"没做"
t.denied = t.denied.filter((c) => c.capabilityId !== it.view.capabilityId);
break;
case "subagent": {
// 子会话卡片跟在派出它的那段进度行下面(在结论正文之前);不切开进度行,
// 一次派两个就是一行「派出了 2 个子会话」+ 两张卡片
const s = segment(it.key);
for (const x of texts) s.activity.push({ kind: "note", key: x.key, text: x.text, at: x.at });
texts = [];
s.subagents.push(it.view);
break;
}
case "compaction":
closeSegment();
t.parts.push({ kind: "compaction", key: it.key, at: it.at, item: it });
@@ -376,3 +385,61 @@ export function lastReportText(items: readonly StreamItem[]): string | null {
}
return null;
}
// ── 子会话卡片(纯函数) ──
export type SubagentState = "running" | "waiting" | "done" | "failed" | "stopped";
/**
* 子会话卡片的状态:镜像里收到 subagent_completed 就按它的结果;还没收到时用快照里的子会话状态
* (等待、已停止更新得更及时);都没有按运行中。
*/
export function subagentState(view: Pick<SubagentView, "status">, nodeStatus?: string | null): SubagentState {
switch (view.status ?? nodeStatus) {
case "succeeded":
return "done";
case "failed":
case "budget_exceeded":
return "failed";
case "cancelled":
return "stopped";
case "waiting":
return "waiting";
default:
return "running";
}
}
/** 子会话已运行多久(毫秒):结束的取 durationMs 或起止时间,运行中的到 now */
export function subagentElapsed(
view: Pick<SubagentView, "startedAt" | "endedAt" | "durationMs">,
now: number,
finishedAt?: string | null,
): number {
if (view.durationMs !== null) return view.durationMs;
const start = Date.parse(view.startedAt);
const end = view.endedAt ? Date.parse(view.endedAt) : finishedAt ? Date.parse(finishedAt) : now;
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
}
/**
* 卡片上的一句结果:服务端摘要是「核查结论\n 模型汇报(未经核查):…」,有模型汇报时取它的第一句,
* 否则取摘要第一行。
*/
export function subagentResultLine(summary: string | null | undefined): string | null {
const t = summary?.trim();
if (!t) return null;
const model = /模型汇报(未经核查):([\s\S]*)$/.exec(t)?.[1]?.trim();
const src = model || t;
return (
src
.split("\n")
.find((l) => l.trim())
?.trim() ?? null
);
}
/** 会话里的全部子会话卡片(只看直接派出的;按出现顺序) */
export function subagentViews(items: readonly StreamItem[]): SubagentView[] {
return items.flatMap((i) => (i.kind === "subagent" ? [i.view] : []));
}