From a87c2c19c41ccff5e1f33566a6fb776998d30f62 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 18:07:21 +0800 Subject: [PATCH] =?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)}