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
16 changes: 8 additions & 8 deletions docs/INDEX.md

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions docs/PITFALLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
| hover 才现的控件刚截完图就点不到、点击静默落空 | 四 · 鼠标事件 + `:hover` → 补「截图会清掉 hover」(2026-09-19) |
| 改完自定义 hook 后整页报「Rendered fewer hooks than expected」 | 四 · HMR 改 hook 数量会假报错(2026-09-19) |
| 清理 dev 进程后端口还占着、CDP 连上但页面全空 | 五 · `pkill -f` 杀 Electron 会留下孤儿 main(2026-09-19) |
| 组里最后一个展开的项目折不掉、切会话又自己展开(空数组身兼两义) | 四 · 空数组不能同时当「未初始化」与「有效空值」(2026-09-20) |
| 打开模型选择器后整页向左偷跑、左栏与顶栏左侧按钮被挤/裁切 | 四 · absolute 弹层越界 + autoFocus = 整页横向偷跑(2026-09-20) |
| CDP 量测得出「弹层在视口内、也没滚动」但界面明明错位(量错元素) | 四 · 同章节「量测三纪律」(2026-09-20) |
| 量测脚本报「draft 没进左栏」,实际是我的选择器点到了分组头 | 四 · 同章节「量测三纪律」→ 侧栏行选择器(2026-09-20) |

## 一、事故复盘(含可复用诊断手法)

Expand Down Expand Up @@ -218,6 +222,30 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b

本轮同时删掉了旧模型里“置顶顺带把会话挪到 tabs 最前”这套副作用(顶栏顺序改由 `pinnedSessions` 表达,`reorderSessions` 已无引用,一并删)。

### 空数组不能同时当「未初始化」与「有效空值」(2026-09-20,sidebar-draft-picker-fixes)

症状:左栏「当前项目是最后一个展开组时,把它折了会立即又展开」;切会话/重渲染也会“自己弹回来”。

根因:展开态只有一个字段 `expandedGroups: string[]`,而推导层把它写成 `expandedGroups.length > 0 ? expandedGroups : defaultExpandedKeys`——**`[]` 被当成“用户还没开合过”**。于是用户真把最后一组折了(存 `[]`)后,下一次派生又回退到默认集(当前会话所在组),折叠永远存不住。同类陷阱:将来任何一个“空集合 = 无操作”的存储字段都会重踩。

修法:**加显式的“用户动过”位**(`expandedGroupsTouched`,shared UiState + main normalize 迁移 + store 单补丁原子写两字段)——`false` 才走默认推断,`true` 时 `[]` 就是“全部折叠”。迁移规则:旧文件缺该字段时按现有记录是否非空推断(非空 ≈ 已操作)。

顺带一并修掉的同源问题:`toggleExpandedGroup(current, key, defaults)` 旧实现用 `current.length > 0 ? current : defaults` 当起点,**全部折叠后再点开一个组会把默认集(当前会话所在组)一起拉出来**——起点同样必须由 touched 位决定(参数化后补了用例)。

### absolute 弹层越界 + autoFocus = 整页横向偷跑(2026-09-20,sidebar-draft-picker-fixes)

症状(用户报):打开 composer 的模型选择器后,**整页向左偏移**,左栏与顶栏左侧按钮被挤压/裁切(实测顶栏最左按钮 left 从 80 被推到 72,窄窗口 44.5)。

量测(dev + CDP,1100/900/700px 窗口均复现):弹层 `left-0 w-72` 从按钮左缘向右展开 → 面板右缘越出视口约 12px;搜索框 `autoFocus` 后 Chromium 会把聚焦元素滚进视口,**被滚的容器是 `#root`**(`#root.scrollLeft` 0 → 8/35.5)。`html, body, #root { overflow: hidden }` **拦不住程序性滚动**——它只挡用户滚动,所以“有 overflow:hidden 就不会跑”是错的假设。

