From bf730059cf4dc28ab3827c0e948e92bc30299d13 Mon Sep 17 00:00:00 2001 From: jiang1997 Date: Tue, 22 Sep 2026 02:27:12 +0800 Subject: [PATCH 1/4] feat(desktop): size workbar from available container space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixed 600px workbar limit with a ceiling derived from the layout container. Preserve the user’s preferred width separately from the constrained display width, so resizing the window temporarily narrows the rail without losing the preference. Measure the parent grid with ResizeObserver and apply the same dynamic ceiling to the reducer, resize handle, and rendered width. Publish the shared shell gap from one TypeScript source for both layout calculation and CSS, including standalone Storybook surfaces. Keep the existing stacked narrow-window layout unchanged. Generated-by: Maka --- apps/desktop/.storybook/preview.tsx | 13 ++ .../__tests__/app-shell-frame-style.test.ts | 20 ++- .../src/main/__tests__/workbar-model.test.ts | 114 +++++++++++++++++- apps/desktop/src/renderer/app-shell.tsx | 7 +- .../controller/use-workbar-controller.ts | 20 ++- .../controller/use-workbar-layout-state.ts | 56 +++++++-- .../features/workbar/model/workbar-layout.ts | 94 ++++++++++++--- apps/desktop/src/renderer/maka-tokens.css | 3 +- .../src/renderer/styles/workbar/artifacts.css | 8 +- apps/desktop/stories/app-shell.stories.tsx | 10 +- 10 files changed, 302 insertions(+), 43 deletions(-) diff --git a/apps/desktop/.storybook/preview.tsx b/apps/desktop/.storybook/preview.tsx index 52b022704d4..6ae94292295 100644 --- a/apps/desktop/.storybook/preview.tsx +++ b/apps/desktop/.storybook/preview.tsx @@ -23,6 +23,7 @@ import { Theme } from '@astryxdesign/core/theme'; import { THEME_PALETTES } from '../../../packages/core/src/settings.js'; import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; import { makaTheme } from '../src/renderer/astryx-theme/maka'; +import { APP_SHELL_CONTENT_AREA_GAP } from '../src/renderer/shell/frame-style'; const PALETTE_LABELS: Record = { default: 'Default', @@ -47,6 +48,18 @@ const withMakaRoot: Decorator = (Story, context) => { root.classList.toggle('dark', colorScheme === 'dark'); root.style.colorScheme = colorScheme; + /** + * The shell's own seam width, from the module that owns it. + * + * AppShell publishes this on `.appFrame`, which every shipped surface sits + * inside. A story that renders a shell-like subtree on its own — the Workbar + * story mounts `.maka-detail-with-artifacts` directly — has no `.appFrame` + * above it, so the variable would be absent and each `calc()` reading it + * would fail whole, silently dropping the reservation and the margins. The + * same import as `appShellFrameStyle` keeps this one number, not a second + * copy of it. + */ + root.style.setProperty('--agents-content-area-gap', `${APP_SHELL_CONTENT_AREA_GAP}px`); if (palette === 'default') { root.removeAttribute('data-maka-theme'); diff --git a/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts b/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts index 9b7f7a65613..63a1d0ae1df 100644 --- a/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts @@ -19,7 +19,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { appShellFrameStyle } from '../../renderer/shell/frame-style.js'; +import { + appShellFrameStyle, + APP_SHELL_CONTENT_AREA_GAP, +} from '../../renderer/shell/frame-style.js'; test('every published width stays a , collapsed included', () => { const collapsed = appShellFrameStyle({ @@ -69,3 +72,18 @@ test('the Workbar cap is published on the frame that owns --maka-sidenav-width', assert.ok(cap.includes('var(--maka-sidenav-width)'), cap); assert.ok(cap.includes('100cqi'), cap); }); + +test('the gap the rail is measured against is the gap the shell draws', () => { + const style = appShellFrameStyle({ + sessionListCollapsed: false, + sessionListWidth: 291, + }) as Record; + + // One source, two readers: the CSS rules that draw the seam and the Workbar + // that subtracts it from the container. A second hand-kept copy would let the + // conversation column lose pixels to a gap the layout does not draw. + assert.equal( + style['--agents-content-area-gap'], + `${APP_SHELL_CONTENT_AREA_GAP}px`, + ); +}); diff --git a/apps/desktop/src/main/__tests__/workbar-model.test.ts b/apps/desktop/src/main/__tests__/workbar-model.test.ts index e33c1084371..0ac2d3d8538 100644 --- a/apps/desktop/src/main/__tests__/workbar-model.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-model.test.ts @@ -32,9 +32,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 +96,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 +104,8 @@ describe('Workbar topology', () => { compactCollapsed: {}, spaceCollapsed: false, bottomOpen: false, - rightWidth: 480, + rightWidthPreference: 480, + rightWidthCeiling: undefined, bottomHeight: 300, }; state = reduceWorkbarLayout(state, { @@ -112,7 +119,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 +140,101 @@ 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: {}, + 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('keeps the rail at its floor when the container cannot fit both columns', () => { + cleanups.push(installMemoryLocalStorage()); + let state: WorkbarLayoutState = { + panels: createSessionWorkbarPanelsState(), + activeSessionId: 'session-a', + collapsedBySession: {}, + bottomOpen: false, + rightWidthPreference: 900, + rightWidthCeiling: undefined, + bottomHeight: 300, + }; + // The ceiling leaves the conversation column exactly its target; the plan's + // worked example is a 1600px container with a 12px gap leaving 1108px. + assert.equal(sessionWorkbarCeiling(1600, 12), 1108); + assert.equal( + 1600 - 12 - sessionWorkbarCeiling(1600, 12), + SESSION_CONVERSATION_MIN_WIDTH, + ); + // 700px of container minus a 4px gap leaves 216 for the rail — below its + // floor, so the rail keeps 340 and the conversation column is the one that + // gives way. + assert.equal(sessionWorkbarCeiling(700, 4), 216); + state = reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: sessionWorkbarCeiling(700, 4) }); + assert.equal(sessionWorkbarMaxWidth(state), SESSION_WORKBAR_MIN_WIDTH); + assert.equal(sessionWorkbarDisplayWidth(state), SESSION_WORKBAR_MIN_WIDTH); + }); + + it('ignores an unreadable or unchanged measurement', () => { + cleanups.push(installMemoryLocalStorage()); + const state: WorkbarLayoutState = { + panels: createSessionWorkbarPanelsState(), + activeSessionId: 'session-a', + collapsedBySession: {}, + 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 +344,8 @@ describe('Workbar topology', () => { compactCollapsed: {}, spaceCollapsed: false, bottomOpen: true, - rightWidth: 544, + rightWidthPreference: 544, + rightWidthCeiling: undefined, bottomHeight: 388, }; persistWorkbarLayout(layout); @@ -272,7 +375,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..f7e610ade45 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -119,7 +119,7 @@ import type { AppShellCommandListOptions } from './app-shell-command-actions'; import { createContextCompactionPresentation, presentContextCompactionResult } from './app-shell-context-compaction'; import { AppShellTitlebar } from './app-shell-chrome-actions'; import { AppShellDetailPanel } from './app-shell-detail-panel'; -import { appShellFrameStyle } from './shell/frame-style'; +import { appShellFrameStyle, APP_SHELL_CONTENT_AREA_GAP } from './shell/frame-style'; import { AppShellOverlays } from './app-shell-overlays'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; import { CustomPetCompanion } from './custom-pet-companion'; @@ -271,6 +271,7 @@ function AppShellContent({ }) { const toastApi = useToast(); const previousInterruptionShownRef = useRef(false); + const workbarLayoutContainerRef = useRef(null); const { readMessages, refreshMessages, @@ -1598,6 +1599,8 @@ function AppShellContent({ authoritativeSessionIds, shellObscured, modelChoices: chatModelChoices, + layoutContainerRef: workbarLayoutContainerRef, + layoutGap: APP_SHELL_CONTENT_AREA_GAP, toastApi, composerRef, openNewTaskSurface, @@ -1757,7 +1760,7 @@ function AppShellContent({ which is correct: those surfaces shouldn't be a navigation entry point. */} -
+
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..9a4f247f60d 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,17 @@ export interface UseWorkbarControllerInput { authoritativeSessionIds: ReadonlySet | undefined; shellObscured: boolean; modelChoices: readonly ChatModelChoice[]; + /** + * The grid container holding both the conversation column and the rail. The + * shell owns the element; the Workbar owns what to do with its width. + */ + layoutContainerRef?: RefObject; + /** + * The spacing between those two columns, in CSS pixels. The shell resolves it + * because a custom property reads back as its declaration, not a length (see + * `ink-ladder-contract`), so the Workbar cannot read the variable itself. + */ + layoutGap?: number; /** Toast surface owned by the shell composition zone. */ toastApi: ToastApi; composerRef?: { current: Pick | null }; @@ -197,7 +209,13 @@ 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, + input.layoutGap, + ); 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..10cdee26e51 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,9 +20,11 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, useReducer, useRef, + type RefObject, type SetStateAction, } from 'react'; import type { ResizableProps } from '@astryxdesign/core/Resizable'; @@ -31,9 +33,11 @@ import { 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 +51,17 @@ 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; `layoutGap` is the spacing between them. Measuring one and knowing the + * other is what makes the rail's ceiling a function of the available space. */ export function useWorkbarLayoutState( activeSessionId: string | undefined, authoritativeSessionIds: ReadonlySet | undefined, compact: boolean, + layoutContainerRef?: RefObject, + layoutGap = 0, ) { const [state, dispatch] = useReducer( reduceWorkbarLayout, @@ -76,17 +86,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, layoutGap), + }); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + return () => observer.disconnect(); + }, [layoutContainerRef, layoutGap]); // 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 +127,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 +231,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 +287,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/model/workbar-layout.ts b/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts index 8f71245980f..e118246c983 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, @@ -44,7 +45,13 @@ export const SESSION_WORKBAR_DEFAULT_WIDTH = 480; * 260px, so below this the strip is always scrolling. */ 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 rail actually renders. The ceiling moves with the window, so + * readers that ask "how wide is the rail" want this; the preference is only + * what a drag writes and what storage keeps. + */ +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 + * returns below the rail's floor on purpose — the reducer owns the floor, so + * the caller can still see that the space ran out. + */ +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,13 @@ export function reduceWorkbarLayout( ? state : { ...state, activeSessionId: action.sessionId }; } + if (action.type === 'measure-right-ceiling') { + 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 +381,17 @@ export function reduceWorkbarLayout( } if (action.type === 'resize') { if (action.placement === 'right') { - const rightWidth = clampSize( + // 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, + Math.min(SESSION_WORKBAR_MIN_WIDTH, sessionWorkbarMaxWidth(state)), + sessionWorkbarMaxWidth(state), ); - 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/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index f4fd0042554..a4e4a28b069 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -960,7 +960,8 @@ 6 → 12 so the floating surface's bottom corners actually read. 6px was geometrically present but optically invisible against a same-color shell. */ - --agents-content-area-gap: 4px; + /* appShellFrameStyle publishes the Workbar seam from + APP_SHELL_CONTENT_AREA_GAP; Storybook publishes the same value at root. */ /* === conversation layout contract ===================================== The Desktop and WorkHub renderers are separate documents, but both load diff --git a/apps/desktop/src/renderer/styles/workbar/artifacts.css b/apps/desktop/src/renderer/styles/workbar/artifacts.css index 75a5885c718..82fe7791148 100644 --- a/apps/desktop/src/renderer/styles/workbar/artifacts.css +++ b/apps/desktop/src/renderer/styles/workbar/artifacts.css @@ -226,10 +226,10 @@ 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 sizes itself against the container it sits in, so this only + binds in the stacked layout a narrow window switches to, where the rail runs + the width of the window. Prose needs a measure there; the pane's own width + already is one everywhere else. */ .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 }); From 0b7e88e979e61ff8ff4a5e0b940b1761a2d640b1 Mon Sep 17 00:00:00 2001 From: jiang1997 Date: Mon, 28 Sep 2026 09:26:47 +0800 Subject: [PATCH 2/4] test(desktop): cover Workbar layout container wiring --- apps/desktop/src/main/__tests__/workbar-boundary.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts index 8cda999cd14..6697f973e02 100644 --- a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts @@ -129,6 +129,11 @@ describe('Workbar feature boundary', () => { } assert.equal(appShell.includes(''), true); assert.equal(appShell.includes('/, + ); assert.equal(provider.includes('useWorkbarController(props.input)'), true); assert.equal(productionEntry.includes('useWorkbarController'), false); }); From 740609c98b87c19aa9814b84d1334b4524cc85cb Mon Sep 17 00:00:00 2001 From: jiang1997 Date: Mon, 28 Sep 2026 10:25:46 +0800 Subject: [PATCH 3/4] fix(desktop): keep Workbar sizing within AppShell architecture budget --- .../main/__tests__/workbar-boundary.test.ts | 9 +++------ apps/desktop/src/renderer/app-shell.tsx | 11 ++++------ .../controller/use-workbar-controller.ts | 9 +-------- .../controller/use-workbar-layout-state.ts | 10 ++++++---- .../src/renderer/features/workbar/index.ts | 2 +- .../features/workbar/ui/workbar-provider.tsx | 20 +++++++++++++++---- 6 files changed, 31 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts index 6697f973e02..6ec3d85150f 100644 --- a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts @@ -129,12 +129,9 @@ describe('Workbar feature boundary', () => { } assert.equal(appShell.includes(''), true); assert.equal(appShell.includes('/, - ); - assert.equal(provider.includes('useWorkbarController(props.input)'), 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/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f7e610ade45..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'; @@ -119,7 +119,7 @@ import type { AppShellCommandListOptions } from './app-shell-command-actions'; import { createContextCompactionPresentation, presentContextCompactionResult } from './app-shell-context-compaction'; import { AppShellTitlebar } from './app-shell-chrome-actions'; import { AppShellDetailPanel } from './app-shell-detail-panel'; -import { appShellFrameStyle, APP_SHELL_CONTENT_AREA_GAP } from './shell/frame-style'; +import { appShellFrameStyle } from './shell/frame-style'; import { AppShellOverlays } from './app-shell-overlays'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; import { CustomPetCompanion } from './custom-pet-companion'; @@ -271,7 +271,6 @@ function AppShellContent({ }) { const toastApi = useToast(); const previousInterruptionShownRef = useRef(false); - const workbarLayoutContainerRef = useRef(null); const { readMessages, refreshMessages, @@ -1599,8 +1598,6 @@ function AppShellContent({ authoritativeSessionIds, shellObscured, modelChoices: chatModelChoices, - layoutContainerRef: workbarLayoutContainerRef, - layoutGap: APP_SHELL_CONTENT_AREA_GAP, toastApi, composerRef, openNewTaskSurface, @@ -1760,7 +1757,7 @@ function AppShellContent({ which is correct: those surfaces shouldn't be a navigation entry point. */} -
+
@@ -2033,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 9a4f247f60d..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 @@ -125,15 +125,9 @@ export interface UseWorkbarControllerInput { modelChoices: readonly ChatModelChoice[]; /** * The grid container holding both the conversation column and the rail. The - * shell owns the element; the Workbar owns what to do with its width. + * WorkbarProvider attaches the ref through WorkbarLayoutContainer. */ layoutContainerRef?: RefObject; - /** - * The spacing between those two columns, in CSS pixels. The shell resolves it - * because a custom property reads back as its declaration, not a length (see - * `ink-ladder-contract`), so the Workbar cannot read the variable itself. - */ - layoutGap?: number; /** Toast surface owned by the shell composition zone. */ toastApi: ToastApi; composerRef?: { current: Pick | null }; @@ -214,7 +208,6 @@ export function useWorkbarController( input.authoritativeSessionIds, compact, input.layoutContainerRef, - input.layoutGap, ); const sideConversations = useSideConversationWorkspace(); const [pendingSideChatClose, setPendingSideChatClose] = useState< 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 10cdee26e51..c962e568c62 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 @@ -53,15 +53,14 @@ const LAYOUT_PERSIST_DEBOUNCE_MS = 200; * `compact` is the shell's narrow-window reading; see `withCompact`. * * `layoutContainerRef` is the grid holding both the conversation column and the - * rail; `layoutGap` is the spacing between them. Measuring one and knowing the - * other is what makes the rail's ceiling a function of the available space. + * rail. Measuring it and reading its seam spacing is what makes the rail's + * ceiling a function of the available space. */ export function useWorkbarLayoutState( activeSessionId: string | undefined, authoritativeSessionIds: ReadonlySet | undefined, compact: boolean, layoutContainerRef?: RefObject, - layoutGap = 0, ) { const [state, dispatch] = useReducer( reduceWorkbarLayout, @@ -100,6 +99,9 @@ export function useWorkbarLayoutState( const measure = () => { const available = container.getBoundingClientRect().width; if (available <= 0) return; + const layoutGap = Number.parseFloat( + getComputedStyle(container).getPropertyValue('--agents-content-area-gap'), + ) || 0; dispatch({ type: 'measure-right-ceiling', ceiling: sessionWorkbarCeiling(available, layoutGap), @@ -109,7 +111,7 @@ export function useWorkbarLayoutState( const observer = new ResizeObserver(measure); observer.observe(container); return () => observer.disconnect(); - }, [layoutContainerRef, layoutGap]); + }, [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. 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/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'); From 4e44d77f5f1a9d110cb3bf679324b856d62c7271 Mon Sep 17 00:00:00 2001 From: jiang1997 Date: Tue, 29 Sep 2026 11:23:25 +0800 Subject: [PATCH 4/4] fix(desktop): align Workbar sizing with narrow shell layout --- apps/desktop/.storybook/preview.tsx | 13 ----- apps/desktop/renderer-architecture.json | 2 +- .../__tests__/app-shell-frame-style.test.ts | 20 +------- .../src/main/__tests__/workbar-model.test.ts | 48 ++++++++++++++----- .../controller/use-workbar-layout-state.ts | 9 ++-- .../features/workbar/model/workbar-layout.ts | 28 +++++++---- apps/desktop/src/renderer/maka-tokens.css | 3 +- .../src/renderer/styles/workbar/artifacts.css | 6 +-- 8 files changed, 63 insertions(+), 66 deletions(-) diff --git a/apps/desktop/.storybook/preview.tsx b/apps/desktop/.storybook/preview.tsx index 6ae94292295..52b022704d4 100644 --- a/apps/desktop/.storybook/preview.tsx +++ b/apps/desktop/.storybook/preview.tsx @@ -23,7 +23,6 @@ import { Theme } from '@astryxdesign/core/theme'; import { THEME_PALETTES } from '../../../packages/core/src/settings.js'; import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; import { makaTheme } from '../src/renderer/astryx-theme/maka'; -import { APP_SHELL_CONTENT_AREA_GAP } from '../src/renderer/shell/frame-style'; const PALETTE_LABELS: Record = { default: 'Default', @@ -48,18 +47,6 @@ const withMakaRoot: Decorator = (Story, context) => { root.classList.toggle('dark', colorScheme === 'dark'); root.style.colorScheme = colorScheme; - /** - * The shell's own seam width, from the module that owns it. - * - * AppShell publishes this on `.appFrame`, which every shipped surface sits - * inside. A story that renders a shell-like subtree on its own — the Workbar - * story mounts `.maka-detail-with-artifacts` directly — has no `.appFrame` - * above it, so the variable would be absent and each `calc()` reading it - * would fail whole, silently dropping the reservation and the margins. The - * same import as `appShellFrameStyle` keeps this one number, not a second - * copy of it. - */ - root.style.setProperty('--agents-content-area-gap', `${APP_SHELL_CONTENT_AREA_GAP}px`); if (palette === 'default') { root.removeAttribute('data-maka-theme'); 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__/app-shell-frame-style.test.ts b/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts index 63a1d0ae1df..9b7f7a65613 100644 --- a/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-frame-style.test.ts @@ -19,10 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { - appShellFrameStyle, - APP_SHELL_CONTENT_AREA_GAP, -} from '../../renderer/shell/frame-style.js'; +import { appShellFrameStyle } from '../../renderer/shell/frame-style.js'; test('every published width stays a , collapsed included', () => { const collapsed = appShellFrameStyle({ @@ -72,18 +69,3 @@ test('the Workbar cap is published on the frame that owns --maka-sidenav-width', assert.ok(cap.includes('var(--maka-sidenav-width)'), cap); assert.ok(cap.includes('100cqi'), cap); }); - -test('the gap the rail is measured against is the gap the shell draws', () => { - const style = appShellFrameStyle({ - sessionListCollapsed: false, - sessionListWidth: 291, - }) as Record; - - // One source, two readers: the CSS rules that draw the seam and the Workbar - // that subtracts it from the container. A second hand-kept copy would let the - // conversation column lose pixels to a gap the layout does not draw. - assert.equal( - style['--agents-content-area-gap'], - `${APP_SHELL_CONTENT_AREA_GAP}px`, - ); -}); diff --git a/apps/desktop/src/main/__tests__/workbar-model.test.ts b/apps/desktop/src/main/__tests__/workbar-model.test.ts index 0ac2d3d8538..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, @@ -147,6 +152,9 @@ describe('Workbar topology', () => { 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 @@ -187,31 +195,46 @@ describe('Workbar topology', () => { assert.equal(sessionWorkbarDisplayWidth(state), SESSION_WORKBAR_MIN_WIDTH); }); - it('keeps the rail at its floor when the container cannot fit both columns', () => { + 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 ceiling leaves the conversation column exactly its target; the plan's - // worked example is a 1600px container with a 12px gap leaving 1108px. - assert.equal(sessionWorkbarCeiling(1600, 12), 1108); + // 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, ); - // 700px of container minus a 4px gap leaves 216 for the rail — below its - // floor, so the rail keeps 340 and the conversation column is the one that - // gives way. - assert.equal(sessionWorkbarCeiling(700, 4), 216); - state = reduceWorkbarLayout(state, { type: 'measure-right-ceiling', ceiling: sessionWorkbarCeiling(700, 4) }); - assert.equal(sessionWorkbarMaxWidth(state), SESSION_WORKBAR_MIN_WIDTH); - assert.equal(sessionWorkbarDisplayWidth(state), SESSION_WORKBAR_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', () => { @@ -220,6 +243,9 @@ describe('Workbar topology', () => { panels: createSessionWorkbarPanelsState(), activeSessionId: 'session-a', collapsedBySession: {}, + compact: false, + compactCollapsed: {}, + spaceCollapsed: false, bottomOpen: false, rightWidthPreference: 480, rightWidthCeiling: 1108, 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 c962e568c62..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 @@ -28,6 +28,7 @@ import { 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, @@ -53,8 +54,7 @@ const LAYOUT_PERSIST_DEBOUNCE_MS = 200; * `compact` is the shell's narrow-window reading; see `withCompact`. * * `layoutContainerRef` is the grid holding both the conversation column and the - * rail. Measuring it and reading its seam spacing is what makes the rail's - * ceiling a function of the available space. + * rail. Its width and the shell's shared seam size determine the ceiling. */ export function useWorkbarLayoutState( activeSessionId: string | undefined, @@ -99,12 +99,9 @@ export function useWorkbarLayoutState( const measure = () => { const available = container.getBoundingClientRect().width; if (available <= 0) return; - const layoutGap = Number.parseFloat( - getComputedStyle(container).getPropertyValue('--agents-content-area-gap'), - ) || 0; dispatch({ type: 'measure-right-ceiling', - ceiling: sessionWorkbarCeiling(available, layoutGap), + ceiling: sessionWorkbarCeiling(available, SHELL_CONTENT_AREA_GAP_PX), }); }; measure(); 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 e118246c983..5a591db7085 100644 --- a/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts +++ b/apps/desktop/src/renderer/features/workbar/model/workbar-layout.ts @@ -40,9 +40,9 @@ 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; /** @@ -135,9 +135,10 @@ export function sessionWorkbarMaxWidth(state: WorkbarLayoutState): number { } /** - * How wide the rail actually renders. The ceiling moves with the window, so - * readers that ask "how wide is the rail" want this; the preference is only - * what a drag writes and what storage keeps. + * 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( @@ -151,8 +152,7 @@ export function sessionWorkbarDisplayWidth(state: WorkbarLayoutState): number { * 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 - * returns below the rail's floor on purpose — the reducer owns the floor, so - * the caller can still see that the space ran out. + * 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; @@ -342,6 +342,9 @@ export function reduceWorkbarLayout( : { ...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 @@ -381,13 +384,18 @@ export function reduceWorkbarLayout( } if (action.type === 'resize') { if (action.placement === 'right') { + 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, - Math.min(SESSION_WORKBAR_MIN_WIDTH, sessionWorkbarMaxWidth(state)), - sessionWorkbarMaxWidth(state), + SESSION_WORKBAR_MIN_WIDTH, + maxWidth, ); return state.rightWidthPreference === rightWidthPreference ? state diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index a4e4a28b069..f4fd0042554 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -960,8 +960,7 @@ 6 → 12 so the floating surface's bottom corners actually read. 6px was geometrically present but optically invisible against a same-color shell. */ - /* appShellFrameStyle publishes the Workbar seam from - APP_SHELL_CONTENT_AREA_GAP; Storybook publishes the same value at root. */ + --agents-content-area-gap: 4px; /* === conversation layout contract ===================================== The Desktop and WorkHub renderers are separate documents, but both load diff --git a/apps/desktop/src/renderer/styles/workbar/artifacts.css b/apps/desktop/src/renderer/styles/workbar/artifacts.css index 82fe7791148..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 sizes itself against the container it sits in, so this only - binds in the stacked layout a narrow window switches to, where the rail runs - the width of the window. Prose needs a measure there; the pane's own width - already 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;