From 177d21c26b927b6cfbd5054419df4a04ee90cbbf Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 17:56:52 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(agent):=20=E5=88=87=E6=8D=A2=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E4=B8=8D=E5=86=8D=E9=87=8D=E6=8C=82=E8=BD=BD=E6=95=B4?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E4=B8=8D=E9=87=8D=E6=92=AD=E5=85=A5=E5=9C=BA?= =?UTF-8?q?=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 控制台主内容区按 pathname 重挂载,每换一个会话整页透明 + 模糊再淡入, 左栏滚动、分页、展开状态全部丢失。智能体列表页与会话页共用一个 key。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/web/app/components/agent/agent-nav.test.ts | 17 +++++++++++++++++ apps/web/app/components/agent/agent-store.ts | 11 +++++++++++ apps/web/app/routes/console/layout.tsx | 6 +++++- 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/components/agent/agent-nav.test.ts diff --git a/apps/web/app/components/agent/agent-nav.test.ts b/apps/web/app/components/agent/agent-nav.test.ts new file mode 100644 index 00000000..27e5b37d --- /dev/null +++ b/apps/web/app/components/agent/agent-nav.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { isAgentPage, outletKey } from "./agent-store.ts"; + +// 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。 + +describe("主内容区重挂载 key", () => { + it("列表页与各会话页共用一个 key,切换会话不重挂载", () => { + expect(outletKey("/console/agent")).toBe("/console/agent"); + expect(outletKey("/console/agent/0b1c")).toBe("/console/agent"); + expect(outletKey("/console/agent/9f2e")).toBe(outletKey("/console/agent/0b1c")); + }); + it("其它页面仍按路径重挂载", () => { + expect(outletKey("/console/library")).toBe("/console/library"); + expect(outletKey("/console/agents-x")).toBe("/console/agents-x"); + expect(isAgentPage("/console/agentx")).toBe(false); + }); +}); diff --git a/apps/web/app/components/agent/agent-store.ts b/apps/web/app/components/agent/agent-store.ts index 78919117..400736fb 100644 --- a/apps/web/app/components/agent/agent-store.ts +++ b/apps/web/app/components/agent/agent-store.ts @@ -123,6 +123,17 @@ export function contextFromPath(pathname: string): AgentContext | null { } } +/** 当前在不在智能体页面(列表页或会话页) */ +export const isAgentPage = (pathname: string) => /^\/console\/agent(\/|$)/.test(pathname); + +/** + * 控制台主内容区的重挂载 key:智能体列表页与各会话页共用一个 key, + * 切换会话不重挂载整页、不重播入场动画,左栏的分栏 / 滚动 / 展开状态保留。 + */ +export function outletKey(pathname: string): string { + return isAgentPage(pathname) ? "/console/agent" : pathname; +} + export function useAgentDrawer(): State { return useSyncExternalStore( (l) => { diff --git a/apps/web/app/routes/console/layout.tsx b/apps/web/app/routes/console/layout.tsx index 9c101a86..9651bcec 100644 --- a/apps/web/app/routes/console/layout.tsx +++ b/apps/web/app/routes/console/layout.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react import { Link, Outlet, useLocation, useMatches } from "react-router"; import { useSession } from "~/api/auth.ts"; import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx"; +import { outletKey } from "~/components/agent/agent-store.ts"; import { AccountMenu } from "~/components/console/account-menu.tsx"; import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launcher.tsx"; import { ErrorState } from "~/components/console/empty-state.tsx"; @@ -248,7 +249,10 @@ function ConsoleShell({ session }: { session: SessionInfo }) { {/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
-
+
From f5234cbcb93ff768f71e9cd9ed6f7fbc3ec3b940 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 18:00:14 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(agent):=20=E4=BC=9A=E8=AF=9D=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=88=86=E6=A0=8F=E8=B7=9F=E9=9A=8F=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=B9=B6=E8=BF=9B=20URL,=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 打开巡检会话(含巡检的子会话)时左栏自动切到「自动巡检」并选中当前项; 手动切换分栏写进 ?tab=,刷新、窄屏「全部会话」返回都保持 - 列表页 / 会话页之间保留搜索、筛选和各分栏的滚动位置;当前会话看不见时滚进可见区 - 跳到子会话时展开父会话 - 演示模式的分栏筛选与服务端一致:子会话随父会话 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/web/app/api/agent.ts | 18 ++- .../web/app/components/agent/agent-drawer.tsx | 4 +- .../app/components/agent/agent-nav.test.ts | 45 +++++++ apps/web/app/components/agent/agent-nav.ts | 110 ++++++++++++++++++ .../components/agent/agent-session-list.tsx | 77 ++++++++---- apps/web/app/routes/console/agent.$runId.tsx | 21 +++- apps/web/app/routes/console/agent.tsx | 12 +- 7 files changed, 257 insertions(+), 30 deletions(-) create mode 100644 apps/web/app/components/agent/agent-nav.ts 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 ( <>

智能体会话

+ + {page} / {known} + {hasMore ? "+" : ""} + + + + ); +} + /** * 会话列表:分栏 + 搜索 + 全部 / 等你处理 / 失败 + 按需加载的分页。 * - hrefFor:列表页 / 会话页左栏,点一条跳到该会话页。 @@ -358,7 +404,13 @@ export function AgentSessionList({ )}
- {visible.length > 0 || canLoadMore ? : null} + { + paged.pagination.onPageChange(n); + if (scroller.current) scroller.current.scrollTop = 0; + }} + /> ); } From 9f499ac7c24220c641c86f029f4c8929276c7dc9 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 18:02:50 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(agent):=20=E8=BE=93=E5=85=A5=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E6=8C=89=E4=BC=9A=E8=AF=9D=E4=BF=9D=E7=95=99,?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D=E3=80=81=E5=85=B3=E6=8A=BD?= =?UTF-8?q?=E5=B1=89=E4=B8=8D=E4=B8=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话面板与新对话输入框的草稿原来是组件内状态,切到别的会话或 Esc 关抽屉就没了。 改为按会话(以及列表页 / 抽屉两处新对话)分键记在模块里,发送成功后清掉。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../web/app/components/agent/agent-drawer.tsx | 7 +++- .../app/components/agent/agent-nav.test.ts | 37 ++++++++++++++++++- apps/web/app/components/agent/agent-nav.ts | 17 +++++++++ .../app/components/agent/agent-run-panel.tsx | 17 ++++++++- apps/web/app/routes/console/agent.tsx | 14 ++++++- 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index ca64f89d..701512e5 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -13,6 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover import { Sheet, 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"; import { @@ -170,12 +171,16 @@ export function AgentDrawer() { context={name} onClearContext={clearAgentContext} defaultGoal={context?.goal ?? ""} + draftKey={DRAFT_DRAWER_GOAL} pending={create.isPending} onSubmit={(goal, opts) => create.mutate( { goal: `${prefix}${goal}`, ...opts }, { - onSuccess: (run) => showAgentRunInDrawer(run.id), + onSuccess: (run) => { + writeDraft(DRAFT_DRAWER_GOAL, ""); + showAgentRunInDrawer(run.id); + }, onError: (e) => toast.error(errorMessage(e)), }, ) diff --git a/apps/web/app/components/agent/agent-nav.test.ts b/apps/web/app/components/agent/agent-nav.test.ts index 55ec1d59..3e34ab87 100644 --- a/apps/web/app/components/agent/agent-nav.test.ts +++ b/apps/web/app/components/agent/agent-nav.test.ts @@ -2,7 +2,18 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { runGroup } from "~/api/agent.ts"; -import { agentListHref, agentRunHref, groupFromSearch, resolveGroup, revealScrollTop } from "./agent-nav.ts"; +import { + agentListHref, + agentRunHref, + DRAFT_DRAWER_GOAL, + DRAFT_PAGE_GOAL, + groupFromSearch, + readDraft, + resolveGroup, + revealScrollTop, + runDraftKey, + writeDraft, +} from "./agent-nav.ts"; import { CompactPager } from "./agent-session-list.tsx"; import { isAgentPage, outletKey } from "./agent-store.ts"; @@ -84,3 +95,27 @@ describe("会话列表分页(窄列表一行)", () => { expect(html).toContain("1 / 1+"); }); }); + +describe("输入草稿", () => { + it("按会话分开记,切走再回来还在,不串到别的会话", () => { + writeDraft(runDraftKey("a"), "A 的草稿"); + writeDraft(runDraftKey("b"), "B 的草稿"); + expect(readDraft(runDraftKey("a"))).toBe("A 的草稿"); + expect(readDraft(runDraftKey("b"))).toBe("B 的草稿"); + expect(readDraft(runDraftKey("c"))).toBe(""); + }); + it("清空(发送成功)后不再恢复;只有空白也算清空", () => { + writeDraft(runDraftKey("a"), ""); + writeDraft(runDraftKey("b"), " "); + expect(readDraft(runDraftKey("a"))).toBe(""); + expect(readDraft(runDraftKey("b"))).toBe(""); + }); + it("列表页与抽屉的新对话输入框各记各的;没有键时不记", () => { + writeDraft(DRAFT_PAGE_GOAL, "页面里写了一半"); + writeDraft(DRAFT_DRAWER_GOAL, "抽屉里写了一半"); + writeDraft(undefined, "不记"); + expect(readDraft(DRAFT_PAGE_GOAL)).toBe("页面里写了一半"); + expect(readDraft(DRAFT_DRAWER_GOAL)).toBe("抽屉里写了一半"); + expect(readDraft(undefined)).toBe(""); + }); +}); diff --git a/apps/web/app/components/agent/agent-nav.ts b/apps/web/app/components/agent/agent-nav.ts index afa9dfb7..98dcae72 100644 --- a/apps/web/app/components/agent/agent-nav.ts +++ b/apps/web/app/components/agent/agent-nav.ts @@ -108,3 +108,20 @@ export function usePageListGroup(activeRun?: AgentRunGroup | null): [AgentRunGro ); return [group, change]; } + +// ── 输入草稿:按会话(和两处新对话输入框)分开记,切换会话、关抽屉不丢;发送成功后清掉。模块级,刷新页面才清 ── + +const drafts = new Map(); +export const DRAFT_PAGE_GOAL = "goal:page"; +export const DRAFT_DRAWER_GOAL = "goal:drawer"; +export const runDraftKey = (runId: string) => `run:${runId}`; + +export function readDraft(key: string | undefined): string { + return key ? (drafts.get(key) ?? "") : ""; +} + +export function writeDraft(key: string | undefined, text: string): void { + if (!key) return; + if (text.trim()) drafts.set(key, text); + else drafts.delete(key); +} diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index 3d3c2d7c..d2b47745 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -40,6 +40,7 @@ import { runPlanIds, } from "./agent-conversation.tsx"; 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"; @@ -71,6 +72,7 @@ export function GoalComposer({ onClearContext, defaultGoal = "", suggestions, + draftKey, }: { onSubmit: (goal: string, opts: AgentSessionOptions) => void; pending: boolean; @@ -80,8 +82,14 @@ export function GoalComposer({ defaultGoal?: string; /** 快捷目标:输入框为空时显示 */ suggestions?: string[]; + /** 草稿记在这个键下(没有预填目标时恢复);发送成功后由调用方清掉 */ + draftKey?: string; }) { - const [goal, setGoal] = useState(defaultGoal); + 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); @@ -295,7 +303,12 @@ export function AgentRunPanel({ const append = useAppendAgentMessage(); const create = useCreateAgentRun(); const navigate = useNavigate(); - const [msg, setMsg] = useState(""); + // 草稿按会话记:切到别的会话再回来还在,不会串到别的会话 + const [msg, setMsgState] = useState(() => readDraft(runDraftKey(runId))); + const setMsg = (text: string) => { + setMsgState(text); + writeDraft(runDraftKey(runId), text); + }; const [confirmStop, setConfirmStop] = useState(false); const sseStatus = useSseStatus(); const qc = useQueryClient(); diff --git a/apps/web/app/routes/console/agent.tsx b/apps/web/app/routes/console/agent.tsx index 495c0d78..db0fc3b6 100644 --- a/apps/web/app/routes/console/agent.tsx +++ b/apps/web/app/routes/console/agent.tsx @@ -2,7 +2,13 @@ import { FlaskConical } from "lucide-react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts"; -import { agentRunHref, pageListMemory, usePageListGroup } from "~/components/agent/agent-nav.ts"; +import { + agentRunHref, + DRAFT_PAGE_GOAL, + pageListMemory, + usePageListGroup, + writeDraft, +} from "~/components/agent/agent-nav.ts"; import { GoalComposer } from "~/components/agent/agent-run-panel.tsx"; import { AgentSessionList } from "~/components/agent/agent-session-list.tsx"; import { quickGoalsFor } from "~/components/agent/quick-goals.ts"; @@ -43,11 +49,15 @@ export default function AgentPage() { create.mutate( { goal, ...opts }, { - onSuccess: (run) => navigate(agentRunHref(run.id, mock)), + onSuccess: (run) => { + writeDraft(DRAFT_PAGE_GOAL, ""); + navigate(agentRunHref(run.id, mock)); + }, onError: (e) => toast.error(errorMessage(e)), }, ) From 93a5341688169b2440a40a662704416e72c1d5c8 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 18:04:14 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(agent):=20=E5=88=87=E5=9B=9E=E7=9C=8B?= =?UTF-8?q?=E8=BF=87=E7=9A=84=E4=BC=9A=E8=AF=9D=E7=9B=B4=E6=8E=A5=E9=93=BA?= =?UTF-8?q?=E5=87=BA=E5=8E=86=E5=8F=B2,=E9=95=BF=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BB=8E=E9=A1=B6=E6=BB=9A=E5=88=B0=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 事件流按会话缓存最近 30 条会话的已读事件,切回时直接用缓存渲染,再从最后一个 seq 之后补读 - 消息区跟随:一次多出一屏以上直接跳到底,流式小段增长才平滑跟随 (原来只有第一次 ResizeObserver 回调算首屏,事件补读回来时整段平滑滚动) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../app/components/agent/agent-nav.test.ts | 31 +++++++++++++++ .../app/components/agent/use-agent-stream.ts | 39 ++++++++++++++++--- .../components/agentui/message-scroller.tsx | 18 ++++++--- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/apps/web/app/components/agent/agent-nav.test.ts b/apps/web/app/components/agent/agent-nav.test.ts index 3e34ab87..eeaeea75 100644 --- a/apps/web/app/components/agent/agent-nav.test.ts +++ b/apps/web/app/components/agent/agent-nav.test.ts @@ -1,7 +1,9 @@ +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, @@ -16,6 +18,7 @@ import { } from "./agent-nav.ts"; import { CompactPager } from "./agent-session-list.tsx"; import { isAgentPage, outletKey } from "./agent-store.ts"; +import { cachedRunEvents, cacheRunEvents } from "./use-agent-stream.ts"; // 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。 @@ -119,3 +122,31 @@ describe("输入草稿", () => { expect(readDraft(undefined)).toBe(""); }); }); + +describe("消息区滚动策略", () => { + it("流式的小段增长平滑跟随", () => { + expect(followBehavior({ gap: 48, viewport: 600, reduce: false })).toBe("smooth"); + }); + it("一次多出一屏以上(打开 / 切回会话铺开历史)直接跳到底", () => { + expect(followBehavior({ gap: 1400, viewport: 600, reduce: false })).toBe("auto"); + }); + it("减少动态效果时一律直接跳", () => { + expect(followBehavior({ gap: 48, viewport: 600, reduce: true })).toBe("auto"); + }); +}); + +describe("切回看过的会话:事件缓存", () => { + const ev = (seq: number) => ({ seq, at: "2026-10-06T00:00:00Z", type: "text_delta", delta: "x" }) as AgentRunEvent; + it("记住最近读到的事件,切回时直接可用", () => { + cacheRunEvents("live:a", [ev(0), ev(1)]); + expect(cachedRunEvents("live:a").map((e) => e.seq)).toEqual([0, 1]); + expect(cachedRunEvents("mock:a")).toEqual([]); + }); + it("空列表不覆盖已有缓存;超过 30 条会话丢掉最早看的", () => { + cacheRunEvents("live:a", []); + expect(cachedRunEvents("live:a")).toHaveLength(2); + for (let i = 0; i < 30; i++) cacheRunEvents(`live:n${i}`, [ev(i)]); + expect(cachedRunEvents("live:a")).toEqual([]); + expect(cachedRunEvents("live:n29")).toHaveLength(1); + }); +}); diff --git a/apps/web/app/components/agent/use-agent-stream.ts b/apps/web/app/components/agent/use-agent-stream.ts index 67b43ffa..b5faf7dc 100644 --- a/apps/web/app/components/agent/use-agent-stream.ts +++ b/apps/web/app/components/agent/use-agent-stream.ts @@ -284,20 +284,49 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] { return items; } +/** + * 最近看过的会话的事件(模块级,最多 30 条会话):切回看过的会话时直接用已读到的事件铺出来, + * 再从最后一个 seq 之后补读;不再先空一帧、再整段补读回来。 + */ +const EVENT_CACHE_MAX = 30; +const eventCache = new Map(); + +export function cachedRunEvents(key: string): AgentRunEvent[] { + return eventCache.get(key) ?? []; +} + +export function cacheRunEvents(key: string, events: AgentRunEvent[]): void { + if (!key || !events.length) return; + eventCache.delete(key); + eventCache.set(key, events); + while (eventCache.size > EVENT_CACHE_MAX) { + const oldest = eventCache.keys().next().value; + if (oldest === undefined) break; + eventCache.delete(oldest); + } +} + +const lastSeqOf = (events: AgentRunEvent[]) => events[events.length - 1]?.seq ?? -1; + export function useAgentStream(runId: string | undefined, active: boolean): AgentStreamState { - const [events, setEvents] = useState([]); + // 演示数据与真实会话分开缓存 + const cacheKey = runId ? `${agentMockEnabled() ? "mock" : "live"}:${runId}` : ""; + const [events, setEvents] = useState(() => cachedRunEvents(cacheKey)); const [error, setError] = useState(null); const [connected, setConnected] = useState("idle"); const [nonce, setNonce] = useState(0); - const lastSeq = useRef(-1); + const lastSeq = useRef(lastSeqOf(events)); - // biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置 + // biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置(有缓存就从缓存接着读) useEffect(() => { - setEvents([]); + const cached = cachedRunEvents(cacheKey); + setEvents(cached); setError(null); - lastSeq.current = -1; + lastSeq.current = lastSeqOf(cached); }, [runId]); + useEffect(() => cacheRunEvents(cacheKey, events), [cacheKey, events]); + // biome-ignore lint/correctness/useExhaustiveDependencies: nonce 变化 = 手动重试,重新建立补读 useEffect(() => { if (!runId) return; diff --git a/apps/web/app/components/agentui/message-scroller.tsx b/apps/web/app/components/agentui/message-scroller.tsx index 46c53bf6..4a17779d 100644 --- a/apps/web/app/components/agentui/message-scroller.tsx +++ b/apps/web/app/components/agentui/message-scroller.tsx @@ -33,6 +33,14 @@ export interface MessageScrollerProps extends Omit, jumpLabel?: string; } +/** + * 跟随内容增长时的滚动方式:流式输出的小段增长平滑跟随;一次多出一屏以上(打开 / 切回会话时历史铺开、 + * 补读回一大段)直接跳到底,不播一段从上滚到底的动画;减少动态效果时一律直接跳。 + */ +export function followBehavior(o: { gap: number; viewport: number; reduce: boolean }): ScrollBehavior { + return o.reduce || o.gap > o.viewport ? "auto" : "smooth"; +} + function layoutMs(el: Element | null): number { if (!el) return 0; const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim(); @@ -113,12 +121,12 @@ export function MessageScroller({ useEffect(() => { const content = contentRef.current; if (!content || typeof ResizeObserver === "undefined") return; - // 首次铺满内容(回放历史)直接跳到底,之后的增长才平滑跟随 - let first = true; + // 首次铺满内容、一次多出一屏以上直接跳到底,流式的小段增长才平滑跟随 const ro = new ResizeObserver(() => { - if (!followOutput || !followingRef.current) return; - scrollToEnd(reduce || first ? "auto" : "smooth"); - first = false; + const vp = viewportRef.current; + if (!vp || !followOutput || !followingRef.current) return; + const gap = vp.scrollHeight - vp.scrollTop - vp.clientHeight; + scrollToEnd(followBehavior({ gap, viewport: vp.clientHeight, reduce })); }); ro.observe(content); return () => ro.disconnect(); From a87c2c19c41ccff5e1f33566a6fb776998d30f62 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 18:07:21 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(agent):=20=E6=8A=BD=E5=B1=89=E4=B8=8E?= =?UTF-8?q?=E6=95=B4=E9=A1=B5=E4=B9=8B=E9=97=B4=E7=9A=84=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在智能体页面按 Ctrl+J / 点智能体入口聚焦页面输入框,不再在抽屉里叠一份同一会话 - 会话页统计行右侧加「在侧边打开」:回到进智能体页面之前的页面,会话在抽屉里接着看 - 窄屏顶栏按钮与快捷键、悬浮按钮共用同一套切换 - 抽屉关闭钮放进标题栏按钮行(原来绝对定位,比其它按钮低 5px) - 关闭抽屉后焦点回到打开前的元素(快捷键打开没有触发元素,原来落到 body) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../web/app/components/agent/agent-drawer.tsx | 35 +++++++--- .../app/components/agent/agent-nav.test.ts | 28 +++++++- .../app/components/agent/agent-run-panel.tsx | 14 +++- apps/web/app/components/agent/agent-store.ts | 64 ++++++++++++++++--- apps/web/app/routes/console/agent.$runId.tsx | 29 +++++++-- apps/web/app/routes/console/agent.tsx | 5 +- 6 files changed, 150 insertions(+), 25 deletions(-) diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index 701512e5..ea124aac 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -1,5 +1,5 @@ import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react"; -import { useRef, useState } from "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"; @@ -10,7 +10,7 @@ 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, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.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"; @@ -20,15 +20,16 @@ import { type AgentContext, agentShortcutLabel, clearAgentContext, - contextFromPath, contextPrefix, minimizeAgentDrawer, newAgentConversation, - openAgentDrawer, + noteAgentReturnPath, restoreAgentDrawer, setAgentDrawerOpen, shortGoalTitle, showAgentRunInDrawer, + takeReturnFocus, + toggleAgentDrawer, useAgentDrawer, } from "./agent-store.ts"; import { AgentRunOpenContext } from "./agent-subagent-tree.tsx"; @@ -99,8 +100,10 @@ function HistoryPopover({ activeId }: { activeId: string | null }) { export function AgentDrawer() { const { open, minimized, context, runId } = useAgentDrawer(); const create = useCreateAgentRun(); - const { pathname } = useLocation(); + const { pathname, search } = useLocation(); const content = useRef(null); + // 记下最近的非智能体页面:会话页「在侧边打开」回到这里 + useEffect(() => noteAgentReturnPath(pathname, search), [pathname, search]); const name = useContextName(context); const mock = agentMockEnabled(); // 目标前缀带名称和 ID:名称给人看,ID 给模型用 @@ -112,6 +115,7 @@ export function AgentDrawer() { { @@ -121,11 +125,18 @@ export function AgentDrawer() { 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(); }} > - + 智能体 @@ -157,6 +168,12 @@ export function AgentDrawer() { ) : null} + {/* 关闭钮放进标题栏这一行,与其它按钮对齐(Sheet 自带的是绝对定位,偏下 5px) */} + + +
{runId ? ( @@ -258,9 +275,8 @@ function AgentMiniDock({ runId }: { runId: string }) { ); } -/** 顶栏按钮:有最小化的运行时恢复它;否则按当前路由带上下文打开(页面内按钮用 openAgentDrawer 传更完整的上下文) */ +/** 顶栏按钮(窄屏):与快捷键、悬浮按钮同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */ export function AgentDrawerTrigger({ className }: { className?: string }) { - const { minimized, runId } = useAgentDrawer(); 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 eeaeea75..3cdc6db0 100644 --- a/apps/web/app/components/agent/agent-nav.test.ts +++ b/apps/web/app/components/agent/agent-nav.test.ts @@ -17,7 +17,7 @@ import { writeDraft, } from "./agent-nav.ts"; import { CompactPager } from "./agent-session-list.tsx"; -import { isAgentPage, outletKey } from "./agent-store.ts"; +import { agentReturnPath, isAgentPage, noteAgentReturnPath, outletKey, toggleAction } from "./agent-store.ts"; import { cachedRunEvents, cacheRunEvents } from "./use-agent-stream.ts"; // 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。 @@ -150,3 +150,29 @@ describe("切回看过的会话:事件缓存", () => { expect(cachedRunEvents("live:n29")).toHaveLength(1); }); }); + +describe("Ctrl+J / 智能体入口在不同页面", () => { + it("抽屉开着:收起", () => { + expect(toggleAction({ open: true, runId: "r" }, "/console/library")).toBe("close"); + expect(toggleAction({ open: true, runId: null }, "/console/agent/r")).toBe("close"); + }); + it("已在智能体页面:聚焦页面输入框,不在抽屉里再开一份同一会话", () => { + expect(toggleAction({ open: false, runId: "r" }, "/console/agent/r")).toBe("focus-page"); + expect(toggleAction({ open: false, runId: null }, "/console/agent")).toBe("focus-page"); + }); + it("其它页面:上次的会话还在就接着看,否则开新对话", () => { + expect(toggleAction({ open: false, runId: "r" }, "/console/library")).toBe("restore"); + expect(toggleAction({ open: false, runId: null }, "/console/library")).toBe("open"); + }); +}); + +describe("会话页「在侧边打开」回到之前的页面", () => { + it("只记非智能体页面,带查询串", () => { + noteAgentReturnPath("/console/library", "?filter=unidentified"); + noteAgentReturnPath("/console/agent"); + noteAgentReturnPath("/console/agent/r1", "?tab=auto"); + expect(agentReturnPath()).toBe("/console/library?filter=unidentified"); + noteAgentReturnPath("/console/sites"); + expect(agentReturnPath()).toBe("/console/sites"); + }); +}); diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index d2b47745..a980c690 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 { useId, useRef, useState } from "react"; +import { type ReactNode, useId, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { @@ -289,10 +289,13 @@ export function AgentRunPanel({ compact = false, className, onCreated, + actions, }: { runId: string; compact?: boolean; className?: string; + /** 统计行右侧的操作(会话页的「在侧边打开」) */ + actions?: ReactNode; /** /重跑 之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */ onCreated?: (runId: string) => void; }) { @@ -392,7 +395,14 @@ export function AgentRunPanel({
) : null} - + {actions ? ( +
+ +
{actions}
+
+ ) : ( + + )} /^\/console\/agent(\/|$)/.test(pathname); + let state: State = { open: false, minimized: false, context: null, runId: null }; const listeners = new Set<() => void>(); const emit = () => { for (const l of listeners) l(); }; +/** + * 打开前的焦点:抽屉多从快捷键打开,没有 Radix 触发元素,关闭时焦点会落到 body。 + * 打开时记下,关闭时还回去(元素还在页面上才还)。 + */ +let returnFocus: HTMLElement | null = null; +function rememberFocus() { + if (state.open || typeof document === "undefined") return; + const el = document.activeElement; + returnFocus = el instanceof HTMLElement && el !== document.body ? el : null; +} + +/** 取出并清掉打开抽屉前的焦点元素(抽屉 onCloseAutoFocus 用) */ +export function takeReturnFocus(): HTMLElement | null { + const el = returnFocus; + returnFocus = null; + return el?.isConnected ? el : null; +} + +/** 最近一个不是智能体页面的地址:会话页「在侧边打开」回到这里 */ +let returnPath: string | null = null; +export function noteAgentReturnPath(pathname: string, search = "") { + if (!isAgentPage(pathname)) returnPath = `${pathname}${search}`; +} +export const agentReturnPath = () => returnPath; + /** 打开抽屉开始新对话(页面内按钮带着对象打开) */ export function openAgentDrawer(context: AgentContext | null = null) { + rememberFocus(); state = { ...state, open: true, minimized: false, context, runId: null }; emit(); } -/** 在抽屉里打开一条会话(历史弹层、/重跑、子会话链接) */ +/** 在抽屉里打开一条会话(历史弹层、/重跑、子会话链接、会话页「在侧边打开」) */ export function showAgentRunInDrawer(runId: string) { + rememberFocus(); state = { ...state, open: true, minimized: false, runId }; emit(); } @@ -73,6 +103,7 @@ export function minimizeAgentDrawer() { /** 从小窗恢复抽屉 */ export function restoreAgentDrawer() { + rememberFocus(); state = { ...state, open: true, minimized: false }; emit(); } @@ -90,13 +121,33 @@ export function isAgentShortcut( return (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "j"; } +export type ToggleAction = "close" | "focus-page" | "restore" | "open"; + /** - * 快捷键 / 悬浮按钮 / 侧栏主入口共用的切换: - * 打开中 → 收起;有最小化的运行 → 恢复;否则按当前路由带上下文打开(上次看的会话还在就接着看)。 + * 切换要做什么(纯函数,单测覆盖):打开中 → 收起;已在智能体页面 → 聚焦页面的输入框(页面就是智能体, + * 不再在抽屉里叠一份同一会话);上次看的会话(含最小化的)还在 → 接着看;否则按当前路由带上下文开新对话。 */ +export function toggleAction(s: Pick, pathname: string): ToggleAction { + if (s.open) return "close"; + if (isAgentPage(pathname)) return "focus-page"; + return s.runId ? "restore" : "open"; +} + +/** 智能体页面的输入框(会话页「发消息」、列表页「目标」) */ +function focusAgentPageInput(): boolean { + if (typeof document === "undefined") return false; + const ta = document.querySelector("[data-agent-page] textarea:not([disabled])"); + if (!ta) return false; + ta.focus(); + ta.setSelectionRange(ta.value.length, ta.value.length); + return true; +} + +/** 快捷键 / 悬浮按钮 / 侧栏主入口 / 顶栏按钮共用的切换 */ export function toggleAgentDrawer(pathname: string) { - if (state.open) setAgentDrawerOpen(false); - else if (state.minimized && state.runId) restoreAgentDrawer(); + const action = toggleAction(state, pathname); + if (action === "close") setAgentDrawerOpen(false); + else if (action === "focus-page" && focusAgentPageInput()) return; else if (state.runId) restoreAgentDrawer(); else openAgentDrawer(contextFromPath(pathname)); } @@ -123,9 +174,6 @@ export function contextFromPath(pathname: string): AgentContext | null { } } -/** 当前在不在智能体页面(列表页或会话页) */ -export const isAgentPage = (pathname: string) => /^\/console\/agent(\/|$)/.test(pathname); - /** * 控制台主内容区的重挂载 key:智能体列表页与各会话页共用一个 key, * 切换会话不重挂载整页、不重播入场动画,左栏的分栏 / 滚动 / 展开状态保留。 diff --git a/apps/web/app/routes/console/agent.$runId.tsx b/apps/web/app/routes/console/agent.$runId.tsx index 908823b2..14e2c837 100644 --- a/apps/web/app/routes/console/agent.$runId.tsx +++ b/apps/web/app/routes/console/agent.$runId.tsx @@ -1,5 +1,5 @@ -import { ArrowLeft } from "lucide-react"; -import { Link, useParams } from "react-router"; +import { ArrowLeft, PanelRightOpen } from "lucide-react"; +import { Link, useNavigate, useParams } from "react-router"; import { agentMockEnabled } from "~/api/agent.ts"; import { agentListHref, @@ -10,19 +10,21 @@ import { } 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 { agentReturnPath, showAgentRunInDrawer } from "~/components/agent/agent-store.ts"; import { Button } from "~/components/ui/button.tsx"; // 会话页(AGUI-3):左栏会话列表(xl 以上)· 会话列占满其余宽度(顶部概况条、底部输入区)。 // 不再有页头目标描述和右侧栏;步骤、子会话、预算与模型在概况条里。 export default function AgentRunPage() { const { runId = "" } = useParams(); + const navigate = useNavigate(); const mock = agentMockEnabled(); // 左栏分栏跟随当前会话(巡检会话及其子会话在「自动巡检」),也可以手动切换(记在 URL) const [group, setGroup] = usePageListGroup(useRunGroup(runId)); return ( <>

智能体会话

-
+
diff --git a/apps/web/app/routes/console/agent.tsx b/apps/web/app/routes/console/agent.tsx index db0fc3b6..d498e7bf 100644 --- a/apps/web/app/routes/console/agent.tsx +++ b/apps/web/app/routes/console/agent.tsx @@ -35,7 +35,10 @@ export default function AgentPage() {
) : null} -
+
agentRunHref(id, mock)} From bbd7459935effda078e7ab10189e2a21f9683dc6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 18:08:19 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(agent):=20=E6=8A=BD=E5=B1=89=E5=BC=80?= =?UTF-8?q?=E7=9D=80=E6=97=B6=E8=B7=AF=E7=94=B1=E5=8F=98=E5=8C=96,?= =?UTF-8?q?=E6=96=B0=E5=AF=B9=E8=AF=9D=E7=9A=84=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E8=B7=9F=E7=9D=80=E9=A1=B5=E9=9D=A2=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 浏览器前进后退或会话里的链接换了页面,抽屉里新对话仍挂着上一页的对象。 按路由推断的上下文随页面更新;页面按钮带进来的上下文、正在看的会话不动。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../web/app/components/agent/agent-drawer.tsx | 2 + .../app/components/agent/agent-nav.test.ts | 44 ++++++++++++++++++- apps/web/app/components/agent/agent-store.ts | 12 +++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index ea124aac..6dee2a57 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -28,6 +28,7 @@ import { setAgentDrawerOpen, shortGoalTitle, showAgentRunInDrawer, + syncAgentContextToPath, takeReturnFocus, toggleAgentDrawer, useAgentDrawer, @@ -104,6 +105,7 @@ export function AgentDrawer() { const content = useRef(null); // 记下最近的非智能体页面:会话页「在侧边打开」回到这里 useEffect(() => noteAgentReturnPath(pathname, search), [pathname, search]); + useEffect(() => syncAgentContextToPath(pathname), [pathname]); const name = useContextName(context); const mock = agentMockEnabled(); // 目标前缀带名称和 ID:名称给人看,ID 给模型用 diff --git a/apps/web/app/components/agent/agent-nav.test.ts b/apps/web/app/components/agent/agent-nav.test.ts index 3cdc6db0..11e862fd 100644 --- a/apps/web/app/components/agent/agent-nav.test.ts +++ b/apps/web/app/components/agent/agent-nav.test.ts @@ -17,7 +17,19 @@ import { writeDraft, } from "./agent-nav.ts"; import { CompactPager } from "./agent-session-list.tsx"; -import { agentReturnPath, isAgentPage, noteAgentReturnPath, outletKey, toggleAction } from "./agent-store.ts"; +import { + agentReturnPath, + clearAgentContext, + isAgentPage, + noteAgentReturnPath, + openAgentDrawer, + outletKey, + setAgentDrawerOpen, + showAgentRunInDrawer, + syncAgentContextToPath, + toggleAction, + useAgentDrawer, +} from "./agent-store.ts"; import { cachedRunEvents, cacheRunEvents } from "./use-agent-stream.ts"; // 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。 @@ -176,3 +188,33 @@ describe("会话页「在侧边打开」回到之前的页面", () => { expect(agentReturnPath()).toBe("/console/sites"); }); }); + +describe("抽屉开着时路由变化", () => { + // useAgentDrawer 是 useSyncExternalStore,测试里直接读快照 + const snapshot = () => { + let out: ReturnType | null = null; + const Probe = () => { + out = useAgentDrawer(); + return null; + }; + renderToStaticMarkup(createElement(Probe)); + return out as unknown as ReturnType; + }; + it("新对话的路由上下文跟着页面换", () => { + openAgentDrawer({ label: "作品 aaaa", entityId: "aaaa", fromPath: true }); + syncAgentContextToPath("/console/sites/hdh"); + expect(snapshot().context).toMatchObject({ siteId: "hdh", fromPath: true }); + syncAgentContextToPath("/console/home"); + expect(snapshot().context).toBeNull(); + }); + it("页面按钮带的上下文、正在看的会话、抽屉关着时不动", () => { + openAgentDrawer({ label: "作品:沙丘", entityId: "e1" }); + syncAgentContextToPath("/console/sites/hdh"); + expect(snapshot().context).toMatchObject({ entityId: "e1" }); + clearAgentContext(); + showAgentRunInDrawer("r1"); + syncAgentContextToPath("/console/sites/hdh"); + expect(snapshot().context).toBeNull(); + setAgentDrawerOpen(false); + }); +}); diff --git a/apps/web/app/components/agent/agent-store.ts b/apps/web/app/components/agent/agent-store.ts index 7d499987..3a21c58d 100644 --- a/apps/web/app/components/agent/agent-store.ts +++ b/apps/web/app/components/agent/agent-store.ts @@ -84,6 +84,18 @@ export function newAgentConversation() { emit(); } +/** + * 抽屉开着时路由变了(浏览器前进后退、会话里的链接):还在新对话、上下文是按路由推断的,就换成新页面的对象; + * 页面按钮带进来的上下文、已经在看的会话不动。 + */ +export function syncAgentContextToPath(pathname: string) { + if (!state.open || state.runId || (state.context && !state.context.fromPath)) return; + const next = contextFromPath(pathname); + if (JSON.stringify(next) === JSON.stringify(state.context)) return; + state = { ...state, context: next }; + emit(); +} + /** 去掉上下文(输入框上的名称标签 ×) */ export function clearAgentContext() { state = { ...state, context: null };