From 05f67cd9377070c15c92b14ab55e3f5548c9c800 Mon Sep 17 00:00:00 2001 From: fsvov Date: Fri, 14 Aug 2026 16:04:05 +0800 Subject: [PATCH] fix(client): share the side-card width across sessions (last drag wins) The panel width lived in the per-session state (dsh-sidebar:v1:), so a drag in one conversation never carried to another: switching conversations snapped the panel back to that session's own width. The width is a layout preference, not session content, so it now persists under a cross-session key (dsh-sidebar:v1:width): - every persist writes the current width to the global key; - loadState prefers the global width over a session's persisted one; - a cache-hit session switch adopts the global width. Backward compatible: with no global key present (first run / older data) the behavior is unchanged. Adds a regression test covering drag-in-A -> switch-to-B and drag-in-B -> switch-back-to-A. --- src/client/state.ts | 60 ++++++++++++++++++++++++++++++++++++++++++--- tests/unit.spec.ts | 37 +++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/client/state.ts b/src/client/state.ts index f822596..5e3a78b 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -723,6 +723,44 @@ export function reconcileAgentTerminals( const STORAGE_PREFIX = 'dsh-sidebar:v1' +/** + * Cross-session panel width: the last dragged width, shared by EVERY + * conversation (the panel width is a layout preference, not per-session + * content). Written on every persist, read at session load and on + * cache-hit session switches, so a drag in one conversation carries to all + * the others (last drag wins). + */ +const GLOBAL_WIDTH_KEY = 'dsh-sidebar:v1:width' + +/** Clamp one width to the contract and the current viewport (mirror of {@link setWidth}). */ +function clampWidth(width: number): number { + const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX + return Math.min(max, Math.max(PANEL_MIN, Math.round(width))) +} + +/** Read the cross-session panel width (undefined when never dragged). */ +function readGlobalWidth(): number | undefined { + try { + const raw = localStorage.getItem(GLOBAL_WIDTH_KEY) + if (raw !== null) { + const parsed = Number(raw) + if (Number.isFinite(parsed) && parsed > 0) return clampWidth(parsed) + } + } catch { + // Storage unavailable: fall back to the per-session behavior. + } + return undefined +} + +/** Persist the cross-session panel width (best-effort, like the session states). */ +function writeGlobalWidth(width: number): void { + try { + localStorage.setItem(GLOBAL_WIDTH_KEY, String(width)) + } catch { + // Storage full or unavailable: layout memory is best-effort. + } +} + /** Immutable snapshot handed to React (replaced only on real changes). */ export interface SidebarSnapshot { sessionId: string | undefined @@ -743,6 +781,10 @@ export function defaultWidthFor(viewport: number, percent: number): number { } function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { + // The panel width is a cross-session preference: the last dragged width + // wins over a session's own persisted value, so switching conversations + // keeps the width the user chose anywhere. + const globalWidth = readGlobalWidth() try { const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`) if (raw !== null) { @@ -751,7 +793,9 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { // sanitize re-ids any duplicates the pre-seeding counter left behind. nextIdCounter = maxCounterId(parsed) const sanitized = sanitizeState(parsed) - if (sanitized !== undefined) return sanitized + if (sanitized !== undefined) { + return globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth } + } } } catch { // Corrupt or unavailable storage: fall through to the default. @@ -767,9 +811,9 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { // first seeding is affected: once the user expands the drawer, // `panelOpen: true` persists like any other state. const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined - const width = viewport === undefined + const width = globalWidth ?? (viewport === undefined ? PANEL_DEFAULT - : defaultWidthFor(viewport, prefs.defaultWidthPercent) + : defaultWidthFor(viewport, prefs.defaultWidthPercent)) const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport)) return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false) } @@ -965,6 +1009,13 @@ export class SidebarStore { // counter below THIS session's persisted ids — re-seed so fresh // pane/split ids can never collide with its tree. nextIdCounter = maxCounterId(state) + // The panel width is cross-session: adopt the latest dragged width + // (a cached session keeps its own layout otherwise). + const globalWidth = readGlobalWidth() + if (globalWidth !== undefined && state.width !== globalWidth) { + state = { ...state, width: globalWidth } + this.bySession.set(sessionId, state) + } } this.snapshot = { sessionId, state, prefs: this.prefs } } @@ -1021,6 +1072,9 @@ export class SidebarStore { } private schedulePersist(sessionId: string, state: SidebarState): void { + // Keep the cross-session width in sync: any width change (drag, fullscreen + // toggle) becomes the shared width for every conversation. + writeGlobalWidth(state.width) window.clearTimeout(this.persistTimer) this.persistTimer = window.setTimeout(() => { try { diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 4b18516..11c3010 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -4,7 +4,7 @@ import { parseLogLines, parsePorcelainZ } from '../src/git.ts' import { parseUnifiedDiff } from '../src/client/DiffView.tsx' import { activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState, - migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel, + migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, setWidth, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel, type SidebarState, type SidebarTab, type SplitNode, } from '../src/client/state.ts' import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' @@ -1277,6 +1277,41 @@ describe('side card preferences', () => { expect(openStore.getSnapshot().state?.panelOpen).toBe(true) }) + it('shares the panel width across sessions (last drag wins)', () => { + // A real localStorage stub so the cross-session width actually persists + // (the default no-op mock would silently drop the write). + const storage = new Map() + const savedWindow = (globalThis as Record).window + const savedStorage = (globalThis as Record).localStorage + ;(globalThis as Record).window = { + innerWidth: 1280, + clearTimeout: () => {}, + setTimeout: () => 0, + } + ;(globalThis as Record).localStorage = { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, String(v)) }, + removeItem: (k: string) => { storage.delete(k) }, + key: () => null, + get length() { return storage.size }, + } + try { + const store = createSidebarStore() + store.setSession('A') + store.reduce(s => setWidth(s, 500)) + // A fresh session adopts the width dragged in A, not its own default. + store.setSession('B') + expect(store.getSnapshot().state?.width).toBe(500) + // A later drag in B carries back to the cached session A. + store.reduce(s => setWidth(s, 600)) + store.setSession('A') + expect(store.getSnapshot().state?.width).toBe(600) + } finally { + ;(globalThis as Record).window = savedWindow + ;(globalThis as Record).localStorage = savedStorage + } + }) + it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => { // Stub a narrow window (the file otherwise runs without one): only // innerWidth is read while seeding a fresh session.