diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index bc3ca0f5..012b5735 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -141,22 +141,34 @@ export const TRIGGER_LABEL: Record = { 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) { const mock = useAgentMockGate(); return useInfiniteQuery({ queryKey: [...agentKeys.runs(status, trigger, group), mock], initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam, signal }) => { - if (mock) + if (mock) { + const all = MOCK_RUNS(); + const byId = new Map(all.map((r) => [r.id, r] as const)); return { - items: MOCK_RUNS().filter( + items: all.filter( (r) => (!status || r.status === status) && (!trigger || r.trigger === trigger) && - (!group || isAutoTrigger(r.trigger) === (group === "auto")), + (!group || runGroup(r, r.parentRunId ? byId.get(r.parentRunId) : null) === group), ), nextCursor: null, }; + } return apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ status, trigger, group, cursor: pageParam, limit: 30 }), signal, diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index 041af395..ca64f89d 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -14,7 +14,7 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from " import { errorMessage } from "~/lib/api.ts"; import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx"; import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; -import { AgentSessionList } from "./agent-session-list.tsx"; +import { AgentSessionList, drawerListMemory } from "./agent-session-list.tsx"; import { type AgentContext, agentShortcutLabel, @@ -71,7 +71,7 @@ function HistoryPopover({ activeId }: { activeId: string | null }) { { diff --git a/apps/web/app/components/agent/agent-nav.test.ts b/apps/web/app/components/agent/agent-nav.test.ts index 27e5b37d..d5995dde 100644 --- a/apps/web/app/components/agent/agent-nav.test.ts +++ b/apps/web/app/components/agent/agent-nav.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { runGroup } from "~/api/agent.ts"; +import { agentListHref, agentRunHref, groupFromSearch, resolveGroup, revealScrollTop } from "./agent-nav.ts"; import { isAgentPage, outletKey } from "./agent-store.ts"; // 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。 @@ -15,3 +17,46 @@ describe("主内容区重挂载 key", () => { expect(isAgentPage("/console/agentx")).toBe(false); }); }); + +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(由会话决定),列表页只在自动巡检时带,保留演示开关", () => { + 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"); + }); +}); + +describe("当前会话滚进可见区", () => { + const vp = { scrollTop: 200, height: 300 }; + it("已经完整可见不滚", () => { + expect(revealScrollTop({ top: 250, height: 40 }, vp)).toBeNull(); + }); + it("在上方:滚到顶边对齐;在下方:滚到底边刚好露出", () => { + expect(revealScrollTop({ top: 120, height: 40 }, vp)).toBe(120); + expect(revealScrollTop({ top: 600, height: 40 }, vp)).toBe(340); + }); +}); diff --git a/apps/web/app/components/agent/agent-nav.ts b/apps/web/app/components/agent/agent-nav.ts new file mode 100644 index 00000000..afa9dfb7 --- /dev/null +++ b/apps/web/app/components/agent/agent-nav.ts @@ -0,0 +1,110 @@ +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"; +} + +function withQuery(path: string, q: Record): string { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries(q)) if (v) p.set(k, v); + const s = p.toString(); + 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 type ListMemory = { + group: AgentRunGroup; + q: string; + view: "all" | "pending" | "failed"; + scroll: Record; +}; +export const newListMemory = (): ListMemory => ({ + group: "conversation", + q: "", + view: "all", + scroll: { conversation: 0, auto: 0 }, +}); +export const pageListMemory: ListMemory = newListMemory(); + +/** + * 当前项要不要滚进可见区:完全看不见才滚,只滚到刚好露出(nearest),返回新的 scrollTop;不用滚时为 null。 + * top / height 是当前项相对滚动内容顶部的位置。 + */ +export function revealScrollTop( + item: { top: number; height: number }, + viewport: { scrollTop: number; height: number }, +): number | null { + const bottom = item.top + item.height; + const viewBottom = viewport.scrollTop + viewport.height; + if (item.top >= viewport.scrollTop && bottom <= viewBottom) return null; + if (item.top < viewport.scrollTop) return Math.max(0, item.top); + 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]; +} diff --git a/apps/web/app/components/agent/agent-session-list.tsx b/apps/web/app/components/agent/agent-session-list.tsx index 6600dddf..126517c3 100644 --- a/apps/web/app/components/agent/agent-session-list.tsx +++ b/apps/web/app/components/agent/agent-session-list.tsx @@ -1,5 +1,5 @@ import { Bot, ChevronRight } from "lucide-react"; -import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, 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"; @@ -11,6 +11,7 @@ import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.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):会话页左栏与抽屉的「历史」弹层共用。 @@ -80,8 +81,8 @@ export function arrangeHistory( return { pinned, rows }; } -/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */ -const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 }; +/** 抽屉历史弹层跨开关保留的分栏、筛选与滚动位置(模块级,刷新页面才清) */ +export const drawerListMemory: ListMemory = newListMemory(); function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; trailing?: ReactNode }) { const goal = splitContextGoal(r.goal); @@ -134,7 +135,12 @@ function Row({ hrefFor?: (id: string) => string; onOpen?: (id: string) => void; }) { - const [open, setOpen] = useState(row.children.some((c) => c.id === activeId)); + 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( @@ -142,12 +148,13 @@ function Row({ current && "pill-selected", ); return onOpen ? ( - ) : ( @@ -183,33 +190,45 @@ function Row({ } /** - * 会话列表:搜索 + 全部 / 等你处理 / 失败 + 按需加载的统一分页。 - * - hrefFor:会话页左栏,点一条跳到该会话页。 - * - onOpen:抽屉历史弹层,点一条在抽屉内打开;remember 时筛选与滚动位置下次打开时恢复。 - * - limit:历史弹层的默认每页条数,可调节并翻页查看更早的会话。 + * 会话列表:分栏 + 搜索 + 全部 / 等你处理 / 失败 + 按需加载的分页。 + * - hrefFor:列表页 / 会话页左栏,点一条跳到该会话页。 + * - onOpen:抽屉历史弹层,点一条在抽屉内打开。 + * - group / onGroupChange:分栏受控(页面把分栏放在 URL 里);不传时列表自己记。 + * - memory:筛选与各分栏滚动位置记在这里,列表重新挂载(下次打开弹层、列表页 ↔ 会话页)时恢复。 + * - activeId:当前会话,高亮;第一次出现在列表里且看不见时滚进可见区,是子会话时展开父会话。 + * - limit:历史弹层的默认每页条数。 */ export function AgentSessionList({ activeId, hrefFor, onOpen, - remember = false, + group: controlledGroup, + onGroupChange, + memory, limit, className, }: { activeId?: string; hrefFor?: (id: string) => string; onOpen?: (id: string) => void; - remember?: boolean; + group?: AgentRunGroup; + onGroupChange?: (group: AgentRunGroup) => void; + memory?: ListMemory; limit?: number; className?: string; }) { - const [f, setF] = useState(remember ? drawerMemory.filters : EMPTY_FILTERS); - const set = (patch: Partial) => - setF((prev) => { + 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 next = { ...prev, ...patch }; - if (remember) drawerMemory.filters = next; + if (memory) Object.assign(memory, { q: next.q, view: next.view, group: next.group }); return next; }); + }; const runs = useAgentRuns(undefined, undefined, f.group); // 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶 const waiting = useAgentRuns("waiting", undefined, f.group); @@ -231,12 +250,29 @@ export function AgentSessionList({ const rows = f.view === "all" ? visible.filter((row) => !awaitingUser(row.run)) : visible; const filtered = !!f.q.trim() || f.view !== "all"; + // 滚动:每个分栏第一次有内容时恢复记住的位置,再把当前会话滚进可见区(各只做一次,之后不跟着用户的滚动抢位置) const scroller = useRef(null); - const restored = useRef(!remember); + const restoredGroup = useRef(null); + const revealed = useRef(null); + const ready = !runs.isPending && visible.length > 0; useLayoutEffect(() => { - if (restored.current || !rows.length || !scroller.current) return; - restored.current = true; - scroller.current.scrollTop = drawerMemory.scroll; + const el = scroller.current; + if (!el || !ready) return; + if (restoredGroup.current !== f.group) { + restoredGroup.current = f.group; + el.scrollTop = memory?.scroll[f.group] ?? 0; + } + const key = `${f.group}|${activeId ?? ""}`; + if (!activeId || revealed.current === key) return; + const item = el.querySelector(`[data-run-id="${activeId}"]`); + if (!item) return; + revealed.current = key; + const top = item.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop; + const next = revealScrollTop( + { top, height: item.offsetHeight }, + { scrollTop: el.scrollTop, height: el.clientHeight }, + ); + if (next !== null) el.scrollTop = next; }); const rowProps = { activeId, hrefFor, onOpen }; return ( @@ -279,7 +315,8 @@ export function AgentSessionList({
{ - if (remember) drawerMemory.scroll = e.currentTarget.scrollTop; + // 换栏后还没恢复位置前的滚动(内容变短被浏览器夹回)不记 + if (memory && restoredGroup.current === f.group) memory.scroll[f.group] = e.currentTarget.scrollTop; }} className="min-h-0 flex-1 overflow-y-auto" > diff --git a/apps/web/app/routes/console/agent.$runId.tsx b/apps/web/app/routes/console/agent.$runId.tsx index a6ea47b7..908823b2 100644 --- a/apps/web/app/routes/console/agent.$runId.tsx +++ b/apps/web/app/routes/console/agent.$runId.tsx @@ -1,6 +1,13 @@ import { ArrowLeft } from "lucide-react"; import { Link, useParams } from "react-router"; import { agentMockEnabled } from "~/api/agent.ts"; +import { + agentListHref, + agentRunHref, + pageListMemory, + usePageListGroup, + useRunGroup, +} from "~/components/agent/agent-nav.ts"; import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx"; import { AgentSessionList } from "~/components/agent/agent-session-list.tsx"; import { Button } from "~/components/ui/button.tsx"; @@ -10,17 +17,25 @@ import { Button } from "~/components/ui/button.tsx"; export default function AgentRunPage() { const { runId = "" } = useParams(); const mock = agentMockEnabled(); - const suffix = mock ? "?mock=1" : ""; + // 左栏分栏跟随当前会话(巡检会话及其子会话在「自动巡检」),也可以手动切换(记在 URL) + const [group, setGroup] = usePageListGroup(useRunGroup(runId)); return ( <>

智能体会话