merge #36: share side-card width across sessions (fsvov)
# Conflicts: # src/client/state.ts # tests/unit.spec.ts
This commit is contained in:
+57
-3
@@ -940,6 +940,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
|
||||
@@ -960,6 +998,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) {
|
||||
@@ -968,7 +1010,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.
|
||||
@@ -985,9 +1029,9 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
// 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))
|
||||
const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
|
||||
return makeDefaultState(width, openByDefault, seed)
|
||||
@@ -1273,6 +1317,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 }
|
||||
}
|
||||
@@ -1367,6 +1418,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)
|
||||
// Per-session debounce timers: one session's pending write must never
|
||||
// cancel another's (targeted opens schedule writes for INACTIVE
|
||||
// sessions while the active session may already have one pending —
|
||||
|
||||
+36
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState } from '../src/client/state.ts'
|
||||
import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState, setWidth } from '../src/client/state.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
|
||||
/** A fake settings wire face whose settingsGet resolves to one raw value. */
|
||||
@@ -343,6 +343,41 @@ describe('side card preferences', () => {
|
||||
expect(makeDefaultState(400, true, 'none').splits.kind).toBe('leaf')
|
||||
expect((makeDefaultState(400, true, 'none').splits as { tabs: unknown[] }).tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('external disable (aionui-panel provider choice)', () => {
|
||||
|
||||
Reference in New Issue
Block a user