fix(ui): integrate responsive interface scale (#3423)

Keep normal web headers at 48px, send native zoom directly to the focused main or Mini Chat window, and restore walkthrough widths through validated persisted ratios.

Validated 40 context-panel tests, the focus-aware ThemeProvider regression, UI type-check and Electron syntax checks. Runtime smoke and combined workspace validation follow before publication.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:57:16 +03:00
21 changed files with 273 additions and 47 deletions
@@ -111,6 +111,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
const [isAnnotating, setIsAnnotating] = React.useState(false);
const [isWaitingForServer, setIsWaitingForServer] = React.useState(false);
const [zoomLevel, setZoomLevel] = React.useState(0);
const zoomLevelRef = React.useRef(0);
const [showDeviceBar, setShowDeviceBar] = React.useState(false);
const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_VIEWPORT);
// Read inside agent actions, which are not re-created when the viewport
@@ -580,6 +581,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
const applyZoom = React.useCallback((level: number) => {
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
zoomLevelRef.current = next;
setZoomLevel(next);
try {
webviewRef.current?.setZoomLevel(next);
@@ -588,6 +590,20 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
}
}, []);
React.useEffect(() => {
const handleZoom = (event: Event) => {
if (!(event instanceof CustomEvent)) return;
const action = event.detail;
const webview = webviewRef.current;
if (!webview || document.activeElement !== webview) return;
if (action === 'zoom-in') applyZoom(zoomLevelRef.current + ZOOM_STEP);
else if (action === 'zoom-out') applyZoom(zoomLevelRef.current - ZOOM_STEP);
else if (action === 'zoom-reset') applyZoom(0);
};
window.addEventListener('openchamber:zoom', handleZoom);
return () => window.removeEventListener('openchamber:zoom', handleZoom);
}, [applyZoom]);
const clearBrowsingData = React.useCallback((what: 'cookies' | 'cache') => {
void invokeDesktopCommand('desktop_browser_clear_data', {
partition: BROWSER_PARTITION,
@@ -94,7 +94,7 @@ const SortableChip: React.FC<{
{...attributes}
{...listeners}
onClick={() => onSubmit(item)}
className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 typography-ui-label text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
style={chipStyle}
title={item.shared ? t('chat.draftStarters.sharedTitle') : undefined}
>
@@ -310,7 +310,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
>
<span className="flex min-w-0 items-center gap-1.5">
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
? <span className="truncate typography-ui-label">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" />
</span>
@@ -498,7 +498,7 @@ export function MobileDraftTargetTriggers(
onClick={() => onOpenPicker('project')}
>
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
? <span className="truncate typography-ui-label">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
@@ -52,7 +52,7 @@ import {
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
const CONTEXT_PANEL_MIN_WIDTH = 380;
const CONTEXT_PANEL_MIN_WIDTH = 320;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const RESIZE_FOLLOW_INTERVAL_MS = 100;
@@ -484,10 +484,21 @@ export const ContextPanel: React.FC = () => {
const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState<number | null>(null);
const activeModeForWidth = activeTab?.mode ?? null;
const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined;
const manualWidthFraction = activeModeForWidth ? panelState?.widthFractionByMode?.[activeModeForWidth] : undefined;
const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5;
const widthFallbackBase = availablePanelAreaWidth
?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2);
const width = clampWidth(manualWidth ?? Math.round(widthFraction * widthFallbackBase));
const effectiveManualWidth = manualWidthFraction != null && availablePanelAreaWidth != null
? Math.round(manualWidthFraction * availablePanelAreaWidth)
: manualWidth;
const width = clampWidth(effectiveManualWidth ?? Math.round(widthFraction * widthFallbackBase));
// Convert legacy pixel-only preferences to a ratio the first time the
// available area is known, so existing users also get responsive sizing.
React.useEffect(() => {
if (!directoryKey || !activeModeForWidth || manualWidthFraction != null || manualWidth == null || availablePanelAreaWidth == null) return;
setContextPanelWidth(directoryKey, activeModeForWidth, manualWidth, availablePanelAreaWidth);
}, [activeModeForWidth, availablePanelAreaWidth, directoryKey, manualWidth, manualWidthFraction, setContextPanelWidth]);
const chatSessionIDs = React.useMemo(() => {
const ids: string[] = [];
for (const tab of tabs) {
@@ -509,8 +520,7 @@ export const ContextPanel: React.FC = () => {
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
const wasOpenRef = React.useRef(false);
// Tracks the panel area width so fraction-based surface defaults stay
// proportional as the window resizes; manual widths remain fixed px.
// Defaults and manually resized surfaces track the same available area.
React.useLayoutEffect(() => {
const parent = panelRef.current?.parentElement;
if (!parent || typeof ResizeObserver === 'undefined') {
@@ -592,6 +602,7 @@ export const ContextPanel: React.FC = () => {
// Apply the final width once, letting the regular 200ms width transition
// carry the panel to the release position.
const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width);
const availableWidth = resizeAvailableWidthRef.current;
resizingWidthRef.current = null;
resizeAvailableWidthRef.current = null;
if (resizeFollowTimerRef.current !== null) {
@@ -600,7 +611,7 @@ export const ContextPanel: React.FC = () => {
}
document.documentElement.style.cursor = '';
if (directoryKey && activeModeForWidth) {
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth);
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth, availableWidth ?? undefined);
}
setIsResizing(false);
activeResizePointerIDRef.current = null;
+3 -1
View File
@@ -1076,7 +1076,9 @@ export const Header: React.FC = () => {
// `--oc-titlebar-left-inset` so the sidebar strip can mirror it.
const titlebarLeftInset = React.useMemo(() => {
if (isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) {
return '5.5rem';
// Native traffic lights have a fixed physical footprint. Keep this
// clearance in pixels so shrinking the interface cannot overlap them.
return '88px';
}
if (isTabletStandalonePwa) {
return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)';
@@ -6,7 +6,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
const SIDEBAR_CONTENT_WIDTH = 280;
const SIDEBAR_MIN_WIDTH = 280;
const SIDEBAR_MIN_WIDTH = 168;
const SIDEBAR_MAX_WIDTH = 500;
interface SidebarProps {
@@ -264,7 +264,8 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
className={cn(
'flex items-center gap-3 bg-background',
usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3',
hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3',
// Native traffic lights are fixed-size OS chrome, not scaled UI.
hasMacTrafficLights ? 'pl-[88px]' : 'pl-3',
usesFramelessChrome ? 'h-12' : macosHeaderSizeClass || 'min-h-14',
)}
style={dragRegionStyle}
@@ -208,7 +208,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
const desktopHeaderPaddingClass = React.useMemo(() => {
if ((isDesktopApp && isMacPlatform) || isTabletStandalonePwa) {
// Match main app header: reserve space for Mac/iPadOS traffic lights.
return 'pl-[5.5rem]';
return 'pl-[88px]';
}
return 'pl-3';
}, [isDesktopApp, isMacPlatform, isTabletStandalonePwa]);
@@ -0,0 +1,73 @@
import React, { act } from 'react';
import { expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import { ThemeProvider } from './ThemeProvider';
import { useUIStore } from '@/stores/useUIStore';
test('zoom works without App menu listeners and routes by focused content', async () => {
const dom = new Window({ url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries({
window: dom, document: dom.document, navigator: dom.navigator,
Element: dom.Element, HTMLElement: dom.HTMLElement, Node: dom.Node,
Event: dom.Event, CustomEvent: dom.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
})) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const { createRoot } = await import('react-dom/client');
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
useUIStore.setState({ fontSize: 100, terminalFontSize: 14, editorFontSize: 13 });
const zoom = (action: string) => window.dispatchEvent(new CustomEvent('openchamber:zoom', { detail: action }));
try {
await act(async () => root.render(<ThemeProvider><input aria-label="composer" /></ThemeProvider>));
await act(async () => { zoom('zoom-in'); zoom('zoom-in'); });
expect(useUIStore.getState().fontSize).toBe(120);
expect(document.documentElement.style.fontSize).toBe('120%');
const terminal = document.createElement('input');
terminal.dataset.terminalOwner = 'test-terminal';
container.append(terminal);
terminal.focus();
await act(async () => zoom('zoom-in'));
expect(useUIStore.getState().terminalFontSize).toBe(15);
expect(useUIStore.getState().fontSize).toBe(120);
await act(async () => zoom('zoom-reset'));
expect(useUIStore.getState().terminalFontSize).toBe(14);
const editor = document.createElement('div');
editor.className = 'cm-editor';
const editorInput = document.createElement('textarea');
editor.append(editorInput);
container.append(editor);
editorInput.focus();
await act(async () => zoom('zoom-out'));
expect(useUIStore.getState().editorFontSize).toBe(12);
expect(useUIStore.getState().fontSize).toBe(120);
const browser = document.createElement('webview');
browser.tabIndex = 0;
container.append(browser);
browser.focus();
expect(document.activeElement).toBe(browser);
await act(async () => zoom('zoom-in'));
expect(useUIStore.getState().fontSize).toBe(120);
browser.blur();
await act(async () => zoom('zoom-reset'));
expect(useUIStore.getState().fontSize).toBe(100);
expect(document.documentElement.style.fontSize).toBe('');
await act(async () => root.unmount());
zoom('zoom-in');
expect(useUIStore.getState().fontSize).toBe(100);
} finally {
await act(async () => root.unmount());
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
await dom.happyDOM.close();
}
});
@@ -1,4 +1,5 @@
import React from 'react';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
import { useUIStore } from '@/stores/useUIStore';
interface ThemeProviderProps {
@@ -16,5 +17,32 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
applyPadding();
}, [fontSize, applyTypography, padding, applyPadding]);
React.useEffect(() => {
const handleZoom = (event: Event) => {
if (!(event instanceof CustomEvent)) return;
const action = event.detail;
if (action !== 'zoom-in' && action !== 'zoom-out' && action !== 'zoom-reset') return;
const active = document.activeElement;
if (active?.tagName === 'WEBVIEW' || active?.closest('webview')) return;
const state = useUIStore.getState();
const isTerminal = isTerminalEventTarget(active)
|| active?.matches('[data-terminal-hidden-input="true"]') === true;
const isEditor = active?.closest('.cm-editor') != null;
if (action === 'zoom-reset') {
if (isTerminal) state.setTerminalFontSize(14);
else if (isEditor) state.setEditorFontSize(13);
else state.setFontSize(100);
} else if (isTerminal) {
state.setTerminalFontSize(state.terminalFontSize + (action === 'zoom-in' ? 1 : -1));
} else if (isEditor) {
state.setEditorFontSize(state.editorFontSize + (action === 'zoom-in' ? 1 : -1));
} else {
state.setFontSize(state.fontSize + (action === 'zoom-in' ? 10 : -10));
}
};
window.addEventListener('openchamber:zoom', handleZoom);
return () => window.removeEventListener('openchamber:zoom', handleZoom);
}, []);
return <>{children}</>;
};
@@ -143,7 +143,7 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
)}
>
<Icon name="chat-new" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-[14px] font-normal leading-tight text-foreground">
<span className="truncate typography-ui-label font-normal leading-tight text-foreground">
{t('sessions.sidebar.header.actions.newSession')}
</span>
</BaseMenu.Item>
@@ -293,7 +293,7 @@ function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isEx
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
</span>
) : null}
<span className={cn('truncate text-[14px] font-normal leading-tight', isActive ? 'text-primary' : 'text-foreground')}>
<span className={cn('truncate typography-ui-label font-normal leading-tight', isActive ? 'text-primary' : 'text-foreground')}>
{sessionTitle}
</span>
</div>
@@ -1146,7 +1146,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
{...(dragHandleProps?.listeners ?? {})}
>
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
<p className="text-[14px] font-normal truncate text-foreground/92">
<p className="typography-ui-label font-normal truncate text-foreground/92">
{group.isArchivedBucket ? (
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
@@ -425,7 +425,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
) : (
<>
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
<span className="truncate typography-ui-label font-semibold lowercase text-foreground">
{t('sessions.sidebar.activity.recentTitle')}
</span>
</>
@@ -96,7 +96,7 @@ export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
<Icon name="folder" className={cn('h-3.5 w-3.5 text-muted-foreground/80', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
)}
</span>
<span className="truncate text-[14px] font-semibold lowercase text-foreground">{projectLabel}</span>
<span className="truncate typography-ui-label font-semibold lowercase text-foreground">{projectLabel}</span>
</>
);
};
@@ -264,7 +264,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
</span>
</span>
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
<span className="typography-ui-label font-semibold lowercase text-foreground">{section.title}</span>
</button>
{section.key === 'chats' && props.onNewChat ? (
<button
@@ -15,8 +15,10 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
rail until a tab of their mode exists, and stay visible for as long as one
does — they must not disappear while in use.
- `defaultWidthFraction` is the panel width as a fraction of the content area,
used until the user manually resizes that surface (manual widths are stored
per mode in `useUIStore.contextPanelByDirectory[dir].widthByMode`).
used until the user manually resizes that surface. Manual widths are stored
per mode in `useUIStore.contextPanelByDirectory[dir].widthFractionByMode`;
`widthByMode` retains the last pixel size until the available area is known.
Every surface, including walkthrough, restores both values on reload.
- Rail order is user-reorderable and persisted globally in
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
registry's default order and appends any missing surfaces.
@@ -6,6 +6,7 @@ import { useUIStore } from './useUIStore';
const getContextPanelTabs = (directory: string) => useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
const getTerminalTab = (directory: string) => getContextPanelTabs(directory).find((tab) => tab.mode === 'terminal');
const originalPersistOptions = useUIStore.persist.getOptions();
beforeEach(() => {
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
@@ -180,6 +181,48 @@ describe('useUIStore context panel tabs', () => {
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(true);
});
test('drops invalid persisted context-panel width fractions', async () => {
const directory = '/repo';
useUIStore.persist.setOptions({ storage: {
getItem: () => ({
version: 20,
state: {
contextPanelByDirectory: {
[directory]: {
isOpen: true,
expanded: false,
widthByMode: { walkthrough: 800 },
widthFractionByMode: {
diff: 0,
file: 1.25,
context: Number.NaN,
plan: '0.4',
chat: 0.4,
walkthrough: 0.8,
},
touchedAt: 1,
activeTabId: null,
tabs: [],
},
},
},
}),
setItem: () => undefined,
removeItem: () => undefined,
} });
try {
useUIStore.setState(useUIStore.getInitialState(), true);
await useUIStore.persist.rehydrate();
const panel = useUIStore.getState().contextPanelByDirectory[directory];
expect(panel?.widthFractionByMode).toEqual({ chat: 0.4, walkthrough: 0.8 });
expect(panel?.widthByMode.walkthrough).toBe(800);
} finally {
useUIStore.persist.setOptions(originalPersistOptions);
}
});
test('drops a persisted saved-plan tab carrying an owner but no plan id', () => {
const directory = '/repo';
const persisted = {
@@ -672,9 +715,30 @@ describe('useUIStore per-surface panel widths', () => {
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.widthByMode.diff).toBe(700);
expect(state?.widthByMode.git).toBe(380);
expect(state?.widthByMode.git).toBe(320);
expect(state?.widthByMode.browser).toBe(undefined);
});
test('captures the clamped width as a responsive ratio when the panel area is known', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
useUIStore.getState().setContextPanelWidth(directory, 'diff', 100, 1000);
useUIStore.getState().setContextPanelWidth(directory, 'git', 700, 1000);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.widthByMode.diff).toBe(320);
expect(state?.widthFractionByMode.diff).toBe(0.32);
expect(state?.widthFractionByMode.git).toBe(0.7);
expect(state?.widthFractionByMode.browser).toBe(undefined);
});
test('a pixel resize without a valid area replaces the previous ratio', () => {
const store = useUIStore.getState();
store.setContextPanelWidth(directory, 'walkthrough', 800, 1000);
store.setContextPanelWidth(directory, 'walkthrough', 600, Number.POSITIVE_INFINITY);
const panel = useUIStore.getState().contextPanelByDirectory[directory];
expect(panel?.widthByMode.walkthrough).toBe(600);
expect(panel?.widthFractionByMode.walkthrough).toBeUndefined();
});
});
describe('useUIStore contextRailOrder', () => {
+41 -26
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { z } from 'zod';
import { devtools, persist } from 'zustand/middleware';
import type { SidebarSection } from '@/constants/sidebar';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
@@ -16,7 +17,12 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch' | 'commit';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
const contextPanelModeSchema = z.enum(['diff', 'walkthrough', 'file', 'context', 'plan', 'chat', 'browser', 'git', 'pr', 'linear', 'notes', 'terminal']);
export type ContextPanelMode = z.infer<typeof contextPanelModeSchema>;
const persistedPanelWidthsSchema = z.object({
widthByMode: z.record(z.string(), z.number().finite().optional().catch(undefined)).catch({}),
widthFractionByMode: z.record(z.string(), z.number().positive().max(1).optional().catch(undefined)).catch({}),
});
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
export type ChatRenderMode = 'sorted' | 'live';
@@ -146,9 +152,12 @@ type ContextPanelDirectoryState = {
expanded: boolean;
tabs: ContextPanelTab[];
activeTabId: string | null;
// Manual per-surface widths (px), populated only by user resize; surfaces
// without an entry fall back to their registry defaultWidthFraction.
// Legacy pixel widths and the last resize value, used until the panel's
// available area is known and a responsive ratio can be captured.
widthByMode: Partial<Record<ContextPanelMode, number>>;
// Ratios captured when a user resizes a surface. These remain responsive
// across window sizes while widthByMode preserves older persisted values.
widthFractionByMode: Partial<Record<ContextPanelMode, number>>;
touchedAt: number;
};
@@ -203,12 +212,12 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
};
const CONTEXT_PANEL_DEFAULT_WIDTH = 380;
const CONTEXT_PANEL_MIN_WIDTH = 380;
const CONTEXT_PANEL_MIN_WIDTH = 320;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
/** Per surface, not per panel: see clampContextPanelTabs. */
const CONTEXT_PANEL_MAX_TABS = 12;
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
const LEFT_SIDEBAR_MIN_WIDTH = 280;
const LEFT_SIDEBAR_MIN_WIDTH = 168;
/** Separates browser tabs opened in the same millisecond. */
let browserTabSequence = 0;
@@ -513,6 +522,7 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel
tabs: [],
activeTabId: null,
widthByMode: {},
widthFractionByMode: {},
touchedAt: Date.now(),
};
};
@@ -703,16 +713,13 @@ const sanitizeContextPanelByDirectory = (
// Legacy single `width` values are intentionally dropped: widths are now
// per-surface, seeded from registry defaults until the user resizes.
const widthByMode: Partial<Record<ContextPanelMode, number>> = {};
if (candidate.widthByMode && typeof candidate.widthByMode === 'object') {
for (const [mode, value] of Object.entries(candidate.widthByMode as Record<string, unknown>)) {
if (
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal')
&& typeof value === 'number'
&& Number.isFinite(value)
) {
widthByMode[mode] = clampContextPanelWidth(value);
}
}
const widthFractionByMode: Partial<Record<ContextPanelMode, number>> = {};
const savedWidths = persistedPanelWidthsSchema.parse(rawState);
for (const mode of contextPanelModeSchema.options) {
const pixels = savedWidths.widthByMode[mode];
const fraction = savedWidths.widthFractionByMode[mode];
if (pixels !== undefined) widthByMode[mode] = clampContextPanelWidth(pixels);
if (fraction !== undefined) widthFractionByMode[mode] = fraction;
}
next[directory] = {
@@ -721,6 +728,7 @@ const sanitizeContextPanelByDirectory = (
tabs: clampedTabs,
activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId),
widthByMode,
widthFractionByMode,
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
? candidate.touchedAt
: Date.now(),
@@ -993,7 +1001,7 @@ interface UIStore {
closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void;
closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number) => void;
setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number, availableWidth?: number) => void;
setNotesPanelHeight: (height: number) => void;
setWorkStatusSectionExpanded: (sectionId: string, expanded: boolean) => void;
setWorkStatusScrollTop: (scrollTop: number) => void;
@@ -1741,7 +1749,7 @@ export const useUIStore = create<UIStore>()(
});
},
setContextPanelWidth: (directory, mode, width) => {
setContextPanelWidth: (directory, mode, width, availableWidth) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
@@ -1750,14 +1758,22 @@ export const useUIStore = create<UIStore>()(
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const clampedWidth = clampContextPanelWidth(width);
const widthFractionByMode = { ...current.widthFractionByMode };
if (availableWidth !== undefined && Number.isFinite(availableWidth) && availableWidth > 0) {
widthFractionByMode[mode] = Math.min(1, clampedWidth / availableWidth);
} else {
delete widthFractionByMode[mode];
}
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
widthByMode: {
...current.widthByMode,
[mode]: clampContextPanelWidth(width),
[mode]: clampedWidth,
},
widthFractionByMode,
},
};
@@ -2140,21 +2156,20 @@ export const useUIStore = create<UIStore>()(
const entries = Object.entries(SEMANTIC_TYPOGRAPHY) as Array<[SemanticTypographyKey, string]>;
// Default must be SEMANTIC_TYPOGRAPHY (from CSS). Remove overrides.
// Scale the root rem unit so regular utility classes, icons, spacing,
// and semantic typography all respond to the same interface setting.
if (scale === 1) {
root.style.removeProperty('font-size');
for (const [key] of entries) {
root.style.removeProperty(getTypographyVariable(key));
}
return;
}
for (const [key, baseValue] of entries) {
const numericValue = parseFloat(baseValue);
if (!Number.isFinite(numericValue)) {
continue;
}
root.style.setProperty(getTypographyVariable(key), `${numericValue * scale}rem`);
}
root.style.fontSize = `${scale * 100}%`;
// The variables remain authored in rem and inherit the root scale.
for (const [key] of entries) root.style.removeProperty(getTypographyVariable(key));
},
applyPadding: () => {