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

+10 -75
View File
@@ -1,28 +1,7 @@
import { useEffect } from "react";
import { useSearchParams } from "react-router";
import { type AgentRunGroup, runGroup, useAgentRun } from "~/api/agent.ts";
// 智能体页面的导航状态(纯函数 + 模块级记忆,单测覆盖):
// - 分栏「对话 / 自动巡检」:URL 的 ?tab= 优先,其次当前会话所在分栏,再次上次看的分栏;
// - 会话链接、列表页链接的拼法(保留演示开关);
// - 页面会话列表的筛选与各分栏滚动位置(列表页 ↔ 会话页之间保留,刷新页面才清)。
export const TAB_PARAM = "tab";
/** URL 里的分栏;没写或写错时为 null */
export function groupFromSearch(search: string | URLSearchParams): AgentRunGroup | null {
const v = (typeof search === "string" ? new URLSearchParams(search) : search).get(TAB_PARAM);
return v === "auto" || v === "conversation" ? v : null;
}
/** 分栏取值:URL 显式指定 > 当前会话所在分栏 > 上次看的分栏 > 对话 */
export function resolveGroup(o: {
fromUrl: AgentRunGroup | null;
activeRun?: AgentRunGroup | null;
remembered?: AgentRunGroup | null;
}): AgentRunGroup {
return o.fromUrl ?? o.activeRun ?? o.remembered ?? "conversation";
}
// - 会话列表的筛选与滚动位置(列表页 ↔ 会话页之间保留,刷新页面才清)。
// 列表不再分「对话 / 自动巡检」两栏:旧链接里的 ?tab= 直接忽略。
function withQuery(path: string, q: Record<string, string | null>): string {
const p = new URLSearchParams();
@@ -31,29 +10,23 @@ function withQuery(path: string, q: Record<string, string | null>): string {
return s ? `${path}?${s}` : path;
}
/** 会话页链接:分栏由会话本身决定,不带 tab */
/** 会话页链接 */
export function agentRunHref(id: string, mock = false): string {
return withQuery(`/console/agent/${id}`, { mock: mock ? "1" : null });
}
/** 列表页链接:「对话」是缺省,不写进 URL */
export function agentListHref(group: AgentRunGroup = "conversation", mock = false): string {
return withQuery("/console/agent", { [TAB_PARAM]: group === "auto" ? "auto" : null, mock: mock ? "1" : null });
/** 列表页链接 */
export function agentListHref(mock = false): string {
return withQuery("/console/agent", { mock: mock ? "1" : null });
}
/** 页面会话列表的记忆:上次看的分栏、搜索与筛选、各分栏的滚动位置 */
/** 会话列表的记忆:搜索、状态筛选、滚动位置 */
export type ListMemory = {
group: AgentRunGroup;
q: string;
view: "all" | "pending" | "failed";
scroll: Record<AgentRunGroup, number>;
view: "all" | "pending" | "active" | "failed";
scroll: number;
};
export const newListMemory = (): ListMemory => ({
group: "conversation",
q: "",
view: "all",
scroll: { conversation: 0, auto: 0 },
});
export const newListMemory = (): ListMemory => ({ q: "", view: "all", scroll: 0 });
export const pageListMemory: ListMemory = newListMemory();
/**
@@ -71,44 +44,6 @@ export function revealScrollTop(
return Math.max(0, bottom - viewport.height);
}
/** 会话所在分栏(子会话按父会话);快照没到之前为 null */
export function useRunGroup(runId: string | undefined): AgentRunGroup | null {
const run = useAgentRun(runId);
const r = run.data?.run;
const parentId = r?.trigger === "subagent" ? (r.parentRunId ?? undefined) : undefined;
const parent = useAgentRun(parentId);
if (!r) return null;
if (parentId && !parent.data) return null;
return runGroup(r, parent.data?.run ?? null);
}
/**
* 页面会话列表的分栏:URL 的 ?tab= 优先;会话页再按当前会话所在分栏,快照没到时沿用上次的分栏(避免闪一下)。
* 列表页没有 tab 就是「对话」,URL 与选中项一致。切换分栏只改 URL(replace,不进历史)。
*/
export function usePageListGroup(activeRun?: AgentRunGroup | null): [AgentRunGroup, (g: AgentRunGroup) => void] {
const [params, setParams] = useSearchParams();
const onRunPage = activeRun !== undefined;
const group = resolveGroup({
fromUrl: groupFromSearch(params),
activeRun: onRunPage ? activeRun : null,
remembered: onRunPage ? pageListMemory.group : null,
});
useEffect(() => {
pageListMemory.group = group;
}, [group]);
const change = (g: AgentRunGroup) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set(TAB_PARAM, g);
return next;
},
{ replace: true, preventScrollReset: true },
);
return [group, change];
}
// ── 输入草稿:按会话(和两处新对话输入框)分开记,切换会话、关抽屉不丢;发送成功后清掉。模块级,刷新页面才清 ──
const drafts = new Map<string, string>();