diff --git a/docs/acceptance/resource-menu-focus-20261022/README.md b/docs/acceptance/resource-menu-focus-20261022/README.md new file mode 100644 index 00000000..ba18e3ff --- /dev/null +++ b/docs/acceptance/resource-menu-focus-20261022/README.md @@ -0,0 +1,20 @@ +# 流式回复中的资源菜单焦点 + +2026-10-22,在 Windows 的 Microsoft Edge 中操作生产前端与 Go API。修复前来自 `fc0935d6fc03ca7abccbd148ce52d26c2bc154f8`,修复后仅修改资源菜单的关闭回调订阅。视口均为 1280 × 900,截图为浏览器原始输出。 + +生产 `workbenchjourney` API 使用临时项目和本地确定性模型;测试模型通过两个 SSE 文本片段输出同一份 `root_lifecycle.v1` 回复。链接先完整出现,操作员打开菜单并选择“复制链接”,再允许第二个片段到达。保留生产流式解析、脱敏和预览缓冲。 + +| 操作 | 修复前 | 修复后 | +| --- | --- | --- | +| 聚焦仓库链接,Shift+F10 打开菜单,再按 End | 选中“复制链接” | 选中“复制链接” | +| 下一段文字到达 | 焦点跳回“在浏览器中打开” | 保持“复制链接” | +| 按 Enter | 未执行,以免打开错误操作 | 复制准确的仓库 URL,显示“已复制链接”,焦点回到链接 | +| 再打开菜单后按 Escape | — | 真实页面确认关闭菜单并回到链接 | + +两张图均在第二段文字到达后截图;黑色焦点框显示当前键盘操作项。 + +| 修复前 | 修复后 | +| --- | --- | +| ![更新时焦点跳回首项](before.png) | ![更新后保留复制链接](after.png) | + +回归测试使用真实 `V2Narrative` 流式更新与文件菜单:在旧实现上失败,修复后通过。资源链接、文件附件和流式 Markdown 共 26 项聚焦测试通过,TypeScript 与生产构建通过。 diff --git a/docs/acceptance/resource-menu-focus-20261022/after.png b/docs/acceptance/resource-menu-focus-20261022/after.png new file mode 100644 index 00000000..60d9a2d9 Binary files /dev/null and b/docs/acceptance/resource-menu-focus-20261022/after.png differ diff --git a/docs/acceptance/resource-menu-focus-20261022/before.png b/docs/acceptance/resource-menu-focus-20261022/before.png new file mode 100644 index 00000000..1419c737 Binary files /dev/null and b/docs/acceptance/resource-menu-focus-20261022/before.png differ diff --git a/web/src/v2/components/resource-link.test.tsx b/web/src/v2/components/resource-link.test.tsx index 24b5d674..e4d4e012 100644 --- a/web/src/v2/components/resource-link.test.tsx +++ b/web/src/v2/components/resource-link.test.tsx @@ -104,6 +104,30 @@ it("supports keyboard file menus and propagates native failures instead of claim expect(screen.queryByText("已复制文件")).not.toBeInTheDocument(); }); +it("keeps the selected link action focused while the assistant continues streaming", () => { + native(); + const query = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const content = (suffix: string) => + + ; + const view = render(content("正在检查。")); + const link = screen.getByRole("link", { name: "报告" }); + fireEvent.keyDown(link, { shiftKey: true, key: "F10" }); + const first = screen.getByRole("menuitem", { name: "打开文件" }); + expect(first).toHaveFocus(); + fireEvent.keyDown(first, { key: "ArrowDown" }); + const selected = screen.getByRole("menuitem", { name: "在资源管理器中显示" }); + expect(selected).toHaveFocus(); + view.rerender(content("正在检查。已读取项目说明。")); + expect(selected).toHaveFocus(); + fireEvent.keyDown(selected, { key: "Escape" }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(link).toHaveFocus(); +}); + it("keeps a tall preview above its link in a short window so the link remains clickable", async () => { native(); vi.stubGlobal("innerHeight", 600); vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(518); diff --git a/web/src/v2/components/resource-menu.tsx b/web/src/v2/components/resource-menu.tsx index e89d674e..5a37558f 100644 --- a/web/src/v2/components/resource-menu.tsx +++ b/web/src/v2/components/resource-menu.tsx @@ -1,5 +1,5 @@ import { useLocale, type LocaleText } from "../../lib/locale"; -import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useEffectEvent, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { Copy, Download, File, FolderOpen, SquareArrowOutUpRight } from "lucide-react"; import { resourceAction, type ChatResourceRef } from "../../lib/chat-resources"; @@ -25,17 +25,19 @@ export function ResourceMenu({ position, items, onClose, onResult }: { const { t } = useLocale(); const menu = useRef(null); const [point, setPoint] = useState({ x: position.x, y: position.y }); + const dismiss = useEffectEvent(onClose); useLayoutEffect(() => { const element = menu.current; if (!element) return; const rect = element.getBoundingClientRect(); setPoint({ x: Math.max(8, Math.min(position.x, window.innerWidth - rect.width - 8)), y: Math.max(8, Math.min(position.y, window.innerHeight - rect.height - 8)) }); element.querySelector("button:not(:disabled)")?.focus(); - const outside = (event: PointerEvent) => { if (!element.contains(event.target as Node)) onClose(); }; - const close = () => onClose(); + const outside = (event: PointerEvent) => { if (!element.contains(event.target as Node)) dismiss(); }; + const close = () => dismiss(); document.addEventListener("pointerdown", outside, true); window.addEventListener("resize", close); return () => { document.removeEventListener("pointerdown", outside, true); window.removeEventListener("resize", close); }; - }, [position, onClose]); + // Streaming updates change callbacks without opening a new menu. + }, [position]); const close = () => { onClose(); if (position.trigger.isConnected) position.trigger.focus(); }; return createPortal(
{