diff --git a/CHANGELOG.md b/CHANGELOG.md index f355ee95..5e937cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。 +## [Unreleased] + +### 调整 +- 智能体抽屉和智能体会话页用同一个对话界面,抽屉是它的紧凑版。抽屉标题就是最近会话切换(点开是和会话页同一份列表),右边是「新对话」「在页面打开」和关闭;不再有单独的「历史」弹层。 +- 智能体只剩一个按钮入口:桌面是侧栏最上方的「智能体」(Ctrl+J),手机是顶栏胶囊里的智能体按钮;去掉右下角悬浮按钮和最小化小窗。关上抽屉会话照常在后台跑,再打开接着看。 +- 会话列表合成一个:对话和自动巡检混在一起,巡检和事件发起的会话带一个小标签;筛选只剩搜索和一个状态下拉(全部状态 / 等你处理 / 进行中 / 失败);子会话不再单独列出。 +- 子会话在父会话里看:派出的位置有一张卡片,实时显示任务、状态、正在做什么、用时、调用次数和费用,展开能看它的过程,也能单独打开;会话顶部的「N 个子会话运行中」点一下跳到卡片。 +- 智能体的回答不再夹带内部字段:模型被要求转述子会话结论、不照抄 ID 和原始字段;旧会话里「childRunId:…」「decisionId: null」这类行和「返回字段」表格从正文里拿掉,原文放进「技术信息」。 +- 新对话的开场建议改成按所在页面给两条完整的问法,点一下直接发出(例如在整理记录页是「整理记录里没识别出的作品是什么」);不再填半句话。 +- 输入框只保留 `/先给方案` 一个指令;原来的 `/重跑` 改成会话结束后顶部的「重新运行」按钮。 + ## [0.3.40] - 2026-10-08 迁移 `0091`。 diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index 7db462b8..a9c534a1 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -127,6 +127,7 @@ export function registerAgentRoutes( if (query.status) q = q.where("status", "=", query.status); if (query.trigger) q = q.where("trigger", "=", query.trigger); if (query.parentRunId) q = q.where("parent_run_id", "=", query.parentRunId); + if (query.roots === "true") q = q.where("parent_run_id", "is", null); if (query.group) { // 自动 = 定时 / 事件触发,子会话按父会话归类 const auto = sql`(trigger in ('schedule', 'event') or (trigger = 'subagent' and parent_run_id in diff --git a/apps/server/src/modules/agent/subagents.ts b/apps/server/src/modules/agent/subagents.ts index 48ecea00..83109f46 100644 --- a/apps/server/src/modules/agent/subagents.ts +++ b/apps/server/src/modules/agent/subagents.ts @@ -302,6 +302,9 @@ function completedBody(c: AgentRunRow): SubagentEventBody { summary: safe(c.summary, 1500), usage: usageOf(c), toolCalls: c.tool_call_count, + ...(c.started_at && c.finished_at + ? { durationMs: Math.max(0, new Date(c.finished_at).getTime() - new Date(c.started_at).getTime()) } + : {}), }; } diff --git a/apps/server/test/agent/run-groups.test.ts b/apps/server/test/agent/run-groups.test.ts index 5c5ae308..75bd878f 100644 --- a/apps/server/test/agent/run-groups.test.ts +++ b/apps/server/test/agent/run-groups.test.ts @@ -3,7 +3,7 @@ import { createAgentModule } from "../../src/modules/agent/index.ts"; import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts"; import { DEV_KEY, json } from "../helpers.ts"; -// 智能体页会话分栏:对话(用户发起)/ 自动巡检(定时、事件触发);子会话随父会话归类 +// 会话列表筛选:group 按来源分(对话 / 自动巡检,子会话随父会话);roots 只列顶层会话 const hasPg = await pgAvailable(); describe.skipIf(!hasPg)("会话列表 group 筛选(真实 PG)", () => { @@ -39,5 +39,14 @@ describe.skipIf(!hasPg)("会话列表 group 筛选(真实 PG)", () => { .sort(); expect(await goals("auto")).toEqual(["事件", "巡检", "巡检子会话"].sort()); expect(await goals("conversation")).toEqual(["对话", "对话子会话"].sort()); + // 会话列表只列顶层会话:子会话在父会话的会话流里看 + const roots = ( + await json( + await h.app.request("/api/v1/agent/runs?roots=true", { headers: { authorization: `Bearer ${DEV_KEY}` } }), + ) + ).data.items + .map((r: { goal: string }) => r.goal) + .sort(); + expect(roots).toEqual(["事件", "巡检", "对话"].sort()); }); }); diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index ae0cf85d..da76d62a 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -120,8 +120,8 @@ function useAgentMockGate(): boolean { export const agentKeys = { all: ["agent"] as const, - runs: (status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) => - ["agent", "list", status ?? "all", trigger ?? "any", group ?? "any"] as const, + runs: (status?: AgentRunStatus, trigger?: AgentTrigger, roots = false) => + ["agent", "list", status ?? "all", trigger ?? "any", roots ? "roots" : "any"] as const, run: (id: string) => ["agent", "detail", id] as const, }; @@ -136,41 +136,28 @@ export const TRIGGER_LABEL: Record = { subagent: "子会话", }; -/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */ -/** 会话分栏:对话 = 用户发起;自动 = 定时巡检 / 事件触发(子会话随父会话) */ -export type AgentRunGroup = "conversation" | "auto"; -const isAutoTrigger = (t: AgentTrigger) => t === "schedule" || t === "event"; - -/** 会话所在分栏:子会话随父会话(与服务端 group 筛选一致);父会话未知时子会话归「对话」 */ -export function runGroup( - run: Pick, - parent?: Pick | null, -): AgentRunGroup { - const t = run.trigger === "subagent" && parent ? parent.trigger : run.trigger; - return isAutoTrigger(t) ? "auto" : "conversation"; -} - -export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) { +/** + * 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页。 + * roots = 只列顶层会话(会话列表用;子会话在父会话的会话流里看)。 + */ +export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger, opts: { roots?: boolean } = {}) { const mock = useAgentMockGate(); + const roots = opts.roots === true; return useInfiniteQuery({ - queryKey: [...agentKeys.runs(status, trigger, group), mock], + queryKey: [...agentKeys.runs(status, trigger, roots), mock], initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam, signal }) => { if (mock) { - const all = MOCK_RUNS(); - const byId = new Map(all.map((r) => [r.id, r] as const)); return { - items: all.filter( + items: MOCK_RUNS().filter( (r) => - (!status || r.status === status) && - (!trigger || r.trigger === trigger) && - (!group || runGroup(r, r.parentRunId ? byId.get(r.parentRunId) : null) === group), + (!status || r.status === status) && (!trigger || r.trigger === trigger) && (!roots || !r.parentRunId), ), nextCursor: null, }; } return apiParsed(RunPage, AGENT_BASE, { - params: cleanParams({ status, trigger, group, cursor: pageParam, limit: 30 }), + params: cleanParams({ status, trigger, roots: roots ? "true" : undefined, cursor: pageParam, limit: 30 }), signal, }); }, diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 76b2ccca..92d172a2 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -50,6 +50,7 @@ import { formatTokens } from "~/lib/tokens.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; import { InlineApproval, OptionsCard } from "./agent-interaction.tsx"; import { splitContextGoal } from "./agent-store.ts"; +import { SubagentCard } from "./agent-subagents.tsx"; import { type ActivityItem, activitySpan, @@ -458,6 +459,7 @@ export function AgentConversation({ audit, after, knownNames, + nested = false, }: { run: AgentRunView; live: AgentStreamState; @@ -467,6 +469,8 @@ export function AgentConversation({ after?: ReactNode; /** 工具结果之外已知的「ID → 名字」(子会话),汇报兜底换名用 */ knownNames?: [string, string][]; + /** 嵌在子会话卡片里:不重复目标(卡片上有),间距收一档 */ + nested?: boolean; }) { const active = isRunActive(run.status) && !live.finished; const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued"; @@ -519,8 +523,8 @@ export function AgentConversation({ ) : null; return ( -
- +
+ {nested ? null : } {blocks.map((b, bi) => b.kind === "user" ? ( @@ -544,6 +548,14 @@ export function AgentConversation({ until={p.body[0]?.at ?? null} /> ) : null} + {p.subagents.map((v) => ( +
+ +
+ ))} {p.activity .flatMap((a) => (a.kind === "tool" ? draftCheckOf(a.call) : [])) .map((d) => ( @@ -711,14 +723,11 @@ export function ResultLine({ run, report, live, - subagents = 0, showCost = true, }: { run: AgentRunView; report: GoalReport | null; live: AgentStreamState; - /** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */ - subagents?: number; /** 上方统计行已显示费用时传 false,不重复 */ showCost?: boolean; }) { @@ -756,7 +765,6 @@ export function ResultLine({ ))} - {subagents ? · 派出 {subagents} 个子会话 : null} {durationMs ? · {durationLabel(durationMs)} : null} {showCost && u.costUsd ? · 约 {formatUsd(u.costUsd)} : null} {mismatch.length ? ( diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index 2f6a2362..2f9c9730 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -1,18 +1,16 @@ -import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react"; +import { Bot, ChevronDown, Maximize2, SquarePen, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Link, useLocation } from "react-router"; import { toast } from "sonner"; -import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts"; +import { agentMockEnabled, useAgentRun, useCreateAgentRun } from "~/api/agent.ts"; import { useDownloadTask } from "~/api/downloads.ts"; import { useLibraryDetail } from "~/api/library.ts"; import { useRecord } from "~/api/records.ts"; import { useSite } from "~/api/sites.ts"; -import { StreamingCursor } from "~/components/agentui/streaming-response.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { errorMessage } from "~/lib/api.ts"; -import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx"; import { DRAFT_DRAWER_GOAL, writeDraft } from "./agent-nav.ts"; import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; import { AgentSessionList, drawerListMemory } from "./agent-session-list.tsx"; @@ -21,10 +19,8 @@ import { agentShortcutLabel, clearAgentContext, contextPrefix, - minimizeAgentDrawer, newAgentConversation, noteAgentReturnPath, - restoreAgentDrawer, setAgentDrawerOpen, shortGoalTitle, showAgentRunInDrawer, @@ -33,11 +29,10 @@ import { toggleAgentDrawer, useAgentDrawer, } from "./agent-store.ts"; -import { AgentRunOpenContext } from "./agent-subagent-tree.tsx"; +import { AgentRunOpenContext } from "./agent-subagents.tsx"; import { quickGoalsFor } from "./quick-goals.ts"; -import { useAgentStream } from "./use-agent-stream.ts"; -/** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */ +/** 侧栏入口、顶栏按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */ export const AGENT_LAUNCHER_ATTR = "data-agent-launcher"; /** @@ -60,23 +55,31 @@ export function useContextName(ctx: AgentContext | null): string | null { return null; } -/** 历史弹层:最近 20 条,等你处理置顶,子会话收在父会话下;点一条在抽屉里打开 */ -function HistoryPopover({ activeId }: { activeId: string | null }) { +/** 抽屉标题 = 最近会话切换:显示当前会话的短标题,点开是和会话页同一份会话列表,点一条在抽屉里打开 */ +function RecentSwitcher({ runId }: { runId: string | null }) { const [open, setOpen] = useState(false); + const run = useAgentRun(runId ?? undefined); const mock = agentMockEnabled(); + const title = runId ? (run.data ? shortGoalTitle(run.data.run.goal, 28) : "会话") : "新对话"; return ( - + - + { setOpen(false); showAgentRunInDrawer(id); @@ -94,12 +97,13 @@ function HistoryPopover({ activeId }: { activeId: string | null }) { } /** - * 智能体抽屉(AGUI-3):任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。 - * 头部只有「历史」弹层和「新对话」;没有 Tab。运行中可最小化到右下角小窗,流式输出继续。 + * 智能体抽屉:任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。内容和会话页是同一个组件 + * (新对话 GoalComposer、会话 AgentRunPanel),这里是紧凑版。 + * 头部:标题即最近会话切换、新对话、在页面打开、关闭。关上抽屉会话照常在后台跑,再打开接着看。 * 挂载:在 console layout 里渲染一次 。页面内打开:openAgentDrawer({ entityId, label, ... })。 */ export function AgentDrawer() { - const { open, minimized, context, runId } = useAgentDrawer(); + const { open, context, runId } = useAgentDrawer(); const create = useCreateAgentRun(); const { pathname, search } = useLocation(); const content = useRef(null); @@ -112,172 +116,99 @@ export function AgentDrawer() { const prefix = contextPrefix(context && name ? { ...context, label: name } : context); return ( - <> - - { - const ta = content.current?.querySelector("textarea:not([disabled])"); - if (!ta) return; - e.preventDefault(); - ta.focus(); - ta.setSelectionRange(ta.value.length, ta.value.length); - }} - // 关闭后焦点回到打开前的位置(快捷键打开没有触发元素,缺省会落到 body) - onCloseAutoFocus={(e) => { - const el = takeReturnFocus(); - if (!el) return; - e.preventDefault(); - el.focus(); - }} - onInteractOutside={(e) => { - if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault(); - }} - > - - - - 智能体 - - {`${agentShortcutLabel()} 打开或收起`} - - - {runId ? ( - <> - - - - ) : null} - {/* 关闭钮放进标题栏这一行,与其它按钮对齐(Sheet 自带的是绝对定位,偏下 5px) */} - - - - -
- {runId ? ( - - - - ) : ( -
- - create.mutate( - { goal: `${prefix}${goal}`, ...opts }, - { - onSuccess: (run) => { - writeDraft(DRAFT_DRAWER_GOAL, ""); - showAgentRunInDrawer(run.id); - }, - onError: (e) => toast.error(errorMessage(e)), - }, - ) - } - /> -
- )} -
-
-
- {minimized && runId ? : null} - - ); -} - -/** 最小化后的小窗:保持事件流订阅,显示状态与最新一句输出 */ -function AgentMiniDock({ runId }: { runId: string }) { - const run = useAgentRun(runId); - const active = isRunActive(run.data?.run.status); - const live = useAgentStream(runId, active || !run.data); - const last = live.items[live.items.length - 1]; - const streaming = active && !live.finished; - const line = - last?.kind === "text" - ? last.text.trim().split("\n").pop() - : last?.kind === "tool" - ? `${isDenied(last.call) ? "被拒绝" : last.call.status === "running" ? "调用" : "完成"} · ${capabilityMeta(last.call.capabilityId)?.title ?? last.call.capabilityId}` - : last?.kind === "thinking" - ? "思考中…" - : last?.kind === "end" - ? last.ok - ? "已完成,展开查看结果" - : last.message - : "等待输出…"; - const status = run.data - ? live.finished && isRunActive(run.data.run.status) - ? "running" - : run.data.run.status - : null; - return ( -
-
- - {status ? : null} - - {run.data ? shortGoalTitle(run.data.run.goal) : ""} - - - -
- -
+ + 智能体 + {`${agentShortcutLabel()} 打开或收起`} + + + {runId ? ( + + ) : null} + {/* 关闭钮放进标题栏这一行,与其它按钮对齐(Sheet 自带的是绝对定位,偏下 5px) */} + + + + +
+ {runId ? ( + + + + ) : ( + + create.mutate( + { goal: `${prefix}${goal}`, ...opts }, + { + onSuccess: (run) => { + writeDraft(DRAFT_DRAWER_GOAL, ""); + showAgentRunInDrawer(run.id); + }, + onError: (e) => toast.error(errorMessage(e)), + }, + ) + } + /> + )} +
+ + ); } -/** 顶栏按钮(窄屏):与快捷键、悬浮按钮同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */ +/** 顶栏胶囊里的智能体按钮(没有侧栏的窄屏):与快捷键、侧栏入口同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */ export function AgentDrawerTrigger({ className }: { className?: string }) { const { pathname } = useLocation(); return ( diff --git a/apps/web/app/components/agent/agent-nav.test.ts b/apps/web/app/components/agent/agent-nav.test.ts index 11e862fd..ac39532f 100644 --- a/apps/web/app/components/agent/agent-nav.test.ts +++ b/apps/web/app/components/agent/agent-nav.test.ts @@ -2,16 +2,13 @@ import type { AgentRunEvent } from "@umt/contracts"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { runGroup } from "~/api/agent.ts"; import { followBehavior } from "~/components/agentui/message-scroller.tsx"; import { agentListHref, agentRunHref, DRAFT_DRAWER_GOAL, DRAFT_PAGE_GOAL, - groupFromSearch, readDraft, - resolveGroup, revealScrollTop, runDraftKey, writeDraft, @@ -32,7 +29,7 @@ import { } from "./agent-store.ts"; import { cachedRunEvents, cacheRunEvents } from "./use-agent-stream.ts"; -// 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。 +// 智能体页面切换的状态逻辑:整页重挂载 key、链接、草稿、滚动策略、快捷键在智能体页面的行为、路由上下文。 describe("主内容区重挂载 key", () => { it("列表页与各会话页共用一个 key,切换会话不重挂载", () => { @@ -47,35 +44,12 @@ describe("主内容区重挂载 key", () => { }); }); -describe("分栏:对话 / 自动巡检", () => { - it("会话所在分栏:巡检 / 事件在自动巡检,子会话随父会话,父会话未知归对话", () => { - expect(runGroup({ trigger: "schedule" })).toBe("auto"); - expect(runGroup({ trigger: "event" })).toBe("auto"); - expect(runGroup({ trigger: "user" })).toBe("conversation"); - expect(runGroup({ trigger: "subagent" }, { trigger: "schedule" })).toBe("auto"); - expect(runGroup({ trigger: "subagent" }, { trigger: "user" })).toBe("conversation"); - expect(runGroup({ trigger: "subagent" })).toBe("conversation"); - }); - it("URL 的 tab 只认两个取值", () => { - expect(groupFromSearch("?tab=auto&mock=1")).toBe("auto"); - expect(groupFromSearch("?tab=conversation")).toBe("conversation"); - expect(groupFromSearch("?tab=xx")).toBeNull(); - expect(groupFromSearch("")).toBeNull(); - }); - it("取值顺序:URL > 当前会话 > 上次 > 对话", () => { - expect(resolveGroup({ fromUrl: "conversation", activeRun: "auto", remembered: "auto" })).toBe("conversation"); - expect(resolveGroup({ fromUrl: null, activeRun: "auto", remembered: "conversation" })).toBe("auto"); - // 打开巡检会话、快照还没到:沿用点它时所在的分栏,不先闪到「对话」 - expect(resolveGroup({ fromUrl: null, activeRun: null, remembered: "auto" })).toBe("auto"); - expect(resolveGroup({ fromUrl: null })).toBe("conversation"); - }); - it("链接:会话页不带 tab(由会话决定),列表页只在自动巡检时带,保留演示开关", () => { +describe("链接", () => { + it("会话页、列表页链接保留演示开关;列表不再分栏,不带 tab", () => { expect(agentRunHref("r1")).toBe("/console/agent/r1"); expect(agentRunHref("r1", true)).toBe("/console/agent/r1?mock=1"); expect(agentListHref()).toBe("/console/agent"); - expect(agentListHref("auto")).toBe("/console/agent?tab=auto"); - expect(agentListHref("auto", true)).toBe("/console/agent?tab=auto&mock=1"); - expect(agentListHref("conversation", true)).toBe("/console/agent?mock=1"); + expect(agentListHref(true)).toBe("/console/agent?mock=1"); }); }); @@ -207,6 +181,23 @@ describe("抽屉开着时路由变化", () => { syncAgentContextToPath("/console/home"); expect(snapshot().context).toBeNull(); }); + it("下载详情页带上下载任务;下载计划页不算", () => { + openAgentDrawer({ label: "作品 aaaa", entityId: "aaaa", fromPath: true }); + syncAgentContextToPath("/console/downloads/t1"); + expect(snapshot().context).toMatchObject({ downloadTaskId: "t1", fromPath: true }); + syncAgentContextToPath("/console/downloads/plans"); + expect(snapshot().context).toBeNull(); + setAgentDrawerOpen(false); + }); + it("关上再打开:接着看同一个会话(没有最小化小窗)", () => { + showAgentRunInDrawer("r9"); + setAgentDrawerOpen(false); + expect(snapshot()).toMatchObject({ open: false, runId: "r9" }); + expect(toggleAction(snapshot(), "/console/library")).toBe("restore"); + setAgentDrawerOpen(true); + expect(snapshot()).toMatchObject({ open: true, runId: "r9" }); + setAgentDrawerOpen(false); + }); it("页面按钮带的上下文、正在看的会话、抽屉关着时不动", () => { openAgentDrawer({ label: "作品:沙丘", entityId: "e1" }); syncAgentContextToPath("/console/sites/hdh"); diff --git a/apps/web/app/components/agent/agent-nav.ts b/apps/web/app/components/agent/agent-nav.ts index 98dcae72..c32507df 100644 --- a/apps/web/app/components/agent/agent-nav.ts +++ b/apps/web/app/components/agent/agent-nav.ts @@ -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 { const p = new URLSearchParams(); @@ -31,29 +10,23 @@ function withQuery(path: string, q: Record): 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; + 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(); diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index 5cc0937b..a9a176df 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { type AgentRunStatus, EventType } from "@umt/contracts"; -import { ChevronDown, FlaskConical, ListChecks, RefreshCw, WifiOff } from "lucide-react"; -import { type ReactNode, useId, useRef, useState } from "react"; +import { Bot, ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, WifiOff } from "lucide-react"; +import { type ReactNode, useId, useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { @@ -42,28 +42,21 @@ import { import { RunPlan } from "./agent-interaction.tsx"; import { readDraft, runDraftKey, writeDraft } from "./agent-nav.ts"; import { shortGoalTitle } from "./agent-store.ts"; -import { SubagentTree } from "./agent-subagent-tree.tsx"; -import { callCounts, sessionStatsParts, stepProgress } from "./conversation-model.ts"; -import { - NEW_RUN_COMMANDS, - PROPOSE_FIRST, - PROPOSE_FIRST_MESSAGE, - parseCommand, - RERUN, - RUN_COMMANDS, -} from "./slash-commands.ts"; +import { ParentRunLink, SubagentChip, SubagentNodesContext } from "./agent-subagents.tsx"; +import { callCounts, sessionStatsParts, stepProgress, subagentViews } from "./conversation-model.ts"; +import { COMMANDS, PROPOSE_FIRST, PROPOSE_FIRST_MESSAGE, parseCommand } from "./slash-commands.ts"; import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts"; /** 新目标的提交内容:`/先给方案` 开头 → proposeFirst(只作用于这一次) */ export function goalFromInput(text: string): { goal: string; opts: AgentSessionOptions } | null { - const { command, rest } = parseCommand(text, NEW_RUN_COMMANDS); + const { command, rest } = parseCommand(text); if (!rest) return null; return { goal: rest, opts: { proposeFirst: command === PROPOSE_FIRST.name } }; } /** - * 新目标输入:只有输入框和发送;`/先给方案` 让这一次先给方案。空态下面 3 个快捷目标,点一下填进输入框 - * (以空格或冒号结尾的是半句,光标停在末尾等补完)。上下文(当前页面的对象)显示成名称标签,可以去掉。 + * 新对话(抽屉和列表页是同一个组件):上面一句说明,下面开场建议和输入框。开场建议点一下直接发出; + * `/先给方案` 让这一次先给方案。上下文(当前页面的对象)显示成输入框上的名称标签,可以去掉。 */ export function GoalComposer({ onSubmit, @@ -73,6 +66,7 @@ export function GoalComposer({ defaultGoal = "", suggestions, draftKey, + className, }: { onSubmit: (goal: string, opts: AgentSessionOptions) => void; pending: boolean; @@ -80,26 +74,17 @@ export function GoalComposer({ context?: string | null; onClearContext?: () => void; defaultGoal?: string; - /** 快捷目标:输入框为空时显示 */ + /** 开场建议:输入框为空时显示 */ suggestions?: string[]; /** 草稿记在这个键下(没有预填目标时恢复);发送成功后由调用方清掉 */ draftKey?: string; + className?: string; }) { const [goal, setGoalState] = useState(() => defaultGoal || readDraft(draftKey)); const setGoal = (text: string) => { setGoalState(text); writeDraft(draftKey, text); }; - const box = useRef(null); - const fill = (text: string) => { - setGoal(text); - requestAnimationFrame(() => { - const ta = box.current?.querySelector("textarea"); - if (!ta) return; - ta.focus(); - ta.setSelectionRange(text.length, text.length); - }); - }; const submit = (text: string) => { const g = goalFromInput(text); if (!g) { @@ -109,7 +94,32 @@ export function GoalComposer({ onSubmit(g.goal, g.opts); }; return ( -
+
+
+ + + +

有什么要我做的?

+

+ 说一个目标就行,比如收一部电影、查一个下载为什么不动。需要批准的操作会先停下来问你。 +

+
+ {suggestions?.length && !goal.trim() ? ( +
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+ ) : null} - {suggestions?.length && !goal.trim() ? ( -
    - {suggestions.slice(0, 3).map((s) => ( -
  • - -
  • - ))} -
- ) : null}
); } @@ -191,13 +186,11 @@ export function RunStatsLine({ export function RunOverviewBar({ snapshot, - status, live, active, className, }: { snapshot: RunSnapshot; - status: AgentRunStatus; live: AgentStreamState; active: boolean; className?: string; @@ -208,8 +201,8 @@ export function RunOverviewBar({ const steps = live.plan?.steps ?? []; const progress = stepProgress(steps); const failed = progress.failedStep; - const family = (snapshot.children?.length ?? 0) > 0 || !!snapshot.parent; - if (!steps.length && !family && !isRunActive(status)) return null; + // 没有步骤清单就不显示(状态在顶部一行,子会话在会话流里) + if (!steps.length) return null; const u = { ...r.usage, ...(live.usage ?? {}) }; return (
@@ -220,40 +213,28 @@ export function RunOverviewBar({ onClick={() => setOpen((v) => !v)} className="flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1 text-left text-meta outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring" > - {isRunActive(status) ? : null} - {steps.length ? ( - <> - - - 步骤 {progress.done}/{progress.total} - - {progress.status} - - ) : ( - - {family ? `${snapshot.children?.length ?? 0} 个子会话` : ""} - - )} + + + 步骤 {progress.done}/{progress.total} + + {progress.status} - {steps.length ? ( -
-
-
- ) : null} +
+
+
-

{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}

@@ -281,8 +262,8 @@ export function RunOverviewBar({ } /** - * 一次运行:顶部概况条 → 会话流(统一最大宽度)→ 底部输入区(只有输入框和发送 / 停止)。 - * 输入区的 `/重跑` 用同一个目标新开;`/先给方案` 让智能体先给方案。compact 用于抽屉。 + * 一次运行(抽屉和会话页是同一个组件,compact = 抽屉):顶部一行状态与统计 → 步骤清单(有才显示)→ + * 会话流(子会话卡片在派出的位置)→ 底部输入区。结束后顶部有「重新运行」;输入框只有 `/先给方案` 一个指令。 */ export function AgentRunPanel({ runId, @@ -296,7 +277,7 @@ export function AgentRunPanel({ className?: string; /** 统计行右侧的操作(会话页的「在侧边打开」) */ actions?: ReactNode; - /** /重跑 之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */ + /** 重新运行之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */ onCreated?: (runId: string) => void; }) { const run = useAgentRun(runId); @@ -354,21 +335,14 @@ export function AgentRunPanel({ const ended = !isRunActive(r.status); const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`)); + const rerun = () => + create.mutate( + { goal: r.goal, proposeFirst: r.scope.proposeFirst === true }, + { onSuccess: (n) => created(n.id), onError: (e) => toast.error(errorMessage(e)) }, + ); + const send = (text: string) => { - const { command, rest } = parseCommand(text, RUN_COMMANDS); - if (command === RERUN.name) { - create.mutate( - { goal: r.goal, proposeFirst: r.scope.proposeFirst === true }, - { - onSuccess: (n) => { - setMsg(""); - created(n.id); - }, - onError: (e) => toast.error(errorMessage(e)), - }, - ); - return; - } + const { command, rest } = parseCommand(text); const body = command === PROPOSE_FIRST.name ? `${PROPOSE_FIRST_MESSAGE}${rest ? `\n${rest}` : ""}` : text; append.mutate( { id: r.id, text: body }, @@ -383,136 +357,154 @@ export function AgentRunPanel({ }; const column = "mx-auto w-full max-w-[88ch]"; + const subagents = subagentViews(live.items); + const nodes = new Map((run.data.children ?? []).map((c) => [c.id, c] as const)); + // 只标需要注意的状态;正常结束不加徽章(结果在会话末尾) + const showBadge = shownStatus !== "succeeded"; return ( -
- - {agentMockEnabled() && !compact ? ( -
- - - 演示数据 - -
- ) : null} - {actions ? ( -
- -
{actions}
-
- ) : ( - - )} - - - {streaming && sseStatus === "reconnecting" ? ( + +
+ + {agentMockEnabled() && !compact ? ( +
+ + + 演示数据 + +
+ ) : null} + {run.data.parent ? ( +
+ +
+ ) : null} + {/* 顶部一行:状态 · 统计 · 子会话 · 操作;窄的时候折行 */}
- - 实时连接中断,正在重连。 + {showBadge ? : null} + +
+ + {ended ? ( + + ) : null} + {actions} +
- ) : null} - {live.error ? ( -
- 事件读取失败:{live.error} - -
- ) : null} - - - [c.id, shortGoalTitle(c.goal, 24)] as [string, string]), - ...(run.data.parent - ? [[run.data.parent.id, shortGoalTitle(run.data.parent.goal, 24)] as [string, string]] - : []), - ]} - audit={audit.data} - userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))} - after={ - <> - {approvals.items - .filter((a) => !inline.has(a.id) && a.status === "pending") - .map((a) => ( - - ))} - {finished ? ( - c.parentRunId === r.id).length} - showCost={false} - /> - ) : null} - + active={streaming || r.status === "waiting"} + className={column} + /> + + {streaming && sseStatus === "reconnecting" ? ( +
+ + 实时连接中断,正在重连。 +
+ ) : null} + {live.error ? ( +
+ 事件读取失败:{live.error} + +
+ ) : null} + + + [c.id, shortGoalTitle(c.goal, 24)] as [string, string]), + ...(run.data.parent + ? [[run.data.parent.id, shortGoalTitle(run.data.parent.goal, 24)] as [string, string]] + : []), + ]} + audit={audit.data} + userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))} + after={ + <> + {approvals.items + .filter((a) => !inline.has(a.id) && a.status === "pending") + .map((a) => ( + + ))} + {finished ? : null} + + } + /> + + +
+ setConfirmStop(true)} + stopLabel={abort.isPending ? "停止中…" : "停止"} + minRows={1} + maxRows={6} + maxLength={8000} + aria-label="发消息" + placeholder={ended ? "继续对话" : "补充或纠正"} + commands={COMMANDS} + /> +
+ + + abort.mutateAsync(r.id).then( + () => toast.success("正在停止"), + (e) => { + toast.error(errorMessage(e)); + throw e; + }, + ) } /> -
- -
- setConfirmStop(true)} - stopLabel={abort.isPending ? "停止中…" : "停止"} - minRows={1} - maxRows={6} - maxLength={8000} - aria-label="发消息" - placeholder={ended ? "继续对话,输入 / 查看指令" : "补充或纠正"} - commands={RUN_COMMANDS} - />
- - - abort.mutateAsync(r.id).then( - () => toast.success("正在停止"), - (e) => { - toast.error(errorMessage(e)); - throw e; - }, - ) - } - /> -
+
); } diff --git a/apps/web/app/components/agent/agent-session-list.tsx b/apps/web/app/components/agent/agent-session-list.tsx index 039abf80..d29759dc 100644 --- a/apps/web/app/components/agent/agent-session-list.tsx +++ b/apps/web/app/components/agent/agent-session-list.tsx @@ -1,36 +1,31 @@ import { Bot, ChevronLeft, ChevronRight, Search } from "lucide-react"; -import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { Link } from "react-router"; -import { type AgentRunGroup, type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts"; -import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx"; +import { type AgentRunView, isRunActive, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import type { PaginationProps } from "~/components/ui/pagination.tsx"; -import { SegmentedControl } from "~/components/ui/segmented.tsx"; -import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { cn } from "~/lib/utils.ts"; import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx"; import { type ListMemory, newListMemory, revealScrollTop } from "./agent-nav.ts"; import { shortGoalTitle, splitContextGoal } from "./agent-store.ts"; -// 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。 -// 分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话;栏内筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。 +// 会话列表:会话页左栏与抽屉的「最近会话」弹层共用同一份。 +// 一个列表:对话和自动巡检混排(巡检 / 事件会话带一个小标签),子会话不单列(在父会话的会话流里看); +// 筛选只有搜索 + 一个状态下拉;「等你处理」(等选方案、等审批)置顶。 -export type HistoryView = "all" | "pending" | "failed"; -export type HistoryFilters = { q: string; view: HistoryView; group: AgentRunGroup }; -export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all", group: "conversation" }; - -const GROUPS: { value: AgentRunGroup; label: string }[] = [ - { value: "conversation", label: "对话" }, - { value: "auto", label: "自动巡检" }, -]; +export type HistoryView = "all" | "pending" | "active" | "failed"; +export type HistoryFilters = { q: string; view: HistoryView }; +export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all" }; const VIEWS: { value: HistoryView; label: string }[] = [ - { value: "all", label: "全部" }, + { value: "all", label: "全部状态" }, { value: "pending", label: "等你处理" }, + { value: "active", label: "进行中" }, { value: "failed", label: "失败" }, ]; @@ -38,6 +33,11 @@ const VIEWS: { value: HistoryView; label: string }[] = [ export const awaitingUser = (r: Pick) => r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批"); const isFailed = (r: Pick) => r.status === "failed" || r.status === "budget_exceeded"; +const PICK: Record, (r: AgentRunView) => boolean> = { + pending: awaitingUser, + active: (r) => isRunActive(r.status) && !awaitingUser(r), + failed: isFailed, +}; /** 纯函数(单测覆盖):搜索词匹配目标正文或会话 ID 前缀 */ export function matchesHistory(run: Pick, f: Pick): boolean { @@ -46,54 +46,38 @@ export function matchesHistory(run: Pick, f: Pick, limit = Number.POSITIVE_INFINITY, -): { pinned: HistoryRow[]; rows: HistoryRow[] } { +): { pinned: AgentRunView[]; rows: AgentRunView[] } { const uniq = [...new Map(runs.map((r) => [r.id, r] as const)).values()]; - const hit = uniq.filter((r) => matchesHistory(r, f)); - if (f.view !== "all") { - const pick = f.view === "pending" ? awaitingUser : isFailed; - return { - pinned: [], - rows: hit - .filter(pick) - .slice(0, limit) - .map((run) => ({ run, children: [] })), - }; - } - const ids = new Set(hit.map((r) => r.id)); - const kids = new Map(); - for (const r of hit) - if (r.parentRunId && ids.has(r.parentRunId)) kids.set(r.parentRunId, [...(kids.get(r.parentRunId) ?? []), r]); - const top = hit.filter((r) => !(r.parentRunId && ids.has(r.parentRunId))); - const row = (run: AgentRunView): HistoryRow => ({ run, children: kids.get(run.id) ?? [] }); - const pinned = top.filter(awaitingUser).map(row); - const rows = top - .filter((r) => !awaitingUser(r)) - .slice(0, Math.max(0, limit - pinned.length)) - .map(row); + const hit = uniq.filter((r) => !r.parentRunId && matchesHistory(r, f)); + if (f.view !== "all") return { pinned: [], rows: hit.filter(PICK[f.view]).slice(0, limit) }; + const pinned = hit.filter(awaitingUser); + const rows = hit.filter((r) => !awaitingUser(r)).slice(0, Math.max(0, limit - pinned.length)); return { pinned, rows }; } -/** 抽屉历史弹层跨开关保留的分栏、筛选与滚动位置(模块级,刷新页面才清) */ +/** 抽屉「最近会话」弹层跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */ export const drawerListMemory: ListMemory = newListMemory(); -function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; trailing?: ReactNode }) { +/** 会话来源的小标签:只给自动发起的(巡检、事件),手动发起的不标 */ +export function runTypeLabel(r: Pick): string | null { + return r.trigger === "schedule" || r.trigger === "event" ? TRIGGER_LABEL[r.trigger] : null; +} + +function RunRow({ r, current }: { r: AgentRunView; current: boolean }) { const goal = splitContextGoal(r.goal); const ctx = contextLabel(goal.context); + const type = runTypeLabel(r); // 正常结束不加徽章;运行中、等待、失败才标 const badge = r.status !== "succeeded" && r.status !== "cancelled"; - const meta = [r.trigger !== "user" ? TRIGGER_LABEL[r.trigger] : null, r.status === "cancelled" ? "已停止" : null, ctx] - .filter(Boolean) - .join(" · "); + const meta = [r.status === "cancelled" ? "已停止" : null, ctx].filter(Boolean).join(" · "); return ( <>
@@ -106,10 +90,17 @@ function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; t > {relTime(r.createdAt)} - {trailing}
- {badge || meta ? ( + {badge || meta || type ? (
+ {type ? ( + + {type} + + ) : null} {badge ? ( awaitingUser(r) ? ( @@ -127,73 +118,50 @@ function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; t } function Row({ - row, + run: r, activeId, hrefFor, onOpen, }: { - row: HistoryRow; + run: AgentRunView; activeId?: string; hrefFor?: (id: string) => string; onOpen?: (id: string) => void; }) { - const childActive = row.children.some((c) => c.id === activeId); - const [open, setOpen] = useState(childActive); - // 列表不随换会话重挂载:跳到子会话(会话内链接、概况条)时把父会话展开,当前项可见 - useEffect(() => { - if (childActive) setOpen(true); - }, [childActive]); - const item = (r: AgentRunView) => { - const current = r.id === activeId; - const cls = cn( - "block w-full min-w-0 cursor-pointer rounded-xl border border-transparent px-3 py-2 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring", - current && "pill-selected", - ); - return onOpen ? ( - - ) : ( - - - - ); - }; + const current = r.id === activeId; + const cls = cn( + "block w-full min-w-0 cursor-pointer rounded-xl border border-transparent px-3 py-2 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring", + current && "pill-selected", + ); return (
  • - {item(row.run)} - {row.children.length ? ( - <> - - {open ? ( -
      - {row.children.map((c) => ( -
    • {item(c)}
    • - ))} -
    - ) : null} - - ) : null} + {onOpen ? ( + + ) : ( + + + + )}
  • ); } /** * 窄列表用的分页:一行「共 N 条 · ‹ 1/3 ›」。整页表格的分页(每页条数 + 完整页码)放进 17rem 的左栏 - * 或历史弹层会折成三行,占掉半个列表。每页条数沿用全站设置。只有一页时不显示。 + * 或抽屉的最近会话弹层会折成三行,占掉半个列表。每页条数沿用全站设置。只有一页时不显示。 */ export function CompactPager({ page, pageSize, total, hasMore = false, busy = false, onPageChange }: PaginationProps) { const pages = Math.max(1, Math.ceil(total / pageSize)) + (hasMore ? 1 : 0); @@ -237,20 +205,17 @@ export function CompactPager({ page, pageSize, total, hasMore = false, busy = fa } /** - * 会话列表:分栏 + 搜索 + 全部 / 等你处理 / 失败 + 按需加载的分页。 - * - hrefFor:列表页 / 会话页左栏,点一条跳到该会话页。 - * - onOpen:抽屉历史弹层,点一条在抽屉内打开。 - * - group / onGroupChange:分栏受控(页面把分栏放在 URL 里);不传时列表自己记。 - * - memory:筛选与各分栏滚动位置记在这里,列表重新挂载(下次打开弹层、列表页 ↔ 会话页)时恢复。 - * - activeId:当前会话,高亮;第一次出现在列表里且看不见时滚进可见区,是子会话时展开父会话。 - * - limit:历史弹层的默认每页条数。 + * 会话列表:搜索 + 状态下拉 + 按需加载的分页。 + * - hrefFor:会话页左栏,点一条跳到该会话页。 + * - onOpen:抽屉的「最近会话」弹层,点一条在抽屉内打开。 + * - memory:筛选与滚动位置记在这里,列表重新挂载(下次打开弹层、列表页 ↔ 会话页)时恢复。 + * - activeId:当前会话,高亮;第一次出现在列表里且看不见时滚进可见区。 + * - limit:弹层的默认每页条数。 */ export function AgentSessionList({ activeId, hrefFor, onOpen, - group: controlledGroup, - onGroupChange, memory, limit, className, @@ -258,31 +223,24 @@ export function AgentSessionList({ activeId?: string; hrefFor?: (id: string) => string; onOpen?: (id: string) => void; - group?: AgentRunGroup; - onGroupChange?: (group: AgentRunGroup) => void; memory?: ListMemory; limit?: number; className?: string; }) { - const [own, setOwn] = useState( - memory ? { q: memory.q, view: memory.view, group: memory.group } : EMPTY_FILTERS, - ); - const f: HistoryFilters = controlledGroup ? { ...own, group: controlledGroup } : own; - const set = (patch: Partial) => { - if (patch.group && controlledGroup) onGroupChange?.(patch.group); - setOwn((prev) => { + const [f, setF] = useState(memory ? { q: memory.q, view: memory.view } : EMPTY_FILTERS); + const set = (patch: Partial) => + setF((prev) => { const next = { ...prev, ...patch }; - if (memory) Object.assign(memory, { q: next.q, view: next.view, group: next.group }); + if (memory) Object.assign(memory, next); return next; }); - }; - const runs = useAgentRuns(undefined, undefined, f.group); + const runs = useAgentRuns(undefined, undefined, { roots: true }); // 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶 - const waiting = useAgentRuns("waiting", undefined, f.group); + const waiting = useAgentRuns("waiting", undefined, { roots: true }); const loaded = runs.data?.pages.flatMap((p) => p.items) ?? []; const pending = waiting.data?.pages.flatMap((p) => p.items) ?? []; const arranged = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f); - const loadWaiting = f.view !== "failed" && !!waiting.hasNextPage; + const loadWaiting = (f.view === "all" || f.view === "pending") && !!waiting.hasNextPage; const canLoadMore = loadWaiting || !!runs.hasNextPage; const paged = useListPagination([...arranged.pinned, ...arranged.rows], { hasMore: canLoadMore, @@ -293,27 +251,26 @@ export function AgentSessionList({ defaultPageSize: limit, }); const visible = paged.items ?? []; - const pinned = f.view === "all" ? visible.filter((row) => awaitingUser(row.run)) : []; - const rows = f.view === "all" ? visible.filter((row) => !awaitingUser(row.run)) : visible; + const pinned = f.view === "all" ? visible.filter(awaitingUser) : []; + const rows = f.view === "all" ? visible.filter((r) => !awaitingUser(r)) : visible; const filtered = !!f.q.trim() || f.view !== "all"; - // 滚动:每个分栏第一次有内容时恢复记住的位置,再把当前会话滚进可见区(各只做一次,之后不跟着用户的滚动抢位置) + // 滚动:第一次有内容时恢复记住的位置,再把当前会话滚进可见区(各只做一次,之后不跟着用户的滚动抢位置) const scroller = useRef(null); - const restoredGroup = useRef(null); + const restored = useRef(false); const revealed = useRef(null); const ready = !runs.isPending && visible.length > 0; useLayoutEffect(() => { const el = scroller.current; if (!el || !ready) return; - if (restoredGroup.current !== f.group) { - restoredGroup.current = f.group; - el.scrollTop = memory?.scroll[f.group] ?? 0; + if (!restored.current) { + restored.current = true; + el.scrollTop = memory?.scroll ?? 0; } - const key = `${f.group}|${activeId ?? ""}`; - if (!activeId || revealed.current === key) return; + if (!activeId || revealed.current === activeId) return; const item = el.querySelector(`[data-run-id="${activeId}"]`); if (!item) return; - revealed.current = key; + revealed.current = activeId; const top = item.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop; const next = revealScrollTop( { top, height: item.offsetHeight }, @@ -324,17 +281,8 @@ export function AgentSessionList({ const rowProps = { activeId, hrefFor, onOpen }; return (