修法:弹层改成贴着触发按钮的**另一侧**展开(`right-0`),让矩形落在视口内(659→947 < 1100);修后三处根 scrollLeft 恒 0、左栏宽恒 240、顶栏按钮零位移。若矩形仍放不下,才考虑 `place-menu.ts` 那套“先渲染再量、越界翻转”的浮层定位(issue #55 方案),**不要只加 overflow:hidden 或靠 clip**。

**量测三纪律**(本次都踩过,会造成假绿/假红):

1. **selector 要限定作用域**:composer 里 tooltip 也是 `div.absolute.bottom-full`(宽 157 vs 弹层 288),全局 `querySelector` 会把 tooltip 当弹层面,得出“在视口内、没滚动”的假绿;必须取**触发按钮的兄弟节点**。
2. **扫多档窗口宽**:1100px 下越界 11.8px 恰好勉强可看,900/700px 才明显;只看默认尺寸容易放过。`window.resizeTo(w,h)` 在 Electron dev 里可用(`Browser.setWindowBounds` 未实现)。另外 mvp 窗口 `minWidth: 640`,扫到 700 就够了。
3. **侧栏行不能按标题分辨**:分组头(`h-[34px]`)与会话行(`h-[31px]`)**都带 title 且文字相同**,按文本找会把分组头当会话行(本次真误点了分组头 → 把项目组折了 → 误判“draft 没进左栏”)。会话行用 `className.includes("h-[31px]")`;两者都拿不准时,优先拿 store 状态(页面内 `await import("/src/stores/*.ts")` 拿到的是应用在用的同一个模块实例)来交叉验证,别只信 DOM 推断。

### 渲染层 JS 堆的大头是模块级基建,不是会话数据(2026-09-20)

背景:要给「会话常驻内存」做自动卸载,先验「卸载后渲染层 JS 堆能不能降」。结论:**降不下来——但原因不在会话**。
Expand Down Expand Up @@ -381,6 +409,14 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b

## 五、工程纪律

### renderer 单测跑在 node 环境:测不了 i18n 与返回 JSX 的模块函数(2026-09-20)

症状:给 `session-menu.test.ts` 加一条“draft 菜单只有一项”用例后,整个测试文件报 `TypeError: Cannot read properties of undefined (reading 'getItem')`(`i18n/index.ts` 的 `detectLanguage` 读 `localStorage`),改成不 import i18n 后变成 `React is not defined`(应用构建( vite plugin-react)与 tsconfig 预期 **automatic** JSX runtime,所以源码里没有 `import React`;但 Vitest 这条路径把 `.tsx` 编成 `React.createElement` 的 **classic** 形式,于是调用 builder 时 `React` 不在作用域)。

原因:本仓 vitest 无 config(`electron.vite.config.ts` 不被 vitest 读取),环境是默认的 **node**:无 DOM/localStorage,且 esbuild 把 `.tsx` 编译成 `React.createElement`(classic)→ 一调就炸(上游:应用构建预期 automatic,两边 JSX runtime 不一致)。

对策(本期采用):**把决策抽成不碰 JSX 的纯函数再测**(如 `sidebarMenuKind()`、`discardDraft()`),JSX 菜单项本身交给 CDP 手测(真跑一遍比单测更接近用户行为)。若真需要渲染测试,得单独引入 jsdom + `esbuild: { jsx: "automatic" }`(新增 `packages/desktop/vitest.config.ts`),**别为一个 builder 就改全局测试环境**。

### 别用 `npm run lint | tail -2` 判断「lint 通过」(2026-09-20)

症状:本地看 `npm run lint | tail -2` 只见 "No fixes applied." + "Checked N files",判定全绿 → 推 PR → **CI 在 `Run npm run lint` 立刻挂**,报 3 个 **format** 错误(多余空行、超长行)。
Expand Down
43 changes: 43 additions & 0 deletions packages/desktop/src/main/ui-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,49 @@ describe("lastCwd 白名单", () => {
});
});

