diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index ee12ca20f71..5542edfee78 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -751,7 +751,7 @@ "react": 1 }, "importSpecifiers": 80, - "nonTriviaTokens": 9576 + "nonTriviaTokens": 9575 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, diff --git a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts index 8cda999cd14..6ec3d85150f 100644 --- a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts @@ -129,7 +129,9 @@ describe('Workbar feature boundary', () => { } assert.equal(appShell.includes(''), true); assert.equal(appShell.includes(''), true); + assert.equal(provider.includes('useWorkbarController({ ...props.input, layoutContainerRef })'), true); + assert.equal(provider.includes('
'), true); assert.equal(productionEntry.includes('useWorkbarController'), false); }); diff --git a/apps/desktop/src/main/__tests__/workbar-model.test.ts b/apps/desktop/src/main/__tests__/workbar-model.test.ts index e33c1084371..f26df814b40 100644 --- a/apps/desktop/src/main/__tests__/workbar-model.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-model.test.ts @@ -21,6 +21,11 @@ import { createSessionCatalogController, selectAuthoritativeSessionIds } from '. import { sessionIdSetsEqual } from '../../renderer/features/conversation/index.js'; import assert from 'node:assert/strict'; import { afterEach, describe, it } from 'node:test'; +import { + SHELL_CONTENT_AREA_GAP_PX, + shellWorkbarGridRoom, +} from '../../renderer/application/contracts/shell-layout-contract.js'; +import { SHELL_WINDOW_MIN_WIDTH } from '../../shared/shell-layout-contract.js'; import { createSessionWorkbarPanelsState, createSessionWorkbarTabsState, @@ -32,9 +37,15 @@ import { reduceWorkbarLayout, reduceWorkbarPanels, SESSION_BOTTOM_PANEL_MAX_HEIGHT, + SESSION_CONVERSATION_MIN_WIDTH, + SESSION_WORKBAR_DEFAULT_WIDTH, SESSION_WORKBAR_MIN_WIDTH, + sessionWorkbarCeiling, + sessionWorkbarDisplayWidth, + sessionWorkbarMaxWidth, terminalSessionWorkbarTabId, WORKBAR_TOOL_DEFINITIONS, + type WorkbarLayoutState, } from '../../renderer/features/workbar/testing.js'; function installMemoryLocalStorage(initial: Record = {}) { @@ -90,7 +101,7 @@ describe('Workbar topology', () => { }); it('routes panel visibility and dimensions through the layout reducer', () => { - let state = { + let state: WorkbarLayoutState = { panels: createSessionWorkbarPanelsState(), activeSessionId: 'session-a' as string | undefined, collapsedBySession: {} as Record, @@ -98,7 +109,8 @@ describe('Workbar topology', () => { compactCollapsed: {}, spaceCollapsed: false, bottomOpen: false, - rightWidth: 480, + rightWidthPreference: 480, + rightWidthCeiling: undefined, bottomHeight: 300, }; state = reduceWorkbarLayout(state, { @@ -112,7 +124,7 @@ describe('Workbar topology', () => { placement: 'right', size: 12, }); - assert.equal(state.rightWidth, SESSION_WORKBAR_MIN_WIDTH); + assert.equal(sessionWorkbarDisplayWidth(state), SESSION_WORKBAR_MIN_WIDTH); state = reduceWorkbarLayout(state, { type: 'open', placement: 'bottom', @@ -133,6 +145,122 @@ describe('Workbar topology', () => { assert.equal(state.bottomOpen, false); }); + it('sizes the right rail from the measured container, not a fixed cap', () => { + cleanups.push(installMemoryLocalStorage()); + // 900 is a width the old 600px cap would have truncated. + const base: WorkbarLayoutState = { + panels: createSessionWorkbarPanelsState(), + activeSessionId: 'session-a', + collapsedBySession: {}, + compact: false, + compactCollapsed: {}, + spaceCollapsed: false, + bottomOpen: false, + rightWidthPreference: 900, + // The hook measures the container; a test states the ceiling the layout + // would have produced, which is `sessionWorkbarCeiling`'s job. + rightWidthCeiling: undefined, + bottomHeight: 300, + }; + let state = reduceWorkbarLayout(base, { + type: 'activate-session', + sessionId: 'session-a', + }); + // Unmeasured: the default is the safe display, but the preference survives. + assert.equal(sessionWorkbarDisplayWidth(state), SESSION_WORKBAR_DEFAULT_WIDTH); + assert.equal(state.rightWidthPreference, 900); + + state = reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: 1108 }); + assert.equal(sessionWorkbarMaxWidth(state), 1108); + assert.equal(sessionWorkbarDisplayWidth(state), 900); + + // A narrower window narrows the display without rewriting the preference. + state = reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: 620 }); + assert.equal(sessionWorkbarDisplayWidth(state), 620); + assert.equal(state.rightWidthPreference, 900); + persistWorkbarLayout(state, 'right-size'); + assert.equal(localStorage.getItem('maka-session-workbar-width-v1'), '900'); + + // Space comes back: so does the width the user chose. + state = reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: 1400 }); + assert.equal(sessionWorkbarDisplayWidth(state), 900); + + // Dragging while narrowed starts from what is displayed, so the constrained + // width becomes the new preference instead of snapping back to 900. + state = reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: 620 }); + state = reduceWorkbarLayout(state, { type: 'resize', placement: 'right', size: 700 }); + assert.equal(state.rightWidthPreference, 620); + assert.equal(sessionWorkbarDisplayWidth(state), 620); + state = reduceWorkbarLayout(state, { type: 'resize', placement: 'right', size: 300 }); + assert.equal(sessionWorkbarDisplayWidth(state), SESSION_WORKBAR_MIN_WIDTH); + }); + + it('lets an explicitly opened Workbar fit below its drag minimum at the window floor', () => { + cleanups.push(installMemoryLocalStorage()); + let state: WorkbarLayoutState = { + panels: createSessionWorkbarPanelsState(), + activeSessionId: 'session-a', + collapsedBySession: {}, + compact: true, + compactCollapsed: { 'session-a': false }, + spaceCollapsed: false, + bottomOpen: false, + rightWidthPreference: 900, + rightWidthCeiling: undefined, + bottomHeight: 300, + }; + // The measured ceiling and the shell's CSS cap share the 400px conversation + // floor and two 4px seams (one outside and one inside the detail grid). + assert.equal(sessionWorkbarCeiling(1600, 12), 1188); + assert.equal( + 1600 - 12 - sessionWorkbarCeiling(1600, 12), + SESSION_CONVERSATION_MIN_WIDTH, + ); + const compactRoom = shellWorkbarGridRoom(SHELL_WINDOW_MIN_WIDTH, 0); + assert.equal(compactRoom, 192); + assert.equal( + sessionWorkbarCeiling( + SHELL_WINDOW_MIN_WIDTH - SHELL_CONTENT_AREA_GAP_PX, + SHELL_CONTENT_AREA_GAP_PX, + ), + compactRoom, + ); + state = reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: compactRoom }); + assert.equal(isSessionWorkbarCollapsed(state), false); + assert.equal(sessionWorkbarMaxWidth(state), 192); + assert.equal(sessionWorkbarDisplayWidth(state), 192); + assert.equal(state.rightWidthPreference, 900); + assert.equal(reduceWorkbarLayout(state, { type: 'resize', placement: 'right', size: 192 }), state); + state = reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: 1188 }); + state = reduceWorkbarLayout(state, { type: 'set-compact', compact: false }); + assert.equal(isSessionWorkbarCollapsed(state), false); + assert.equal(sessionWorkbarDisplayWidth(state), 900); + }); + + it('ignores an unreadable or unchanged measurement', () => { + cleanups.push(installMemoryLocalStorage()); + const state: WorkbarLayoutState = { + panels: createSessionWorkbarPanelsState(), + activeSessionId: 'session-a', + collapsedBySession: {}, + compact: false, + compactCollapsed: {}, + spaceCollapsed: false, + bottomOpen: false, + rightWidthPreference: 480, + rightWidthCeiling: 1108, + bottomHeight: 300, + }; + assert.equal( + reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: 1108 }), + state, + ); + assert.equal( + reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: Number.NaN }), + state, + ); + }); + it('keeps static tabs and removes dynamic metadata from persistence', () => { const terminalRef = 'run:1'; const state = createSessionWorkbarPanelsState( @@ -242,7 +370,8 @@ describe('Workbar topology', () => { compactCollapsed: {}, spaceCollapsed: false, bottomOpen: true, - rightWidth: 544, + rightWidthPreference: 544, + rightWidthCeiling: undefined, bottomHeight: 388, }; persistWorkbarLayout(layout); @@ -272,7 +401,8 @@ describe('Workbar topology', () => { compactCollapsed: {}, spaceCollapsed: false, bottomOpen: true, - rightWidth: 544, + rightWidthPreference: 544, + rightWidthCeiling: undefined, bottomHeight: 388, }); }); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 12f363ef90f..0d575a30cb5 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -69,7 +69,7 @@ import * as Conversation from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { AgentGraphPanel } from './agent-graph-panel'; import { ChatComposerRegion } from './chat-composer-region'; -import { WorkbarHost, WorkbarProvider, WorkbarShellRoot, type WorkbarShellProjection } from './features/workbar'; +import { WorkbarHost, WorkbarLayoutContainer, WorkbarProvider, WorkbarShellRoot, type WorkbarShellProjection } from './features/workbar'; import { AppUpdateProvider } from './features/app-update/index.js'; import * as Goals from './features/goals'; import * as ModuleHub from './features/module-hub'; @@ -1757,7 +1757,7 @@ function AppShellContent({ which is correct: those surfaces shouldn't be a navigation entry point. */} -
+
@@ -2030,7 +2030,7 @@ function AppShellContent({
{/* Collapse hides the Workbar surface without unmounting its tools. */} -
+
diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index b0438eb0723..8e7067038f6 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -26,6 +26,7 @@ import { useRef, useState, type ComponentProps, + type RefObject, } from 'react'; import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import { useMediaQuery } from '@astryxdesign/core/hooks'; @@ -122,6 +123,11 @@ export interface UseWorkbarControllerInput { authoritativeSessionIds: ReadonlySet | undefined; shellObscured: boolean; modelChoices: readonly ChatModelChoice[]; + /** + * The grid container holding both the conversation column and the rail. The + * WorkbarProvider attaches the ref through WorkbarLayoutContainer. + */ + layoutContainerRef?: RefObject; /** Toast surface owned by the shell composition zone. */ toastApi: ToastApi; composerRef?: { current: Pick | null }; @@ -197,7 +203,12 @@ export function useWorkbarController( const terminalCopy = getDesktopConversationCopy(locale).terminalPanel; const { browser, sideChat, terminal, workBoard } = useWorkbarServices(); const compact = useMediaQuery(SHELL_WORKBAR_COMPACT_QUERY); - const layout = useWorkbarLayoutState(input.layoutSessionId, input.authoritativeSessionIds, compact); + const layout = useWorkbarLayoutState( + input.layoutSessionId, + input.authoritativeSessionIds, + compact, + input.layoutContainerRef, + ); const sideConversations = useSideConversationWorkspace(); const [pendingSideChatClose, setPendingSideChatClose] = useState< Array<{ placement: SessionWorkbarPlacement; tab: SessionWorkbarTab }> diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-layout-state.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-layout-state.ts index a4b8ab3ba7f..33b1b62dc10 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-layout-state.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-layout-state.ts @@ -20,20 +20,25 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, useReducer, useRef, + type RefObject, type SetStateAction, } from 'react'; import type { ResizableProps } from '@astryxdesign/core/Resizable'; +import { SHELL_CONTENT_AREA_GAP_PX } from '../../../application/contracts/shell-layout-contract.js'; import { loadWorkbarLayout, isSessionWorkbarCollapsed, persistWorkbarLayout, reduceWorkbarLayout, + sessionWorkbarCeiling, + sessionWorkbarDisplayWidth, + sessionWorkbarMaxWidth, SESSION_BOTTOM_PANEL_MAX_HEIGHT, SESSION_BOTTOM_PANEL_MIN_HEIGHT, - SESSION_WORKBAR_MAX_WIDTH, SESSION_WORKBAR_MIN_WIDTH, } from '../model/workbar-layout.js'; import { @@ -47,11 +52,15 @@ const LAYOUT_PERSIST_DEBOUNCE_MS = 200; * Owns the application-level Workbar topology, dimensions and persistence. * Right-panel visibility belongs to each Session; topology and sizes stay global. * `compact` is the shell's narrow-window reading; see `withCompact`. + * + * `layoutContainerRef` is the grid holding both the conversation column and the + * rail. Its width and the shell's shared seam size determine the ceiling. */ export function useWorkbarLayoutState( activeSessionId: string | undefined, authoritativeSessionIds: ReadonlySet | undefined, compact: boolean, + layoutContainerRef?: RefObject, ) { const [state, dispatch] = useReducer( reduceWorkbarLayout, @@ -76,17 +85,39 @@ export function useWorkbarLayoutState( }, [authoritativeSessionIds, activeSessionId, state.panels]); const stateRef = useRef(state); stateRef.current = state; - const rightDragStartRef = useRef(state.rightWidth); + const rightWidth = sessionWorkbarDisplayWidth(state); + const rightWidthMax = sessionWorkbarMaxWidth(state); + const rightDragStartRef = useRef(rightWidth); const bottomDragStartRef = useRef(state.bottomHeight); + // The measured input to the width policy. Observed is the parent grid, never a + // child whose width the ceiling sets — that would feed the measurement back + // into its own result. The synchronous pass matters as much as the observer: it + // lands in this commit, so the first painted frame already has the ceiling. + useLayoutEffect(() => { + const container = layoutContainerRef?.current; + if (!container || typeof ResizeObserver === 'undefined') return; + const measure = () => { + const available = container.getBoundingClientRect().width; + if (available <= 0) return; + dispatch({ + type: 'measure-right-ceiling', + ceiling: sessionWorkbarCeiling(available, SHELL_CONTENT_AREA_GAP_PX), + }); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + return () => observer.disconnect(); + }, [layoutContainerRef]); // The Workbar reducer is the controlled size authority. These props adapt it // to Astryx's ResizeHandle contract without introducing useResizable state; // snapping and handle-driven collapse are deliberately disabled here. const workbarResizable = useMemo( () => ({ - _size: state.rightWidth, + _size: rightWidth, _isCollapsed: false, _onResizeStart: () => { - rightDragStartRef.current = state.rightWidth; + rightDragStartRef.current = rightWidth; }, _onResizeMove: (delta) => dispatch({ @@ -95,14 +126,16 @@ export function useWorkbarLayoutState( size: rightDragStartRef.current + Math.round(delta), }), _onResizeEnd: () => undefined, - _minSizePx: SESSION_WORKBAR_MIN_WIDTH, - _maxSizePx: SESSION_WORKBAR_MAX_WIDTH, + _minSizePx: Math.min(SESSION_WORKBAR_MIN_WIDTH, rightWidthMax), + // The handle and the reducer hold the same ceiling, so the drag stops + // where the state machine would stop it instead of snapping back. + _maxSizePx: rightWidthMax, _snaps: [], _collapsedSize: 40, _collapsible: false, _isResizableProps: true, }), - [state.rightWidth], + [rightWidth, rightWidthMax], ); const bottomPanelResizable = useMemo( () => ({ @@ -197,7 +230,9 @@ export function useWorkbarLayoutState( persistWorkbarLayout(stateRef.current, 'right-size'); }, LAYOUT_PERSIST_DEBOUNCE_MS); return () => window.clearTimeout(handle); - }, [state.rightWidth]); + // Only the preference is persisted: a ceiling that narrowed the display is a + // property of this window, not of what the user asked for. + }, [state.rightWidthPreference]); useEffect(() => { persistWorkbarLayout(stateRef.current, 'right-visibility'); }, [state.collapsedBySession]); @@ -251,7 +286,9 @@ export function useWorkbarLayoutState( setSpaceCollapsed, bottomPanelOpen: state.bottomOpen, setBottomPanelOpen, - workbarWidth: state.rightWidth, + // What the frame publishes to CSS is the width the rail displays at, not + // the stored preference the ceiling holds back. + workbarWidth: rightWidth, workbarResizable, bottomPanelHeight: state.bottomHeight, bottomPanelResizable, diff --git a/apps/desktop/src/renderer/features/workbar/index.ts b/apps/desktop/src/renderer/features/workbar/index.ts index a99caebf47d..cbfb6494c7c 100644 --- a/apps/desktop/src/renderer/features/workbar/index.ts +++ b/apps/desktop/src/renderer/features/workbar/index.ts @@ -25,7 +25,7 @@ export { WorkbarTitlebarActions } from './ui/workbar-toggle'; export { WorkbarHost } from './ui/workbar-host'; export { loadTerminalWebLinks } from './tools/terminal/terminal-web-links'; -export { WorkbarProvider } from './ui/workbar-provider'; +export { WorkbarProvider, WorkbarLayoutContainer } from './ui/workbar-provider'; export { WorkbarShellRoot, type WorkbarShellProjection } from './ui/workbar-shell-root'; export { WorkbarServicesProvider } from './services-context'; export type { SessionWorkbarTabKind } from './model/workbar-tabs'; diff --git a/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts b/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts index 8f71245980f..5a591db7085 100644 --- a/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts +++ b/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts @@ -21,6 +21,7 @@ import { safeLocalStorageGet, safeLocalStorageSet, } from '../../../browser-storage.js'; +import { SHELL_CONVERSATION_MIN_WIDTH_PX } from '../../../application/contracts/shell-layout-contract.js'; import { persistableSessionWorkbarPanels, parseSessionWorkbarPanels, @@ -39,12 +40,18 @@ import { */ export const SESSION_WORKBAR_DEFAULT_WIDTH = 480; /** - * 340 is the floor `astryx docs layout` gives a detail/inspector panel, and it - * is also where the strip stops fitting: five faces need 386px of tab and have - * 260px, so below this the strip is always scrolling. + * 340 is the normal drag minimum `astryx docs layout` gives a detail/inspector + * panel. A compact window can force the rendered panel below it while keeping + * the saved preference; there is no adjustable drag range in that case. */ export const SESSION_WORKBAR_MIN_WIDTH = 340; -export const SESSION_WORKBAR_MAX_WIDTH = 600; +/** + * The conversation column the rail leaves behind on a window wide enough for + * both. That is why the ceiling is a layout measurement (`container − gap − + * this`) rather than a constant. This matches the shell's CSS token, including + * windows where the Workbar has less than its usual 340px minimum available. + */ +export const SESSION_CONVERSATION_MIN_WIDTH = SHELL_CONVERSATION_MIN_WIDTH_PX; export const SESSION_BOTTOM_PANEL_DEFAULT_HEIGHT = 300; export const SESSION_BOTTOM_PANEL_MIN_HEIGHT = 180; export const SESSION_BOTTOM_PANEL_MAX_HEIGHT = 520; @@ -66,7 +73,18 @@ export interface WorkbarLayoutState { cleared by any explicit collapse/expand the user makes. */ spaceCollapsed: boolean; bottomOpen: boolean; - rightWidth: number; + /** + * The width the user last chose, persisted as-is. A narrow window holds the + * *display* width (`sessionWorkbarDisplayWidth`) back instead of overwriting + * this, so widening the window gives the width back. + */ + rightWidthPreference: number; + /** + * The measured ceiling, `undefined` until the layout container reports one. + * `measure-right-ceiling` is its only writer: the policy lives in the reducer, + * the measurement in the hook that observes the element. + */ + rightWidthCeiling: number | undefined; bottomHeight: number; } @@ -92,7 +110,8 @@ export type WorkbarLayoutAction = type: 'resize'; placement: 'right' | 'bottom'; size: number; - }; + } + | { type: 'measure-right-ceiling'; ceiling: number }; export type WorkbarLayoutPersistenceTarget = | 'all' @@ -107,9 +126,43 @@ function clampSize(size: number, min: number, max: number): number { } /** - * Reads the persisted width without applying bounds. `loadWorkbarLayout` - * applies the shared reducer policy so hydration and resize actions use one - * clamping rule. + * The ceiling the rail may display at. Unmeasured there is no bound to trust, + * and the default — not the stored preference — is the safe one: a stale wide + * value must not overrun the conversation column on the first frame. + */ +export function sessionWorkbarMaxWidth(state: WorkbarLayoutState): number { + return state.rightWidthCeiling ?? SESSION_WORKBAR_DEFAULT_WIDTH; +} + +/** + * How wide the Workbar actually renders. At the native window floor the shell + * may leave less than 340px even after hiding the sidebar. That measured cap + * takes precedence over the normal drag minimum without changing the saved + * preference, so widening the window restores the user's chosen width. + */ +export function sessionWorkbarDisplayWidth(state: WorkbarLayoutState): number { + return clampSize( + state.rightWidthPreference, + SESSION_WORKBAR_MIN_WIDTH, + sessionWorkbarMaxWidth(state), + ); +} + +/** + * The ceiling a container grants the rail, where `gap` is the spacing between + * the two columns: what is left once the conversation keeps its + * `SESSION_CONVERSATION_MIN_WIDTH` target. A container too small for both + * can fall below the normal Workbar drag minimum in a compact window. + */ +export function sessionWorkbarCeiling(containerWidth: number, gap: number): number { + return containerWidth - gap - SESSION_CONVERSATION_MIN_WIDTH; +} + +/** + * Reads the persisted width without applying bounds. `loadWorkbarLayout` keeps + * it unclamped because the container, not a constant, decides how wide the rail + * may display, and a window that is too narrow right now must not truncate the + * preference the next window would fit. */ export function readSessionWorkbarWidth(): number { const stored = Number(safeLocalStorageGet('maka-session-workbar-width-v1')); @@ -198,11 +251,8 @@ export function loadWorkbarLayout(activeSessionId?: string, compact = false): Wo compactCollapsed: {}, bottomOpen: readSessionBottomPanelOpen(), spaceCollapsed: false, - rightWidth: clampSize( - readSessionWorkbarWidth(), - SESSION_WORKBAR_MIN_WIDTH, - SESSION_WORKBAR_MAX_WIDTH, - ), + rightWidthPreference: readSessionWorkbarWidth(), + rightWidthCeiling: undefined, bottomHeight: clampSize( readSessionBottomPanelHeight(), SESSION_BOTTOM_PANEL_MIN_HEIGHT, @@ -243,7 +293,7 @@ export function persistWorkbarLayout( if (target === 'all' || target === 'right-size') { safeLocalStorageSet( 'maka-session-workbar-width-v1', - String(state.rightWidth), + String(state.rightWidthPreference), ); } if (target === 'all' || target === 'bottom-size') { @@ -291,6 +341,16 @@ export function reduceWorkbarLayout( ? state : { ...state, activeSessionId: action.sessionId }; } + if (action.type === 'measure-right-ceiling') { + // The shell keeps the 400px conversation floor even in a 600px window. + // A Workbar the user opens there is allowed to be narrower than its + // normal drag minimum; measurement never overwrites the saved preference. + if (!Number.isFinite(action.ceiling)) return state; + const ceiling = Math.max(0, Math.round(action.ceiling)); + return state.rightWidthCeiling === ceiling + ? state + : { ...state, rightWidthCeiling: ceiling }; + } if (action.type === 'retain-sessions') { let panels = state.panels; for (const placement of ['right', 'bottom'] as const) { @@ -324,14 +384,22 @@ export function reduceWorkbarLayout( } if (action.type === 'resize') { if (action.placement === 'right') { - const rightWidth = clampSize( + const maxWidth = sessionWorkbarMaxWidth(state); + // Below the normal drag minimum there is no adjustable range. Keep the + // preference until more space is available instead of saving the forced + // narrow display width as the user's choice. + if (maxWidth < SESSION_WORKBAR_MIN_WIDTH) return state; + // A drag is the user's new preference, inside the space the container has. + // Its start is the *displayed* width, so a narrowed window still drags + // from what the user can see. + const rightWidthPreference = clampSize( action.size, SESSION_WORKBAR_MIN_WIDTH, - SESSION_WORKBAR_MAX_WIDTH, + maxWidth, ); - return state.rightWidth === rightWidth + return state.rightWidthPreference === rightWidthPreference ? state - : { ...state, rightWidth }; + : { ...state, rightWidthPreference }; } const bottomHeight = clampSize( action.size, diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx index 106fa11a27f..c6acababd6b 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-provider.tsx @@ -21,8 +21,10 @@ import { createContext, useContext, useLayoutEffect, + useRef, type CSSProperties, type ReactNode, + type RefObject, } from 'react'; import { useWorkbarController, @@ -32,6 +34,7 @@ import type { WorkbarShellBridge } from '../controller/workbar-shell-bridge.js'; import type { WorkbarHostModel } from './workbar-host.js'; const WorkbarHostModelContext = createContext(null); +const WorkbarLayoutContainerRefContext = createContext | null>(null); /** * Sole Workbar controller owner. Its updates reuse children built by the @@ -43,7 +46,8 @@ export function WorkbarProvider(props: { readonly input: UseWorkbarControllerInput; readonly children?: ReactNode; }) { - const controller = useWorkbarController(props.input); + const layoutContainerRef = useRef(null); + const controller = useWorkbarController({ ...props.input, layoutContainerRef }); useLayoutEffect(() => props.bridge.publish(controller), [props.bridge, controller]); useLayoutEffect(() => () => props.bridge.disconnect(), [props.bridge]); // Shell columns and the titlebar reserve both size from the Workbar width. @@ -56,13 +60,21 @@ export function WorkbarProvider(props: { } as CSSProperties; return ( -
- {props.children} -
+ +
+ {props.children} +
+
); } +export function WorkbarLayoutContainer({ children }: { readonly children?: ReactNode }) { + const ref = useContext(WorkbarLayoutContainerRefContext); + if (!ref) throw new Error('WorkbarLayoutContainer is missing its provider'); + return
{children}
; +} + export function useWorkbarHostModel(): WorkbarHostModel { const model = useContext(WorkbarHostModelContext); if (!model) throw new Error('WorkbarProvider is missing'); diff --git a/apps/desktop/src/renderer/styles/workbar/artifacts.css b/apps/desktop/src/renderer/styles/workbar/artifacts.css index 75a5885c718..49cabe8051e 100644 --- a/apps/desktop/src/renderer/styles/workbar/artifacts.css +++ b/apps/desktop/src/renderer/styles/workbar/artifacts.css @@ -226,10 +226,8 @@ background: var(--background); } -/* The workbar caps its own width (`SESSION_WORKBAR_MAX_WIDTH`), so this only - binds in the stacked layout a narrow window switches to, where that cap is - dropped and the preview can run the width of the window. Prose needs a - measure there; the pane's own width is one everywhere else. */ +/* The Workbar stays in the right column at every window width. Keep prose to + a reading measure when the available column grows beyond it. */ .maka-artifact-preview-markdown { max-width: var(--maka-reading-measure); min-width: 0; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 154cba14abc..b7eb3f7fc9d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -58,6 +58,7 @@ import { reduceWorkbarLayout, SESSION_BOTTOM_PANEL_DEFAULT_HEIGHT, SESSION_WORKBAR_DEFAULT_WIDTH, + sessionWorkbarDisplayWidth, type WorkbarLayoutState, } from '../src/renderer/features/workbar/testing'; import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel'; @@ -3940,6 +3941,8 @@ export const RailStaysOnTheVisiblePrompt: Story = { }; // What the real `open` action leaves behind, rather than a hand-written topology. +// Storybook mounts the shell without the app's measurement hook, so the fixture +// states the ceiling the app would have measured — the demo's own handle limit. const workbarLayoutWithOneFace: WorkbarLayoutState = reduceWorkbarLayout( { panels: createSessionWorkbarPanelsState(), @@ -3949,7 +3952,8 @@ const workbarLayoutWithOneFace: WorkbarLayoutState = reduceWorkbarLayout( compactCollapsed: {}, spaceCollapsed: false, bottomOpen: false, - rightWidth: SESSION_WORKBAR_DEFAULT_WIDTH, + rightWidthPreference: SESSION_WORKBAR_DEFAULT_WIDTH, + rightWidthCeiling: 760, bottomHeight: SESSION_BOTTOM_PANEL_DEFAULT_HEIGHT, }, { type: 'open', placement: 'right', tab: { id: 'workbar:files', kind: 'files' } }, @@ -3965,11 +3969,11 @@ function WorkbarInShell(props: { } = {}) { const [layout, dispatch] = useReducer(reduceWorkbarLayout, workbarLayoutWithOneFace); const resizable = useResizable({ - defaultSize: props.workbarWidth ?? layout.rightWidth, + defaultSize: props.workbarWidth ?? sessionWorkbarDisplayWidth(layout), minSize: 320, maxSize: 760, onSizeChange: (size) => dispatch({ type: 'resize', placement: 'right', size }), }); - const workbarWidth = props.workbarWidth ?? layout.rightWidth; + const workbarWidth = props.workbarWidth ?? sessionWorkbarDisplayWidth(layout); const rightCollapsed = isSessionWorkbarCollapsed(layout); const collapseRight = (collapsed: boolean) => dispatch({ type: 'collapse', placement: 'right', collapsed });