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
39 changes: 39 additions & 0 deletions docs/acceptance/project-pins/README.md
Original file line number Diff line number Diff line change
@@ -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,没有横向溢出。

置顶保存在当前设备,并按服务地址、数据存储隔离;同组项目沿用原排序,项目内聊天和置顶聊天保持原有规则。上一/下一聊天快捷导航读取实际渲染顺序。

## 真实截图

变更前:

![项目详情原状态](before.png)

变更后,键盘置顶并保留焦点、草稿:

![文档维护置顶](after.png)

窄屏:

![390px 项目详情](after-narrow.png)

## 验证范围

- `project-pins.test.tsx`、`sidebar.test.tsx`、`project-header.test.tsx`:35项通过。覆盖置顶/取消、实际聊天导航顺序、重新挂载、服务/数据库隔离、损坏的本地值和英文按钮。
- 悬停打开后点击详情再置顶的焦点回归,在修复前实际失败,修复后通过;浏览器重复同一触发路径通过。
- `npm run build`:通过,包含 TypeScript 检查。
- `go run ./cmd/protocolregistry -check`:通过;复用现有恢复存储,不新增协议标识。
- 原生桌面包未单独运行。

准备真实聊天时,第一份测试 README 未包含离线模型约定的证明文本,导致模型重复读取并失败;补回测试数据后通过真实发送得到成功回复。最终截图使用后续成功保存的聊天,未将该夹具问题计为产品验证通过。
Binary file added docs/acceptance/project-pins/after-narrow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/acceptance/project-pins/after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/acceptance/project-pins/before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
103 changes: 52 additions & 51 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
21 changes: 21 additions & 0 deletions web/src/lib/monaco-sanitizer.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<p>Kept <strong>formatting</strong></p><script>alert(1)</script>' +
'<img src="data:," onerror="alert(2)"><a href="javascript:alert(3)">unsafe</a>' +
'<a href="https://example.com">safe</a>';
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");
});
});
18 changes: 13 additions & 5 deletions web/src/v2/components/project-header.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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) => {
Expand Down Expand Up @@ -69,17 +70,24 @@ export function ProjectHeader({ workspace, label, expanded, onToggle, onNewChat,
<FolderOpen aria-hidden="true" className="v2-project-folder" size={17} />
<ChevronRight aria-hidden="true" className={`v2-project-chevron${expanded ? " is-expanded" : ""}`} size={16} />
<span>{label}</span>
{pinned && <Pin className="v2-project-pin-marker" size={12} aria-hidden="true" />}
</button>
<div className="v2-project-row-actions">
<button aria-label={t(`${label} 项目信息`, `${label} project details`)} aria-expanded={open} aria-haspopup="dialog" ref={more}
onClick={() => { clearTimer(); focusCard.current = true; if (open) (edit.current ?? card.current)?.focus(); else setOpen(true); }} type="button">
onClick={() => { clearTimer(); if (open) (edit.current ?? card.current)?.focus(); else { focusCard.current = true; setOpen(true); } }} type="button">
<MoreHorizontal aria-hidden="true" size={16} /></button>
{onNewChat && <button aria-label={t(`在 ${label} 中新建聊天`, `New chat in ${label}`)}
onClick={() => { clearTimer(); setOpen(false); onNewChat(); }} type="button"><SquarePen aria-hidden="true" size={15} /></button>}
</div>
{open && createPortal(<div className="v2-project-card" ref={card} role="dialog" tabIndex={-1} aria-label={t(`${label} 项目信息`, `${label} project details`)}
style={position} onMouseEnter={clearTimer} onMouseLeave={scheduleClose} onBlur={(event) => leaveFocus(event.relatedTarget)}>
<strong><FolderOpen aria-hidden="true" size={18} /><span>{details.data?.name ?? workspace.name}</span></strong>
<strong><FolderOpen aria-hidden="true" size={18} /><span>{details.data?.name ?? workspace.name}</span>
{onTogglePin && <button className="v2-project-pin" type="button" onClick={onTogglePin} aria-pressed={pinned}
aria-label={pinned ? t("取消置顶项目", "Unpin project") : t("置顶项目", "Pin project")}
title={pinned ? t("取消置顶项目", "Unpin project") : t("置顶项目", "Pin project")}>
<Pin aria-hidden="true" size={16} fill={pinned ? "currentColor" : "none"} />
</button>}
</strong>
{details.data ? <>
<p><MessageCircle aria-hidden="true" size={16} /><span>{t(`${details.data.thread_count} 个聊天 · ${details.data.active_thread_count} 个活跃聊天`,
`${details.data.thread_count} chats · ${details.data.active_thread_count} active`)}</span></p>
Expand Down
Loading
Loading