describe("expandedGroupsTouched 迁移与读写(空数组不再兼任「未操作」)", () => {
it("缺字段 + 非空记录 → 推断为 true(旧版非空记录仍是「完全以用户选择为准」)", async () => {
writeFileSync(file(), JSON.stringify({ expandedGroups: ["/work/alpha"] }));
const state = await loadUiState();
expect(state?.expandedGroupsTouched).toBe(true);
expect(state?.expandedGroups).toEqual(["/work/alpha"]);
});

it("缺字段 + 空/非法记录 → false(旧版无法区分,只能继续按未操作处理)", async () => {
for (const raw of [
'{"expandedGroups":[]}',
"{}",
'{"expandedGroups":"x"}',
'{"expandedGroups":[1,""]}',
]) {
writeFileSync(file(), raw);
expect((await loadUiState())?.expandedGroupsTouched, raw).toBe(false);
}
});

it("显式布尔值优先(含显式 false 配空数组、显式 true 配非空记录)", async () => {
writeFileSync(file(), JSON.stringify({ expandedGroups: [], expandedGroupsTouched: true }));
expect((await loadUiState())?.expandedGroupsTouched).toBe(true);

writeFileSync(file(), JSON.stringify({ expandedGroups: ["/work/alpha"], expandedGroupsTouched: false }));
expect((await loadUiState())?.expandedGroupsTouched).toBe(false);

// 非布尔脏值 → 按缺字段语义推断(这里记录非空 → true)
writeFileSync(file(), JSON.stringify({ expandedGroups: ["/work/alpha"], expandedGroupsTouched: "yes" }));
expect((await loadUiState())?.expandedGroupsTouched).toBe(true);
});

it("保存补丁:显式空数组 + touched=true 能原样写盘读回(全部折叠可持久化)", async () => {
const patch = { expandedGroups: [] as string[], expandedGroupsTouched: true };
await saveUiState(patch);
await saveUiState({ pinnedSessions: ["s1"] }); // 后续补丁不能把 touched 洗掉
const state = await loadUiState();
expect(state?.expandedGroupsTouched).toBe(true);
expect(state?.expandedGroups).toEqual([]);
expect(state?.pinnedSessions).toEqual(["s1"]);
});
});

