fix(client): share the side-card width across sessions (last drag wins)

The panel width lived in the per-session state (dsh-sidebar:v1:<id>), 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.
This commit is contained in:
fsvov
2026-08-14 16:04:05 +08:00
parent 09de13c7bc
commit 05f67cd937
2 changed files with 93 additions and 4 deletions
+57 -3
View File
@@ -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 {
+36 -1
View File
@@ -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<string, string>()
const savedWindow = (globalThis as Record<string, unknown>).window
const savedStorage = (globalThis as Record<string, unknown>).localStorage
;(globalThis as Record<string, unknown>).window = {
innerWidth: 1280,
clearTimeout: () => {},
setTimeout: () => 0,
}
;(globalThis as Record<string, unknown>).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<string, unknown>).window = savedWindow
;(globalThis as Record<string, unknown>).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.