From 0916f47ad7d0f6fcea20613183ab194af54f9ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sun, 16 Aug 2026 13:07:00 +0800 Subject: [PATCH 1/2] fix(desktop): persist rail grouping across restarts viewMode was a hard-coded useState('conversation'), so the rail grouping control reset to time-order on every launch. Hydrate and persist it through the same localStorage seam as the rail's collapsed flag. Fixes #3060 Generated-by: Grok --- .../__tests__/session-list-layout.test.ts | 79 +++++++++++++++++++ .../desktop/src/renderer/app-shell-effects.ts | 8 +- apps/desktop/src/renderer/app-shell.tsx | 4 +- .../src/renderer/session-list-layout.ts | 15 +++- 4 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-list-layout.test.ts diff --git a/apps/desktop/src/main/__tests__/session-list-layout.test.ts b/apps/desktop/src/main/__tests__/session-list-layout.test.ts new file mode 100644 index 0000000000..3c9ff34d77 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-list-layout.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; +import { + readSessionListViewMode, + writeSessionListViewMode, +} from '../../renderer/session-list-layout.js'; + +const VIEW_MODE_KEY = 'maka-chat-list-view-mode-v1'; + +function installMemoryLocalStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + const memory: Storage = { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key) { + return store.has(key) ? store.get(key)! : null; + }, + key(index) { + return [...store.keys()][index] ?? null; + }, + removeItem(key) { + store.delete(key); + }, + setItem(key, value) { + store.set(key, String(value)); + }, + }; + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + writable: true, + value: memory, + }); + return { + store, + restore() { + if (previous) Object.defineProperty(globalThis, 'localStorage', previous); + else Reflect.deleteProperty(globalThis, 'localStorage'); + }, + }; +} + +describe('session list view mode persistence', () => { + const cleanups: Array<() => void> = []; + afterEach(() => { + while (cleanups.length > 0) cleanups.pop()?.(); + }); + + it('defaults to conversation when nothing is stored', () => { + cleanups.push(installMemoryLocalStorage().restore); + assert.equal(readSessionListViewMode(), 'conversation'); + }); + + it('round-trips a project grouping through the same key the shell hydrates', () => { + const memory = installMemoryLocalStorage(); + cleanups.push(memory.restore); + writeSessionListViewMode('project'); + assert.equal(memory.store.get(VIEW_MODE_KEY), 'project'); + assert.equal(readSessionListViewMode(), 'project'); + }); + + it('keeps conversation when that is what was written', () => { + cleanups.push(installMemoryLocalStorage({ [VIEW_MODE_KEY]: 'project' }).restore); + writeSessionListViewMode('conversation'); + assert.equal(readSessionListViewMode(), 'conversation'); + }); + + it('fails open to conversation for garbage or empty stored values', () => { + for (const stored of ['', 'time', 'true', 'PROJECT', 'conversation\n']) { + const memory = installMemoryLocalStorage({ [VIEW_MODE_KEY]: stored }); + assert.equal(readSessionListViewMode(), 'conversation', stored); + memory.restore(); + } + }); +}); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 2508b25b5d..2decd984f7 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -9,13 +9,14 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { generalizedErrorMessageChinese } from '@maka/core/redaction'; import { sessionExpectsEventStream } from '@maka/core/session-event-health'; import { type ShellRunUpdate } from '@maka/core/events'; -import type { LiveTurnProjection, NavSelection } from '@maka/ui'; +import type { LiveTurnProjection, NavSelection, SessionViewMode } from '@maka/ui'; import { messageReadErrorMessage } from './app-shell-copy'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; import { applyTheme, applyThemePalette } from './theme'; import { safeLocalStorageSet } from './browser-storage'; import type { NavigationState } from './nav-selection.js'; +import { writeSessionListViewMode } from './session-list-layout.js'; import { createSessionEventStreamSubscription, evaluateSessionEventStreamSnapshot, @@ -118,6 +119,7 @@ export function useAppShellPersistenceEffects(options: { navigationState: NavigationState; sessionListCollapsed: boolean; sessionListWidth: number; + sessionListViewMode: SessionViewMode; workbarCollapsed: boolean; workbarWidth: number; bottomPanelOpen: boolean; @@ -161,6 +163,10 @@ export function useAppShellPersistenceEffects(options: { safeLocalStorageSet('maka-chat-list-collapsed-v1', options.sessionListCollapsed ? 'true' : 'false'); }, [options.sessionListCollapsed]); + useEffect(() => { + writeSessionListViewMode(options.sessionListViewMode); + }, [options.sessionListViewMode]); + useEffect(() => { const handle = window.setTimeout(() => { safeLocalStorageSet('maka-session-workbar-width-v1', String(options.workbarWidth)); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5b4ae8ec44..704b37e5eb 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -128,6 +128,7 @@ import { useActiveExecutionBoundary } from './use-active-execution-boundary'; import { SESSION_LIST_EXPANDED_MAX_WIDTH, SESSION_LIST_EXPANDED_MIN_WIDTH, + readSessionListViewMode, } from './session-list-layout'; import { modelSetupToastCopy } from './model-connection-errors'; import type { AppShellCommandListOptions } from './app-shell-command-actions'; @@ -602,7 +603,7 @@ function AppShellContent({ const persistedComposerDefaults = loadComposerDefaults(); const [helpOpen, closeHelp, openHelp] = useKeyboardHelp(); const [paletteOpen, openPalette, closePalette] = useCommandPalette(); - const [viewMode, setViewMode] = useState('conversation'); + const [viewMode, setViewMode] = useState(() => readSessionListViewMode()); const composerRef = useRef(null); // The rail's toggle has to reach Astryx's resizable state, not just this // boolean — see the prop's note on SessionListPanel. The sidenav is mounted @@ -2343,6 +2344,7 @@ function AppShellContent({ navigationState, sessionListCollapsed, sessionListWidth, + sessionListViewMode: viewMode, workbarCollapsed, workbarWidth, bottomPanelOpen, diff --git a/apps/desktop/src/renderer/session-list-layout.ts b/apps/desktop/src/renderer/session-list-layout.ts index ab76b2e3c1..7a1a5d4159 100644 --- a/apps/desktop/src/renderer/session-list-layout.ts +++ b/apps/desktop/src/renderer/session-list-layout.ts @@ -1,9 +1,22 @@ -import { safeLocalStorageGet } from './browser-storage.js'; +import type { SessionViewMode } from '@maka/ui'; +import { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage.js'; export const SESSION_LIST_EXPANDED_DEFAULT_WIDTH = 260; export const SESSION_LIST_EXPANDED_MIN_WIDTH = 180; export const SESSION_LIST_EXPANDED_MAX_WIDTH = 480; +const SESSION_LIST_VIEW_MODE_KEY = 'maka-chat-list-view-mode-v1'; + +export function readSessionListViewMode(): SessionViewMode { + const stored = safeLocalStorageGet(SESSION_LIST_VIEW_MODE_KEY); + if (stored === 'project' || stored === 'conversation') return stored; + return 'conversation'; +} + +export function writeSessionListViewMode(mode: SessionViewMode): void { + safeLocalStorageSet(SESSION_LIST_VIEW_MODE_KEY, mode); +} + export function readSessionListWidth(): number { const stored = Number(safeLocalStorageGet('maka-chat-list-width-v1')); if (Number.isFinite(stored) && stored > 0) return clampSessionListWidth(stored); From 9c83001f419010a74b9ac50eae573d36028e0254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sun, 16 Aug 2026 13:15:32 +0800 Subject: [PATCH 2/2] test(desktop): keep rail grouping after a renderer reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click 按项目, wait for the localStorage write, reload the Electron renderer, and assert the control is still project-grouped. Generated-by: Grok --- apps/desktop/e2e/sidebar-project-row.spec.ts | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/desktop/e2e/sidebar-project-row.spec.ts b/apps/desktop/e2e/sidebar-project-row.spec.ts index 168fefad0c..c8bf75c2c7 100644 --- a/apps/desktop/e2e/sidebar-project-row.spec.ts +++ b/apps/desktop/e2e/sidebar-project-row.spec.ts @@ -62,3 +62,29 @@ test('project navigation and actions remain adjacent keyboard controls', async ( await page.getByRole('button', { name: '关闭', exact: true }).click(); await expect(action).toBeFocused(); }); + +test('rail grouping survives a renderer reload', async ({ projectSidebarWindow: page }) => { + await page.keyboard.press('Escape'); + await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); + + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + const byTime = sidebar.getByRole('radio', { name: '按时间', exact: true }); + const byProject = sidebar.getByRole('radio', { name: '按项目', exact: true }); + + await expect(byTime).toBeChecked(); + await byProject.click(); + await expect(byProject).toBeChecked(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('maka-chat-list-view-mode-v1'))) + .toBe('project'); + + await page.reload(); + await expect(page.locator('[data-maka-contract="search-modal"]')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); + + await expect(sidebar.getByRole('radio', { name: '按项目', exact: true })).toBeChecked(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('maka-chat-list-view-mode-v1'))) + .toBe('project'); +});