describe("sessionPermissionModes 白名单(D7:按会话记住权限模式)", () => {
it("只收 fullAccess,且丢掉 default 与非法值", async () => {
writeFileSync(
Expand Down
9 changes: 8 additions & 1 deletion packages/desktop/src/main/ui-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ function normalize(parsed: UiStateFileShape): UiState {
const background = parsed.background;
const dim =
typeof background?.dim === "number" && background.dim >= 0 && background.dim <= 1 ? background.dim : 0.8;
// 展开态:先清洗数组,再用它推断缺字段时的 touched(旧版「非空记录 = 已操作」语义)
const expandedGroups = stringArray(parsed.expandedGroups);
return {
lastUsedModel: model ? { provider: model.provider, modelId: model.modelId } : null,
lastUsedThinkingLevel: typeof level === "string" ? level : "medium",
Expand All @@ -65,7 +67,12 @@ function normalize(parsed: UiStateFileShape): UiState {
// 顶栏是否显示置顶会话胶囊(旧字段 topBarVisible 已废弃:顶栏现在常驻,不再整条隐藏)
barSessionsVisible: typeof parsed.barSessionsVisible === "boolean" ? parsed.barSessionsVisible : true,
sidebarCollapsed: typeof parsed.sidebarCollapsed === "boolean" ? parsed.sidebarCollapsed : false,
expandedGroups: stringArray(parsed.expandedGroups),
expandedGroups,
// 显式布尔优先(含显式 false 配空数组);缺字段/脏值才按清洗后的记录是否非空推断
expandedGroupsTouched:
typeof parsed.expandedGroupsTouched === "boolean"
? parsed.expandedGroupsTouched
: expandedGroups.length > 0,
pinnedProjects: stringArray(parsed.pinnedProjects),
// 上次项目目录:只收非空字符串(旧文件/脏值 → null)
lastCwd: typeof parsed.lastCwd === "string" && parsed.lastCwd.length > 0 ? parsed.lastCwd : null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,12 @@ export function ModelPicker() {
<span className="truncate">{label}</span>
<ChevronDownIcon className={open ? "rotate-180 transition-transform" : "transition-transform"} />
</button>
{/* 右对齐(不是 left-0):模型按钮就在 composer 右侧,288px 弹层向左展开才能留在视口内。
旧版 left-0 会让面板右缘越出视口约 12px(1100/900/700px 窗口实测均如此),
配合搜索框 autoFocus 触发 Chromium 对 #root 的程序性横向滚动(overflow:hidden 拦不住):
窄窗口下实测根横滚约 35.5px,顶栏最左按钮 left 从 80 被挤到 72(1100px)/44.5(窄窗口)。 */}
{open && (
<div className="absolute bottom-full left-0 z-30 mb-1 w-72 rounded-xl bg-surface p-1 shadow-pop">
<div className="absolute right-0 bottom-full z-30 mb-1 w-72 rounded-xl bg-surface p-1 shadow-pop">
<div className="flex items-center gap-1.5 rounded-lg px-2 py-1.5">
<SearchIcon size={13} className="shrink-0 text-ink-faint" />
<input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useCallback, useEffect, useState } from "react";
import { getPi } from "../../api";
import { useT } from "../../i18n";
import { useProjectsStore } from "../../stores/projects";
import { isDraftSessionId, selectBarSessions, useSessionsStore } from "../../stores/sessions";
import { selectBarSessions, useSessionsStore } from "../../stores/sessions";
import { useTranscriptStore } from "../../stores/transcript";
import { useUiStore } from "../../stores/ui";
import { useUiPreferencesStore } from "../../stores/ui-preferences";
Expand Down Expand Up @@ -91,8 +91,8 @@ function TabPill({
buttonProps?: ComponentProps<"button">;
}) {
const t = useT();
const closeSession = useSessionsStore((s) => s.closeSession);
// v9:叉叉 = 取消置顶 + 从顶栏清除(会话不删、tab 也不关);draft 例外(它不是“置顶”,叉叉就是丢弃这个新会话)
// v9:叉叉 = 取消置顶 + 从顶栏清除(会话不删、tab 也不关)。顶栏严格只放置顶会话,
// draft 已不再进顶栏(其名题与丢弃入口都在左栏,见 spec D1/D3)
const unpin = useUiPreferencesStore((s) => s.unpin);
// 置顶标记:顶栏会滚动、顺序会被拖动,必须有常显 glyph(不是只靠排序表达)
const pinned = useUiPreferencesStore((s) => s.pinnedSessions.includes(session.sessionId));
Expand Down Expand Up @@ -142,13 +142,10 @@ function TabPill({
aria-hidden="true"
/* 胶囊本体是 button,这里不能再塞 button(嵌套非法)→ 用 codebase 同款做法:装饰 span + aria-hidden,
语义提示走原生 title(同 SessionRow),语义入口靠胶囊右键菜单的「取消置顶」 */
title={
isDraftSessionId(session.sessionId) ? t("tabbar.discardDraft") : t("tabbar.unpinFromBar")
}
title={t("tabbar.unpinFromBar")}
onClick={(e) => {
e.stopPropagation();
if (isDraftSessionId(session.sessionId)) void closeSession(session.sessionId);
else unpin(session.sessionId);
unpin(session.sessionId);
}}
>
<CloseIcon />
Expand All @@ -175,8 +172,7 @@ function SessionTab({
contextOpen: boolean;
onContextMenu: (sessionId: string, anchor: MenuAnchor) => void;
}) {
const switchSession = useSessionsStore((s) => s.switchSession);
// v8:顶栏里可能是「已置顶但 tab 未打开」的会话,点击要能把它开起来(openSession 一条路兼容两种情况)
// 顶栏里可能是「已置顶但 tab 未打开」的会话,点击要能把它开起来(openSession 一条路兼容两种情况)
const openSession = useProjectsStore((s) => s.openSession);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: session.sessionId,
Expand Down Expand Up @@ -208,11 +204,7 @@ function SessionTab({
e.preventDefault();
onContextMenu(session.sessionId, anchorOfElement(e.currentTarget));
},
onClick: () => {
// draft(内存态、后端没有该会话)只能切;其余走 openSession(已打开则切、未打开则从历史开)
if (isDraftSessionId(session.sessionId)) switchSession(session.sessionId);
else void openSession(session);
},
onClick: () => void openSession(session),
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SessionMeta } from "@percho/shared";
import { describe, expect, it } from "vitest";
import { canOpenSessionMenu } from "./session-menu";
import { beforeEach, describe, expect, it } from "vitest";
import { useSessionsStore } from "../../stores/sessions";
import { canOpenSessionMenu, discardDraft, sidebarMenuKind } from "./session-menu";

function meta(overrides: Partial<SessionMeta>): SessionMeta {
return { sessionId: "s1", cwd: "/proj/demo", active: true, messageCount: 0, createdAt: 1, ...overrides };
Expand All @@ -23,3 +24,29 @@ describe("canOpenSessionMenu", () => {
expect(canOpenSessionMenu(undefined)).toBe(false);
});
});

describe("sidebarMenuKind(左栏行右键菜单的形态判定)", () => {
it("draft → 最小菜单;普通会话 → 完整菜单;只读子会话/找不到 → 无菜单", () => {
expect(sidebarMenuKind(meta({ sessionId: "draft:1" }))).toBe("draft");
expect(sidebarMenuKind(meta({}))).toBe("session");
expect(sidebarMenuKind(meta({ readOnly: true }))).toBe("none");
expect(sidebarMenuKind(undefined)).toBe("none");
});
});

describe("discardDraft(draft 的丢弃动作)", () => {
beforeEach(() => {
useSessionsStore.setState({ sessions: [], activeSessionId: null, cwd: null, permissionModes: {} });
});

it("纯本地移除:draft 没有后端会话,不调 IPC,也不影响其它会话", () => {
useSessionsStore.setState({
sessions: [meta({ sessionId: "draft:1", cwd: "/p" }), meta({ sessionId: "r1", cwd: "/p" })],
activeSessionId: "draft:1",
});
discardDraft("draft:1");
const state = useSessionsStore.getState();
expect(state.sessions.map((s) => s.sessionId)).toEqual(["r1"]);
expect(state.activeSessionId).toBe("r1");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,46 @@ import type { ContextMenuItem } from "../ui/ContextMenu";
* 重命名失败要弹 toast —— 复制三份迟早漂移。
*/

/** draft(后端没有该会话)与只读子会话(后端拒绝写)上的动作全会失败 → 干脆不给菜单 */
/** draft(后端没有该会话)与只读子会话(后端拒绝写)上的动作全会失败 → 通用菜单不给。
* draft 在左栏有自己的**最小菜单**(只有一项「丢弃新会话」,见 `draftSessionMenuItems`),
* 不要为了左栏放开这里让 draft 进通用菜单(spec D3)。 */
export function canOpenSessionMenu(session: SessionMeta | undefined): boolean {
return !!session && !session.readOnly && !isDraftSessionId(session.sessionId);
}

/**
* 左栏会话行的右键菜单形态(纯逻辑,便于单测;组件只负责按形态渲染):
* - `draft`:draft 专属最小菜单(只有「丢弃新会话」);
* - `session`:普通真实会话的完整菜单;
* - `none`:找不到会话或只读子会话,不给菜单。
*/
export function sidebarMenuKind(session: SessionMeta | undefined): "draft" | "session" | "none" {
if (!session) return "none";
if (isDraftSessionId(session.sessionId)) return "draft";
return canOpenSessionMenu(session) ? "session" : "none";
}

/**
* draft 左栏行的右键菜单:**只有「丢弃新会话」这一项**(spec D3)。
* 重命名/置顶/复制诊断/删后端会话对 draft 都无意义或必败,一律不给;只读子会话仍完全无菜单。
*/
export function draftSessionMenuItems(t: Translate, options: { onDiscard: () => void }): ContextMenuItem[] {
return [
{
key: "discardDraft",
label: t("sessionMenu.discardDraft"),
icon: <TrashIcon size={13} />,
danger: true,
onSelect: options.onDiscard,
},
];
}

/** 丢弃 draft:纯本地移除(draft 没有任何后端对象),不弹确认——与旧顶栏胶囊叉号同语义 */
export function discardDraft(sessionId: string): void {
void useSessionsStore.getState().closeSession(sessionId);
}

/**
* 置顶 / 取消置顶(v8):只动 `pinnedSessions`(新置顶自动排最左)——顶栏胶囊内容 = 置顶表,
* 所以**不再**顺带重排 tabs(旧模型靠把会话挪到 tabs 最前才会进顶栏置顶区)。
Expand Down
Loading
Loading