From ce52149b23fa9c0500fde3b99763531cd633b8e1 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 23 Aug 2026 17:16:46 +0800 Subject: [PATCH] feat(web-ui): allow right panel drag beyond 1200px to dynamic max The right panel resizer was hard-capped at RIGHT_PANEL_CONFIG.MAX_WIDTH (1200px). On wide windows a dragged right panel snapped back to 1200px and could never widen past one page. Let the drag reach the dynamic upper bound (container - resizer - MIN_CENTER_WIDTH) while always preserving a 400px chat column. - calculateValidRightWidth now clamps to the pure dynamic max, dropping the MAX_WIDTH cap. - Remove the SessionScene aux-pane CSS max-width:1200px cap and de-cap updateRightPanelWidth (clamp only to the compact minimum) so a wide manual drag is no longer pulled back to 1200px on validate/restore. - Start each drag from the DOM's real width to avoid a bounce back, and report the dynamic max via aria-valuemax. - Clamp visibilitychange and panel-expand restore paths to the dynamic max so a shrunken window can't transiently squash the chat pane below its minimum. - Include calculateValidRightWidth in the visibilitychange effect dependency array (it is a useCallback([]) with a stable reference) to satisfy react-hooks/exhaustive-deps. A 400px minimum chat column is preserved and default open/restore paths still cap at MAX_WIDTH. Test: pnpm --dir src/web-ui run lint (clean; react-hooks/exhaustive-deps 0) pnpm --dir src/web-ui run type-check (green) AI: lightly tested --- .../hooks/usePanelTabCoordinator.ts | 12 ++++++-- src/web-ui/src/app/hooks/useApp.ts | 8 +++-- .../src/app/scenes/session/SessionScene.scss | 4 ++- .../src/app/scenes/session/SessionScene.tsx | 30 ++++++++++++++----- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts index 7031d58987..397e03a155 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts @@ -12,7 +12,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { useCanvasStore } from '../stores'; import { useApp } from '@/app/hooks/useApp'; import { TAB_EVENTS } from '../types'; -import { loadPanelWidth, STORAGE_KEYS, RIGHT_PANEL_CONFIG } from '@/app/layout/panelConfig'; +import { loadPanelWidth, STORAGE_KEYS, RIGHT_PANEL_CONFIG, PANEL_COMMON_CONFIG } from '@/app/layout/panelConfig'; interface UsePanelTabCoordinatorOptions { /** Auto-collapse when all tabs are closed */ autoCollapseOnEmpty?: boolean; @@ -88,7 +88,15 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = if (rightPanelCollapsedRef.current && toggleRightPanelRef.current && updateRightPanelWidth) { // Restore last width if available, otherwise use default const lastWidth = loadPanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, RIGHT_PANEL_CONFIG.COMFORTABLE_DEFAULT); - updateRightPanelWidth(lastWidth); + // Clamp to the dynamic upper bound (session-scene container - resizer - min chat) + // so a width widened past 1200px on a larger window does not squash the chat pane + // below its one-page minimum when the panel expands on a narrower window (P2-1). + const sceneEl = document.querySelector('[data-bf-part="root"]'); + const sceneWidth = sceneEl?.offsetWidth ?? 0; + const dynamicMax = sceneWidth > 0 + ? Math.max(RIGHT_PANEL_CONFIG.COMPACT_WIDTH, sceneWidth - PANEL_COMMON_CONFIG.RESIZER_WIDTH - PANEL_COMMON_CONFIG.MIN_CENTER_WIDTH) + : RIGHT_PANEL_CONFIG.MAX_WIDTH; + updateRightPanelWidth(Math.min(dynamicMax, Math.max(RIGHT_PANEL_CONFIG.COMPACT_WIDTH, lastWidth))); // Expand immediately without animation (notify WorkspaceLayout) window.dispatchEvent(new CustomEvent('expand-right-panel-immediate', { diff --git a/src/web-ui/src/app/hooks/useApp.ts b/src/web-ui/src/app/hooks/useApp.ts index da983bb6ce..016fb545a4 100644 --- a/src/web-ui/src/app/hooks/useApp.ts +++ b/src/web-ui/src/app/hooks/useApp.ts @@ -90,10 +90,12 @@ export const useApp = (): UseAppReturn => { }, []); const updateRightPanelWidth = useCallback((width: number) => { - // Clamp width: 200px min, 1200px max + // Clamp to the right-panel compact minimum. There is no fixed MAX_WIDTH cap: + // the drag path bounds the width to the dynamic upper bound + // (container - resizer - MIN_CENTER_WIDTH) via calculateValidRightWidth, so a + // wider manual width survives and the chat pane keeps its one-page minimum. const MIN_WIDTH = 200; - const MAX_WIDTH = 1200; - const clampedWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width)); + const clampedWidth = Math.max(MIN_WIDTH, width); appManager.updateLayout({ rightPanelWidth: clampedWidth diff --git a/src/web-ui/src/app/scenes/session/SessionScene.scss b/src/web-ui/src/app/scenes/session/SessionScene.scss index 3a6a700bc9..1956007d4d 100644 --- a/src/web-ui/src/app/scenes/session/SessionScene.scss +++ b/src/web-ui/src/app/scenes/session/SessionScene.scss @@ -85,7 +85,9 @@ overflow: hidden; position: relative; box-sizing: border-box; - max-width: 1200px; + // No fixed max-width cap: the JS-calculated inline width (dynamic upper bound = + // container - resizer - min chat width) is authoritative, so the right panel can + // stretch until the chat pane reaches its one-page minimum. transition: width $motion-base $easing-standard, opacity $motion-base $easing-standard; diff --git a/src/web-ui/src/app/scenes/session/SessionScene.tsx b/src/web-ui/src/app/scenes/session/SessionScene.tsx index 8d03765d05..8f6fdde894 100644 --- a/src/web-ui/src/app/scenes/session/SessionScene.tsx +++ b/src/web-ui/src/app/scenes/session/SessionScene.tsx @@ -91,6 +91,12 @@ const SessionScene: React.FC = ({ const currentBottomHeight = state.layout.bottomTerminalPanelHeight || BOTTOM_TERMINAL_PANEL_CONFIG.COMFORTABLE_DEFAULT; const isTerminalDockedBottom = terminalPanelPosition === 'bottom'; const isDragging = isDraggingRight || isDraggingBottom; + // Dynamic right-panel resize max: the right panel may extend past the classic + // 1200px cap up to (container - resizer - min chat width). Falls back to the + // MAX_WIDTH constant before the container is measured. + const rightPanelResizeMax = containerRef.current + ? Math.max(RIGHT_PANEL_CONFIG.COMPACT_WIDTH, containerRef.current.offsetWidth - PANEL_COMMON_CONFIG.RESIZER_WIDTH - PANEL_COMMON_CONFIG.MIN_CENTER_WIDTH) + : RIGHT_PANEL_CONFIG.MAX_WIDTH; const stopRightPanelTransition = useCallback(() => { if (rightPanelTransitionTimerRef.current !== null) { @@ -159,11 +165,12 @@ const SessionScene: React.FC = ({ // When the container hasn't been laid out yet (e.g. window just restored from // minimize), offsetWidth may be 0. Bail early to avoid clamping to a tiny value. if (containerWidth <= 0) return newWidth; - // NavPanel (240px) is outside SessionScene — only account for resizer + min chat width + // NavPanel (240px) is outside SessionScene — only account for resizer + min chat width. + // Pure dynamic upper bound (no MAX_WIDTH hard cap): the right panel stretches until + // the chat pane reaches its one-page minimum (MIN_CENTER_WIDTH). const reserved = PANEL_COMMON_CONFIG.RESIZER_WIDTH + PANEL_COMMON_CONFIG.MIN_CENTER_WIDTH; const dynamicMax = containerWidth - reserved; - const maxWidth = Math.min(RIGHT_PANEL_CONFIG.MAX_WIDTH, dynamicMax); - return Math.min(maxWidth, Math.max(RIGHT_PANEL_CONFIG.COMPACT_WIDTH, newWidth)); + return Math.min(dynamicMax, Math.max(RIGHT_PANEL_CONFIG.COMPACT_WIDTH, newWidth)); }, []); const calculateValidBottomHeight = useCallback((newHeight: number): number => { @@ -204,7 +211,11 @@ const SessionScene: React.FC = ({ if (!containerRef.current) return; const startX = e.clientX; - const startWidth = currentRightWidth; + // Start from the DOM's real width, not the (possibly stale) state value: the + // previous drag wrote the width directly to the DOM, so the state can lag + // behind a width pulled past the classic cap. Reading the DOM keeps the next + // drag starting from the actual position (no "bounce back"). + const startWidth = auxPaneElementRef.current?.offsetWidth ?? currentRightWidth; let lastValidWidth = startWidth; setIsDraggingRight(true); @@ -236,6 +247,8 @@ const SessionScene: React.FC = ({ if (snapped !== lastValidWidth) { saveAndUpdateRightWidth(snapped); } else { + // updateRightPanelWidth has no fixed cap — the effective limit is the + // dynamic bound from calculateValidRightWidth, so the wider width survives. updateRightPanelWidth(lastValidWidth); setLastRightWidth(lastValidWidth); savePanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, lastValidWidth); @@ -376,14 +389,17 @@ const SessionScene: React.FC = ({ if (nowVisible && !prevVisibleRef.current) { const saved = loadPanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, currentRightWidth); if (saved !== currentRightWidth && !state.layout.rightPanelCollapsed) { - updateRightPanelWidth(saved); + // Clamp to the dynamic upper bound so a width widened past 1200px on a + // larger window does not squash the chat pane below its one-page minimum + // after the window shrank (P2-1). + updateRightPanelWidth(calculateValidRightWidth(saved)); } } prevVisibleRef.current = nowVisible; }; document.addEventListener('visibilitychange', handleVisibility); return () => document.removeEventListener('visibilitychange', handleVisibility); - }, [currentRightWidth, updateRightPanelWidth, state.layout.rightPanelCollapsed]); + }, [currentRightWidth, calculateValidRightWidth, updateRightPanelWidth, state.layout.rightPanelCollapsed]); // Cleanup animation frames useEffect(() => () => { @@ -553,7 +569,7 @@ const SessionScene: React.FC = ({ aria-label={t('layout.resizer.rightAriaLabel')} aria-valuenow={currentRightWidth} aria-valuemin={RIGHT_PANEL_CONFIG.COMPACT_WIDTH} - aria-valuemax={RIGHT_PANEL_CONFIG.MAX_WIDTH} + aria-valuemax={rightPanelResizeMax} title={t('layout.resizer.title', { mode: panelModeLabels[rightPanelMode] })} data-testid="session-right-pane-resizer" >