diff --git a/apps/desktop/src/components/workpanel/WorkPanel.tsx b/apps/desktop/src/components/workpanel/WorkPanel.tsx index f553112ae4..b7a65bea7b 100644 --- a/apps/desktop/src/components/workpanel/WorkPanel.tsx +++ b/apps/desktop/src/components/workpanel/WorkPanel.tsx @@ -48,9 +48,10 @@ import { SubagentTranscriptTab } from "./SubagentTranscriptTab"; import { MAIN_PANE_MIN_WIDTH, WORK_PANEL_COMPACT_MIN_WIDTH, - WORK_PANEL_MIN_WIDTH, clampWorkPanelWidth, + workPanelGestureMinimum, workPanelLayout, + workPanelRequestedWidth, workPanelResetWidth, workPanelWidthBounds, } from "../../lib/work-panel-resize"; @@ -211,6 +212,7 @@ function LocalWorkPanel({ const activeSessionId = useAppStore((s) => s.activeSessionId); const pluginViews = useAppStore((s) => s.pluginViews); const width = useAppStore((s) => s.workPanelWidth); + const yieldedWidth = useAppStore((s) => s.workPanelYieldedWidth); const activateTab = useAppStore((s) => s.activateWorkPanelTab); const reorderTabs = useAppStore((s) => s.reorderWorkPanelTabs); const closeTab = useAppStore((s) => s.closeWorkPanelTab); @@ -251,11 +253,6 @@ function LocalWorkPanel({ const [nativeSurfaceReadyForExit, setNativeSurfaceReadyForExit] = useState(false); - const requestedPanelWidth = panelDragWidth ?? width; - const panelMinimum = - requestedPanelWidth < WORK_PANEL_MIN_WIDTH - ? WORK_PANEL_COMPACT_MIN_WIDTH - : WORK_PANEL_MIN_WIDTH; // The first render can precede ResizeObserver's first notification. Use a // conservative shell estimate for that frame; the measured width takes over // before a user can interact with the divider. @@ -263,9 +260,20 @@ function LocalWorkPanel({ const budgetWidth = containerWidth > 0 ? containerWidth - : requestedPanelWidth + + : (panelDragWidth ?? width) + (sidebarOccupiesBudget ? sidebarWidth : 0) + MAIN_PANE_MIN_WIDTH; + // A drag preview is an explicit width; otherwise the panel asks for its + // preferred width, or the width it lent to a manually reopened sidebar. + const requestedPanelWidth = + panelDragWidth ?? + workPanelRequestedWidth({ + containerWidth: budgetWidth, + sidebarWidth, + sidebarCollapsed: !sidebarOccupiesBudget, + preferredWidth: width, + yieldedWidth, + }); const layout = workPanelLayout({ containerWidth: budgetWidth, sidebarWidth, @@ -274,6 +282,7 @@ function LocalWorkPanel({ maximized, }); const renderPanelWidth = layout.panelWidth; + const panelMinimum = workPanelGestureMinimum(renderPanelWidth); const isResizing = panelDragWidth !== null; useLayoutEffect(() => { @@ -731,7 +740,11 @@ function LocalWorkPanel({ else if (event.key === "End") nextWidth = maximum; if (nextWidth === null) return; event.preventDefault(); - setWidth(clampWorkPanelWidth(nextWidth, minimum)); + const committedWidth = clampWorkPanelWidth(nextWidth, minimum); + // A key that cannot move the separator commits nothing, so it neither + // rewrites the preferred width nor ends a width lent to the sidebar. + if (committedWidth === renderPanelWidth) return; + setWidth(committedWidth); }, [finishPanelResize, layout.maxPanelWidth, maximized, panelMinimum, renderPanelWidth, setWidth], ); diff --git a/apps/desktop/src/features/app/AppShell.tsx b/apps/desktop/src/features/app/AppShell.tsx index 0b405f5436..a9f72172a5 100644 --- a/apps/desktop/src/features/app/AppShell.tsx +++ b/apps/desktop/src/features/app/AppShell.tsx @@ -92,7 +92,7 @@ export function AppShell() { startupRetrying, sidebarToggleShortcut, workPanelToggleTooltip, - workPanelWidth, + requestedWorkPanelWidth, } = useAppShellRuntime(); useCopyTex(); @@ -113,7 +113,7 @@ export function AppShell() { containerWidth: shellWidth, sidebarWidth, sidebarCollapsed, - requestedPanelWidth: workPanelWidth, + requestedPanelWidth: requestedWorkPanelWidth, maximized: workPanelMaximized, }).panelWidth; diff --git a/apps/desktop/src/features/app/useAppShellRuntime.tsx b/apps/desktop/src/features/app/useAppShellRuntime.tsx index 8b0be61859..2685b1d78a 100644 --- a/apps/desktop/src/features/app/useAppShellRuntime.tsx +++ b/apps/desktop/src/features/app/useAppShellRuntime.tsx @@ -26,6 +26,7 @@ import { sidebarWidthBudget } from "../../lib/sidebar-resize"; import { commitWorkPanelPresentation } from "../../lib/work-panel-presentation"; import { MAIN_PANE_MIN_WIDTH, + workPanelRequestedWidth, workPanelWidthForSidebarReopen, } from "../../lib/work-panel-resize"; import { browserPluginTab, browserPluginTabForReveal } from "../../lib/work-panel-tabs"; @@ -62,6 +63,7 @@ export function useAppShellRuntime() { const settings = useAppStore((s) => s.settings); const workPanelOpen = useAppStore((s) => s.workPanelOpen); const workPanelWidth = useAppStore((s) => s.workPanelWidth); + const workPanelYieldedWidth = useAppStore((s) => s.workPanelYieldedWidth); const pluginThemes = useAppStore((s) => s.pluginThemes); const refreshPluginThemes = useAppStore((s) => s.refreshPluginThemes); const plugins = useAppStore((s) => s.plugins); @@ -87,6 +89,7 @@ export function useAppShellRuntime() { const sidebarPreferredWidthRef = useRef(sidebarWidth); const shellWidthRef = useRef(shellWidth); const workPanelWidthRef = useRef(workPanelWidth); + const workPanelYieldedWidthRef = useRef(workPanelYieldedWidth); const workPanelOpenRef = useRef(workPanelVisible); const workPanelMaximizedRef = useRef(false); const autoCollapsedSidebarRef = useRef(false); @@ -94,6 +97,7 @@ export function useAppShellRuntime() { sidebarWidthRef.current = sidebarWidth; shellWidthRef.current = shellWidth; workPanelWidthRef.current = workPanelWidth; + workPanelYieldedWidthRef.current = workPanelYieldedWidth; workPanelOpenRef.current = workPanelVisible; // The shell is a fixed client area: the three-column budget needs its real @@ -112,13 +116,22 @@ export function useAppShellRuntime() { observer.observe(shell); return () => observer.disconnect(); }, []); - const resolveSidebarMax = () => - sidebarWidthBudget({ - containerWidth: appShellRef.current?.clientWidth || shellWidthRef.current, + const resolveSidebarMax = () => { + const containerWidth = + appShellRef.current?.clientWidth || shellWidthRef.current; + return sidebarWidthBudget({ + containerWidth, workPanelOpen: workPanelOpenRef.current, - workPanelWidth: workPanelWidthRef.current, + workPanelWidth: workPanelRequestedWidth({ + containerWidth, + sidebarWidth: sidebarWidthRef.current, + sidebarCollapsed: sidebarCollapsedRef.current, + preferredWidth: workPanelWidthRef.current, + yieldedWidth: workPanelYieldedWidthRef.current, + }), workPanelMaximized: workPanelMaximizedRef.current, }); + }; const handleSidebarWidthChange = useCallback((width: number) => { setSidebarWidth(clampSidebarWidth(width, resolveSidebarMax())); }, []); @@ -135,7 +148,8 @@ export function useAppShellRuntime() { // Reopening prefers the right column: the work panel gives up width first so // MainChat keeps the width it already had, and only a would-be breach of the // 450px floor falls back to the 460px reopen target. The column comes back at - // the user's preferred width, which a collapsing drag never overwrites. + // the user's preferred width, which a collapsing drag never overwrites. The + // panel only lends that width: its own preferred width stays stored. const reopenSidebar = useCallback(() => { if (!sidebarCollapsedRef.current) return; const preferredWidth = clampSidebarWidth(sidebarPreferredWidthRef.current); @@ -150,7 +164,11 @@ export function useAppShellRuntime() { sidebarWidth: preferredWidth, currentPanelWidth, }); - useAppStore.getState().setWorkPanelWidth(nextPanelWidth); + useAppStore + .getState() + .setWorkPanelYieldedWidth( + nextPanelWidth < currentPanelWidth ? nextPanelWidth : null, + ); } autoCollapsedSidebarRef.current = false; setSidebarWidth(preferredWidth); @@ -175,6 +193,13 @@ export function useAppShellRuntime() { autoCollapsedSidebarRef.current = true; setSidebarCollapsed(true); }, []); + // Width the panel lent to a reopened sidebar returns once the sidebar has + // finished collapsing, whichever path collapsed it. + useEffect(() => { + if (sidebarCollapsed && !sidebarExiting) { + useAppStore.getState().setWorkPanelYieldedWidth(null); + } + }, [sidebarCollapsed, sidebarExiting]); const [presentedWorkPanelOpen, setPresentedWorkPanelOpen] = useState(false); const [workPanelMaximized, setWorkPanelMaximized] = useState(false); const [workPanelExiting, setWorkPanelExiting] = useState(false); @@ -943,10 +968,19 @@ export function useAppShellRuntime() { : workPanelToggleLabel; + // What the panel asks of the shared budget, including a width it lent to a + // reopened sidebar; the stored preference may be wider. + const requestedWorkPanelWidth = workPanelRequestedWidth({ + containerWidth: shellWidth, + sidebarWidth, + sidebarCollapsed: sidebarCollapsed && !sidebarExiting, + preferredWidth: workPanelWidth, + yieldedWidth: workPanelYieldedWidth, + }); const sidebarWidthMax = sidebarWidthBudget({ containerWidth: shellWidth, workPanelOpen: workPanelVisible || presentedWorkPanelOpen, - workPanelWidth, + workPanelWidth: requestedWorkPanelWidth, workPanelMaximized, }); @@ -972,7 +1006,7 @@ export function useAppShellRuntime() { autoCollapseSidebar, appShellRef, shellWidth, - workPanelWidth, + requestedWorkPanelWidth, runMenuCommand, handleSidebarAnimationEnd, presentedWorkPanelOpen, diff --git a/apps/desktop/src/lib/work-panel-resize.ts b/apps/desktop/src/lib/work-panel-resize.ts index 708195840a..465f552c07 100644 --- a/apps/desktop/src/lib/work-panel-resize.ts +++ b/apps/desktop/src/lib/work-panel-resize.ts @@ -18,7 +18,8 @@ export const MAIN_PANE_REOPEN_TARGET_WIDTH = MAIN_PANE_MIN_WIDTH + 10; /** * The regular panel minimum is a presentation affordance. The sidebar reopen * path may temporarily spend the whole right column to preserve MainChat, so - * a positive compact width must remain representable in persisted state. + * a positive compact width must remain representable as a displayed width. + * It is never persisted: the preferred width keeps the regular minimum. */ export const WORK_PANEL_COMPACT_MIN_WIDTH = 1; @@ -46,6 +47,50 @@ export function clampWorkPanelWidth( return Math.max(workPanelWidthLimits(min).min, width); } +/** + * Lower bound for a manual resize gesture. Gestures keep the regular minimum; + * a panel shown narrower than that, by a tight budget or a reopened sidebar, + * starts from its current width instead of jumping, and cannot shrink further. + */ +export function workPanelGestureMinimum(displayedWidth: number) { + return Math.min( + WORK_PANEL_MIN_WIDTH, + Math.max(WORK_PANEL_COMPACT_MIN_WIDTH, Math.round(displayedWidth)), + ); +} + +/** + * The width the panel asks of the three-column budget. A manual sidebar reopen + * lends part of the panel to the sidebar (see `workPanelWidthForSidebarReopen`) + * without changing the preferred width. While that sidebar stays expanded the + * panel keeps at least the lent width, and extra client width goes back to the + * panel first, up to the preferred width, so MainChat stays at its 460px + * reopen target. A collapsed sidebar ends the loan. + */ +export function workPanelRequestedWidth({ + containerWidth, + sidebarWidth, + sidebarCollapsed, + preferredWidth, + yieldedWidth, +}: { + containerWidth: number; + sidebarWidth: number; + sidebarCollapsed: boolean; + preferredWidth: number; + yieldedWidth: number | null; +}) { + if (yieldedWidth === null || sidebarCollapsed) return preferredWidth; + const returned = + Math.round(containerWidth) - + Math.round(sidebarWidth) - + MAIN_PANE_REOPEN_TARGET_WIDTH; + return Math.max( + WORK_PANEL_COMPACT_MIN_WIDTH, + Math.min(preferredWidth, Math.max(Math.round(yieldedWidth), returned)), + ); +} + /** * Live bounds for a manual panel width: the compact minimum replaces the * regular minimum while the panel is below it, and the shared three-column diff --git a/apps/desktop/src/stores/app-state.ts b/apps/desktop/src/stores/app-state.ts index 7858a3e7b8..21b76b5b9c 100644 --- a/apps/desktop/src/stores/app-state.ts +++ b/apps/desktop/src/stores/app-state.ts @@ -387,7 +387,13 @@ export type AppState = { activeWorkPanelTabId: string | null; /** Runtime-only work panel state owned by each conversation. */ workPanelContexts: Record; + /** The user's preferred panel width; persisted, never below the regular minimum. */ workPanelWidth: number; + /** + * Runtime-only width a manual sidebar reopen lends to the sidebar. It ends + * when the sidebar collapses or the user resizes the panel. + */ + workPanelYieldedWidth: number | null; /** Chat-initiated "preview this file" request consumed by the files viewer. */ workPanelFileRequest: { path: string; @@ -419,6 +425,7 @@ export type AppState = { /** Hide the visible panel while retaining its session-owned context. */ resetWorkPanelContext: () => void; setWorkPanelWidth: (width: number) => void; + setWorkPanelYieldedWidth: (width: number | null) => void; openFileInWorkPanel: ( path: string, mimeType?: string, diff --git a/apps/desktop/src/stores/slices/initial-state.ts b/apps/desktop/src/stores/slices/initial-state.ts index 486f0ee7d0..2b98d86978 100644 --- a/apps/desktop/src/stores/slices/initial-state.ts +++ b/apps/desktop/src/stores/slices/initial-state.ts @@ -49,6 +49,7 @@ export function createInitialState(): AppStateData { activeWorkPanelTabId: null, workPanelContexts: {}, workPanelWidth: initialWorkPanelWidth, + workPanelYieldedWidth: null, workPanelFileRequest: null, projectSort: initialSidebarPreferences.projectSort, messages: [], diff --git a/apps/desktop/src/stores/slices/work-panel-slice.ts b/apps/desktop/src/stores/slices/work-panel-slice.ts index 666a43bb95..d3c5630285 100644 --- a/apps/desktop/src/stores/slices/work-panel-slice.ts +++ b/apps/desktop/src/stores/slices/work-panel-slice.ts @@ -21,6 +21,7 @@ import { import { WORK_PANEL_COMPACT_MIN_WIDTH, WORK_PANEL_DEFAULT_WIDTH, + WORK_PANEL_MIN_WIDTH, } from "../../lib/work-panel-resize"; import type { AppState } from "../app-state"; import type { StoreAccess } from "./types"; @@ -33,11 +34,10 @@ export function loadWorkPanelWidth(): number { if (!raw) return WORK_PANEL_DEFAULT_WIDTH; const parsed = JSON.parse(raw) as Record; const width = Number(parsed.width); + // Only the former sidebar-reopen path stored widths below the regular + // minimum; that width was lent to the sidebar, not chosen by the user. return Number.isFinite(width) - ? Math.max( - WORK_PANEL_COMPACT_MIN_WIDTH, - Math.round(width), - ) + ? Math.max(WORK_PANEL_MIN_WIDTH, Math.round(width)) : WORK_PANEL_DEFAULT_WIDTH; } catch { return WORK_PANEL_DEFAULT_WIDTH; @@ -114,6 +114,7 @@ export function createWorkPanelSlice({ | "collapseWorkPanel" | "resetWorkPanelContext" | "setWorkPanelWidth" + | "setWorkPanelYieldedWidth" | "openFileInWorkPanel" | "openUrlInWorkPanel" | "updateBrowserWorkPanelTab" @@ -352,13 +353,20 @@ export function createWorkPanelSlice({ setWorkPanelWidth: (width) => { const committedWidth = Math.round(width); set({ - workPanelWidth: Math.max( - WORK_PANEL_COMPACT_MIN_WIDTH, - committedWidth, - ), + workPanelWidth: Math.max(WORK_PANEL_MIN_WIDTH, committedWidth), + // An explicit resize replaces any width lent to a reopened sidebar. + workPanelYieldedWidth: null, }); saveWorkPanelWidth(get().workPanelWidth); }, + setWorkPanelYieldedWidth: (width) => { + const next = + width === null + ? null + : Math.max(WORK_PANEL_COMPACT_MIN_WIDTH, Math.round(width)); + if (get().workPanelYieldedWidth === next) return; + set({ workPanelYieldedWidth: next }); + }, openFileInWorkPanel: (path, mimeType, position) => { get().openWorkPanelTab(fileWorkPanelTab(path, mimeType, position)); diff --git a/apps/desktop/test/work-panel-resize.test.mjs b/apps/desktop/test/work-panel-resize.test.mjs index cde92239f9..126a635b10 100644 --- a/apps/desktop/test/work-panel-resize.test.mjs +++ b/apps/desktop/test/work-panel-resize.test.mjs @@ -16,6 +16,8 @@ import { workPanelResetWidth, workPanelWidthBounds, workPanelChatWidthFromPointer, + workPanelGestureMinimum, + workPanelRequestedWidth, workPanelWidthForSidebarReopen, } from "../src/lib/work-panel-resize.ts"; @@ -112,6 +114,75 @@ test("the compact width stays representable for the reopen path", () => { assert.equal(clampWorkPanelWidth(0, WORK_PANEL_COMPACT_MIN_WIDTH), WORK_PANEL_COMPACT_MIN_WIDTH); }); +// The reported sequence: an 866px shell, a 275px sidebar and a 360px panel. +const narrowShell = { containerWidth: 866, sidebarWidth: 275 }; +const panelLayout = (sidebarCollapsed, yieldedWidth, containerWidth = 866) => + workPanelLayout({ + ...narrowShell, + containerWidth, + sidebarCollapsed, + requestedPanelWidth: workPanelRequestedWidth({ + ...narrowShell, + containerWidth, + sidebarCollapsed, + preferredWidth: 360, + yieldedWidth, + }), + }); + +test("a narrow sidebar reopen only lends panel width", () => { + const lent = workPanelWidthForSidebarReopen({ + ...narrowShell, + currentPanelWidth: 360, + }); + assert.equal(lent, 131); + + const reopened = panelLayout(false, lent); + assert.equal(reopened.panelWidth, 131); + assert.equal(reopened.mainWidth, MAIN_PANE_REOPEN_TARGET_WIDTH); + assert.equal(reopened.shouldCollapseSidebar, false); + + // Collapsing the sidebar again ends the loan: the panel returns to its + // preferred width, and resizing keeps the regular minimum. + const collapsed = panelLayout(true, lent); + assert.equal(collapsed.panelWidth, 360); + assert.equal(workPanelGestureMinimum(collapsed.panelWidth), WORK_PANEL_MIN_WIDTH); +}); + +test("space returning to a reopened sidebar goes back to the panel first", () => { + assert.equal(panelLayout(false, 131, 1000).panelWidth, 265); + assert.equal(panelLayout(false, 131, 1000).mainWidth, MAIN_PANE_REOPEN_TARGET_WIDTH); + assert.equal(panelLayout(false, 131, 1200).panelWidth, 360); + assert.ok(panelLayout(false, 131, 1200).mainWidth > MAIN_PANE_REOPEN_TARGET_WIDTH); + + // A narrower window keeps the lent width until it no longer fits beside + // the 450px floor; then the sidebar yields as usual. + const narrower = panelLayout(false, 131, 860); + assert.equal(narrower.panelWidth, 131); + assert.equal(narrower.shouldCollapseSidebar, false); + assert.equal(panelLayout(false, 131, 856).shouldCollapseSidebar, true); +}); + +test("without a loan the panel asks for its preferred width", () => { + assert.equal( + workPanelRequestedWidth({ + ...narrowShell, + sidebarCollapsed: false, + preferredWidth: 360, + yieldedWidth: null, + }), + 360, + ); +}); + +test("a resize gesture never starts below its regular minimum or jumps", () => { + assert.equal(workPanelGestureMinimum(360), WORK_PANEL_MIN_WIDTH); + assert.equal(workPanelGestureMinimum(WORK_PANEL_MIN_WIDTH), WORK_PANEL_MIN_WIDTH); + // A panel shown narrower than the minimum cannot be dragged narrower. + assert.equal(workPanelGestureMinimum(131), 131); + assert.equal(workPanelGestureMinimum(0), WORK_PANEL_COMPACT_MIN_WIDTH); +}); + test("a wide window lets the panel grow past the old fixed cap", () => { // Regression guard: the panel maximum is the live budget, so a 1600px shell // must allow 875px (1600 - 275 sidebar - 450 floor) rather than stopping at diff --git a/apps/desktop/test/work-panel-width-preference.test.mjs b/apps/desktop/test/work-panel-width-preference.test.mjs new file mode 100644 index 0000000000..f285312e7f --- /dev/null +++ b/apps/desktop/test/work-panel-width-preference.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; +import * as workPanelResize from "../src/lib/work-panel-resize.ts"; +import * as workPanelTabs from "../src/lib/work-panel-tabs.ts"; + +const STORAGE_KEY = "pi.desktop.workPanel"; + +/** Runs the real work-panel slice; only the Desktop API is stubbed. */ +function loadSlice() { + const relative = "../src/stores/slices/work-panel-slice.ts"; + const file = new URL(relative, import.meta.url); + const { outputText } = ts.transpileModule(readFileSync(file, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: file.pathname, + }); + const imports = { + "../../lib/api": { api: {} }, + "../../lib/remote-session-safety": { isRemoteSession: () => false }, + "../../lib/work-panel-tabs": workPanelTabs, + "../../lib/work-panel-resize": workPanelResize, + }; + const module = { exports: {} }; + new Function("require", "exports", "module", outputText)( + (id) => { + assert.ok(Object.hasOwn(imports, id), `unmocked dependency: ${id}`); + return imports[id]; + }, + module.exports, + module, + ); + return module.exports; +} + +const { createWorkPanelSlice, loadWorkPanelWidth } = loadSlice(); + +function installStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + globalThis.localStorage = { + getItem: (key) => (values.has(key) ? values.get(key) : null), + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + }; + return values; +} + +function createStore(overrides = {}) { + let state = { + activeSessionId: "session-1", + workPanelOpen: true, + workPanelTabs: [], + activeWorkPanelTabId: null, + workPanelContexts: {}, + workPanelFileRequest: null, + workPanelWidth: 360, + workPanelYieldedWidth: null, + sessions: [], + pluginViews: [], + ...overrides, + }; + const get = () => state; + const set = (partial) => { + const next = typeof partial === "function" ? partial(state) : partial; + state = { ...state, ...next }; + }; + const slice = createWorkPanelSlice({ + get, + set, + isSessionSelectionPending: () => false, + }); + state = { ...state, ...slice }; + return get; +} + +test("a width lent to a reopened sidebar is never stored", () => { + const storage = installStorage({ [STORAGE_KEY]: JSON.stringify({ width: 360 }) }); + const get = createStore(); + + get().setWorkPanelYieldedWidth(131); + + assert.equal(get().workPanelYieldedWidth, 131); + assert.equal(get().workPanelWidth, 360); + assert.equal(storage.get(STORAGE_KEY), JSON.stringify({ width: 360 })); + + get().setWorkPanelYieldedWidth(null); + assert.equal(get().workPanelYieldedWidth, null); +}); + +test("an explicit resize ends the loan and keeps the regular minimum", () => { + const storage = installStorage(); + const get = createStore({ workPanelYieldedWidth: 131 }); + + get().setWorkPanelWidth(131); + + assert.equal(get().workPanelYieldedWidth, null); + assert.equal(get().workPanelWidth, workPanelResize.WORK_PANEL_MIN_WIDTH); + assert.equal( + storage.get(STORAGE_KEY), + JSON.stringify({ width: workPanelResize.WORK_PANEL_MIN_WIDTH }), + ); + + get().setWorkPanelWidth(500); + assert.equal(get().workPanelWidth, 500); +}); + +test("a stored width below the regular minimum is restored to it on load", () => { + // Earlier builds stored the width a sidebar reopen borrowed. + installStorage({ [STORAGE_KEY]: JSON.stringify({ width: 131 }) }); + assert.equal(loadWorkPanelWidth(), workPanelResize.WORK_PANEL_MIN_WIDTH); + installStorage({ [STORAGE_KEY]: JSON.stringify({ width: 1 }) }); + assert.equal(loadWorkPanelWidth(), workPanelResize.WORK_PANEL_MIN_WIDTH); + installStorage({ [STORAGE_KEY]: JSON.stringify({ width: 500 }) }); + assert.equal(loadWorkPanelWidth(), 500); + installStorage(); + assert.equal(loadWorkPanelWidth(), workPanelResize.WORK_PANEL_DEFAULT_WIDTH); +}); diff --git a/docs/adr/0238-three-column-width-priority.md b/docs/adr/0238-three-column-width-priority.md index 48ef27932e..e308509769 100644 --- a/docs/adr/0238-three-column-width-priority.md +++ b/docs/adr/0238-three-column-width-priority.md @@ -43,8 +43,14 @@ MainChat to its floor while the expanded sidebar kept its full width. the panel can continue growing after the sidebar has yielded. 3. A manual sidebar reopen spends work-panel width first. It preserves the current MainChat width where possible; if the 450px floor would be crossed, - it targets `460px`. This reopen path may persist a positive compact panel - width below the ordinary `244px` presentation minimum. + it targets `460px`. The panel only lends that width, which may be a positive + compact width below the ordinary `244px` presentation minimum. The loan + holds while the sidebar stays expanded; extra client width goes back to the + panel first, and collapsing the sidebar or resizing the panel ends it. The + preferred panel width stays stored, and resize gestures keep the `244px` + minimum. *(Amended 2026-10-17: the reopen previously stored the compact + width as the preferred width, so the panel stayed narrow when space returned + and its drag minimum fell to 1px.)* 4. Automatic sidebar collapse is remembered only until the work panel closes. Closing the panel restores a sidebar collapsed by the layout mechanism. Manual sidebar collapse, manual reopen and a subsequent manual collapse clear diff --git a/docs/spec/04-ux/01-ui-ia.md b/docs/spec/04-ux/01-ui-ia.md index fe40a2ce4f..9575762cfe 100644 --- a/docs/spec/04-ux/01-ui-ia.md +++ b/docs/spec/04-ux/01-ui-ia.md @@ -124,7 +124,8 @@ destination, chat as the home surface, tools and permissions inline. left takes space until MainChat reaches 450px, at which point the expanded sidebar yields immediately, and moving it right gives space back. A manual sidebar reopen spends work-panel width first and otherwise targets a 460px - MainChat width. The sole + MainChat width, without changing the stored panel width, which returns when + the sidebar collapses again. The sole panel-level control is the viewport-fixed toggle; each session retains its own runtime open state, tab set, active tab, and Browser resource in renderer memory. Selecting another session swaps the visible panel context without deleting diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index 633d419be5..ff4d89818b 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -1211,8 +1211,11 @@ Work-panel and application-window resizing are implemented in MVP: occupies flex space, the shared budget continues to count the sidebar. - Reopening a sidebar the layout collapsed spends work-panel width first: the panel keeps its width while MainChat stays at or above 450px, and otherwise - the reopen targets 460px. Closing the panel restores only a sidebar the - layout collapsed; a manual collapse stays collapsed. + the reopen targets 460px. That width is only lent: the preferred panel width + stays stored, extra client width returns to the panel first, collapsing the + sidebar again restores the preferred width, and divider drags keep the 244px + minimum. Closing the panel restores only a sidebar the layout collapsed; a + manual collapse stays collapsed. - No panel action requests a positive native reservation: the preferred panel width is renderer-local, the native seam stays at zero, and native window edges resize only the fixed app window. Background-session artifacts never diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 782a1ee518..13fd6a8c63 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -16017,7 +16017,9 @@ plugin-form fixtures in an isolated temporary directory at runtime. 1. Open the work panel and request the user's preferred width. 2. Drag the inner divider toward MainChat's left edge, including during pointer preview, then release. - 3. Manually reopen the sidebar after the layout collapsed it. + 3. Manually reopen the sidebar after the layout collapsed it. Repeat in an + 866px viewport, where the reopen narrows the panel below 244px, then + collapse the sidebar again and drag the divider toward the panel. 4. Close the work panel and confirm the sidebar returns; repeat after manually collapsing the sidebar. 5. Repeat divider changes with `ArrowLeft`, `ArrowRight`, `Home`, and `End`. @@ -16037,7 +16039,9 @@ plugin-form fixtures in an isolated temporary directory at runtime. budget is exhausted the expanded sidebar collapses immediately, and the panel may keep growing afterwards. A manual reopen spends panel width first; MainChat is preserved where possible and otherwise lands on the 460px reopen - target. Closing the panel restores only a sidebar the layout collapsed. The + target. That width is lent, not stored: the stored preferred width is + unchanged, collapsing the sidebar again restores it, and divider drags still + stop at 244px. Closing the panel restores only a sidebar the layout collapsed. The separator's ARIA minimum/maximum follow the same dynamic budget. The panel header's `+`, maximize, and viewport-fixed collapse toggle resolve to a single control gap (`--ds-work-panel-control-gap`) with no divider, inset, or margin diff --git a/docs/zh-CN/spec/04-ux/01-ui-ia.md b/docs/zh-CN/spec/04-ux/01-ui-ia.md index dc61495f11..4b52295294 100644 --- a/docs/zh-CN/spec/04-ux/01-ui-ia.md +++ b/docs/zh-CN/spec/04-ux/01-ui-ia.md @@ -101,7 +101,8 @@ 审阅卡呈现其证据。 拖动通过共享三栏预算调整面板宽度:向左拖动直到 MainChat 到达 450px, 此时展开的左栏立即让位;向右拖动把空间还回去。手动重开左栏会优先 - 占用工作面板宽度,必要时以 460px MainChat 为目标。唯一的面板级控制崩溃 + 占用工作面板宽度,必要时以 460px MainChat 为目标,但不改变已存储的 + 面板宽度,再次收起左栏时面板恢复该宽度。唯一的面板级控制崩溃 面板; 每个会话保留其自己的运行时打开状态、选项卡集、活动选项卡和 渲染器内存中的浏览器资源。选择另一个会话会交换 diff --git a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md index 1277d6776b..4ddf2fee89 100644 --- a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md +++ b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md @@ -937,6 +937,11 @@ Mode/provider/model/permission/shell 配置和新提示仍然存在 关闭,然后保持在设定的客户端宽度;窗口没有变化。 在支持的固定窗口范围内,MainChat 保持 450px 硬下限;展开的左栏会在 阈值处让位,而不会把聊天压到该下限以下。 +- 手动重开被布局收起的左栏时优先占用工作面板宽度:MainChat 能保持在 + 450px 及以上时面板宽度不变,否则以 460px 为目标。这部分宽度只是借出: + 已存储的首选面板宽度不变,客户端变宽时先还给面板,再次收起左栏即恢复 + 首选宽度,分隔线拖动仍以 244px 为最小值。关闭面板只恢复由布局收起的 + 左栏;手动收起的左栏保持收起。 - 本机窗口和侧边栏调整大小不会夹紧或重写面板。原生边缘和角落仅通过 回流调整 MainChat 的大小。操作系统保留原生命中区域所有权;恢复逻辑会 等待 300ms 的稳定边界窗口,状态持久化会在最后一次调整大小/移动事件 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 313ccd8d6f..bfbfd76b2c 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -8398,7 +8398,7 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture - **步骤**: 1. 打开工作面板并请求用户的首选宽度。 2. 将内部分隔线向 MainChat 左边缘拖动,包含指针预览阶段,然后释放。 - 3. 在布局收起左栏后手动重开左栏。 + 3. 在布局收起左栏后手动重开左栏。在 866px 视口中重复一次,此时重开会让面板窄于 244px;然后再次收起左栏,并朝面板方向拖动分隔线。 4. 关闭工作面板并确认左栏恢复;再在手动收起左栏后重复一次。 5. 用 `ArrowLeft`、`ArrowRight`、`Home`、`End` 重复调整分隔线。 6. Navigate to the real Plugins and Scheduled routes with the @@ -8412,7 +8412,7 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture ink. Hover does not change geometry; sidebar and New Task remain usable. Automated by `pnpm test:e2e:layout` using real route components and DOM/CDP interaction. This is renderer evidence, not native Windows/Linux hit-test proof. -- **预期**:原生窗口宽度全程不变。MainChat 永不低于 360px —— 包含拖动过程中以及 `sidebar-out` 仍占位弹性空间期间。工作面板有效上限为客户端宽度减去 360px 下限与展开的左栏宽度,且无固定像素上限。预算耗尽时展开的左栏立即收起,面板之后仍可继续增长。手动重开优先占用右栏宽度;能保住当前 MainChat 则保持,否则落在 370px 的重开目标。关闭面板只恢复由布局机制收起的左栏。分隔线的 ARIA 最小/最大值遵循同一动态预算。 +- **预期**:原生窗口宽度全程不变。MainChat 永不低于 360px —— 包含拖动过程中以及 `sidebar-out` 仍占位弹性空间期间。工作面板有效上限为客户端宽度减去 360px 下限与展开的左栏宽度,且无固定像素上限。预算耗尽时展开的左栏立即收起,面板之后仍可继续增长。手动重开优先占用右栏宽度;能保住当前 MainChat 则保持,否则落在 370px 的重开目标。重开借出的宽度不会被存储:已存储的首选宽度不变,再次收起左栏即恢复该宽度,分隔线拖动仍停在 244px。关闭面板只恢复由布局机制收起的左栏。分隔线的 ARIA 最小/最大值遵循同一动态预算。 - **在运行中的应用里实测的浮层覆盖**:在 Plugins 路由上打开一个插件模态框;模态遮罩是标题栏带内工作面板开关处的命中首选;标题栏带不是命中首选,因此不会绘制在模态遮罩之上;关闭模态框后路由保持干净。这四项命中由 `pnpm test:e2e:layout` 断言,属于渲染器 DOM/CDP 证据,不是原生命中测试证明。 - **链接规格**:`04-ux/01-ui-ia.md`、`04-ux/07-ui-design-system.md` §10、`04-ux/08-component-spec.md` §1 与 §5、`04-ux/09-interaction-patterns.md` §8、ADR 0238 - **验收**:F(持久化)、品质 diff --git a/scripts/e2e-three-column-layout.mjs b/scripts/e2e-three-column-layout.mjs index c276f3e5c5..869c488a28 100644 --- a/scripts/e2e-three-column-layout.mjs +++ b/scripts/e2e-three-column-layout.mjs @@ -12,7 +12,10 @@ * while `sidebar-out` still occupies flex space; * - the expanded sidebar yields at the threshold and returns when the panel * closes; - * - a manual reopen spends work-panel width first, otherwise targeting 460px. + * - a manual reopen spends work-panel width first, otherwise targeting 460px; + * that width is only borrowed: the stored preferred width is unchanged, + * collapsing the sidebar again restores it, and divider drags still stop + * at the 244px minimum. * * Prereqs: `pnpm --filter @pi-desktop/desktop build` (or `pnpm build:js`) and a * host-core binary (target/debug, target/release, or PI_DESKTOP_HOST_BIN). @@ -39,6 +42,7 @@ const electronBin = const cdpPort = Number(process.env.PI_DESKTOP_LAYOUT_CDP_PORT || 9336); const MAIN_PANE_MIN_WIDTH = 450; const MAIN_PANE_REOPEN_TARGET_WIDTH = 460; +const WORK_PANEL_MIN_WIDTH = 244; function resolveHostBinary() { const candidates = [ @@ -446,6 +450,45 @@ async function main() { await delay(600); return { start, after: await measure(), minMain, windowChanged: false }; }; + // Moves the inner divider by a signed step per frame; positive steps + // narrow the panel. Reports the narrowest panel seen during the preview. + const dragDividerBy = async (stepPx, steps) => { + const start = await measure(); + if (!start.handle) throw new Error("divider not found"); + await cdp.send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: start.handle.x, + y: start.handle.y, + button: "left", + clickCount: 1, + buttons: 1, + }); + let minPanel = Infinity; + let x = start.handle.x; + for (let index = 1; index <= steps; index += 1) { + x = start.handle.x + index * stepPx; + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x, + y: start.handle.y, + button: "left", + buttons: 1, + }); + await delay(50); + const sample = await measure(); + if (typeof sample.panel === "number") minPanel = Math.min(minPanel, sample.panel); + } + await cdp.send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x, + y: start.handle.y, + button: "left", + clickCount: 1, + buttons: 0, + }); + await delay(600); + return { start, after: await measure(), minPanel }; + }; await waitFor( () => cdp.evaluate(`!!document.querySelector(".main-pane")`), @@ -641,6 +684,62 @@ async function main() { `main=${reopened.main} panel=${reopened.panel}`, ); + // 3b. A narrow reopen only borrows panel width. An 866px viewport makes + // the reopen squeeze the panel below its 244px minimum. + const viewportHeight = await cdp.evaluate("window.innerHeight"); + await cdp.send("Emulation.setDeviceMetricsOverride", { + width: 866, + height: viewportHeight, + deviceScaleFactor: 0, + mobile: false, + }); + await delay(500); + if ((await measure()).sidebarKind !== "sidebar") await clickSidebarToggle(); + // 360px does not fit beside the sidebar here, so the layout collapses it. + await rig(`window.__PI_DESKTOP__.setWorkPanelWidth(360)`); + if ((await measure()).sidebarKind === "sidebar") await clickSidebarToggle(); + const narrowCollapsed = await measure(); + await clickSidebarToggle(); + const narrowReopened = await measure(); + check( + narrowCollapsed.panel === 360 && + narrowReopened.sidebarKind === "sidebar" && + narrowReopened.main === MAIN_PANE_REOPEN_TARGET_WIDTH && + narrowReopened.panel === + narrowReopened.windowWidth - + narrowReopened.sidebar - + MAIN_PANE_REOPEN_TARGET_WIDTH && + narrowReopened.panel < WORK_PANEL_MIN_WIDTH, + "a narrow reopen shows the panel below its regular minimum", + JSON.stringify({ narrowCollapsed, narrowReopened }), + ); + const storedAfterNarrowReopen = await cdp.evaluate( + `localStorage.getItem("pi.desktop.workPanel")`, + ); + check( + JSON.parse(storedAfterNarrowReopen ?? "{}").width === 360, + "a narrow reopen keeps the stored preferred panel width", + String(storedAfterNarrowReopen), + ); + await clickSidebarToggle(); + await delay(400); + const narrowRecollapsed = await measure(); + check( + narrowRecollapsed.sidebarKind !== "sidebar" && narrowRecollapsed.panel === 360, + "collapsing the sidebar again restores the preferred panel width", + JSON.stringify(narrowRecollapsed), + ); + const narrowShrink = await dragDividerBy(30, 12); + check( + narrowShrink.minPanel >= WORK_PANEL_MIN_WIDTH && + narrowShrink.after.panel === WORK_PANEL_MIN_WIDTH, + "divider drags still stop at the 244px minimum after a narrow reopen", + `min=${narrowShrink.minPanel} after=${narrowShrink.after.panel}`, + ); + await cdp.send("Emulation.clearDeviceMetricsOverride"); + await delay(500); + if ((await measure()).sidebarKind !== "sidebar") await clickSidebarToggle(); + // 4. Closing the panel restores a layout-collapsed sidebar only. await rig(`window.__PI_DESKTOP__.setWorkPanelWidth(720)`); const pressed = await measure();