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 | — | 真实页面确认关闭菜单并回到链接 |
+
+两张图均在第二段文字到达后截图;黑色焦点框显示当前键盘操作项。
+
+| 修复前 | 修复后 |
+| --- | --- |
+|  |  |
+
+回归测试使用真实 `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( {