diff --git a/docs/acceptance/project-pins/README.md b/docs/acceptance/project-pins/README.md
new file mode 100644
index 00000000..bcf6d5cf
--- /dev/null
+++ b/docs/acceptance/project-pins/README.md
@@ -0,0 +1,39 @@
+# 项目置顶
+
+2026-10-22,Windows Chrome。前后截图使用同一个生产 Go API、SQLite 和三个实际项目;前端通过现有 Vite preview API 代理加载生产构建。未替换 API 响应、修改页面 DOM 或伪造聊天记录。
+
+原版构建基于 `cace352a4b6515b211b826be598ca8f0fbded440`。宽屏前后为 1280 × 900,窄屏为 390 × 844。
+
+## 实际操作
+
+- 创建页面设计、接口实现、文档维护三个本地项目;接口实现中已有真实模型回复的聊天。
+- 原顺序为接口实现、文档维护、页面设计。悬停打开文档维护详情,再点击详情按钮;通过 Shift+Tab、Enter 置顶,文档维护移到项目列表第一位,焦点保留在取消置顶按钮。
+- 再次 Enter 取消置顶,恢复原顺序;随后重新置顶。三次操作没有发送 API 写入请求。
+- 重新加载并连接同一服务,置顶顺序和“保留这条接口设计草稿”均保留。
+- 390 px 下打开侧栏和项目详情,卡片 x=28、宽度350,文档 scrollWidth 与视口均为390,没有横向溢出。
+
+置顶保存在当前设备,并按服务地址、数据存储隔离;同组项目沿用原排序,项目内聊天和置顶聊天保持原有规则。上一/下一聊天快捷导航读取实际渲染顺序。
+
+## 真实截图
+
+变更前:
+
+
+
+变更后,键盘置顶并保留焦点、草稿:
+
+
+
+窄屏:
+
+
+
+## 验证范围
+
+- `project-pins.test.tsx`、`sidebar.test.tsx`、`project-header.test.tsx`:35项通过。覆盖置顶/取消、实际聊天导航顺序、重新挂载、服务/数据库隔离、损坏的本地值和英文按钮。
+- 悬停打开后点击详情再置顶的焦点回归,在修复前实际失败,修复后通过;浏览器重复同一触发路径通过。
+- `npm run build`:通过,包含 TypeScript 检查。
+- `go run ./cmd/protocolregistry -check`:通过;复用现有恢复存储,不新增协议标识。
+- 原生桌面包未单独运行。
+
+准备真实聊天时,第一份测试 README 未包含离线模型约定的证明文本,导致模型重复读取并失败;补回测试数据后通过真实发送得到成功回复。最终截图使用后续成功保存的聊天,未将该夹具问题计为产品验证通过。
diff --git a/docs/acceptance/project-pins/after-narrow.png b/docs/acceptance/project-pins/after-narrow.png
new file mode 100644
index 00000000..deabf107
Binary files /dev/null and b/docs/acceptance/project-pins/after-narrow.png differ
diff --git a/docs/acceptance/project-pins/after.png b/docs/acceptance/project-pins/after.png
new file mode 100644
index 00000000..ebd72cf9
Binary files /dev/null and b/docs/acceptance/project-pins/after.png differ
diff --git a/docs/acceptance/project-pins/before.png b/docs/acceptance/project-pins/before.png
new file mode 100644
index 00000000..420a5092
Binary files /dev/null and b/docs/acceptance/project-pins/before.png differ
diff --git a/web/package-lock.json b/web/package-lock.json
index 9d8c1bd7..03e7da3c 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -20,6 +20,7 @@
"@tiptap/starter-kit": "3.32.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
+ "dompurify": "3.4.17",
"highlight.js": "11.11.1",
"lucide-react": "1.31.0",
"monaco-editor": "0.56.0",
@@ -42,7 +43,7 @@
"openapi-typescript": "7.13.0",
"typescript": "5.9.3",
"vite": "8.3.4",
- "vitest": "4.1.11"
+ "vitest": "4.1.12"
}
},
"node_modules/@adobe/css-tools": {
@@ -1388,16 +1389,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
- "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
+ "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.11",
- "@vitest/utils": "4.1.11",
+ "@vitest/spy": "4.1.12",
+ "@vitest/utils": "4.1.12",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -1406,13 +1407,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
- "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
+ "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.11",
+ "@vitest/spy": "4.1.12",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1433,9 +1434,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
- "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
+ "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1446,13 +1447,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
- "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
+ "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.11",
+ "@vitest/utils": "4.1.12",
"pathe": "^2.0.3"
},
"funding": {
@@ -1460,14 +1461,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
- "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
+ "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.11",
- "@vitest/utils": "4.1.11",
+ "@vitest/pretty-format": "4.1.12",
+ "@vitest/utils": "4.1.12",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1476,9 +1477,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
- "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
+ "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1486,13 +1487,13 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
- "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
+ "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.11",
+ "@vitest/pretty-format": "4.1.12",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -1840,9 +1841,9 @@
"peer": true
},
"node_modules/dompurify": {
- "version": "3.4.16",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
- "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
+ "version": "3.4.17",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
+ "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -4281,9 +4282,9 @@
}
},
"node_modules/undici": {
- "version": "7.29.1",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
- "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
+ "version": "7.30.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
+ "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4498,19 +4499,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
- "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
+ "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.11",
- "@vitest/mocker": "4.1.11",
- "@vitest/pretty-format": "4.1.11",
- "@vitest/runner": "4.1.11",
- "@vitest/snapshot": "4.1.11",
- "@vitest/spy": "4.1.11",
- "@vitest/utils": "4.1.11",
+ "@vitest/expect": "4.1.12",
+ "@vitest/mocker": "4.1.12",
+ "@vitest/pretty-format": "4.1.12",
+ "@vitest/runner": "4.1.12",
+ "@vitest/snapshot": "4.1.12",
+ "@vitest/spy": "4.1.12",
+ "@vitest/utils": "4.1.12",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -4538,12 +4539,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.11",
- "@vitest/browser-preview": "4.1.11",
- "@vitest/browser-webdriverio": "4.1.11",
- "@vitest/coverage-istanbul": "4.1.11",
- "@vitest/coverage-v8": "4.1.11",
- "@vitest/ui": "4.1.11",
+ "@vitest/browser-playwright": "4.1.12",
+ "@vitest/browser-preview": "4.1.12",
+ "@vitest/browser-webdriverio": "^4.1.11",
+ "@vitest/coverage-istanbul": "4.1.12",
+ "@vitest/coverage-v8": "4.1.12",
+ "@vitest/ui": "4.1.12",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
diff --git a/web/package.json b/web/package.json
index 89d9de27..2f3eed72 100644
--- a/web/package.json
+++ b/web/package.json
@@ -27,6 +27,7 @@
"@tiptap/starter-kit": "3.32.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
+ "dompurify": "3.4.17",
"highlight.js": "11.11.1",
"lucide-react": "1.31.0",
"monaco-editor": "0.56.0",
@@ -49,16 +50,16 @@
"openapi-typescript": "7.13.0",
"typescript": "5.9.3",
"vite": "8.3.4",
- "vitest": "4.1.11"
+ "vitest": "4.1.12"
},
"overrides": {
"@redocly/openapi-core": {
"js-yaml": "4.3.2"
},
"brace-expansion": "5.0.12",
- "dompurify": "3.4.16",
+ "dompurify": "3.4.17",
"nanoid": "3.3.18",
"postcss": "8.5.25",
- "undici": "7.29.1"
+ "undici": "7.30.1"
}
}
diff --git a/web/src/lib/monaco-sanitizer.test.ts b/web/src/lib/monaco-sanitizer.test.ts
new file mode 100644
index 00000000..a6fb29d0
--- /dev/null
+++ b/web/src/lib/monaco-sanitizer.test.ts
@@ -0,0 +1,21 @@
+import DOMPurify from "dompurify";
+// @ts-expect-error Monaco ships this runtime sanitizer without declarations.
+import { safeSetInnerHtml, sanitizeHtml } from "monaco-editor/base/browser/domSanitize.js";
+
+describe("Monaco sanitizer", () => {
+ it.each(["trusted HTML", "DOM fragment"])("uses the installed DOMPurify for %s", (output) => {
+ const sanitize = vi.spyOn(DOMPurify, "sanitize");
+ const dirty = '
Kept formatting
' +
+ '
unsafe' +
+ 'safe';
+ const target = document.createElement("div");
+
+ if (output === "trusted HTML") target.innerHTML = String(sanitizeHtml(dirty));
+ else safeSetInnerHtml(target, dirty);
+
+ expect(sanitize).toHaveBeenCalled();
+ expect(target.querySelector("strong")).toHaveTextContent("formatting");
+ expect(target.querySelector('script, [onerror], a[href^="javascript:"]')).toBeNull();
+ expect(target.querySelector('a[href="https://example.com"]')).toHaveTextContent("safe");
+ });
+});
diff --git a/web/src/v2/components/project-header.tsx b/web/src/v2/components/project-header.tsx
index 9a30f323..09119aa5 100644
--- a/web/src/v2/components/project-header.tsx
+++ b/web/src/v2/components/project-header.tsx
@@ -1,16 +1,17 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
-import { ChevronRight, Folder, FolderOpen, MessageCircle, MoreHorizontal, Settings, SquarePen } from "lucide-react";
+import { ChevronRight, Folder, FolderOpen, MessageCircle, MoreHorizontal, Pin, Settings, SquarePen } from "lucide-react";
import type { APIClient } from "../../api/client";
import type { WorkspaceView } from "../../api/types";
import { useLocale } from "../../lib/locale";
export const projectDetailsKey = (id: string) => ["v2", "workspace-project", id] as const;
-export function ProjectHeader({ workspace, label, expanded, onToggle, onNewChat, onEdit, client }: {
+export function ProjectHeader({ workspace, label, expanded, onToggle, onNewChat, onEdit, client, pinned = false, onTogglePin }: {
workspace: WorkspaceView; label: string; expanded: boolean; onToggle: () => void;
onNewChat?: () => void; onEdit?: (workspace: WorkspaceView) => void; client?: APIClient;
+ pinned?: boolean; onTogglePin?: () => void;
}) {
const { t } = useLocale();
const [open, setOpen] = useState(false);
@@ -35,7 +36,7 @@ export function ProjectHeader({ workspace, label, expanded, onToggle, onNewChat,
setPosition({ left: fitsRight ? bounds.right + 8 : Math.max(12, window.innerWidth - popup.width - 12),
top: Math.max(12, Math.min(fitsRight ? bounds.top : bounds.bottom + 6, window.innerHeight - popup.height - 12)) });
if (focusCard.current) { (edit.current ?? card.current).focus(); focusCard.current = false; }
- }, [open, details.data, details.isError]);
+ }, [open, details.data, details.isError, pinned]);
useEffect(() => {
if (!open) return;
const close = (event: Event) => {
@@ -69,17 +70,24 @@ export function ProjectHeader({ workspace, label, expanded, onToggle, onNewChat,
{label}
+ {pinned && }
{onNewChat && }
{open && createPortal( leaveFocus(event.relatedTarget)}>
-
{details.data?.name ?? workspace.name}
+
{details.data?.name ?? workspace.name}
+ {onTogglePin && }
+
{details.data ? <>
{t(`${details.data.thread_count} 个聊天 · ${details.data.active_thread_count} 个活跃聊天`,
`${details.data.thread_count} chats · ${details.data.active_thread_count} active`)}
diff --git a/web/src/v2/components/project-pins.test.tsx b/web/src/v2/components/project-pins.test.tsx
new file mode 100644
index 00000000..cf0d9b28
--- /dev/null
+++ b/web/src/v2/components/project-pins.test.tsx
@@ -0,0 +1,94 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ThreadView } from "../../api/types";
+import { LocaleProvider } from "../../lib/locale";
+import { V2RecoveryProvider } from "../recovery-storage";
+import { V2Sidebar } from "./sidebar";
+
+const projects = [
+ { id: "project-a", name: "页面设计", created_at: "2026-10-22T00:00:00Z" },
+ { id: "project-b", name: "接口实现", created_at: "2026-10-22T00:00:00Z" },
+];
+const thread: ThreadView = { id: "chat-a", protocol_version: "thread.v1", workspace_id: "project-a", project_workspace_id: "project-a",
+ mission_id: "mission-a", title: "页面讨论", status: "active", active_run_id: "run-a", last_run_id: "run-a", version: 1,
+ composer_state: "ready", created_at: "2026-10-22T00:00:00Z", updated_at: "2026-10-22T00:00:00Z" };
+const threads = [thread, { ...thread, id: "chat-b", title: "接口讨论", project_workspace_id: "project-b" },
+ { ...thread, id: "chat-local", title: "本地讨论", project_workspace_id: "" },
+ { ...thread, id: "chat-pin", title: "置顶讨论", pinned: true }];
+const onVisibleChatOrder = vi.fn();
+function page(scopeID = "store-a", baseURL = "/api/v1") {
+ return
+
+
+
+ ;
+}
+function projectOrder() {
+ return screen.getAllByRole("region", { name: /^项目 / }).map((element) => element.getAttribute("aria-label"));
+}
+beforeEach(() => { localStorage.clear(); localStorage.setItem("prayu.locale.v1", "zh-CN"); vi.clearAllMocks(); });
+afterEach(cleanup);
+
+it("pins and unpins a project from the keyboard and keeps chat navigation in rendered order", async () => {
+ const user = userEvent.setup();
+ render(page());
+ expect(onVisibleChatOrder).toHaveBeenLastCalledWith(["chat-pin", "chat-a", "chat-b", "chat-local"]);
+ await user.click(screen.getByRole("button", { name: "接口实现 项目信息" }));
+ await user.tab();
+ const pin = screen.getByRole("button", { name: "置顶项目" });
+ expect(pin).toHaveFocus();
+ await user.keyboard("{Enter}");
+ expect(projectOrder()).toEqual(["项目 接口实现", "项目 页面设计", "项目 本地聊天"]);
+ expect(onVisibleChatOrder).toHaveBeenLastCalledWith(["chat-pin", "chat-b", "chat-a", "chat-local"]);
+ expect(within(screen.getByRole("region", { name: "项目 接口实现" })).getByRole("button", { name: "接口讨论" })).toBeVisible();
+ expect(screen.getByRole("button", { name: "取消置顶项目" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("button", { name: "取消置顶项目" })).toHaveFocus();
+ await user.keyboard("{Enter}");
+ expect(projectOrder()).toEqual(["项目 页面设计", "项目 接口实现", "项目 本地聊天"]);
+ expect(onVisibleChatOrder).toHaveBeenLastCalledWith(["chat-pin", "chat-a", "chat-b", "chat-local"]);
+});
+
+it("restores project pins only for the same API and data store", async () => {
+ const user = userEvent.setup();
+ const first = render(page());
+ await user.click(screen.getByRole("button", { name: "接口实现 项目信息" }));
+ await user.click(screen.getByRole("button", { name: "置顶项目" }));
+ first.unmount();
+ const restored = render(page());
+ expect(projectOrder()[0]).toBe("项目 接口实现");
+ restored.rerender(page("store-b"));
+ expect(projectOrder()[0]).toBe("项目 页面设计");
+ restored.rerender(page("store-a", "/other-api"));
+ expect(projectOrder()[0]).toBe("项目 页面设计");
+ restored.rerender(page());
+ expect(projectOrder()[0]).toBe("项目 接口实现");
+});
+
+it("keeps pin focus after opening details that were already visible on hover", async () => {
+ const user = userEvent.setup();
+ render(page());
+ const details = screen.getByRole("button", { name: "接口实现 项目信息" });
+ await user.hover(details);
+ await screen.findByRole("dialog", { name: "接口实现 项目信息" });
+ await user.click(details);
+ await user.tab();
+ expect(screen.getByRole("button", { name: "置顶项目" })).toHaveFocus();
+ await user.keyboard("{Enter}");
+ expect(screen.getByRole("button", { name: "取消置顶项目" })).toHaveFocus();
+});
+
+it("ignores invalid stored preference values and offers the action in English", async () => {
+ localStorage.setItem("prayu.locale.v1", "en-US");
+ const key = `v2_recovery.v1:${encodeURIComponent(window.location.origin)}:${encodeURIComponent("/api/v1")}:store-a:pinned-projects`;
+ localStorage.setItem(key, JSON.stringify({ version: "v2_recovery.v1", value: { invalid: true } }));
+ const user = userEvent.setup();
+ render(page());
+ expect(screen.getAllByRole("region", { name: /^Project / })[0]).toHaveAccessibleName("Project 页面设计");
+ await user.click(screen.getByRole("button", { name: "接口实现 project details" }));
+ await user.click(screen.getByRole("button", { name: "Pin project" }));
+ expect(screen.getByRole("button", { name: "Unpin project" })).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getAllByRole("region", { name: /^Project / })[0]).toHaveAccessibleName("Project 接口实现");
+});
diff --git a/web/src/v2/components/project-sidebar.css b/web/src/v2/components/project-sidebar.css
index 9729db49..50e96023 100644
--- a/web/src/v2/components/project-sidebar.css
+++ b/web/src/v2/components/project-sidebar.css
@@ -24,6 +24,11 @@
.v2-project-card { background: var(--prayu-surface-solid); border: 1px solid var(--v2-border-strong); border-radius: 14px; box-shadow: 0 8px 28px rgb(0 0 0 / 12%); box-sizing: border-box; color: var(--v2-ink); font: 13px/1.6 var(--prayu-font-ui); max-height: calc(100dvh - 24px); overflow: auto; padding: 12px; position: fixed; width: min(350px, calc(100vw - 24px)); z-index: 200; }
.v2-project-card > strong, .v2-project-card > p { align-items: flex-start; display: flex; gap: 10px; margin: 0; padding: 5px 2px; overflow-wrap: anywhere; }
.v2-project-card > strong { font-size: 15px; font-weight: 600; }
+.v2-project-card > strong > span { flex: 1; min-width: 0; }
+.v2-project-pin { align-items: center; background: transparent; border: 0; border-radius: 6px; color: var(--v2-muted); cursor: pointer; display: inline-flex; flex: 0 0 28px; height: 28px; justify-content: center; margin-top: -2px; padding: 0; }
+.v2-project-pin:hover, .v2-project-pin[aria-pressed="true"] { background: var(--v2-control-hover); color: var(--v2-ink); }
+.v2-project-pin:focus-visible { outline: 2px solid var(--v2-accent); outline-offset: 2px; }
+.v2-project-pin-marker { color: var(--v2-muted); }
.v2-project-card svg { flex-shrink: 0; margin-top: 3px; }
.v2-project-card > p svg { color: var(--v2-muted); }
.v2-project-card > .v2-project-card-path { border-top: 1px solid var(--v2-border); margin-top: 7px; padding-block: 12px; }
diff --git a/web/src/v2/components/sidebar.tsx b/web/src/v2/components/sidebar.tsx
index f15ee20b..09795f06 100644
--- a/web/src/v2/components/sidebar.tsx
+++ b/web/src/v2/components/sidebar.tsx
@@ -8,8 +8,13 @@ import { useLocale } from "../../lib/locale";
import { ThreadProjectDialog, threadProjectID } from "./thread-project-dialog";
import { ProjectHeader } from "./project-header";
import { ThreadActions } from "./thread-actions";
+import { useV2PersistentState } from "../recovery-storage";
import "./project-sidebar.css";
+function projectPinIDs(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string" && id.length > 0) : [];
+}
+
export type V2SettingsSection = "general" | "permissions" | "appearance" | "voice" |
"models" | "plugins" | "browser" | "hooks" | "git" | "environment" |
"worktrees" | "keyboard" | "inspector" | "archived" | "extensions" | "skills" |
@@ -89,6 +94,12 @@ export function V2Sidebar({ threads, workspaces, selectedThreadID, searchOpen, o
const [projectCandidate, setProjectCandidate] = useState
(null);
const [projectsExpanded, setProjectsExpanded] = useState(true);
const [collapsedProjects, setCollapsedProjects] = useState>(() => new Set());
+ const [savedProjectPins, setProjectPins] = useV2PersistentState("pinned-projects", []);
+ const pinnedProjects = useMemo(() => new Set(projectPinIDs(savedProjectPins)), [savedProjectPins]);
+ const toggleProjectPin = (id: string) => setProjectPins((previous: unknown) => {
+ const ids = projectPinIDs(previous);
+ return ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id];
+ });
const pinsQuery = useInfiniteQuery({
queryKey: ["v2", "threads", "active", "pinned", executionFilter],
queryFn: ({ signal, pageParam }) => client!.getPage("/threads", {
@@ -119,8 +130,8 @@ export function V2Sidebar({ threads, workspaces, selectedThreadID, searchOpen, o
if (draggingThreadID && !result.has("")) result.set("", []);
const order = new Map(workspaces.map((workspace, index) => [workspace.id, index]));
return [...result.entries()].sort(([a], [b]) => a === "" ? 1 : b === "" ? -1
- : (order.get(a) ?? order.size) - (order.get(b) ?? order.size));
- }, [threads, workspaces, onNewInProject, draggingThreadID, executionFilter, pinnedIDs]);
+ : Number(pinnedProjects.has(b)) - Number(pinnedProjects.has(a)) || (order.get(a) ?? order.size) - (order.get(b) ?? order.size));
+ }, [threads, workspaces, onNewInProject, draggingThreadID, executionFilter, pinnedIDs, pinnedProjects]);
const duplicateNames = useMemo(() => {
const counts = new Map();
for (const { name } of workspaces) counts.set(name, (counts.get(name) ?? 0) + 1);
@@ -261,6 +272,7 @@ export function V2Sidebar({ threads, workspaces, selectedThreadID, searchOpen, o
event.preventDefault(); setDropProjectID(null); setDraggingThreadID(""); onMoveThread?.(draggedThread, workspaceID);
}}>
{workspaceID ? toggleProjectPin(workspaceID)}
onToggle={() => setCollapsedProjects((previous) => { const next = new Set(previous); if (next.has(workspaceID)) next.delete(workspaceID); else next.add(workspaceID); return next; })}
onNewChat={onNewInProject ? () => onNewInProject(workspaceID) : undefined} onEdit={onEditProject} />
: {label}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 7faf222b..fd8574df 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,7 +1,23 @@
+import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
-import { loadEnv } from "vite";
+import { loadEnv, normalizePath, type Plugin } from "vite";
import { defineConfig } from "vitest/config";
+// npm overrides cannot replace Monaco's embedded copy. Redirect its sanitizer import
+// in both Vite and the separate dependency prebundler to the installed DOMPurify.
+const monacoSanitizer = normalizePath(fileURLToPath(import.meta.resolve("monaco-editor/base/browser/domSanitize.js")));
+const monacoDOMPurify: Plugin = {
+ name: "monaco-dompurify",
+ enforce: "pre",
+ async resolveId(source, importer) {
+ if (source === "./dompurify/dompurify.js" && importer && normalizePath(importer) === monacoSanitizer) {
+ const resolved = await this.resolve("dompurify", importer, { skipSelf: true });
+ if (!resolved) throw new Error("Cannot resolve installed DOMPurify for the Monaco sanitizer");
+ return resolved;
+ }
+ },
+};
+
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, ".", "");
const apiTarget = env.CYBERAGENT_API_TARGET || "http://127.0.0.1:8765";
@@ -19,7 +35,10 @@ export default defineConfig(({ mode }) => {
};
return {
- plugins: [react()],
+ plugins: [react(), monacoDOMPurify],
+ optimizeDeps: {
+ rolldownOptions: { plugins: [monacoDOMPurify] },
+ },
server: {
host: "127.0.0.1",
port: 5173,
@@ -39,6 +58,7 @@ export default defineConfig(({ mode }) => {
},
test: {
include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ server: { deps: { inline: ["monaco-editor"] } },
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.ts",