From ba300dca0532d7aea3126a2043fb0e5e15c5d7dc Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 14:40:57 +0800 Subject: [PATCH 1/7] =?UTF-8?q?test(sidebar):=20=E9=98=B6=E6=AE=B5=200=20?= =?UTF-8?q?=E2=80=94=20=E8=A1=A5=20draft=20=E9=A1=B6=E6=A0=8F=E4=BE=8B?= =?UTF-8?q?=E5=A4=96=E4=B8=8E=E5=B1=95=E5=BC=80=E6=80=81=20touched=20?= =?UTF-8?q?=E4=BD=8D=E7=9A=84=E5=A4=B1=E8=B4=A5=E6=96=AD=E8=A8=80=EF=BC=88?= =?UTF-8?q?=E6=9C=89=E6=84=8F=E7=BA=A2=EF=BC=8Ctest-first=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/desktop/src/main/ui-state.test.ts | 43 ++++++++++ .../renderer/src/lib/sidebar-groups.test.ts | 78 +++++++++++++++---- .../src/renderer/src/stores/sessions.test.ts | 10 +-- .../src/stores/ui-preferences.test.ts | 34 +++++++- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/packages/desktop/src/main/ui-state.test.ts b/packages/desktop/src/main/ui-state.test.ts index fea4f3f0..3b4b5802 100644 --- a/packages/desktop/src/main/ui-state.test.ts +++ b/packages/desktop/src/main/ui-state.test.ts @@ -13,6 +13,11 @@ vi.mock("electron", () => ({ app: { getPath: () => env.dir } })); import { loadUiState, saveUiState } from "./ui-state"; +/** 展开态「用户已操作」位(阶段 1 落进 shared UiState):字段落地前用受控读取,避免测试引用未声明成员 */ +function touched(state: Awaited>): boolean | undefined { + return (state as unknown as { expandedGroupsTouched?: boolean } | null)?.expandedGroupsTouched; +} + let root = ""; const file = () => join(root, "ui-state.json"); @@ -53,6 +58,44 @@ describe("lastCwd 白名单", () => { }); }); +describe("expandedGroupsTouched 迁移与读写(空数组不再兼任「未操作」)", () => { + it("缺字段 + 非空记录 → 推断为 true(旧版非空记录仍是「完全以用户选择为准」)", async () => { + writeFileSync(file(), JSON.stringify({ expandedGroups: ["/work/alpha"] })); + const state = await loadUiState(); + expect(touched(state)).toBe(true); + expect(state?.expandedGroups).toEqual(["/work/alpha"]); + }); + + it("缺字段 + 空/非法记录 → false(旧版无法区分,只能继续按未操作处理)", async () => { + for (const raw of ['{"expandedGroups":[]}', "{}", '{"expandedGroups":"x"}', '{"expandedGroups":[1,""]}']) { + writeFileSync(file(), raw); + expect(touched(await loadUiState()), raw).toBe(false); + } + }); + + it("显式布尔值优先(含显式 false 配空数组、显式 true 配非空记录)", async () => { + writeFileSync(file(), JSON.stringify({ expandedGroups: [], expandedGroupsTouched: true })); + expect(touched(await loadUiState())).toBe(true); + + writeFileSync(file(), JSON.stringify({ expandedGroups: ["/work/alpha"], expandedGroupsTouched: false })); + expect(touched(await loadUiState())).toBe(false); + + // 非布尔脏值 → 按缺字段语义推断(这里记录非空 → true) + writeFileSync(file(), JSON.stringify({ expandedGroups: ["/work/alpha"], expandedGroupsTouched: "yes" })); + expect(touched(await loadUiState())).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(touched(state)).toBe(true); + expect(state?.expandedGroups).toEqual([]); + expect(state?.pinnedSessions).toEqual(["s1"]); + }); +}); + describe("sessionPermissionModes 白名单(D7:按会话记住权限模式)", () => { it("只收 fullAccess,且丢掉 default 与非法值", async () => { writeFileSync( diff --git a/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts b/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts index e851c1f0..b81f53b4 100644 --- a/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts +++ b/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts @@ -30,7 +30,15 @@ function project(cwd: string, addedIndex = -1, lastActive = 0, sessionCount = 0) } function derive(overrides: Partial = {}) { - return deriveSidebarGroups({ + return deriveWithTouched(overrides, false); +} + +/** + * 展开态「用户已操作」位(阶段 1 落进 `SidebarGroupsInput`):这里先经**变量**传入(对象字面量会被 + * 多余属性检查拦住),字段落地后即为正式入参。所有用例显式给值,不依赖实现里的缺省推断。 + */ +function deriveWithTouched(overrides: Partial, expandedGroupsTouched: boolean) { + const input = { sessions: [], projects: [], search: "", @@ -39,7 +47,9 @@ function derive(overrides: Partial = {}) { pinnedProjects: [], expandedGroups: [], ...overrides, - }); + expandedGroupsTouched, + }; + return deriveSidebarGroups(input); } describe("deriveSidebarGroups · 组内排序", () => { @@ -100,13 +110,16 @@ describe("deriveSidebarGroups · 展开推断", () => { expect(result.projects.map((p) => p.expanded)).toEqual([false]); }); - it("有用户记录:完全以记录为准(当前组被折叠也尊重)", () => { - const result = derive({ - sessions: [session("a", P1, 100), session("b", P2, 200)], - projects: [project(P1), project(P2)], - activeSessionId: "a", - expandedGroups: [P2], - }); + it("有用户记录(touched=true):完全以记录为准(当前组被折叠也尊重)", () => { + const result = deriveWithTouched( + { + sessions: [session("a", P1, 100), session("b", P2, 200)], + projects: [project(P1), project(P2)], + activeSessionId: "a", + expandedGroups: [P2], + }, + true, + ); expect(result.projects.map((p) => [p.cwd, p.expanded])).toEqual([ [P1, false], [P2, true], @@ -114,13 +127,52 @@ describe("deriveSidebarGroups · 展开推断", () => { }); it("历史遗留的 __projects__ 值不匹配任何组,不报错也不影响其它组(无需数据迁移)", () => { - const result = derive({ + // 遗留非空记录 → 迁移推断为「用户操作过」(touched=true),但值本身不匹配任何组 + const result = deriveWithTouched( + { + sessions: [session("a", P1, 100)], + projects: [project(P1)], + activeSessionId: "a", + expandedGroups: ["__projects__"], + }, + true, + ); + expect(result.projects.map((p) => p.expanded)).toEqual([false]); + }); +}); + +describe("deriveSidebarGroups · 展开状态 touched 位", () => { + it("touched=false + 空记录:按默认推断只展开当前会话所在组", () => { + const result = deriveWithTouched( + { sessions: [session("a", P1, 100)], projects: [project(P1)], activeSessionId: "a" }, + false, + ); + expect(result.projects.map((p) => [p.cwd, p.expanded])).toEqual([[P1, true]]); + }); + + it("touched=true + 空记录:空数组合法 = 全部折叠(不能再被当成「未操作」回退默认集)", () => { + const result = deriveWithTouched( + { sessions: [session("a", P1, 100)], projects: [project(P1)], activeSessionId: "a" }, + true, + ); + expect(result.projects.map((p) => [p.cwd, p.expanded])).toEqual([[P1, false]]); + // 默认集仍照常给出(供首次开合当起点),只是不再参与展开推断 + expect(result.defaultExpandedKeys).toEqual([P1]); + }); + + it("折叠最后一个展开组 → 再派生:保持全部折叠(切会话/重渲染不复活)", () => { + const base = { sessions: [session("a", P1, 100)], projects: [project(P1)], activeSessionId: "a", - expandedGroups: ["__projects__"], - }); - expect(result.projects.map((p) => p.expanded)).toEqual([false]); + }; + const first = deriveWithTouched(base, false); + expect(first.defaultExpandedKeys).toEqual([P1]); + // 用户点当前项目:toggle 以默认集为起点翻转 → [](store 同时置 touched=true) + const next = toggleExpandedGroup([], P1, first.defaultExpandedKeys); + expect(next).toEqual([]); + const second = deriveWithTouched({ ...base, expandedGroups: next }, true); + expect(second.projects.map((p) => [p.cwd, p.expanded])).toEqual([[P1, false]]); }); }); diff --git a/packages/desktop/src/renderer/src/stores/sessions.test.ts b/packages/desktop/src/renderer/src/stores/sessions.test.ts index 456f7c90..bd91d2aa 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.test.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.test.ts @@ -89,7 +89,7 @@ describe("partitionSessionsByPin", () => { }); }); -describe("selectBarSessions(顶栏 = 置顶表驱动 + draft)", () => { +describe("selectBarSessions(顶栏 = 置顶表驱动)", () => { const tabs = [realMeta("a", "/p"), realMeta("b", "/p"), realMeta("c", "/p")]; const history = [...tabs, realMeta("h1", "/p"), realMeta("h2", "/p")]; const ids = (sessions: SessionMeta[]) => sessions.map((s) => s.sessionId); @@ -113,12 +113,10 @@ describe("selectBarSessions(顶栏 = 置顶表驱动 + draft)", () => { expect(out[0]?.name).toBe("新名字"); }); - it("未命名的 draft 永远展示(它还没落盘、左栏历史里也查不到),并排在置顶之后", () => { + it("draft 不进顶栏:无置顶时即使存在 draft 也返回空(顶栏严格 = 置顶表)", () => { const draft = realMeta(`${DRAFT_SESSION_PREFIX}x`, "/p"); - expect(ids(selectBarSessions([...tabs, draft], ["c"], history))).toEqual([ - "c", - `${DRAFT_SESSION_PREFIX}x`, - ]); + expect(ids(selectBarSessions([...tabs, draft], [], history))).toEqual([]); + expect(ids(selectBarSessions([...tabs, draft], ["c"], history))).toEqual(["c"]); }); it("置顶表里的未知 id(会话已删)直接跳过,不生成空胶囊", () => { diff --git a/packages/desktop/src/renderer/src/stores/ui-preferences.test.ts b/packages/desktop/src/renderer/src/stores/ui-preferences.test.ts index f0e5b6d3..58b567d0 100644 --- a/packages/desktop/src/renderer/src/stores/ui-preferences.test.ts +++ b/packages/desktop/src/renderer/src/stores/ui-preferences.test.ts @@ -9,6 +9,12 @@ vi.mock("../api", () => ({ getPi: () => piMock })); import { useUiPreferencesStore } from "./ui-preferences"; +/** 展开态「用户已操作」位(阶段 1 落进 store):字段落地前用受控读取,避免测试引用未声明成员 */ +function readTouched(): boolean | undefined { + return (useUiPreferencesStore.getState() as unknown as { expandedGroupsTouched?: boolean }) + .expandedGroupsTouched; +} + beforeEach(() => { vi.clearAllMocks(); useUiPreferencesStore.setState({ @@ -85,7 +91,9 @@ describe("useUiPreferencesStore", () => { expect(piMock.saveUiState).toHaveBeenLastCalledWith({ state: { sidebarCollapsed: true } }); store.setExpandedGroups(["__projects__"]); - expect(piMock.saveUiState).toHaveBeenLastCalledWith({ state: { expandedGroups: ["__projects__"] } }); + expect(piMock.saveUiState).toHaveBeenLastCalledWith({ + state: { expandedGroups: ["__projects__"], expandedGroupsTouched: true }, + }); store.toggleProjectPin("/work/alpha"); expect(useUiPreferencesStore.getState().pinnedProjects).toEqual(["/work/alpha"]); @@ -96,6 +104,30 @@ describe("useUiPreferencesStore", () => { }); }); + describe("展开态 touched 位(空数组合法 = 全部折叠)", () => { + it("init 恢复 touched 位:显式 true + 空数组 = 用户已全部折叠", async () => { + piMock.loadUiState.mockResolvedValue({ expandedGroupsTouched: true, expandedGroups: [] }); + await useUiPreferencesStore.getState().init(); + expect(readTouched()).toBe(true); + expect(useUiPreferencesStore.getState().expandedGroups).toEqual([]); + }); + + it("init 缺字段 = false(旧文件按「未操作」处理,继续走默认推断)", async () => { + piMock.loadUiState.mockResolvedValue({ expandedGroups: [] }); + await useUiPreferencesStore.getState().init(); + expect(readTouched()).toBe(false); + }); + + it("setExpandedGroups 同步置 touched=true,并以单个补丁原子落盘两字段", () => { + useUiPreferencesStore.getState().setExpandedGroups([]); + expect(readTouched()).toBe(true); + expect(piMock.saveUiState).toHaveBeenLastCalledWith({ + state: { expandedGroups: [], expandedGroupsTouched: true }, + }); + expect(piMock.saveUiState).toHaveBeenCalledTimes(1); + }); + }); + describe("上次项目目录(lastCwd)", () => { it("默认 null(旧版本 ui-state 无该字段)", async () => { expect(useUiPreferencesStore.getState().lastCwd).toBeNull(); From 0a694c3d516a15b444797483bfa3d07728518af4 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 14:48:48 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(sidebar):=20=E9=98=B6=E6=AE=B5=201=20?= =?UTF-8?q?=E2=80=94=20=E5=B1=95=E5=BC=80=E6=80=81=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=20touched=20=E4=BD=8D=EF=BC=88=E7=A9=BA?= =?UTF-8?q?=E6=95=B0=E7=BB=84=E5=90=88=E6=B3=95=20=3D=20=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared UiState.expandedGroupsTouched(缺字段按记录非空推断,兼容旧文件) - main ui-state normalize:显式布尔优先,脏值/缺失才推断 - ui-preferences:init 读取;setExpandedGroups 一次 set + 单个补丁原子写两字段 - lib/sidebar-groups:derive 判据从「数组空不空」改为 touched;toggleExpandedGroup 以 touched 决定起点 - useExpandedGroups/Sidebar 接线(全部折叠后再点一个组只展开它,不回退默认集) --- packages/desktop/src/main/ui-state.test.ts | 17 ++++----- packages/desktop/src/main/ui-state.ts | 9 ++++- .../src/components/sidebar/Sidebar.tsx | 13 ++++++- .../components/sidebar/useExpandedGroups.ts | 10 +++--- .../renderer/src/lib/sidebar-groups.test.ts | 35 ++++++++++++++----- .../src/renderer/src/lib/sidebar-groups.ts | 21 +++++++---- .../src/stores/ui-preferences.test.ts | 13 +++---- .../src/renderer/src/stores/ui-preferences.ts | 16 ++++++--- packages/shared/src/session.ts | 11 ++++-- 9 files changed, 98 insertions(+), 47 deletions(-) diff --git a/packages/desktop/src/main/ui-state.test.ts b/packages/desktop/src/main/ui-state.test.ts index 3b4b5802..3973401c 100644 --- a/packages/desktop/src/main/ui-state.test.ts +++ b/packages/desktop/src/main/ui-state.test.ts @@ -13,11 +13,6 @@ vi.mock("electron", () => ({ app: { getPath: () => env.dir } })); import { loadUiState, saveUiState } from "./ui-state"; -/** 展开态「用户已操作」位(阶段 1 落进 shared UiState):字段落地前用受控读取,避免测试引用未声明成员 */ -function touched(state: Awaited>): boolean | undefined { - return (state as unknown as { expandedGroupsTouched?: boolean } | null)?.expandedGroupsTouched; -} - let root = ""; const file = () => join(root, "ui-state.json"); @@ -62,27 +57,27 @@ describe("expandedGroupsTouched 迁移与读写(空数组不再兼任「未操 it("缺字段 + 非空记录 → 推断为 true(旧版非空记录仍是「完全以用户选择为准」)", async () => { writeFileSync(file(), JSON.stringify({ expandedGroups: ["/work/alpha"] })); const state = await loadUiState(); - expect(touched(state)).toBe(true); + 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(touched(await loadUiState()), raw).toBe(false); + expect((await loadUiState())?.expandedGroupsTouched, raw).toBe(false); } }); it("显式布尔值优先(含显式 false 配空数组、显式 true 配非空记录)", async () => { writeFileSync(file(), JSON.stringify({ expandedGroups: [], expandedGroupsTouched: true })); - expect(touched(await loadUiState())).toBe(true); + expect((await loadUiState())?.expandedGroupsTouched).toBe(true); writeFileSync(file(), JSON.stringify({ expandedGroups: ["/work/alpha"], expandedGroupsTouched: false })); - expect(touched(await loadUiState())).toBe(false); + expect((await loadUiState())?.expandedGroupsTouched).toBe(false); // 非布尔脏值 → 按缺字段语义推断(这里记录非空 → true) writeFileSync(file(), JSON.stringify({ expandedGroups: ["/work/alpha"], expandedGroupsTouched: "yes" })); - expect(touched(await loadUiState())).toBe(true); + expect((await loadUiState())?.expandedGroupsTouched).toBe(true); }); it("保存补丁:显式空数组 + touched=true 能原样写盘读回(全部折叠可持久化)", async () => { @@ -90,7 +85,7 @@ describe("expandedGroupsTouched 迁移与读写(空数组不再兼任「未操 await saveUiState(patch); await saveUiState({ pinnedSessions: ["s1"] }); // 后续补丁不能把 touched 洗掉 const state = await loadUiState(); - expect(touched(state)).toBe(true); + expect(state?.expandedGroupsTouched).toBe(true); expect(state?.expandedGroups).toEqual([]); expect(state?.pinnedSessions).toEqual(["s1"]); }); diff --git a/packages/desktop/src/main/ui-state.ts b/packages/desktop/src/main/ui-state.ts index 317e6d77..7f7f11e7 100644 --- a/packages/desktop/src/main/ui-state.ts +++ b/packages/desktop/src/main/ui-state.ts @@ -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", @@ -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, diff --git a/packages/desktop/src/renderer/src/components/sidebar/Sidebar.tsx b/packages/desktop/src/renderer/src/components/sidebar/Sidebar.tsx index 4ad8e29d..8c033349 100644 --- a/packages/desktop/src/renderer/src/components/sidebar/Sidebar.tsx +++ b/packages/desktop/src/renderer/src/components/sidebar/Sidebar.tsx @@ -19,6 +19,7 @@ export function Sidebar() { const t = useT(); const collapsed = useUiPreferencesStore((s) => s.sidebarCollapsed); const expandedGroups = useUiPreferencesStore((s) => s.expandedGroups); + const expandedGroupsTouched = useUiPreferencesStore((s) => s.expandedGroupsTouched); const pinnedProjects = useUiPreferencesStore((s) => s.pinnedProjects); const pinnedSessions = useUiPreferencesStore((s) => s.pinnedSessions); const toggleProjectPin = useUiPreferencesStore((s) => s.toggleProjectPin); @@ -40,8 +41,18 @@ export function Sidebar() { pinnedSessions, pinnedProjects, expandedGroups, + expandedGroupsTouched, }), - [allSessions, addedProjects, search, activeSessionId, pinnedSessions, pinnedProjects, expandedGroups], + [ + allSessions, + addedProjects, + search, + activeSessionId, + pinnedSessions, + pinnedProjects, + expandedGroups, + expandedGroupsTouched, + ], ); // 首次开合的起点由派生层给(当前会话所在组 + 项目小标),组件不自己再算一遍 diff --git a/packages/desktop/src/renderer/src/components/sidebar/useExpandedGroups.ts b/packages/desktop/src/renderer/src/components/sidebar/useExpandedGroups.ts index d5e4fcf0..93dc2f8d 100644 --- a/packages/desktop/src/renderer/src/components/sidebar/useExpandedGroups.ts +++ b/packages/desktop/src/renderer/src/components/sidebar/useExpandedGroups.ts @@ -3,17 +3,17 @@ import { toggleExpandedGroup } from "../../lib/sidebar-groups"; import { useUiPreferencesStore } from "../../stores/ui-preferences"; /** - * 左侧栏展开态的读写(日常 / 各项目 / 「项目」小标 三处共用)。 - * 默认推断在纯函数层(`deriveSidebarGroups().defaultExpandedKeys`):`expandedGroups` 为空 = 用户还没手动开合过, - * 首次开合以默认集为起点(`toggleExpandedGroup`),之后完全以用户记录为准;每次变更都落盘,重启保持。 + * 左侧栏展开态的读写(日常 / 各项目两处共用)。 + * 默认推断在纯函数层(`deriveSidebarGroups().defaultExpandedKeys`):`expandedGroupsTouched === false` + * (用户从没手动开合过)时首次开合以默认集为起点;之后完全以用户记录为准(空数组 = 全部折叠)。 * 取值用 `getState()` 而不是闭包里的快照:同一 tick 内连点两个组时,闭包快照会让后一次覆盖前一次。 */ export function useExpandedGroups() { const setExpandedGroups = useUiPreferencesStore((s) => s.setExpandedGroups); const toggleGroup = useCallback( (key: string, defaults: readonly string[]) => { - const current = useUiPreferencesStore.getState().expandedGroups; - setExpandedGroups(toggleExpandedGroup(current, key, defaults)); + const { expandedGroups, expandedGroupsTouched } = useUiPreferencesStore.getState(); + setExpandedGroups(toggleExpandedGroup(expandedGroups, key, defaults, expandedGroupsTouched)); }, [setExpandedGroups], ); diff --git a/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts b/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts index b81f53b4..a47ea698 100644 --- a/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts +++ b/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts @@ -34,11 +34,10 @@ function derive(overrides: Partial = {}) { } /** - * 展开态「用户已操作」位(阶段 1 落进 `SidebarGroupsInput`):这里先经**变量**传入(对象字面量会被 - * 多余属性检查拦住),字段落地后即为正式入参。所有用例显式给值,不依赖实现里的缺省推断。 + * 展开态「用户已操作」位:所有用例**显式给值**,不依赖实现里的缺省推断。 */ function deriveWithTouched(overrides: Partial, expandedGroupsTouched: boolean) { - const input = { + return deriveSidebarGroups({ sessions: [], projects: [], search: "", @@ -48,8 +47,7 @@ function deriveWithTouched(overrides: Partial, expandedGroup expandedGroups: [], ...overrides, expandedGroupsTouched, - }; - return deriveSidebarGroups(input); + }); } describe("deriveSidebarGroups · 组内排序", () => { @@ -174,6 +172,22 @@ describe("deriveSidebarGroups · 展开状态 touched 位", () => { const second = deriveWithTouched({ ...base, expandedGroups: next }, true); expect(second.projects.map((p) => [p.cwd, p.expanded])).toEqual([[P1, false]]); }); + + it("touched=true 时切换当前会话:不自动展开任何组(新当前组也保持折叠)", () => { + const result = deriveWithTouched( + { + sessions: [session("a", P1, 100), session("b", P2, 200)], + projects: [project(P1), project(P2)], + activeSessionId: "b", + expandedGroups: [], + }, + true, + ); + expect(result.projects.map((p) => [p.cwd, p.expanded])).toEqual([ + [P1, false], + [P2, false], + ]); + }); }); describe("deriveSidebarGroups · 搜索过滤", () => { @@ -227,8 +241,13 @@ describe("toggleInList / toggleExpandedGroup", () => { }); it("展开切换:无记录时以默认集为起点翻转,不误伤其它组", () => { - expect(toggleExpandedGroup([], P2, [P1])).toEqual([P1, P2]); - expect(toggleExpandedGroup([P1], P1, [])).toEqual([]); - expect(toggleExpandedGroup([P1], P2, [P1])).toEqual([P1, P2]); + expect(toggleExpandedGroup([], P2, [P1], false)).toEqual([P1, P2]); + expect(toggleExpandedGroup([P1], P1, [], true)).toEqual([]); + expect(toggleExpandedGroup([P1], P2, [P1], true)).toEqual([P1, P2]); + }); + + it("已操作过(touched=true)且记录为空(全部折叠):再点一个组只展开它,不能回退默认集", () => { + // 这是「折了最后一个组后又点开一个组」的场景:起点必须是空记录,否则会把当前会话所在组一起拉出来 + expect(toggleExpandedGroup([], P2, [P1], true)).toEqual([P2]); }); }); diff --git a/packages/desktop/src/renderer/src/lib/sidebar-groups.ts b/packages/desktop/src/renderer/src/lib/sidebar-groups.ts index f91c0177..8d3e35d4 100644 --- a/packages/desktop/src/renderer/src/lib/sidebar-groups.ts +++ b/packages/desktop/src/renderer/src/lib/sidebar-groups.ts @@ -54,7 +54,10 @@ export type SidebarGroupsInput = { activeSessionId: string | null; pinnedSessions: readonly string[]; pinnedProjects: readonly string[]; + /** 已展开的分组 key(**含义由 `expandedGroupsTouched` 决定**) */ expandedGroups: readonly string[]; + /** false = 用户还没手动开合过(走默认推断,`expandedGroups` 不参与);true = 完全以 `expandedGroups` 为准(空数组 = 全部折叠) */ + expandedGroupsTouched: boolean; }; function matchesSearch(session: SessionMeta, query: string): boolean { @@ -79,8 +82,9 @@ function groupSessions(list: readonly SessionMeta[], pinned: ReadonlySet * 「其余按 lastActive 倒序」,与同段「直接用 deriveProjects 的输出」互斥,取后者)。 * 组内 = 置顶会话在前,其余按最后活动倒序。 * - * 展开推断:`expandedGroups` 为空(用户没手动开合过)→ 只展开当前会话所在组; - * 非空 → 完全以记录为准(用户把当前组折叠了也尊重)。 + * 展开推断:`expandedGroupsTouched === false`(用户从没手动开合过)→ 只展开当前会话所在组; + * `true` → 完全以 `expandedGroups` 为准(把当前组折了也尊重,**空数组 = 全部折叠**)。 + * 旧版拿「空数组」兼任两种含义,导致最后一个展开组折不掉(见 spec D4)。 * * 搜索:命中为空的分组整体隐藏(含日常组),避免满屏空组。 */ @@ -101,7 +105,8 @@ export function deriveSidebarGroups(input: SidebarGroupsInput): SidebarGroupsRes ? (input.sessions.find((session) => session.sessionId === input.activeSessionId)?.cwd ?? null) : null; const defaultExpandedKeys = activeCwd ? [activeCwd] : []; - const expandedKeys = input.expandedGroups.length > 0 ? input.expandedGroups : defaultExpandedKeys; + // 展开态的唯一判据是 touched 位,不是「数组空不空」:空数组合法表示「用户把最后一组也折了」 + const expandedKeys = input.expandedGroupsTouched ? input.expandedGroups : defaultExpandedKeys; const isExpanded = (key: string) => expandedKeys.includes(key); const dailyCwd = getDailyDirCached(); @@ -153,14 +158,18 @@ export function toggleInList(list: readonly string[], id: string): string[] { } /** - * 展开 / 折叠一个分组(纯逻辑):`current` 为空 = 用户还没手动开合过, - * 以 `defaults`(派生层给的默认展开集)为起点再翻转 —— 否则首次点开某个组会把当前组一起折掉。 + * 展开 / 折叠一个分组(纯逻辑):以 `touched` 判据——用户还没操作过就用 `defaults` + * (派生层给的默认展开集)当起点再翻转,否则在用户的现有记录上翻转。 + * 后者是关键:touched=true 且记录为空(全部折叠)时,点一个组应当**只展开它**, + * 不能再回退默认集(否则会把当前会话所在组一起拉出来)。 + * `touched` 缺省按 `current.length > 0` 推断,兼容旧调用(旧版非空记录即「已操作」)。 */ export function toggleExpandedGroup( current: readonly string[], key: string, defaults: readonly string[], + touched: boolean = current.length > 0, ): string[] { - const base = current.length > 0 ? [...current] : [...defaults]; + const base = touched ? [...current] : [...defaults]; return base.includes(key) ? base.filter((item) => item !== key) : [...base, key]; } diff --git a/packages/desktop/src/renderer/src/stores/ui-preferences.test.ts b/packages/desktop/src/renderer/src/stores/ui-preferences.test.ts index 58b567d0..1b28b738 100644 --- a/packages/desktop/src/renderer/src/stores/ui-preferences.test.ts +++ b/packages/desktop/src/renderer/src/stores/ui-preferences.test.ts @@ -9,12 +9,6 @@ vi.mock("../api", () => ({ getPi: () => piMock })); import { useUiPreferencesStore } from "./ui-preferences"; -/** 展开态「用户已操作」位(阶段 1 落进 store):字段落地前用受控读取,避免测试引用未声明成员 */ -function readTouched(): boolean | undefined { - return (useUiPreferencesStore.getState() as unknown as { expandedGroupsTouched?: boolean }) - .expandedGroupsTouched; -} - beforeEach(() => { vi.clearAllMocks(); useUiPreferencesStore.setState({ @@ -23,6 +17,7 @@ beforeEach(() => { barSessionsVisible: true, sidebarCollapsed: false, expandedGroups: [], + expandedGroupsTouched: false, pinnedProjects: [], lastCwd: null, }); @@ -108,19 +103,19 @@ describe("useUiPreferencesStore", () => { it("init 恢复 touched 位:显式 true + 空数组 = 用户已全部折叠", async () => { piMock.loadUiState.mockResolvedValue({ expandedGroupsTouched: true, expandedGroups: [] }); await useUiPreferencesStore.getState().init(); - expect(readTouched()).toBe(true); + expect(useUiPreferencesStore.getState().expandedGroupsTouched).toBe(true); expect(useUiPreferencesStore.getState().expandedGroups).toEqual([]); }); it("init 缺字段 = false(旧文件按「未操作」处理,继续走默认推断)", async () => { piMock.loadUiState.mockResolvedValue({ expandedGroups: [] }); await useUiPreferencesStore.getState().init(); - expect(readTouched()).toBe(false); + expect(useUiPreferencesStore.getState().expandedGroupsTouched).toBe(false); }); it("setExpandedGroups 同步置 touched=true,并以单个补丁原子落盘两字段", () => { useUiPreferencesStore.getState().setExpandedGroups([]); - expect(readTouched()).toBe(true); + expect(useUiPreferencesStore.getState().expandedGroupsTouched).toBe(true); expect(piMock.saveUiState).toHaveBeenLastCalledWith({ state: { expandedGroups: [], expandedGroupsTouched: true }, }); diff --git a/packages/desktop/src/renderer/src/stores/ui-preferences.ts b/packages/desktop/src/renderer/src/stores/ui-preferences.ts index 60ed2cec..a785ce4f 100644 --- a/packages/desktop/src/renderer/src/stores/ui-preferences.ts +++ b/packages/desktop/src/renderer/src/stores/ui-preferences.ts @@ -16,8 +16,10 @@ interface UiPreferencesStore { barSessionsVisible: boolean; /** 左侧栏收起(宽 0,彻底藏起;只有顶栏最左按钮能改,默认展开) */ sidebarCollapsed: boolean; - /** 左侧栏已展开的分组 key;空数组 = 用户没手动开合过(走 Sidebar 的默认推断,见 lib/sidebar-groups) */ + /** 左侧栏已展开的分组 key;含义由 `expandedGroupsTouched` 决定(见 shared UiState,空数组不再兼任「未操作」) */ expandedGroups: string[]; + /** 展开态是否已被用户手动开合过(false = 走 Sidebar 的默认推断,true = 空数组合法表示全部折叠) */ + expandedGroupsTouched: boolean; /** 置顶项目 cwd(新置顶在前,决定左侧栏项目区排序) */ pinnedProjects: string[]; /** 上次使用的项目目录(重启后启动页预填;只记目录、不恢复会话);null = 未记过 */ @@ -30,7 +32,8 @@ interface UiPreferencesStore { setBarSessionsVisible: (visible: boolean) => void; /** 收起 / 展开左侧栏(宽 240 ↔ 0) */ toggleSidebarCollapsed: () => void; - /** 覆盖左侧栏展开分组(开合一个组的起点由 useExpandedGroups 算好,见 lib/sidebar-groups) */ + /** 左侧栏开合一个分组(由 useExpandedGroups 算好新的展开集):**同时置 touched 位**, + * 空数组合法表示「用户把最后一组也折了」(旧版空数组只能表示「没操作过」) */ setExpandedGroups: (groups: string[]) => void; /** 置顶 / 取消置顶项目(新置顶排最前) */ toggleProjectPin: (cwd: string) => void; @@ -62,7 +65,10 @@ export const useUiPreferencesStore = create((set, get) => ({ sessionPermissionModes: {}, barSessionsVisible: true, sidebarCollapsed: false, + /** 左侧栏展开分组的记录(`setExpandedGroups` 同时置 touched 位) */ expandedGroups: [], + expandedGroupsTouched: false, + /** 置顶项目 cwd(新置顶在前,决定左侧栏项目区排序) */ pinnedProjects: [], lastCwd: null, @@ -77,6 +83,7 @@ export const useUiPreferencesStore = create((set, get) => ({ barSessionsVisible: saved?.barSessionsVisible ?? true, sidebarCollapsed: saved?.sidebarCollapsed ?? false, expandedGroups: saved?.expandedGroups ?? [], + expandedGroupsTouched: saved?.expandedGroupsTouched ?? false, pinnedProjects: saved?.pinnedProjects ?? [], lastCwd: saved?.lastCwd ?? null, }); @@ -105,8 +112,9 @@ export const useUiPreferencesStore = create((set, get) => ({ }, setExpandedGroups: (groups) => { - set({ expandedGroups: groups }); - persistPatch({ expandedGroups: groups }); + // 一次 set + 一个补丁同时写两个字段:分开写会出现「记录已存但 touched 没存」的中间态(重启后语义反转) + set({ expandedGroups: groups, expandedGroupsTouched: true }); + persistPatch({ expandedGroups: groups, expandedGroupsTouched: true }); }, togglePin: (sessionId) => { diff --git a/packages/shared/src/session.ts b/packages/shared/src/session.ts index 731297e8..65ebbd81 100644 --- a/packages/shared/src/session.ts +++ b/packages/shared/src/session.ts @@ -37,10 +37,17 @@ export interface UiState { /** 左侧栏收起(宽 0,彻底藏起;只有顶栏最左按钮能改);旧版本文件缺省为 false */ sidebarCollapsed: boolean; /** - * 左侧栏已展开的分组 key(日常 cwd + 各项目 cwd + 项目小标专用 key)= 用户手动开合过的记录。 - * **空数组 = 无用户记录** → 走默认推断(只展开当前会话所在组);一旦有记录就完全以它为准。 + * 左侧栏已展开的分组 key(日常 cwd + 各项目 cwd)= 用户手动开合过的记录。 + * 空数组的含义**只由 `expandedGroupsTouched` 决定**(空 = 全部折叠 / 未操作)。 */ expandedGroups: string[]; + /** + * 展开态是否已被用户手动开合过: + * `false` = 没操作过 → 走默认推断(只展开当前会话所在组),`expandedGroups` 此时只是空值; + * `true` = 完全以 `expandedGroups` 为准,**空数组合法表示「全部折叠」**。 + * 旧文件缺该字段时按 `expandedGroups` 是否非空推断(旧版非空记录即「以用户选择为准」)。 + */ + expandedGroupsTouched: boolean; /** 置顶项目 cwd(新置顶在前,决定左侧栏项目区排序) */ pinnedProjects: string[]; /** From 1bcc58f57605e7999f523b71fd8606e87a162df2 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 14:50:18 +0800 Subject: [PATCH 3/7] =?UTF-8?q?style(test):=20=E9=98=B6=E6=AE=B5=201.1=20?= =?UTF-8?q?=E2=80=94=20=E4=BF=AE=20ui-state.test.ts=20=E6=95=B0=E7=BB=84?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=EF=BC=88Biome=20=E5=A4=9A=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/desktop/src/main/ui-state.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/main/ui-state.test.ts b/packages/desktop/src/main/ui-state.test.ts index 3973401c..d777022f 100644 --- a/packages/desktop/src/main/ui-state.test.ts +++ b/packages/desktop/src/main/ui-state.test.ts @@ -62,7 +62,12 @@ describe("expandedGroupsTouched 迁移与读写(空数组不再兼任「未操 }); it("缺字段 + 空/非法记录 → false(旧版无法区分,只能继续按未操作处理)", async () => { - for (const raw of ['{"expandedGroups":[]}', "{}", '{"expandedGroups":"x"}', '{"expandedGroups":[1,""]}']) { + for (const raw of [ + '{"expandedGroups":[]}', + "{}", + '{"expandedGroups":"x"}', + '{"expandedGroups":[1,""]}', + ]) { writeFileSync(file(), raw); expect((await loadUiState())?.expandedGroupsTouched, raw).toBe(false); } From 2ec7eb415bfc8201398baac35acc0940681f357c Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 15:00:54 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(sidebar):=20=E9=98=B6=E6=AE=B5=202=20?= =?UTF-8?q?=E2=80=94=20=E9=A1=B6=E6=A0=8F=20pinned-only=EF=BC=8Cdraft=20?= =?UTF-8?q?=E8=BF=9B=E5=B7=A6=E6=A0=8F=E5=B9=B6=E4=BB=A5=E3=80=8C=E6=96=B0?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E3=80=8D=E5=91=88=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectBarSessions 去掉 draft 例外:顶栏严格 = 置顶表(SessionTabBar 删掉不可达的 draft 分支) - 新增纯函数 mergeSidebarSessions(历史 + 内存,按 id 去重、内存覆盖),Sidebar 用它同时驱动项目表与会话分组 - SessionRow:draft 行标题固定「新会话」 - session-menu:sidebarMenuKind 判定 + draftSessionMenuItems(仅「丢弃新会话」一项,走纯本地 closeSession) - i18n 双字典:+sidebar.newSession / +sessionMenu.discardDraft,删顶栏已无用的 tabbar.discardDraft --- .../src/components/session/SessionTabBar.tsx | 22 ++++------- .../components/session/session-menu.test.ts | 31 +++++++++++++++- .../src/components/session/session-menu.tsx | 37 ++++++++++++++++++- .../src/components/sidebar/SessionMenu.tsx | 30 ++++++++++----- .../src/components/sidebar/SessionRow.tsx | 11 ++++-- .../src/components/sidebar/Sidebar.tsx | 17 ++++++--- packages/desktop/src/renderer/src/i18n/en.ts | 3 +- packages/desktop/src/renderer/src/i18n/zh.ts | 7 ++-- .../renderer/src/lib/sidebar-groups.test.ts | 34 +++++++++++++++++ .../src/renderer/src/lib/sidebar-groups.ts | 21 +++++++++++ .../src/renderer/src/stores/sessions.ts | 11 ++---- 11 files changed, 175 insertions(+), 49 deletions(-) diff --git a/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx b/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx index 656ff0d1..849ea33d 100644 --- a/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx +++ b/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx @@ -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"; @@ -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)); @@ -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); }} > @@ -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, @@ -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), }} /> diff --git a/packages/desktop/src/renderer/src/components/session/session-menu.test.ts b/packages/desktop/src/renderer/src/components/session/session-menu.test.ts index c72c1da1..4c6292d6 100644 --- a/packages/desktop/src/renderer/src/components/session/session-menu.test.ts +++ b/packages/desktop/src/renderer/src/components/session/session-menu.test.ts @@ -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 { return { sessionId: "s1", cwd: "/proj/demo", active: true, messageCount: 0, createdAt: 1, ...overrides }; @@ -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"); + }); +}); diff --git a/packages/desktop/src/renderer/src/components/session/session-menu.tsx b/packages/desktop/src/renderer/src/components/session/session-menu.tsx index 8d0f4b73..f59e80d9 100644 --- a/packages/desktop/src/renderer/src/components/session/session-menu.tsx +++ b/packages/desktop/src/renderer/src/components/session/session-menu.tsx @@ -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: , + danger: true, + onSelect: options.onDiscard, + }, + ]; +} + +/** 丢弃 draft:纯本地移除(draft 没有任何后端对象),不弹确认——与旧顶栏胶囊叉号同语义 */ +export function discardDraft(sessionId: string): void { + void useSessionsStore.getState().closeSession(sessionId); +} + /** * 置顶 / 取消置顶(v8):只动 `pinnedSessions`(新置顶自动排最左)——顶栏胶囊内容 = 置顶表, * 所以**不再**顺带重排 tabs(旧模型靠把会话挪到 tabs 最前才会进顶栏置顶区)。 diff --git a/packages/desktop/src/renderer/src/components/sidebar/SessionMenu.tsx b/packages/desktop/src/renderer/src/components/sidebar/SessionMenu.tsx index 5fdcf0f8..222b9a04 100644 --- a/packages/desktop/src/renderer/src/components/sidebar/SessionMenu.tsx +++ b/packages/desktop/src/renderer/src/components/sidebar/SessionMenu.tsx @@ -5,10 +5,12 @@ import { useProjectsStore } from "../../stores/projects"; import { useUiPreferencesStore } from "../../stores/ui-preferences"; import { RenamePopover } from "../session/RenamePopover"; import { - canOpenSessionMenu, copySessionDiagnostics, + discardDraft, + draftSessionMenuItems, renameSession, sessionMenuItems, + sidebarMenuKind, } from "../session/session-menu"; import { ConfirmDialog } from "../ui/ConfirmDialog"; import { ContextMenu } from "../ui/ContextMenu"; @@ -16,7 +18,8 @@ import type { MenuAnchor } from "../ui/place-menu"; /** * 左侧栏会话行的右键菜单状态机(菜单 → 改名气泡 → 删除确认三层,都在这里,行组件只管文案与状态点)。 - * 「能不能弹」与「有哪些项」跟顶栏胶囊共用 `session-menu.tsx`,两处行为不会漂移。 + * 「能不能弹」与「有哪些项」跟顶栏胶囊共用 `session-menu.tsx`,两处行为不会漂移; + * 例外是 draft:它走左栏专属的 `draftSessionMenuItems`(只有「丢弃新会话」一项)。 * 返回的 `element` 由分组挂到自己的子树里(portal 到 body,放哪都不影响定位)。 */ export function useSessionMenu() { @@ -28,23 +31,30 @@ export function useSessionMenu() { const [deleting, setDeleting] = useState(null); const open = (session: SessionMeta, anchor: MenuAnchor) => { - if (!canOpenSessionMenu(session)) return; // draft / 只读子会话:不给菜单,免得每个动作都失败 + // draft / 只读子会话的判定在纯函数里(sidebarMenuKind,有单测);none 就不弹菜单 + if (sidebarMenuKind(session) === "none") return; setMenu({ session, anchor }); }; + const kind = menu ? sidebarMenuKind(menu.session) : "none"; + const element = ( <> {menu && ( setMenu(null)} - items={sessionMenuItems(t, { - sessionId: menu.session.sessionId, - pinned: pinnedSessions.includes(menu.session.sessionId), - onRename: () => setRenaming({ session: menu.session, anchor: menu.anchor }), - onCopyDiagnostics: () => void copySessionDiagnostics(menu.session), - onDelete: () => setDeleting(menu.session), - })} + items={ + kind === "draft" + ? draftSessionMenuItems(t, { onDiscard: () => discardDraft(menu.session.sessionId) }) + : sessionMenuItems(t, { + sessionId: menu.session.sessionId, + pinned: pinnedSessions.includes(menu.session.sessionId), + onRename: () => setRenaming({ session: menu.session, anchor: menu.anchor }), + onCopyDiagnostics: () => void copySessionDiagnostics(menu.session), + onDelete: () => setDeleting(menu.session), + }) + } /> )} {renaming && ( diff --git a/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx b/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx index 53042baa..ae249b97 100644 --- a/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx +++ b/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx @@ -1,6 +1,7 @@ import type { SessionMeta } from "@percho/shared"; import type { MouseEvent as ReactMouseEvent } from "react"; import { useT } from "../../i18n"; +import { isDraftSessionId } from "../../stores/sessions"; import { PinIcon } from "../icons"; import { sessionTitle, useSessionStatus } from "../session/session-status"; import type { MenuAnchor } from "../ui/place-menu"; @@ -35,10 +36,14 @@ export function SessionRow({ }) { const t = useT(); const status = useSessionStatus(session.sessionId); + // draft 还没落盘、也没有名字:固定显示「新会话」(后来真的有名字了才回落到 sessionTitle) + const title = isDraftSessionId(session.sessionId) + ? t("sidebar.newSession") + : sessionTitle(session, t("projects.untitled"), t("projects.daily")); return ( + {/* 右对齐(不是 left-0):模型按钮就在 composer 右侧,288px 弹层向左展开才能留在视口内。 + 旧版 left-0 会让面板右缘越出视口(1100px 窗口就已越界 ~12px,窄窗口 35px+), + 配合搜索框 autoFocus 触发 Chromium 的程序性横向滚动(overflow:hidden 拦不住), + 整页左移,表现成左栏与顶栏左侧按钮被挤压/裁切(本轮实测顶栏按钮 left 80 → 72/44.5)。 */} {open && ( -
+
Date: Sun, 20 Sep 2026 15:16:28 +0800 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20=E9=98=B6=E6=AE=B5=204=20=E2=80=94?= =?UTF-8?q?=20INDEX=20=E5=90=8C=E6=AD=A5=EF=BC=88=E9=A1=B6=E6=A0=8F?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E7=BD=AE=E9=A1=B6=20/=20=E5=B7=A6=E6=A0=8F?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E8=A7=86=E5=9B=BE=20/=20touched=20=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=20/=20ModelPicker=20=E5=8F=B3=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=EF=BC=89=EF=BC=8CPITFALLS=20=E8=A1=A5=E4=B8=89=E6=9D=A1?= =?UTF-8?q?=E6=96=B0=E5=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - INDEX:sidebar-groups(mergeSidebarSessions + touched 起点)、selectBarSessions 顶栏范围、 ui-preferences expandedGroupsTouched、session/ 与 sidebar/ 两行同步、模型弹层定位职责、 修正过期字段名 topBarVisible → barSessionsVisible - PITFALLS:空数组不能同时当「未初始化」与「有效空值」;absolute 弹层越界 + autoFocus 触发 #root 程序性横滚(overflow:hidden 拦不住,含量测三纪律:selector 限定作用域 / 扫多档窗口宽 / 侧栏行用行高而非标题分辨);renderer 单测跑 node 环境测不了 i18n 与 JSX - ModelPicker 注释量值表述修正(右越界约 12px;35.5px 是窄窗口的根横滚量) --- docs/INDEX.md | 16 ++++----- docs/PITFALLS.md | 36 +++++++++++++++++++ .../src/components/composer/ModelPicker.tsx | 6 ++-- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index f41c2fae..1d45acad 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -175,16 +175,16 @@ src/ | `api.ts` | `getPi()`:window.pi 类型化访问 | | `lib/thinking.ts` | `THINKING_LEVELS`/`ThinkingLevel` re-export(单一事实源在 shared `src/thinking.ts`)+ `clampThinkingLevel` 就近向上收敛(仅 renderer UI 用;backend 不 import,交 SDK clamp) | | `lib/daily.ts` | 日常空间目录 renderer 缓存:`initDailyDir`(App 启动调一次,幂等)+ `isDailyCwd` 同步判定 + `setDailyDirForTest`;各处共享,不散落路径字符串 | -| `lib/sidebar-groups.ts` | 左栏纯派生层(有单测):`deriveSidebarGroups`(历史会话 + 项目表 → 日常/项目分组、置顶排序、搜索过滤、展开态推断)+ `toggleInList`(置顶切换)+ `toggleExpandedGroup`(首次开合以派生层默认集为起点)。「项目」小标 v7 起不可折叠,不再有 `PROJECTS_GROUP_KEY` 这类专用 key | +| `lib/sidebar-groups.ts` | 左栏纯派生层(有单测):`mergeSidebarSessions`(**历史 + 内存会话合并**:按 `sessionId` 去重、内存覆盖历史、历史保序;draft 与刚创建还没落盘的会话由此进左栏)+ `deriveSidebarGroups`(合并结果 + 项目表 → 日常/项目分组、置顶排序、搜索过滤、展开态推断)+ `toggleInList`(置顶切换)+ `toggleExpandedGroup`(第 4 参 `touched` 定起点:没操作过用派生层默认集,已操作过用用户记录——**空数组合法表示全部折叠**)。「项目」小标 v7 起不可折叠,不再有 `PROJECTS_GROUP_KEY` 这类专用 key | | `lib/session-gc.ts` | 会话内存驻留策略(**纯函数,有单测**):`pickUnloadCandidates`(保护/草稿/活跃/刚用过 → 剔除;剩余按最久未用排序,保留 K 个,超的与晾过 `idleTimeoutMs` 的一并作候选)+ `isProtected`(保护条件唯一事实源:draft / 0 消息 / agentActive / 待审批 / 待应答 / 未读绿点 / 压缩中 / 排队跟发;**权限模式曾在清单里,D7 改成按会话持久化+打开时恢复后已移除**)+ `GC_DEFAULTS`(K=3、fresh **3s**、idle 5min)。不 import React / 不调 IPC | | `lib/use-session-gc.ts` | 内存策略接线层:挂载跑一次 + **20s** 兜底 tick(实测:仅靠它时收敛延迟 ≤ 一个 tick) + 只订阅 `sessions`/`activeSessionId`(**不订阅 `transcript.bySession`**,它每 token 都变)+ 卸载前逐个再确认(活跃/已关就跳过)+ dev 打 `[session-gc] unload reason=...`;K=3 常量在这里 | | `lib/diagnostics.ts` | `buildDiagnosticsText`(会话诊断纯文本,供「复制诊断信息」用;从 `components/projects/` 移入) | -| `stores/sessions.ts` | 会话列表/当前会话/cwd/模型/权限模式(`permissionModes` map,缺 key = default;draft 态纯 renderer,转正 ensureSession 应用到后端)。draft 会话(`draft:` 前缀,空 tab 重启消失;发首条消息 `ensureSession` 用 draft cwd 原地转正);信任前置 ensureProjectTrust(trustVersion 触发重拉);打开(含从历史按需打开)/fork/撤回统一 `loadSessionBundle` 四件套(history→queue→todos→permissionMode 对齐后端);**`selectBarSessions`(v8)算顶栏展示集 = 置顶表(meta 从 tabs → `allSessions` 兜底)+ 未命名 draft**;**v10 起不持久化打开列表**——启动纯空(新会话页),会话只在你点开时才加载,退出即清(与 pi 原生 `AgentSessionRuntime` 的"单会话按需替换"对齐);切会话/打开/新建三处调 `rememberCwd` 记住**上次项目目录**(`lastCwd`,只记目录不恢复会话);**`loadSessionBundle` 末尾按 `sessionPermissionModes` 恢复档位(await IPC,保证「打开完即正确档位」)**;**会话内存驻留策略**:`lastUsedAt` LRU 打点 + `unloadSession`(自动卸载专用入口,受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次) | +| `stores/sessions.ts` | 会话列表/当前会话/cwd/模型/权限模式(`permissionModes` map,缺 key = default;draft 态纯 renderer,转正 ensureSession 应用到后端)。draft 会话(`draft:` 前缀,空 tab 重启消失;发首条消息 `ensureSession` 用 draft cwd 原地转正);信任前置 ensureProjectTrust(trustVersion 触发重拉);打开(含从历史按需打开)/fork/撤回统一 `loadSessionBundle` 四件套(history→queue→todos→permissionMode 对齐后端);**`selectBarSessions`(v8)算顶栏展示集 = **严格置顶表**(meta 从 tabs → `allSessions` 兜底;draft 不再进顶栏,导航与丢弃都在左栏)**;**v10 起不持久化打开列表**——启动纯空(新会话页),会话只在你点开时才加载,退出即清(与 pi 原生 `AgentSessionRuntime` 的"单会话按需替换"对齐);切会话/打开/新建三处调 `rememberCwd` 记住**上次项目目录**(`lastCwd`,只记目录不恢复会话);**`loadSessionBundle` 末尾按 `sessionPermissionModes` 恢复档位(await IPC,保证「打开完即正确档位」)**;**会话内存驻留策略**:`lastUsedAt` LRU 打点 + `unloadSession`(自动卸载专用入口,受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次) | | `stores/transcript.ts` | re-export shared reducer + per-session 字段(agentActive/unseenCompletion/todos);reducer 细节见 shared `src/transcript/` | | `stores/event-conflator.ts` | 流式事件合流:纯追加型 delta 按会话/类型拼接 + rAF 每帧最多一次 flush,其余事件边界透传保序(可注入调度器,有测试) | | `stores/drafts.ts` | 草稿(文本/图片/slash 胶囊/@ 引用 attachments/选中引用 quotes)按会话持久 + `COMPOSER_FOCUS_EVENT`(撤回回填后聚焦输入框) | | `stores/settings.ts` / `catalog.ts` / `provider-login.ts` | 设置域(providers + 上下文管理/channel-watch 开关,乐观更新回滚;permissionGateOff = 手改 permissions.json 卸载门控的逃生舱态只读感知)/ 社区包目录(300ms 防抖 + seq 防陈旧)/ OAuth 登录状态机(**取消时机 = LoginDialog 卸载 cleanup**;先订阅事件再 invoke) | -| `stores/projects.ts` / `theme.ts` / `ui.ts` / `ui-preferences.ts` / `update.ts` / `ui-plugins.ts` / `toasts.ts` | 会话历史与项目表(`deriveProjects`:手动添加的按时间倒排;`deriveSessions`:置顶分区在左 + 其余按最后活动倒序;`deleteSession` / `deleteProject` / `openSession` / `applySessionName`)/ 主题与背景(init 在 render 前 await 防闪烁)/ todo 面板展开 + diff 侧栏开关(内存态)/ **左栏四项偏好 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` / `pinnedProjects` + 中央动画开关 + 置顶会话 `pinnedSessions`(v8 起它同时就是顶栏胶囊内容与顺序,拖动排序走 `reorderPinned`)+ **`sessionPermissionModes`(D7:按会话记住的权限模式,只存非 default;`rememberPermissionMode` / `forgetPermissionMode`)** + 上次项目目录 `lastCwd`(App 启动预填 cwd,只带目录不恢复会话)**(均持久化 ui-state;删会话时 unpin 清理)/ 更新态 / UI 插件面板 / 全局 Toast(顶栏右侧,非阻塞自动消失;扩展 notify 走 pushExtension:同源同文 8s 去重 + 可见栈 3 溢出折叠) | +| `stores/projects.ts` / `theme.ts` / `ui.ts` / `ui-preferences.ts` / `update.ts` / `ui-plugins.ts` / `toasts.ts` | 会话历史与项目表(`deriveProjects`:手动添加的按时间倒排;`deriveSessions`:置顶分区在左 + 其余按最后活动倒序;`deleteSession` / `deleteProject` / `openSession` / `applySessionName`)/ 主题与背景(init 在 render 前 await 防闪烁)/ todo 面板展开 + diff 侧栏开关(内存态)/ **左栏偏好 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` + `expandedGroupsTouched`(成对语义:false = 从没手动开合过 → 走默认推断,true = 完全以 `expandedGroups` 为准,**空数组 = 全部折叠**;旧文件缺字段时按记录是否非空推断)/ `pinnedProjects` + 中央动画开关 + 置顶会话 `pinnedSessions`(v8 起它同时就是顶栏胶囊内容与顺序,拖动排序走 `reorderPinned`)+ **`sessionPermissionModes`(D7:按会话记住的权限模式,只存非 default;`rememberPermissionMode` / `forgetPermissionMode`)** + 上次项目目录 `lastCwd`(App 启动预填 cwd,只带目录不恢复会话)**(均持久化 ui-state;删会话时 unpin 清理)/ 更新态 / UI 插件面板 / 全局 Toast(顶栏右侧,非阻塞自动消失;扩展 notify 走 pushExtension:同源同文 8s 去重 + 可见栈 3 溢出折叠) | | `hooks/` | `use-context-usage`(上下文用量,事件驱动刷新)/ `use-language` / `use-session-state`(useSessionReadOnly/useSessionBusy 收敛)/ `use-session-event-bridge`(App 事件桥装配层专用) | | `plugins/` | UI 插件运行时:`slots.ts`(槽位名+props 契约单一来源)/ `registry.ts`(zustand:overrides + contributions 堆叠 + headless activate/cleanups + 崩溃计数/loadNonces)/ `Slot.tsx`(总开关门控 + PluginBoundary 包裹)/ `RegionHost.tsx`(区域挂载点,容器语义)/ `PluginBoundary.tsx`(class 错误边界,崩溃回退)/ `host-api.ts`(`window.PerchoUI` 挂载,main.tsx render 前 import)/ `loader.ts`(initUiPlugins/reloadAll/computeAssignedSlots) | | `i18n/` | zh/en 字典 + `useT()`(文案改这里,双字典) | @@ -197,9 +197,9 @@ src/ | `chat/` | **MessageList**(底部跟随 + 脱离回底;行模型 `useMemo`,轮末行定位规则在 shared chat-rows;**挂载窗口**见 `mount-window.ts`)/ **SelectionToolbar**(对话区选中文字浮出菜单:添加到对话/新会话继续)/ **MessageItem**(纯分发壳)+ `message-actions.tsx`(复制/Fork/撤回按钮)/ **UserMessage** / **SystemMessage**(compaction 分割线 + mutex 通知)/ **AssistantMessage** / **Markdown**(markstream-react 流式丝滑渲染,fade 关闭避免合成层闪烁;代码块只读编辑器关闭自动 decoration,样式覆写在 globals.css `.markdown-body`,见 PITFALLS)+ **MermaidBlock**(接管 markstream 的 mermaid code_block:loose 模式 + 悬浮工具栏 + 渲染失败报错卡;见 PITFALLS)/ **ToolCallCard** / **SubagentRunCard**(独立行,有 sessionFile 可点开子会话)/ **TodoPanel**(呼吸灯 + 展开 morph 同一容器)/ **MetaGroup**(memo 折叠组 + 圆点行/分类统计行)+ `use-sweep-highlight`(统一扫光)+ `use-shown-working`(working→worked 滞后缓冲)/ **PreviewTicker** + `activity-ticker`(工作中预览行调度)/ **StreamingMarquee**(溢出 tail-follow,位移用 shared marquee-motion)/ **ImagePreview**(全屏多图,portal 到 body)/ **ErrorNote**(统一报错卡)+ **RetryNote**(自动重试瞬态行)/ **CenterOrb** + `center-orb-draw`(中央状态动画,绘制闭式函数)/ **TurnDiffChip**(轮末计时行 + 文件变更 chip;计时器运行中 1s 心跳跳动/定格;文件行右键 + hover「⋯」→ 路径菜单 `ui/FilePathMenu`)/ `meta-summary-label`(i18n 胶水) | | `diff/` | **useDiffSidebarOverlay**(宽度账本纯计算 + rAF 合帧 resize 监听:可用宽不够就转浮层)/ **DiffSidebar**(右侧变更侧栏:按轮分组 unified diff + 内嵌 BranchRow git 分支行;开关在 SessionTabBar;`cwd` 下传给卡片做相对路径基准)+ DiffFileCard(卡片头右键 + hover「⋯」→ 路径菜单) | | `composer/` | **Composer**(装配层 ~330 行,键盘事件分发)+ 三 hook:`use-composer-send`(ensureSession 懒创建/followUp 排队/停止先 clearQueue/发送失败草稿回填)、`use-slash-menu`(命令拉取+胶囊回填+导航)、`use-at-completion`(@ token 探测/续钻/胶囊弹回)+ 展示件 QueueBar/ImageTray/AttachmentChip/**QuoteChip**(选中引用胶囊)/SendErrorBar + **ModelPicker** / **ThinkingPicker**(按模型 thinkingLevels 过滤+clamp)/ **PermissionPicker**(会话权限模式 chip:默认/完全访问)/ **SlashMenu** / **AtMenu** / **ContextRing** + 纯函数 `slash-filter`/`at-files`/`send-error`/`quote`/`model-filter`(各有测试) | -| `session/` | **SessionTabBar** + SessionTab/TabPill(**v8:顶栏胶囊 = 置顶会话(不看 tab 开没开)+ 未命名 draft**,顺序就是 `pinnedSessions`;未置顶会话只在左栏;空态一行 12px `ink-faint` 提示;状态收拢到头像图标;dnd-kit 拖拽排序(改的也是 `pinnedSessions`),DragOverlay ghost + 轴锁定;拖拽期间退出 drag-region;**最左 = 左栏开合按钮 `PanelLeftIcon`**;胶囊右键 → `ui/ContextMenu`「重命名 / 置顶」;叉叉(关 tab)仅在该 tab 已打开时显示)/ **RenamePopover**(重命名浮层:锚在胶囊/指针锚点,160ms 进场 / 120ms 退场 + Enter/点外提交/Esc 取消)/ **SessionAvatar** + `session-status`(头像底色语义 + 状态/标题纯逻辑,顶栏与左栏共用;`SessionAvatar` 已无引用,留作备用)/ `session-menu`(菜单可用性与动作:draft/只读不给菜单、置顶只动 `pinnedSessions`、重命名落盘(**同步 tabs 与历史列表两份拷贝**)+失败 toast、复制诊断、删除会话;顶栏胶囊与左栏会话行共用)/ **ProjectBranchPicker**(从 `projects/` 移入的 draft 态项目/分支选择器 chip)/ **DockSlot**(底部交换槽仲裁:权限 > 扩展对话框 > Composer)/ **ApprovalDock**(权限审批:async respond 成功才移除面板,失败保留重试)/ **InteractionDock**(扩展对话框四卡 select/input/editor/confirm:键盘 ↑↓/Enter/数字/Esc + 倒计时显示,裁决在 backend)/ **TrustDialog**(项目信任两选项)/ **UpdateButton**(顶栏更新按钮) | +| `session/` | **SessionTabBar** + SessionTab/TabPill(**v8:顶栏胶囊 = 置顶会话(不看 tab 开没开),严格只含置顶**,顺序就是 `pinnedSessions`;未置顶会话只在左栏;空态一行 12px `ink-faint` 提示;状态收拢到头像图标;dnd-kit 拖拽排序(改的也是 `pinnedSessions`),DragOverlay ghost + 轴锁定;拖拽期间退出 drag-region;**最左 = 左栏开合按钮 `PanelLeftIcon`**;胶囊右键 → `ui/ContextMenu`「重命名 / 置顶」;叉叉 = 取消置顶 + 从顶栏清除)/ **RenamePopover**(重命名浮层:锚在胶囊/指针锚点,160ms 进场 / 120ms 退场 + Enter/点外提交/Esc 取消)/ **SessionAvatar** + `session-status`(头像底色语义 + 状态/标题纯逻辑,顶栏与左栏共用;`SessionAvatar` 已无引用,留作备用)/ `session-menu`(菜单可用性与动作:`sidebarMenuKind` 定形态(draft / 普通 / 无菜单)、`draftSessionMenuItems` = 左栏 draft 专属最小菜单(只「丢弃新会话」,纯本地 `closeSession`)、只读会话不给菜单、置顶只动 `pinnedSessions`、重命名落盘(**同步 tabs 与历史列表两份拷贝**)+失败 toast、复制诊断、删除会话;顶栏胶囊与左栏会话行共用)/ **ProjectBranchPicker**(从 `projects/` 移入的 draft 态项目/分支选择器 chip)/ **DockSlot**(底部交换槽仲裁:权限 > 扩展对话框 > Composer)/ **ApprovalDock**(权限审批:async respond 成功才移除面板,失败保留重试)/ **InteractionDock**(扩展对话框四卡 select/input/editor/confirm:键盘 ↑↓/Enter/数字/Esc + 倒计时显示,裁决在 backend)/ **TrustDialog**(项目信任两选项)/ **UpdateButton**(顶栏更新按钮) | | `settings/` | **SettingsDialog**(PANELS 注册表;分类 = 静态 + 插件 settings.panel 贡献动态拼接)/ **GeneralPanel**(语言/上下文管理二态/channel-watch 开关)/ **AppearancePanel**(顶部 Tab 分栏:「基础」=主题三段/背景图/**显示顶栏开关**/中央动画,「UI 插件」= 原独立分类并入的 UiPluginsSection:总开关/插件卡/槽位指派,设计稿 .local/design/ux/appearance-ui-plugins)/ **SkillsPanel** / **McpPanel**(SDK 0.84 无 MCP,占位)/ **ExtensionsPanel** + `extensions/`(目录浏览/安装/卸载,subagent 包安装两段式确认)/ **UiPluginsSection**(挂在 AppearancePanel 的 UI 插件 Tab 下) / **LanObserverPanel** / **AboutPanel** / `providers/`(ProvidersPanel + ProviderRow 操作全图标化 / LoginDialog(交互登录对话框:OAuth + api_key 交互流)/ CustomProviderForm + ModelRowsEditor(逐模型行编辑器)/ BuiltinProviderEditForm(内置端点覆写)/ SubagentPanel(子代理执行器开关 + 逐代理模型/思考深度偏好)+ `model-rows` 纯函数) | -| `sidebar/` | **Sidebar**(常驻左栏容器:只做「取 store 数据 → 调 `lib/sidebar-groups` 派生 → 分发 props」,240↔0 push 过渡 + 收起时 `inert`)/ **SidebarHeader**(只有搜索框——v9 顶栏常驻,不再需要「顶栏关掉时补拖拽带 + +」)/ **SidebarGroup**(日常与各项目共用的分组骨架:分组行 + 可折叠会话列表 + 空态)/ **ProjectRow**(图标表达展开态:项目 = folder ↔ folder-open、日常 = 恒咖啡 + 行末 hover 箭头;hover 出 «⋯»(锚在**指针点**)、置顶标记、移除项目二次确认)/ **SessionRow**(无头像文字 + 行尾状态点 + 右键菜单锚点)/ **SessionMenu**(会话行右键三层状态机:菜单 → 改名气泡 → 删除确认)/ **ProjectSection**(「项目」小标:**v7 起固定标题不可折叠** + 常显「+」)/ **SidebarFooter**(设置;顶栏关闭时补「本轮改动」)/ **ProjectMenu**(项目 «⋯» 菜单项 builder)/ `useExpandedGroups`(展开态读写,首次开合以派生层给的默认集为起点) | +| `sidebar/` | **Sidebar**(常驻左栏容器:取 store 数据(**历史 + 内存会话经 `mergeSidebarSessions` 合并**)→ 调 `lib/sidebar-groups` 派生 → 分发 props,240↔0 push 过渡 + 收起时 `inert`)/ **SidebarHeader**(只有搜索框——v9 顶栏常驻,不再需要「顶栏关掉时补拖拽带 + +」)/ **SidebarGroup**(日常与各项目共用的分组骨架:分组行 + 可折叠会话列表 + 空态)/ **ProjectRow**(图标表达展开态:项目 = folder ↔ folder-open、日常 = 恒咖啡 + 行末 hover 箭头;hover 出 «⋯»(锚在**指针点**)、置顶标记、移除项目二次确认)/ **SessionRow**(无头像文字 + 行尾状态点 + 右键菜单锚点;**draft 行标题固定「新会话」**(`sidebar.newSession`))/ **SessionMenu**(会话行右键三层状态机:菜单 → 改名气泡 → 删除确认;draft 走 `draftSessionMenuItems`)/ **ProjectSection**(「项目」小标:**v7 起固定标题不可折叠** + 常显「+」)/ **SidebarFooter**(设置;顶栏关闭时补「本轮改动」)/ **ProjectMenu**(项目 «⋯» 菜单项 builder)/ `useExpandedGroups`(展开态读写:起点由 `expandedGroupsTouched` 决定;写入时 store 同一次 set + 单个补丁置 touched) | | `ui/` | Button(ghost/primary × sm/md × danger)/ Dropdown / Switch(统一受控开关,支持 indeterminate)/ **Tooltip**(自定义悬浮提示,新增提示一律用它不用原生 title;右缘元素传 `align="end"` 防幻影横向滚动条)/ **ContextMenu**(通用右键菜单:portal 到 body + `place-menu` 纯函数定位,点外/Esc/选中/滚动关闭;有边框卡片外观(`rounded-xl border border-border bg-surface shadow-pop`),项高 30px、支持 `danger` 红项与 `separatorBefore` 分隔线,不再有 veil 变体;会话胶囊 / 文件行 / 左栏会话行共用,文件行三项菜单见 **FilePathMenu**)/ `place-menu`(`placeMenu` 定位 + `anchorOfElement` 锚点转换,纯函数有测试;**左栏项目 «⋯» 与右键菜单一律传指针锚点** `{ left: clientX, top: clientY, width: 0, height: 0 }` —— 传元素矩形会把高度多算一遍,菜单掉到行下方)/ **ConfirmDialog**(破坏性操作二次确认:极淡蒙层 `bg-ink/6` + 392px 细边框卡片 + Portal 到 body 且 `z-[60]` 压过右键菜单;点卡片外/Esc/✕ = 取消,文案与按钮由调用方给) | | `icons/` | 内联 SVG 集中管理;`icons.test.ts` 校验所有 path `d` 语法(防残缺数据被 Chromium 丢弃) | @@ -213,7 +213,7 @@ src/ | 报错卡 / 错误分类 | `chat/ErrorNote.tsx` + globals.css(`.error-note*`/`.retry-note`/`.send-error`/`.toast` 段)+ `shared/src/errors.ts`(classifyLlmError 模式表 + 信封构造)+ i18n `error.*`;severity 色改 `--color-err/warn/info`(深浅各一份) | | 工作中预览行 / 状态动画 | `chat/PreviewTicker.tsx` + `activity-ticker.ts`(minDwellMs 350)+ `MetaGroup.tsx`(状态行 orb + liveItems)+ `use-sweep-highlight.ts`(统一扫光)+ `use-shown-working.ts`(1500ms 滞后缓冲)+ `CenterOrb.tsx`/`center-orb-draw.ts`(中央版,开关 ui-preferences)+ shared `transcript/meta-summary.ts`(圆点行/统计行) | | 输入框 / 发送 / 停止 / 排队 | `composer/Composer.tsx`(装配层)+ `use-composer-send.ts`(发送/停止/取回排队);草稿持久在 `stores/drafts.ts`;排队事件 `queue_update` + getFollowUpMessages | -| 模型快速切换 | `composer/ModelPicker.tsx` + `stores/sessions.ts`(models/currentModel/setCurrentModel) | +| 模型快速切换 | `composer/ModelPicker.tsx`(弹层 `absolute right-0 bottom-full w-72`:**必须右对齐**——模型按钮在 composer 右侧,向左展开才不出视口;`left-0` 会越界并因搜索框 autoFocus 触发 `#root` 程序性横滚,见 PITFALLS)+ `stores/sessions.ts`(models/currentModel/setCurrentModel) | | 思考深度切换 | `composer/ThinkingPicker.tsx` + `stores/sessions.ts` + shared `src/thinking.ts`(档位常量单一事实源)+ `lib/thinking.ts`(clamp 共用;后端 SDK 内再 clamp 兜底) | | 图片附件(选图/粘贴/预览/门控) | `composer/Composer.tsx`(images state + Ctrl+V + imageInput 门控 fail-open)+ `chat/MessageItem.tsx`(历史缩略图)+ `chat/ImagePreview.tsx`(全屏预览三处共用);事件流提取在 shared reducer,历史回放在 backend `toSessionMessages` | | show_image 发图 | 工具本体 `backend/src/tools/show-image.ts`;实时 = shared reducer(pendingImages 缓冲,turn_end 固化排 assistant 之后);历史 = `toSessionMessages` 的 `role:"image"`;渲染 = MessageItem image 分支(缩略图按数量分档) | @@ -223,8 +223,8 @@ src/ | 上下文蒸发 | backend `tools/context-evaporation/`(见 backend 表);开关 = 设置 GeneralPanel 二态(默认蒸发,写 settings.json 单 key,2s 生效免重开);调参 `scripts/replay-evaporation.mts`;观测 = log `context-evaporation` 行 + trace_custom | | 上下文用量圆环 | `composer/ContextRing.tsx` + `hooks/use-context-usage.ts`(事件驱动刷新,与插件 host API 共用)→ IPC getContextUsage → SDK `session.getContextUsage()`(percent null 或无消息不渲染;<60% 灰 / 60-85% 琥珀 / >85% 红) | | 每轮计时行 + 文件变更 chip + diff 侧栏 | 计时 shared `transcript/turn-timings.ts`(deriveTurnTimings;分量 = reducer 盖戳的 `UIToolCall.endedAt` + `runEndedAt` 定格 + 历史回放透传 toolResult timestamp);变更 shared `transcript/turn-files.ts`(deriveTurnChanges)+ `chat-rows.ts`(行定位:每轮必有计时行,lan-web 不传 timings 保持旧行为);渲染 `chat/TurnDiffChip.tsx`(timer 恒在首位)+ `diff/DiffSidebar.tsx`(含 BranchRow);开关 = SessionTabBar 的 DiffIcon 按钮 + `stores/ui.ts` diffSidebarOpen | -| 顶栏 tab / 拖拽排序 / 置顶 | `session/SessionTabBar.tsx`(dnd-kit,DragOverlay ghost 拾起时实测宽度沿用原胶囊 + 轴锁定 + drag-region 退出)+ `stores/sessions.ts` `selectBarSessions`(**顶栏 = 置顶表驱动**:会话被置顶就算 tab 没开也在顶栏,点击自动开)+ `stores/ui-preferences.ts` `reorderPinned`(拖拽改 `pinnedSessions` 顺序) | -| 左侧栏(分组 / 开合 / 置顶 / 搜索 / 移除项目 / 会话右键菜单) | 组件 `components/sidebar/*`(容器 → `Sidebar`;纯派生 → `lib/sidebar-groups.ts`,有单测)+ 持久化字段 `topBarVisible` / `sidebarCollapsed` / `expandedGroups` / `pinnedProjects`(`ui-preferences.ts` → main `ui-state.ts` normalize)+ 宽度过渡与状态点呼吸 `styles/globals.css` 的 `.sidebar*` 段;置顶会话的行首图钉在 `sidebar/SessionRow.tsx`(行首 16×16 图标槽,标题 x 恒 38);逐帧截图 `scripts/shoot-sidebar.mjs` | +| 顶栏 tab / 拖拽排序 / 置顶 | `session/SessionTabBar.tsx`(dnd-kit,DragOverlay ghost 拾起时实测宽度沿用原胶囊 + 轴锁定 + drag-region 退出)+ `stores/sessions.ts` `selectBarSessions`(**顶栏严格 = 置顶表**:会话被置顶就算 tab 没开也在顶栏,点击自动开;draft 不进顶栏)+ `stores/ui-preferences.ts` `reorderPinned`(拖拽改 `pinnedSessions` 顺序) | +| 左侧栏(分组 / 开合 / 置顶 / 搜索 / 移除项目 / 会话右键菜单) | 组件 `components/sidebar/*`(容器 → `Sidebar`;纯派生 → `lib/sidebar-groups.ts`,有单测)+ 持久化字段 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` + `expandedGroupsTouched` / `pinnedProjects`(`ui-preferences.ts` → main `ui-state.ts` normalize + 旧文件缺字段时的 touched 迁移)+ 宽度过渡与状态点呼吸 `styles/globals.css` 的 `.sidebar*` 段;置顶会话的行首图钉在 `sidebar/SessionRow.tsx`(行首 16×16 图标槽,标题 x 恒 38);逐帧截图 `scripts/shoot-sidebar.mjs` | | 顶栏内容 / 显隐 | 顶栏**常驻**(窗口拖动、左栏开合、变更侧栏入口都在上面,v9 起不再整条隐藏)→ `App.tsx` 无条件渲染 `SessionTabBar`;设置项「顶栏显示会话」(`settings/AppearancePanel.tsx` → `barSessionsVisible`)只控制**要不要出置顶会话胶囊**:关掉后顶栏不出胶囊、也不出空态提示,会话全在左栏(v9 已删旧版的三处兜底:左栏头 44px 拖拽带 + 「+」、聊天列顶 12px 隐形拖拽带、左栏底部「本轮改动」入口) | | 右栏(push ↔ 窄窗浮层) | `components/diff/DiffSidebar.tsx` + `useDiffSidebarOverlay.ts`(宽度账本:可用宽 < 380 + min(420, 38vw) → 转浮层)+ `globals.css` 的 `.diff-sidebar.is-overlay` 段;开关在顶栏(顶栏关闭时在左栏底部) | | 权限审批面板 | `backend/src/permissions/gate.ts`(队列)+ `session/ApprovalDock.tsx`(快捷键 Enter/A/D/Esc;await 成功才移除) | diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index fab435c1..261300a5 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -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) | ## 一、事故复盘(含可复用诊断手法) @@ -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 堆能不能降」。结论:**降不下来——但原因不在会话**。 @@ -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`(但 builder 返回的 JSX 用的是自动 runtime,模块里没 `import React`)。 + +原因:本仓 vitest 无 config(`electron.vite.config.ts` 不被 vitest 读取),环境是默认的 **node**:无 DOM/localStorage,且 esbuild 把 `.tsx` 编译成 `React.createElement`(classic)→ 一调就炸。 + +对策(本期采用):**把决策抽成不碰 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** 错误(多余空行、超长行)。 diff --git a/packages/desktop/src/renderer/src/components/composer/ModelPicker.tsx b/packages/desktop/src/renderer/src/components/composer/ModelPicker.tsx index 14b5afea..07e17c01 100644 --- a/packages/desktop/src/renderer/src/components/composer/ModelPicker.tsx +++ b/packages/desktop/src/renderer/src/components/composer/ModelPicker.tsx @@ -116,9 +116,9 @@ export function ModelPicker() { {/* 右对齐(不是 left-0):模型按钮就在 composer 右侧,288px 弹层向左展开才能留在视口内。 - 旧版 left-0 会让面板右缘越出视口(1100px 窗口就已越界 ~12px,窄窗口 35px+), - 配合搜索框 autoFocus 触发 Chromium 的程序性横向滚动(overflow:hidden 拦不住), - 整页左移,表现成左栏与顶栏左侧按钮被挤压/裁切(本轮实测顶栏按钮 left 80 → 72/44.5)。 */} + 旧版 left-0 会让面板右缘越出视口约 12px(1100/900/700px 窗口实测均如此), + 配合搜索框 autoFocus 触发 Chromium 对 #root 的程序性横向滚动(overflow:hidden 拦不住): + 窄窗口下实测根横滚约 35.5px,顶栏最左按钮 left 从 80 被挤到 72(1100px)/44.5(窄窗口)。 */} {open && (
From db53cebf02a53a490f17a1e6b61ba9f01802ffc1 Mon Sep 17 00:00:00 2001 From: Jaxton07 Date: Sun, 20 Sep 2026 15:20:14 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=E9=98=B6=E6=AE=B5=204.1=20?= =?UTF-8?q?=E2=80=94=20=E4=BF=AE=20INDEX=20=E7=B2=97=E4=BD=93=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=E3=80=81PITFALLS=20JSX=20runtime=20=E8=A1=A8=E8=BF=B0?= =?UTF-8?q?=EF=BC=88=E5=BA=94=E7=94=A8=20automatic=20vs=20Vitest=20classic?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/INDEX.md:182:**…** 单层粗体(原来在粗体内嵌了 **严格置顶表**,Markdown 不支持重叠) - docs/PITFALLS.md:明确「应用构建/tsconfig 预期 automatic → 源码无 import React;Vitest 路径编成 classic → React is not defined」,删掉自相矛盾表述 - 纯文档,无业务代码改动 --- docs/INDEX.md | 2 +- docs/PITFALLS.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 1d45acad..2d10c4ec 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -179,7 +179,7 @@ src/ | `lib/session-gc.ts` | 会话内存驻留策略(**纯函数,有单测**):`pickUnloadCandidates`(保护/草稿/活跃/刚用过 → 剔除;剩余按最久未用排序,保留 K 个,超的与晾过 `idleTimeoutMs` 的一并作候选)+ `isProtected`(保护条件唯一事实源:draft / 0 消息 / agentActive / 待审批 / 待应答 / 未读绿点 / 压缩中 / 排队跟发;**权限模式曾在清单里,D7 改成按会话持久化+打开时恢复后已移除**)+ `GC_DEFAULTS`(K=3、fresh **3s**、idle 5min)。不 import React / 不调 IPC | | `lib/use-session-gc.ts` | 内存策略接线层:挂载跑一次 + **20s** 兜底 tick(实测:仅靠它时收敛延迟 ≤ 一个 tick) + 只订阅 `sessions`/`activeSessionId`(**不订阅 `transcript.bySession`**,它每 token 都变)+ 卸载前逐个再确认(活跃/已关就跳过)+ dev 打 `[session-gc] unload reason=...`;K=3 常量在这里 | | `lib/diagnostics.ts` | `buildDiagnosticsText`(会话诊断纯文本,供「复制诊断信息」用;从 `components/projects/` 移入) | -| `stores/sessions.ts` | 会话列表/当前会话/cwd/模型/权限模式(`permissionModes` map,缺 key = default;draft 态纯 renderer,转正 ensureSession 应用到后端)。draft 会话(`draft:` 前缀,空 tab 重启消失;发首条消息 `ensureSession` 用 draft cwd 原地转正);信任前置 ensureProjectTrust(trustVersion 触发重拉);打开(含从历史按需打开)/fork/撤回统一 `loadSessionBundle` 四件套(history→queue→todos→permissionMode 对齐后端);**`selectBarSessions`(v8)算顶栏展示集 = **严格置顶表**(meta 从 tabs → `allSessions` 兜底;draft 不再进顶栏,导航与丢弃都在左栏)**;**v10 起不持久化打开列表**——启动纯空(新会话页),会话只在你点开时才加载,退出即清(与 pi 原生 `AgentSessionRuntime` 的"单会话按需替换"对齐);切会话/打开/新建三处调 `rememberCwd` 记住**上次项目目录**(`lastCwd`,只记目录不恢复会话);**`loadSessionBundle` 末尾按 `sessionPermissionModes` 恢复档位(await IPC,保证「打开完即正确档位」)**;**会话内存驻留策略**:`lastUsedAt` LRU 打点 + `unloadSession`(自动卸载专用入口,受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次) | +| `stores/sessions.ts` | 会话列表/当前会话/cwd/模型/权限模式(`permissionModes` map,缺 key = default;draft 态纯 renderer,转正 ensureSession 应用到后端)。draft 会话(`draft:` 前缀,空 tab 重启消失;发首条消息 `ensureSession` 用 draft cwd 原地转正);信任前置 ensureProjectTrust(trustVersion 触发重拉);打开(含从历史按需打开)/fork/撤回统一 `loadSessionBundle` 四件套(history→queue→todos→permissionMode 对齐后端);**`selectBarSessions`(v8)算顶栏展示集 = 严格置顶表(meta 从 tabs → `allSessions` 兜底;draft 不再进顶栏,导航与丢弃都在左栏)**;**v10 起不持久化打开列表**——启动纯空(新会话页),会话只在你点开时才加载,退出即清(与 pi 原生 `AgentSessionRuntime` 的"单会话按需替换"对齐);切会话/打开/新建三处调 `rememberCwd` 记住**上次项目目录**(`lastCwd`,只记目录不恢复会话);**`loadSessionBundle` 末尾按 `sessionPermissionModes` 恢复档位(await IPC,保证「打开完即正确档位」)**;**会话内存驻留策略**:`lastUsedAt` LRU 打点 + `unloadSession`(自动卸载专用入口,受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次) | | `stores/transcript.ts` | re-export shared reducer + per-session 字段(agentActive/unseenCompletion/todos);reducer 细节见 shared `src/transcript/` | | `stores/event-conflator.ts` | 流式事件合流:纯追加型 delta 按会话/类型拼接 + rAF 每帧最多一次 flush,其余事件边界透传保序(可注入调度器,有测试) | | `stores/drafts.ts` | 草稿(文本/图片/slash 胶囊/@ 引用 attachments/选中引用 quotes)按会话持久 + `COMPOSER_FOCUS_EVENT`(撤回回填后聚焦输入框) | diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 261300a5..8561cbc2 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -411,9 +411,9 @@ 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`(但 builder 返回的 JSX 用的是自动 runtime,模块里没 `import React`)。 +症状:给 `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)→ 一调就炸。 +原因:本仓 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 就改全局测试环境**。