Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/acceptance/resource-menu-focus-20261022/README.md
Original file line number Diff line number Diff line change
@@ -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 与生产构建通过。
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions web/src/v2/components/resource-link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <QueryClientProvider client={query}>
<V2Narrative client={{} as APIClient} entries={[{
kind: "assistant", id: "answer", text: `[报告](report.md)\n\n${suffix}`,
createdAt: "", runId: "run", provisional: true,
}]} threadID="thread" workspaceID="workspace" />
</QueryClientProvider>;
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);
Expand Down
10 changes: 6 additions & 4 deletions web/src/v2/components/resource-menu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,17 +25,19 @@ export function ResourceMenu({ position, items, onClose, onResult }: {
const { t } = useLocale();
const menu = useRef<HTMLDivElement>(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<HTMLButtonElement>("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(<div className="v2-resource-menu" role="menu" aria-label={t("资源操作", "Resource actions")} ref={menu} style={{ left: point.x, top: point.y }}
onKeyDown={(event) => {
Expand Down
Loading