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
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+14
View File
@@ -2308,6 +2308,13 @@ const getMenuTargetWindow = () => {
const dispatchMenuAction = (action) => { const dispatchMenuAction = (action) => {
const target = getMenuTargetWindow(); const target = getMenuTargetWindow();
// Zoom actions are consumed by the renderer's DOM listener. Sending them
// through both the IPC bridge and the DOM event would invoke the handler
// multiple times because preload fans the IPC event back into both paths.
if (action === 'zoom-in' || action === 'zoom-out' || action === 'zoom-reset') {
dispatchDomEventToWindow(target, 'openchamber:zoom', action);
return;
}
emitToWindow(target, 'openchamber:menu-action', action); emitToWindow(target, 'openchamber:menu-action', action);
dispatchDomEventToWindow(target, 'openchamber:menu-action', action); dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
}; };
@@ -4864,6 +4871,10 @@ const buildMacMenu = () => {
{ role: 'minimize' }, { role: 'minimize' },
{ role: 'zoom' }, { role: 'zoom' },
{ type: 'separator' }, { type: 'separator' },
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+=', click: () => dispatchAction('zoom-in') },
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', click: () => dispatchAction('zoom-out') },
{ label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', click: () => dispatchAction('zoom-reset') },
{ type: 'separator' },
{ role: 'close' }, { role: 'close' },
], ],
}, },
@@ -4977,6 +4988,9 @@ const buildAutoHiddenMenu = () => {
label: 'Window', label: 'Window',
submenu: [ submenu: [
{ role: 'minimize' }, { role: 'minimize' },
{ label: 'Zoom In', accelerator: 'Ctrl+=', click: () => dispatchAction('zoom-in') },
{ label: 'Zoom Out', accelerator: 'Ctrl+-', click: () => dispatchAction('zoom-out') },
{ label: 'Reset Zoom', accelerator: 'Ctrl+0', click: () => dispatchAction('zoom-reset') },
{ role: 'togglefullscreen' }, { role: 'togglefullscreen' },
{ type: 'separator' }, { type: 'separator' },
{ role: 'close' }, { role: 'close' },
@@ -111,6 +111,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
const [isAnnotating, setIsAnnotating] = React.useState(false); const [isAnnotating, setIsAnnotating] = React.useState(false);
const [isWaitingForServer, setIsWaitingForServer] = React.useState(false); const [isWaitingForServer, setIsWaitingForServer] = React.useState(false);
const [zoomLevel, setZoomLevel] = React.useState(0); const [zoomLevel, setZoomLevel] = React.useState(0);
const zoomLevelRef = React.useRef(0);
const [showDeviceBar, setShowDeviceBar] = React.useState(false); const [showDeviceBar, setShowDeviceBar] = React.useState(false);
const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_VIEWPORT); const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_VIEWPORT);
// Read inside agent actions, which are not re-created when the 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 applyZoom = React.useCallback((level: number) => {
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level)); const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
zoomLevelRef.current = next;
setZoomLevel(next); setZoomLevel(next);
try { try {
webviewRef.current?.setZoomLevel(next); 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') => { const clearBrowsingData = React.useCallback((what: 'cookies' | 'cache') => {
void invokeDesktopCommand('desktop_browser_clear_data', { void invokeDesktopCommand('desktop_browser_clear_data', {
partition: BROWSER_PARTITION, partition: BROWSER_PARTITION,
@@ -94,7 +94,7 @@ const SortableChip: React.FC<{
{...attributes} {...attributes}
{...listeners} {...listeners}
onClick={() => onSubmit(item)} 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} style={chipStyle}
title={item.shared ? t('chat.draftStarters.sharedTitle') : undefined} 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"> <span className="flex min-w-0 items-center gap-1.5">
{selectedProject.kind === 'chat' {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} />} : <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" /> <Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" />
</span> </span>
@@ -498,7 +498,7 @@ export function MobileDraftTargetTriggers(
onClick={() => onOpenPicker('project')} onClick={() => onOpenPicker('project')}
> >
{selectedProject.kind === 'chat' {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} />} : <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" /> <Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button> </button>
@@ -52,7 +52,7 @@ import {
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry'; import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
import { isTerminalEventTarget } from '@/lib/terminalFocus'; 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_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600; const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const RESIZE_FOLLOW_INTERVAL_MS = 100; const RESIZE_FOLLOW_INTERVAL_MS = 100;
@@ -484,10 +484,21 @@ export const ContextPanel: React.FC = () => {
const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState<number | null>(null); const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState<number | null>(null);
const activeModeForWidth = activeTab?.mode ?? null; const activeModeForWidth = activeTab?.mode ?? null;
const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined; const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined;
const manualWidthFraction = activeModeForWidth ? panelState?.widthFractionByMode?.[activeModeForWidth] : undefined;
const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5; const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5;
const widthFallbackBase = availablePanelAreaWidth const widthFallbackBase = availablePanelAreaWidth
?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2); ?? (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 chatSessionIDs = React.useMemo(() => {
const ids: string[] = []; const ids: string[] = [];
for (const tab of tabs) { for (const tab of tabs) {
@@ -509,8 +520,7 @@ export const ContextPanel: React.FC = () => {
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map()); const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
const wasOpenRef = React.useRef(false); const wasOpenRef = React.useRef(false);
// Tracks the panel area width so fraction-based surface defaults stay // Defaults and manually resized surfaces track the same available area.
// proportional as the window resizes; manual widths remain fixed px.
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const parent = panelRef.current?.parentElement; const parent = panelRef.current?.parentElement;
if (!parent || typeof ResizeObserver === 'undefined') { 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 // Apply the final width once, letting the regular 200ms width transition
// carry the panel to the release position. // carry the panel to the release position.
const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width); const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width);
const availableWidth = resizeAvailableWidthRef.current;
resizingWidthRef.current = null; resizingWidthRef.current = null;
resizeAvailableWidthRef.current = null; resizeAvailableWidthRef.current = null;
if (resizeFollowTimerRef.current !== null) { if (resizeFollowTimerRef.current !== null) {
@@ -600,7 +611,7 @@ export const ContextPanel: React.FC = () => {
} }
document.documentElement.style.cursor = ''; document.documentElement.style.cursor = '';
if (directoryKey && activeModeForWidth) { if (directoryKey && activeModeForWidth) {
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth); setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth, availableWidth ?? undefined);
} }
setIsResizing(false); setIsResizing(false);
activeResizePointerIDRef.current = null; 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. // `--oc-titlebar-left-inset` so the sidebar strip can mirror it.
const titlebarLeftInset = React.useMemo(() => { const titlebarLeftInset = React.useMemo(() => {
if (isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) { 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) { if (isTabletStandalonePwa) {
return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)'; 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'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
const SIDEBAR_CONTENT_WIDTH = 280; const SIDEBAR_CONTENT_WIDTH = 280;
const SIDEBAR_MIN_WIDTH = 280; const SIDEBAR_MIN_WIDTH = 168;
const SIDEBAR_MAX_WIDTH = 500; const SIDEBAR_MAX_WIDTH = 500;
interface SidebarProps { interface SidebarProps {
@@ -264,7 +264,8 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
className={cn( className={cn(
'flex items-center gap-3 bg-background', 'flex items-center gap-3 bg-background',
usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3', 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', usesFramelessChrome ? 'h-12' : macosHeaderSizeClass || 'min-h-14',
)} )}
style={dragRegionStyle} style={dragRegionStyle}
@@ -208,7 +208,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
const desktopHeaderPaddingClass = React.useMemo(() => { const desktopHeaderPaddingClass = React.useMemo(() => {
if ((isDesktopApp && isMacPlatform) || isTabletStandalonePwa) { if ((isDesktopApp && isMacPlatform) || isTabletStandalonePwa) {
// Match main app header: reserve space for Mac/iPadOS traffic lights. // Match main app header: reserve space for Mac/iPadOS traffic lights.
return 'pl-[5.5rem]'; return 'pl-[88px]';
} }
return 'pl-3'; return 'pl-3';
}, [isDesktopApp, isMacPlatform, isTabletStandalonePwa]); }, [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 React from 'react';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
interface ThemeProviderProps { interface ThemeProviderProps {
@@ -16,5 +17,32 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
applyPadding(); applyPadding();
}, [fontSize, applyTypography, padding, 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}</>; 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" /> <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')} {t('sessions.sidebar.header.actions.newSession')}
</span> </span>
</BaseMenu.Item> </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" />} {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> </span>
) : null} ) : 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} {sessionTitle}
</span> </span>
</div> </div>
@@ -1146,7 +1146,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
{...(dragHandleProps?.listeners ?? {})} {...(dragHandleProps?.listeners ?? {})}
> >
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden"> <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 ? ( {group.isArchivedBucket ? (
<span className="inline-flex min-w-0 max-w-full items-center gap-1"> <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"> <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" /> <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')} {t('sessions.sidebar.activity.recentTitle')}
</span> </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} /> <Icon name="folder" className={cn('h-3.5 w-3.5 text-muted-foreground/80', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
)} )}
</span> </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" />} {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> </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> </button>
{section.key === 'chats' && props.onNewChat ? ( {section.key === 'chats' && props.onNewChat ? (
<button <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 rail until a tab of their mode exists, and stay visible for as long as one
does — they must not disappear while in use. does — they must not disappear while in use.
- `defaultWidthFraction` is the panel width as a fraction of the content area, - `defaultWidthFraction` is the panel width as a fraction of the content area,
used until the user manually resizes that surface (manual widths are stored used until the user manually resizes that surface. Manual widths are stored
per mode in `useUIStore.contextPanelByDirectory[dir].widthByMode`). 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 - Rail order is user-reorderable and persisted globally in
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the `useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
registry's default order and appends any missing surfaces. 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 getContextPanelTabs = (directory: string) => useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
const getTerminalTab = (directory: string) => getContextPanelTabs(directory).find((tab) => tab.mode === 'terminal'); const getTerminalTab = (directory: string) => getContextPanelTabs(directory).find((tab) => tab.mode === 'terminal');
const originalPersistOptions = useUIStore.persist.getOptions();
beforeEach(() => { beforeEach(() => {
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] }); useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
@@ -180,6 +181,48 @@ describe('useUIStore context panel tabs', () => {
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(true); 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', () => { test('drops a persisted saved-plan tab carrying an owner but no plan id', () => {
const directory = '/repo'; const directory = '/repo';
const persisted = { const persisted = {
@@ -672,9 +715,30 @@ describe('useUIStore per-surface panel widths', () => {
const state = useUIStore.getState().contextPanelByDirectory[directory]; const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.widthByMode.diff).toBe(700); expect(state?.widthByMode.diff).toBe(700);
expect(state?.widthByMode.git).toBe(380); expect(state?.widthByMode.git).toBe(320);
expect(state?.widthByMode.browser).toBe(undefined); 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', () => { describe('useUIStore contextRailOrder', () => {
+41 -26
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { z } from 'zod';
import { devtools, persist } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import type { SidebarSection } from '@/constants/sidebar'; import type { SidebarSection } from '@/constants/sidebar';
import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { createDeferredSafeJSONStorage } from './utils/safeStorage';
@@ -16,7 +17,12 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch'; import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch' | 'commit'; 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 MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain'; export type UserMessageRenderingMode = 'markdown' | 'plain';
export type ChatRenderMode = 'sorted' | 'live'; export type ChatRenderMode = 'sorted' | 'live';
@@ -146,9 +152,12 @@ type ContextPanelDirectoryState = {
expanded: boolean; expanded: boolean;
tabs: ContextPanelTab[]; tabs: ContextPanelTab[];
activeTabId: string | null; activeTabId: string | null;
// Manual per-surface widths (px), populated only by user resize; surfaces // Legacy pixel widths and the last resize value, used until the panel's
// without an entry fall back to their registry defaultWidthFraction. // available area is known and a responsive ratio can be captured.
widthByMode: Partial<Record<ContextPanelMode, number>>; 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; touchedAt: number;
}; };
@@ -203,12 +212,12 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
}; };
const CONTEXT_PANEL_DEFAULT_WIDTH = 380; const CONTEXT_PANEL_DEFAULT_WIDTH = 380;
const CONTEXT_PANEL_MIN_WIDTH = 380; const CONTEXT_PANEL_MIN_WIDTH = 320;
const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_MAX_WIDTH = 1400;
/** Per surface, not per panel: see clampContextPanelTabs. */ /** Per surface, not per panel: see clampContextPanelTabs. */
const CONTEXT_PANEL_MAX_TABS = 12; const CONTEXT_PANEL_MAX_TABS = 12;
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120; 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. */ /** Separates browser tabs opened in the same millisecond. */
let browserTabSequence = 0; let browserTabSequence = 0;
@@ -513,6 +522,7 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel
tabs: [], tabs: [],
activeTabId: null, activeTabId: null,
widthByMode: {}, widthByMode: {},
widthFractionByMode: {},
touchedAt: Date.now(), touchedAt: Date.now(),
}; };
}; };
@@ -703,16 +713,13 @@ const sanitizeContextPanelByDirectory = (
// Legacy single `width` values are intentionally dropped: widths are now // Legacy single `width` values are intentionally dropped: widths are now
// per-surface, seeded from registry defaults until the user resizes. // per-surface, seeded from registry defaults until the user resizes.
const widthByMode: Partial<Record<ContextPanelMode, number>> = {}; const widthByMode: Partial<Record<ContextPanelMode, number>> = {};
if (candidate.widthByMode && typeof candidate.widthByMode === 'object') { const widthFractionByMode: Partial<Record<ContextPanelMode, number>> = {};
for (const [mode, value] of Object.entries(candidate.widthByMode as Record<string, unknown>)) { const savedWidths = persistedPanelWidthsSchema.parse(rawState);
if ( for (const mode of contextPanelModeSchema.options) {
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal') const pixels = savedWidths.widthByMode[mode];
&& typeof value === 'number' const fraction = savedWidths.widthFractionByMode[mode];
&& Number.isFinite(value) if (pixels !== undefined) widthByMode[mode] = clampContextPanelWidth(pixels);
) { if (fraction !== undefined) widthFractionByMode[mode] = fraction;
widthByMode[mode] = clampContextPanelWidth(value);
}
}
} }
next[directory] = { next[directory] = {
@@ -721,6 +728,7 @@ const sanitizeContextPanelByDirectory = (
tabs: clampedTabs, tabs: clampedTabs,
activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId), activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId),
widthByMode, widthByMode,
widthFractionByMode,
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt) touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
? candidate.touchedAt ? candidate.touchedAt
: Date.now(), : Date.now(),
@@ -993,7 +1001,7 @@ interface UIStore {
closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void; closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void;
closeContextPanel: (directory: string) => void; closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (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; setNotesPanelHeight: (height: number) => void;
setWorkStatusSectionExpanded: (sectionId: string, expanded: boolean) => void; setWorkStatusSectionExpanded: (sectionId: string, expanded: boolean) => void;
setWorkStatusScrollTop: (scrollTop: number) => 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()); const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) { if (!normalizedDirectory) {
return; return;
@@ -1750,14 +1758,22 @@ export const useUIStore = create<UIStore>()(
set((state) => { set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory]; const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev); 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 = { const byDirectory = {
...state.contextPanelByDirectory, ...state.contextPanelByDirectory,
[normalizedDirectory]: { [normalizedDirectory]: {
...current, ...current,
widthByMode: { widthByMode: {
...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]>; 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) { if (scale === 1) {
root.style.removeProperty('font-size');
for (const [key] of entries) { for (const [key] of entries) {
root.style.removeProperty(getTypographyVariable(key)); root.style.removeProperty(getTypographyVariable(key));
} }
return; return;
} }
for (const [key, baseValue] of entries) { root.style.fontSize = `${scale * 100}%`;
const numericValue = parseFloat(baseValue);
if (!Number.isFinite(numericValue)) { // The variables remain authored in rem and inherit the root scale.
continue; for (const [key] of entries) root.style.removeProperty(getTypographyVariable(key));
}
root.style.setProperty(getTypographyVariable(key), `${numericValue * scale}rem`);
}
}, },
applyPadding: () => { applyPadding: () => {