Keep workspace focus mode scoped and easy to exit (#2151)

* fix(workspace): keep focus mode scoped and easy to exit

Route focus mode through the active workspace so persisted state cannot hide chrome on settings or other screens. Add a visible exit control and keep desktop window chrome aligned and visually quiet.

* fix(app): clarify muted chrome semantics
This commit is contained in:
Mohamed Boudra
2026-07-16 23:46:24 +02:00
committed by GitHub
parent d2308f4835
commit a622860a3e
25 changed files with 261 additions and 30 deletions
+2
View File
@@ -40,6 +40,8 @@ The rule, condensed: text that _names_ a surface or a group is `medium`. Text th
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text. Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself. Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent. Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
@@ -0,0 +1,51 @@
import { expect, test } from "./fixtures";
const modifier = process.platform === "darwin" ? "Meta" : "Control";
async function pressFocusModeShortcut(page: import("@playwright/test").Page) {
await page.keyboard.press(`${modifier}+Shift+F`);
}
async function pressSettingsShortcut(page: import("@playwright/test").Page) {
await page.keyboard.press(`${modifier}+Comma`);
}
async function blurActiveElement(page: import("@playwright/test").Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
}
test("focus mode only applies to the active workspace screen", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "focus-mode-boundary-" });
await workspace.navigateTo();
const exitFocusMode = page.getByRole("button", { name: "Exit focus mode" });
const settingsButton = page.getByRole("button", { name: "Settings", exact: true });
const settingsSidebar = page.getByRole("navigation", { name: "Settings" });
await expect(settingsButton).toBeVisible();
await expect(exitFocusMode).toHaveCount(0);
await blurActiveElement(page);
await pressFocusModeShortcut(page);
await expect(exitFocusMode).toBeVisible();
await expect(settingsButton).toHaveCount(0);
const workspaceUrl = page.url();
await pressSettingsShortcut(page);
await expect(settingsSidebar).toBeVisible();
await expect(exitFocusMode).toHaveCount(0);
await page.reload();
await expect(settingsSidebar).toBeVisible();
await expect(exitFocusMode).toHaveCount(0);
await pressFocusModeShortcut(page);
await page.goto(workspaceUrl);
await expect(exitFocusMode).toBeVisible();
await exitFocusMode.click();
await expect(exitFocusMode).toHaveCount(0);
await expect(settingsButton).toBeVisible();
});
+20 -8
View File
@@ -40,6 +40,7 @@ import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { import {
getIsElectronRuntime, getIsElectronRuntime,
getIsElectronRuntimeMac,
HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT,
useIsCompactFormFactor, useIsCompactFormFactor,
} from "@/constants/layout"; } from "@/constants/layout";
@@ -108,7 +109,11 @@ import {
WindowChromeRegion, WindowChromeRegion,
WindowChromeSafeArea, WindowChromeSafeArea,
} from "@/utils/desktop-window"; } from "@/utils/desktop-window";
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import {
buildOpenProjectRoute,
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { import {
@@ -425,7 +430,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList); const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList); const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
@@ -442,6 +446,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
useCompactWebViewportZoomLock(isCompactLayout); useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname(); const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
const isWorkspaceFocusModeEnabled = isWorkspaceRoute && isFocusModeEnabled;
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList; const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => { const toggleDesktopSidebars = useCallback(() => {
@@ -470,7 +476,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
isMobile: isCompactLayout, isMobile: isCompactLayout,
toggleAgentList, toggleAgentList,
toggleBothSidebars: toggleDesktopSidebars, toggleBothSidebars: toggleDesktopSidebars,
toggleFocusMode,
cycleTheme, cycleTheme,
}); });
@@ -479,11 +484,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const appContentMinimumWidth = resolveDesktopAppContentMinimum({ const appContentMinimumWidth = resolveDesktopAppContentMinimum({
isSettingsRoute: pathname.includes("/settings"), isSettingsRoute: pathname.includes("/settings"),
isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen, isWorkspaceExplorerOpen: isWorkspaceRoute && isDesktopFileExplorerOpen,
requestedExplorerWidth: explorerWidth, requestedExplorerWidth: explorerWidth,
viewportWidth, viewportWidth,
}); });
const desktopSidebarMounted = chromeEnabled && !isFocusModeEnabled; const desktopSidebarMounted = chromeEnabled && !isWorkspaceFocusModeEnabled;
const desktopSidebarVisible = const desktopSidebarVisible =
!isCompactLayout && !isCompactLayout &&
desktopSidebarMounted && desktopSidebarMounted &&
@@ -497,7 +502,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const appChromeLayout = resolveDesktopAppChromeLayout({ const appChromeLayout = resolveDesktopAppChromeLayout({
desktopSidebarRendered: desktopSidebarVisible, desktopSidebarRendered: desktopSidebarVisible,
hasTopLeftWindowControls, hasTopLeftWindowControls,
sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled, sidebarControlsEnabled: chromeEnabled && !isWorkspaceFocusModeEnabled,
}); });
const sidebarChrome = ( const sidebarChrome = (
<SidebarChrome <SidebarChrome
@@ -659,16 +664,23 @@ function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const surface0 = theme.colors.surface0; const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground; const foreground = theme.colors.foreground;
const pathname = usePathname();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const liftTrafficLights =
getIsElectronRuntimeMac() &&
isFocusModeEnabled &&
parseHostWorkspaceRouteFromPathname(pathname) !== null;
useEffect(() => { useEffect(() => {
if (!enabled || isNative) return; if (!enabled || isNative) return;
void updateDesktopWindowControls({ void updateDesktopWindowControls({
backgroundColor: surface0, backgroundColor: surface0,
foregroundColor: foreground, foregroundColor: foreground,
trafficLightOffsetY: liftTrafficLights ? -5 : 0.5,
}).catch((error) => { }).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error); console.warn("[DesktopWindow] Failed to update window controls overlay", error);
}); });
}, [enabled, surface0, foreground]); }, [enabled, surface0, foreground, liftTrafficLights]);
return null; return null;
} }
@@ -987,7 +999,7 @@ const layoutStyles = StyleSheet.create((theme) => ({
}, },
windowSidebarToggle: { windowSidebarToggle: {
position: "absolute", position: "absolute",
top: 0, top: 1,
left: 0, left: 0,
zIndex: 20, zIndex: 20,
height: HEADER_INNER_HEIGHT, height: HEADER_INNER_HEIGHT,
@@ -45,12 +45,14 @@ function MobileMenuIcon({ color }: { color: string }) {
function SidebarMenuToggleButton({ function SidebarMenuToggleButton({
isMobile, isMobile,
extraMutedIdleIcon = false,
resolvedStyle, resolvedStyle,
tooltipSide = "right", tooltipSide = "right",
testID = "menu-button", testID = "menu-button",
nativeID = "menu-button", nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & { }: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean; isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>; resolvedStyle: StyleProp<ViewStyle>;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -83,7 +85,12 @@ function SidebarMenuToggleButton({
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
> >
{({ hovered, pressed }) => { {({ hovered, pressed }) => {
const color = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted; let color = extraMutedIdleIcon
? theme.colors.foregroundExtraMuted
: theme.colors.foregroundMuted;
if (hovered || pressed) {
color = theme.colors.foreground;
}
return isMobile ? ( return isMobile ? (
<MobileMenuIcon color={color} /> <MobileMenuIcon color={color} />
) : ( ) : (
@@ -121,7 +128,14 @@ export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps =
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return <SidebarMenuToggleButton {...props} isMobile={false} resolvedStyle={resolvedStyle} />; return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
} }
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) { export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
@@ -123,6 +123,7 @@ interface SplitContainerProps {
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode; renderPaneEmptyState?: () => ReactNode;
focusModeEnabled?: boolean; focusModeEnabled?: boolean;
onExitFocusMode: () => void;
} }
interface WorkspaceTabDragData { interface WorkspaceTabDragData {
@@ -390,6 +391,7 @@ export function SplitContainer({
onReorderTabsInPane, onReorderTabsInPane,
renderPaneEmptyState = () => null, renderPaneEmptyState = () => null,
focusModeEnabled, focusModeEnabled,
onExitFocusMode,
}: SplitContainerProps) { }: SplitContainerProps) {
const inheritedWindowChromeCorners = useWindowChromeCorners(); const inheritedWindowChromeCorners = useWindowChromeCorners();
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none"; const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
@@ -611,6 +613,8 @@ export function SplitContainer({
dropPreview={dropPreview} dropPreview={dropPreview}
tabDropPreview={tabDropPreview} tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners} windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/> />
<DragOverlay dropAnimation={null}> <DragOverlay dropAnimation={null}>
{activeDragTabId ? ( {activeDragTabId ? (
@@ -755,6 +759,8 @@ function SplitNodeView({
dropPreview, dropPreview,
tabDropPreview, tabDropPreview,
windowChromeCorners, windowChromeCorners,
focusModeEnabled,
onExitFocusMode,
}: SplitNodeViewProps) { }: SplitNodeViewProps) {
const groupId = node.kind === "group" ? node.group.id : null; const groupId = node.kind === "group" ? node.group.id : null;
const groupDirection = node.kind === "group" ? node.group.direction : null; const groupDirection = node.kind === "group" ? node.group.direction : null;
@@ -808,6 +814,8 @@ function SplitNodeView({
showDropZones={showDropZones} showDropZones={showDropZones}
dropPreview={dropPreview} dropPreview={dropPreview}
tabDropPreview={tabDropPreview} tabDropPreview={tabDropPreview}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/> />
</WindowChromeRegion> </WindowChromeRegion>
); );
@@ -857,6 +865,8 @@ function SplitNodeView({
dropPreview={dropPreview} dropPreview={dropPreview}
tabDropPreview={tabDropPreview} tabDropPreview={tabDropPreview}
windowChromeCorners={windowChromeCorners} windowChromeCorners={windowChromeCorners}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/> />
</SplitGroupChild> </SplitGroupChild>
{index < node.group.children.length - 1 ? ( {index < node.group.children.length - 1 ? (
@@ -908,6 +918,8 @@ function SplitPaneView({
showDropZones, showDropZones,
dropPreview, dropPreview,
tabDropPreview, tabDropPreview,
focusModeEnabled,
onExitFocusMode,
}: SplitPaneViewProps) { }: SplitPaneViewProps) {
const { theme: _theme } = useUnistyles(); const { theme: _theme } = useUnistyles();
const paneRef = useRef<View | null>(null); const paneRef = useRef<View | null>(null);
@@ -1043,6 +1055,8 @@ function SplitPaneView({
tabDropPreviewIndex={ tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
} }
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/> />
</WindowChromeSafeArea> </WindowChromeSafeArea>
+1
View File
@@ -95,6 +95,7 @@ export interface DesktopWindowControlsOverlayUpdate {
height?: number; height?: number;
backgroundColor?: string; backgroundColor?: string;
foregroundColor?: string; foregroundColor?: string;
trafficLightOffsetY?: number;
} }
export interface DesktopWindowBridge { export interface DesktopWindowBridge {
@@ -34,14 +34,12 @@ export function useKeyboardShortcuts({
isMobile, isMobile,
toggleAgentList, toggleAgentList,
toggleBothSidebars, toggleBothSidebars,
toggleFocusMode,
cycleTheme, cycleTheme,
}: { }: {
enabled: boolean; enabled: boolean;
isMobile: boolean; isMobile: boolean;
toggleAgentList: () => void; toggleAgentList: () => void;
toggleBothSidebars?: () => void; toggleBothSidebars?: () => void;
toggleFocusMode?: () => void;
cycleTheme?: () => void; cycleTheme?: () => void;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -105,7 +103,6 @@ export function useKeyboardShortcuts({
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = { const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
"toggle-agent-list": toggleAgentList, "toggle-agent-list": toggleAgentList,
"toggle-both-sidebars": toggleBothSidebars, "toggle-both-sidebars": toggleBothSidebars,
"toggle-focus-mode": toggleFocusMode,
"cycle-theme": cycleTheme, "cycle-theme": cycleTheme,
}; };
@@ -305,6 +302,5 @@ export function useKeyboardShortcuts({
router, router,
toggleAgentList, toggleAgentList,
toggleBothSidebars, toggleBothSidebars,
toggleFocusMode,
]); ]);
} }
+1
View File
@@ -499,6 +499,7 @@ export const ar: TranslationResources = {
preparingTerminal: "إعداد علامة التبويب المحطة الطرفية", preparingTerminal: "إعداد علامة التبويب المحطة الطرفية",
preparingTerminalTooltip: "جارٍ تحضير المحطة...", preparingTerminalTooltip: "جارٍ تحضير المحطة...",
newBrowser: "متصفح جديد", newBrowser: "متصفح جديد",
exitFocusMode: "إنهاء وضع التركيز",
splitRight: "تقسيم الجزء الأيمن", splitRight: "تقسيم الجزء الأيمن",
splitDown: "تقسيم الجزء لأسفل", splitDown: "تقسيم الجزء لأسفل",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
+1
View File
@@ -498,6 +498,7 @@ export const en = {
preparingTerminal: "Preparing terminal tab", preparingTerminal: "Preparing terminal tab",
preparingTerminalTooltip: "Preparing terminal...", preparingTerminalTooltip: "Preparing terminal...",
newBrowser: "New browser", newBrowser: "New browser",
exitFocusMode: "Exit focus mode",
splitRight: "Split pane right", splitRight: "Split pane right",
splitDown: "Split pane down", splitDown: "Split pane down",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
+1
View File
@@ -504,6 +504,7 @@ export const es: TranslationResources = {
preparingTerminal: "Preparando la pestaña del terminal", preparingTerminal: "Preparando la pestaña del terminal",
preparingTerminalTooltip: "Preparando terminal...", preparingTerminalTooltip: "Preparando terminal...",
newBrowser: "Nuevo navegador", newBrowser: "Nuevo navegador",
exitFocusMode: "Salir del modo de concentración",
splitRight: "Panel dividido a la derecha", splitRight: "Panel dividido a la derecha",
splitDown: "Dividir panel hacia abajo", splitDown: "Dividir panel hacia abajo",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
+1
View File
@@ -503,6 +503,7 @@ export const fr: TranslationResources = {
preparingTerminal: "Préparation de l'onglet du terminal", preparingTerminal: "Préparation de l'onglet du terminal",
preparingTerminalTooltip: "Préparation du terminal...", preparingTerminalTooltip: "Préparation du terminal...",
newBrowser: "Nouveau navigateur", newBrowser: "Nouveau navigateur",
exitFocusMode: "Quitter le mode concentration",
splitRight: "Volet divisé à droite", splitRight: "Volet divisé à droite",
splitDown: "Diviser le volet vers le bas", splitDown: "Diviser le volet vers le bas",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
+1
View File
@@ -504,6 +504,7 @@ export const ja: TranslationResources = {
preparingTerminal: "ターミナルタブを準備中", preparingTerminal: "ターミナルタブを準備中",
preparingTerminalTooltip: "ターミナルを準備中...", preparingTerminalTooltip: "ターミナルを準備中...",
newBrowser: "新しいブラウザ", newBrowser: "新しいブラウザ",
exitFocusMode: "フォーカスモードを終了",
splitRight: "右にペインを分割", splitRight: "右にペインを分割",
splitDown: "下にペインを分割", splitDown: "下にペインを分割",
terminalProfilesMenu: "ターミナルプロファイル", terminalProfilesMenu: "ターミナルプロファイル",
+1
View File
@@ -503,6 +503,7 @@ export const ptBR: TranslationResources = {
preparingTerminal: "Preparando aba de terminal", preparingTerminal: "Preparando aba de terminal",
preparingTerminalTooltip: "Preparando terminal...", preparingTerminalTooltip: "Preparando terminal...",
newBrowser: "Novo navegador", newBrowser: "Novo navegador",
exitFocusMode: "Sair do modo de foco",
splitRight: "Dividir painel à direita", splitRight: "Dividir painel à direita",
splitDown: "Dividir painel abaixo", splitDown: "Dividir painel abaixo",
terminalProfilesMenu: "Perfis de terminal", terminalProfilesMenu: "Perfis de terminal",
+1
View File
@@ -503,6 +503,7 @@ export const ru: TranslationResources = {
preparingTerminal: "Подготовка вкладки терминала", preparingTerminal: "Подготовка вкладки терминала",
preparingTerminalTooltip: "Подготовка терминала...", preparingTerminalTooltip: "Подготовка терминала...",
newBrowser: "Новый браузер", newBrowser: "Новый браузер",
exitFocusMode: "Выйти из режима фокусировки",
splitRight: "Разделить панель справа", splitRight: "Разделить панель справа",
splitDown: "Разделить панель вниз", splitDown: "Разделить панель вниз",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
+1
View File
@@ -499,6 +499,7 @@ export const zhCN: TranslationResources = {
preparingTerminal: "正在准备 Terminal 标签", preparingTerminal: "正在准备 Terminal 标签",
preparingTerminalTooltip: "正在准备 Terminal...", preparingTerminalTooltip: "正在准备 Terminal...",
newBrowser: "新建浏览器", newBrowser: "新建浏览器",
exitFocusMode: "退出专注模式",
splitRight: "向右拆分窗格", splitRight: "向右拆分窗格",
splitDown: "向下拆分窗格", splitDown: "向下拆分窗格",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
@@ -25,6 +25,7 @@ export type KeyboardActionId =
| "workspace.pane.move-tab.up" | "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down" | "workspace.pane.move-tab.down"
| "workspace.pane.close" | "workspace.pane.close"
| "workspace.focus.toggle"
| "workspace.terminal.new" | "workspace.terminal.new"
| "sidebar.toggle.right" | "sidebar.toggle.right"
| "workspace.new" | "workspace.new"
@@ -57,6 +58,7 @@ export type KeyboardActionDefinition =
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope } | { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope } | { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.close"; scope: KeyboardActionScope } | { id: "workspace.pane.close"; scope: KeyboardActionScope }
| { id: "workspace.focus.toggle"; scope: KeyboardActionScope }
| { id: "workspace.terminal.new"; scope: KeyboardActionScope } | { id: "workspace.terminal.new"; scope: KeyboardActionScope }
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope } | { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "workspace.new"; scope: KeyboardActionScope } | { id: "workspace.new"; scope: KeyboardActionScope }
@@ -47,6 +47,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
["workspace.pane.move-tab.up", { id: "workspace.pane.move-tab.up", scope: "workspace" }], ["workspace.pane.move-tab.up", { id: "workspace.pane.move-tab.up", scope: "workspace" }],
["workspace.pane.move-tab.down", { id: "workspace.pane.move-tab.down", scope: "workspace" }], ["workspace.pane.move-tab.down", { id: "workspace.pane.move-tab.down", scope: "workspace" }],
["workspace.pane.close", { id: "workspace.pane.close", scope: "workspace" }], ["workspace.pane.close", { id: "workspace.pane.close", scope: "workspace" }],
["view.toggle.focus", { id: "workspace.focus.toggle", scope: "workspace" }],
])("%s → dispatch %j", (action, expected) => { ])("%s → dispatch %j", (action, expected) => {
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({ expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({
kind: "dispatch", kind: "dispatch",
@@ -333,7 +334,6 @@ describe("routeKeyboardShortcut — callbacks and pickers", () => {
it.each([ it.each([
["sidebar.toggle.left", "toggle-agent-list"], ["sidebar.toggle.left", "toggle-agent-list"],
["sidebar.toggle.both", "toggle-both-sidebars"], ["sidebar.toggle.both", "toggle-both-sidebars"],
["view.toggle.focus", "toggle-focus-mode"],
["theme.cycle", "cycle-theme"], ["theme.cycle", "cycle-theme"],
] as const)("%s → callback %s", (action, name) => { ] as const)("%s → callback %s", (action, name) => {
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual<ShortcutAction>({ expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual<ShortcutAction>({
+2 -6
View File
@@ -20,11 +20,7 @@ export interface ShortcutRoutingInput {
payload: KeyboardShortcutPayload; payload: KeyboardShortcutPayload;
} }
export type ShortcutCallbackName = export type ShortcutCallbackName = "toggle-agent-list" | "toggle-both-sidebars" | "cycle-theme";
| "toggle-agent-list"
| "toggle-both-sidebars"
| "toggle-focus-mode"
| "cycle-theme";
export type ShortcutAction = export type ShortcutAction =
| { kind: "none" } | { kind: "none" }
@@ -63,12 +59,12 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"workspace.pane.move-tab.up": { id: "workspace.pane.move-tab.up", scope: "workspace" }, "workspace.pane.move-tab.up": { id: "workspace.pane.move-tab.up", scope: "workspace" },
"workspace.pane.move-tab.down": { id: "workspace.pane.move-tab.down", scope: "workspace" }, "workspace.pane.move-tab.down": { id: "workspace.pane.move-tab.down", scope: "workspace" },
"workspace.pane.close": { id: "workspace.pane.close", scope: "workspace" }, "workspace.pane.close": { id: "workspace.pane.close", scope: "workspace" },
"view.toggle.focus": { id: "workspace.focus.toggle", scope: "workspace" },
}; };
const SIMPLE_CALLBACKS: Record<string, ShortcutCallbackName> = { const SIMPLE_CALLBACKS: Record<string, ShortcutCallbackName> = {
"sidebar.toggle.left": "toggle-agent-list", "sidebar.toggle.left": "toggle-agent-list",
"sidebar.toggle.both": "toggle-both-sidebars", "sidebar.toggle.both": "toggle-both-sidebars",
"view.toggle.focus": "toggle-focus-mode",
"theme.cycle": "cycle-theme", "theme.cycle": "cycle-theme",
}; };
+6 -1
View File
@@ -1081,7 +1081,12 @@ function SettingsSidebar({
); );
return ( return (
<View style={outerContainerStyle} testID="settings-sidebar"> <View
accessibilityLabel={t("settings.title")}
role="navigation"
style={outerContainerStyle}
testID="settings-sidebar"
>
{isDesktop ? ( {isDesktop ? (
<View style={innerContainerStyle}> <View style={innerContainerStyle}>
<View style={sidebarStyles.sidebarDragArea}> <View style={sidebarStyles.sidebarDragArea}>
@@ -438,6 +438,8 @@ interface WorkspaceDesktopTabsRowProps {
activeDragTabId?: string | null; activeDragTabId?: string | null;
tabDropPreviewIndex?: number | null; tabDropPreviewIndex?: number | null;
showPaneSplitActions?: boolean; showPaneSplitActions?: boolean;
focusModeEnabled: boolean;
onExitFocusMode: () => void;
} }
function getFallbackTabLabel( function getFallbackTabLabel(
@@ -758,15 +760,19 @@ export function WorkspaceDesktopTabsRow({
activeDragTabId = null, activeDragTabId = null,
tabDropPreviewIndex = null, tabDropPreviewIndex = null,
showPaneSplitActions = true, showPaneSplitActions = true,
focusModeEnabled,
onExitFocusMode,
}: WorkspaceDesktopTabsRowProps) { }: WorkspaceDesktopTabsRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const newTabKeys = useShortcutKeys("workspace-tab-new"); const newTabKeys = useShortcutKeys("workspace-tab-new");
const focusModeKeys = useShortcutKeys("toggle-focus");
const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
const splitDownKeys = useShortcutKeys("workspace-pane-split-down"); const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0); const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0); const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setTabsContainerWidth, event); updateMeasuredWidth(setTabsContainerWidth, event);
@@ -780,12 +786,18 @@ export function WorkspaceDesktopTabsRow({
updateMeasuredWidth(setInlineAddButtonWidth, event); updateMeasuredWidth(setInlineAddButtonWidth, event);
}, []); }, []);
const handleExitFocusModeLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setExitFocusModeWidth, event);
}, []);
const layoutMetrics = useMemo( const layoutMetrics = useMemo(
() => ({ () => ({
rowHorizontalInset: 0, rowHorizontalInset: 0,
actionsReservedWidth: Math.max( actionsReservedWidth: Math.max(
0, 0,
tabsActionsWidth + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH), tabsActionsWidth +
(inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) +
(focusModeEnabled ? exitFocusModeWidth : 0),
), ),
rowPaddingHorizontal: 0, rowPaddingHorizontal: 0,
tabGap: 0, tabGap: 0,
@@ -795,7 +807,7 @@ export function WorkspaceDesktopTabsRow({
estimatedCharWidth: 7, estimatedCharWidth: 7,
closeButtonWidth: 22, closeButtonWidth: 22,
}), }),
[inlineAddButtonWidth, tabsActionsWidth], [exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, tabsActionsWidth],
); );
const fallbackTabLabels = useMemo( const fallbackTabLabels = useMemo(
@@ -968,6 +980,31 @@ export function WorkspaceDesktopTabsRow({
testID="workspace-tabs-row" testID="workspace-tabs-row"
onLayout={handleTabsContainerLayout} onLayout={handleTabsContainerLayout}
> >
{focusModeEnabled ? (
<View style={styles.exitFocusModeSlot} onLayout={handleExitFocusModeLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-exit-focus-mode"
onPress={onExitFocusMode}
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.exitFocusMode")}
style={inlineAddActionButtonStyle}
>
<ThemedX size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>
{t("workspace.tabs.actions.exitFocusMode")}
</Text>
{focusModeKeys ? (
<Shortcut chord={focusModeKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
) : null}
<ScrollView <ScrollView
horizontal horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback} scrollEnabled={layout.requiresHorizontalScrollFallback}
@@ -1184,6 +1221,14 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
}, },
exitFocusModeSlot: {
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[1],
borderRightWidth: 1,
borderRightColor: theme.colors.border,
},
inlineAddButton: { inlineAddButton: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -266,6 +266,9 @@ const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 }; const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
@@ -1712,6 +1715,7 @@ function WorkspaceScreenContent({
const toast = useToast(); const toast = useToast();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const normalizedServerId = useMemo(() => trimNonEmpty(decodeSegment(serverId)) ?? "", [serverId]); const normalizedServerId = useMemo(() => trimNonEmpty(decodeSegment(serverId)) ?? "", [serverId]);
@@ -2966,6 +2970,11 @@ function WorkspaceScreenContent({
const handleWorkspacePaneAction = useCallback( const handleWorkspacePaneAction = useCallback(
(action: KeyboardActionDefinition): boolean => { (action: KeyboardActionDefinition): boolean => {
if (action.id === "workspace.focus.toggle") {
toggleFocusMode();
return true;
}
if (!persistenceKey || !workspaceLayout) { if (!persistenceKey || !workspaceLayout) {
return true; return true;
} }
@@ -3039,6 +3048,7 @@ function WorkspaceScreenContent({
persistenceKey, persistenceKey,
focusedPaneTabState.activeTabId, focusedPaneTabState.activeTabId,
focusedPaneTabState.pane, focusedPaneTabState.pane,
toggleFocusMode,
workspaceLayout, workspaceLayout,
], ],
); );
@@ -3072,6 +3082,7 @@ function WorkspaceScreenContent({
"workspace.pane.move-tab.up", "workspace.pane.move-tab.up",
"workspace.pane.move-tab.down", "workspace.pane.move-tab.down",
"workspace.pane.close", "workspace.pane.close",
"workspace.focus.toggle",
] as const, ] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
priority: 100, priority: 100,
@@ -3366,8 +3377,8 @@ function WorkspaceScreenContent({
style={explorerToggleStyle} style={explorerToggleStyle}
> >
{({ hovered, pressed }) => { {({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed; const active = hovered || pressed;
const colorMapping = active ? foregroundColorMapping : mutedColorMapping; const colorMapping = active ? foregroundColorMapping : extraMutedColorMapping;
return ( return (
<> <>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} /> <ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
@@ -3412,9 +3423,9 @@ function WorkspaceScreenContent({
accessibilityLabel={explorerToggleLabel} accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState} accessibilityState={explorerToggleAccessibilityState}
> >
{({ hovered }) => { {({ hovered, pressed }) => {
const colorMapping = const colorMapping =
isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping; hovered || pressed ? foregroundColorMapping : extraMutedColorMapping;
return <ThemedPanelRight size={16} uniProps={colorMapping} />; return <ThemedPanelRight size={16} uniProps={colorMapping} />;
}} }}
</HeaderToggleButton> </HeaderToggleButton>
@@ -3502,6 +3513,7 @@ function WorkspaceScreenContent({
<SplitContainer <SplitContainer
layout={workspaceLayout} layout={workspaceLayout}
focusModeEnabled={desktopFocusModeEnabled} focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
workspaceKey={persistenceKey} workspaceKey={persistenceKey}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
@@ -3539,6 +3551,7 @@ function WorkspaceScreenContent({
workspaceLayout, workspaceLayout,
persistenceKey, persistenceKey,
desktopFocusModeEnabled, desktopFocusModeEnabled,
toggleFocusMode,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
isRouteFocused, isRouteFocused,
@@ -3668,6 +3681,8 @@ function WorkspaceScreenContent({
onSplitRight={noop} onSplitRight={noop}
onSplitDown={noop} onSplitDown={noop}
showPaneSplitActions={false} showPaneSplitActions={false}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
/> />
) : null} ) : null}
+8
View File
@@ -153,6 +153,7 @@ const lightSemanticColors = {
// Text // Text
foreground: "#1a1a1e", foreground: "#1a1a1e",
foregroundMuted: "#71717a", foregroundMuted: "#71717a",
foregroundExtraMuted: "#a1a1aa",
// Controls // Controls
scrollbarHandle: "#3f3f46", // zinc-700 scrollbarHandle: "#3f3f46", // zinc-700
@@ -231,6 +232,7 @@ interface DarkThemeConfig {
surfaceSidebar: string; surfaceSidebar: string;
surfaceSidebarHover: string; surfaceSidebarHover: string;
foregroundMuted: string; foregroundMuted: string;
foregroundExtraMuted: string;
scrollbarHandle: string; scrollbarHandle: string;
border: string; border: string;
borderAccent: string; borderAccent: string;
@@ -271,6 +273,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
foreground: "#fafafa", foreground: "#fafafa",
foregroundMuted: tint.foregroundMuted, foregroundMuted: tint.foregroundMuted,
foregroundExtraMuted: tint.foregroundExtraMuted,
scrollbarHandle: tint.scrollbarHandle, scrollbarHandle: tint.scrollbarHandle,
@@ -332,6 +335,7 @@ const paseoDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#141716", surfaceSidebar: "#141716",
surfaceSidebarHover: "#1c1f1e", surfaceSidebarHover: "#1c1f1e",
foregroundMuted: "#A1A5A4", foregroundMuted: "#A1A5A4",
foregroundExtraMuted: "#717574",
scrollbarHandle: "#717574", scrollbarHandle: "#717574",
border: "#252B2A", border: "#252B2A",
borderAccent: "#2F3534", borderAccent: "#2F3534",
@@ -351,6 +355,7 @@ const zincDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#131316", surfaceSidebar: "#131316",
surfaceSidebarHover: "#1b1b1e", surfaceSidebarHover: "#1b1b1e",
foregroundMuted: "#a1a1aa", foregroundMuted: "#a1a1aa",
foregroundExtraMuted: "#71717a",
scrollbarHandle: "#71717a", scrollbarHandle: "#71717a",
border: "#27272a", border: "#27272a",
borderAccent: "#303036", borderAccent: "#303036",
@@ -371,6 +376,7 @@ const midnightDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#121420", surfaceSidebar: "#121420",
surfaceSidebarHover: "#1a1c28", surfaceSidebarHover: "#1a1c28",
foregroundMuted: "#9a9db0", foregroundMuted: "#9a9db0",
foregroundExtraMuted: "#6b6e82",
scrollbarHandle: "#6b6e82", scrollbarHandle: "#6b6e82",
border: "#242636", border: "#242636",
borderAccent: "#2e3040", borderAccent: "#2e3040",
@@ -390,6 +396,7 @@ const claudeDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#1a1918", surfaceSidebar: "#1a1918",
surfaceSidebarHover: "#222120", surfaceSidebarHover: "#222120",
foregroundMuted: "#ada9a5", foregroundMuted: "#ada9a5",
foregroundExtraMuted: "#78746f",
scrollbarHandle: "#78746f", scrollbarHandle: "#78746f",
border: "#2c2a27", border: "#2c2a27",
borderAccent: "#36332f", borderAccent: "#36332f",
@@ -409,6 +416,7 @@ const ghosttyDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#21252d", surfaceSidebar: "#21252d",
surfaceSidebarHover: "#292d36", surfaceSidebarHover: "#292d36",
foregroundMuted: "#c8ccd8", foregroundMuted: "#c8ccd8",
foregroundExtraMuted: "#a0a4b2",
scrollbarHandle: "#a0a4b2", scrollbarHandle: "#a0a4b2",
border: "#353a47", border: "#353a47",
borderAccent: "#3f4454", borderAccent: "#3f4454",
+1
View File
@@ -45,6 +45,7 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
height?: number; height?: number;
backgroundColor?: string; backgroundColor?: string;
foregroundColor?: string; foregroundColor?: string;
trafficLightOffsetY?: number;
}) => ipcRenderer.invoke("paseo:window:updateWindowControls", update), }) => ipcRenderer.invoke("paseo:window:updateWindowControls", update),
onResized: (handler: EventHandler): (() => void) => { onResized: (handler: EventHandler): (() => void) => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => { const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
applyMacWindowControlsUpdate,
applyWindowControlsOverlayUpdate, applyWindowControlsOverlayUpdate,
createWindowControlsOverlayState, createWindowControlsOverlayState,
DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_HEIGHT,
@@ -70,10 +71,12 @@ describe("window-manager", () => {
readWindowControlsOverlayUpdate({ readWindowControlsOverlayUpdate({
height: 48, height: 48,
backgroundColor: "#181B1A", backgroundColor: "#181B1A",
trafficLightOffsetY: -5,
}), }),
).toEqual({ ).toEqual({
height: 48, height: 48,
backgroundColor: "#181B1A", backgroundColor: "#181B1A",
trafficLightOffsetY: -5,
}); });
}); });
@@ -82,6 +85,13 @@ describe("window-manager", () => {
expect(readWindowControlsOverlayUpdate({})).toBeNull(); expect(readWindowControlsOverlayUpdate({})).toBeNull();
expect(readWindowControlsOverlayUpdate({ height: 0 })).toBeNull(); expect(readWindowControlsOverlayUpdate({ height: 0 })).toBeNull();
expect(readWindowControlsOverlayUpdate({ backgroundColor: 12 })).toBeNull(); expect(readWindowControlsOverlayUpdate({ backgroundColor: 12 })).toBeNull();
expect(readWindowControlsOverlayUpdate({ trafficLightOffsetY: -11 })).toBeNull();
});
it("preserves fractional traffic-light offsets", () => {
expect(readWindowControlsOverlayUpdate({ trafficLightOffsetY: 1.5 })).toEqual({
trafficLightOffsetY: 1.5,
});
}); });
}); });
@@ -139,6 +149,24 @@ describe("window-manager", () => {
}); });
}); });
describe("applyMacWindowControlsUpdate", () => {
it("uses the focus and normal traffic-light positions", () => {
const setWindowButtonPosition = vi.fn();
applyMacWindowControlsUpdate({
win: { setWindowButtonPosition },
update: { trafficLightOffsetY: -5 },
});
applyMacWindowControlsUpdate({
win: { setWindowButtonPosition },
update: { trafficLightOffsetY: 0.5 },
});
expect(setWindowButtonPosition).toHaveBeenNthCalledWith(1, { x: 16, y: 9 });
expect(setWindowButtonPosition).toHaveBeenNthCalledWith(2, { x: 16, y: 14.5 });
});
});
describe("getMainWindowChromeOptions", () => { describe("getMainWindowChromeOptions", () => {
it("uses frameless hidden title bars with overlay on windows", () => { it("uses frameless hidden title bars with overlay on windows", () => {
expect( expect(
+35 -2
View File
@@ -13,6 +13,8 @@ import {
import type { WindowState, WindowStateStore } from "../settings/window-state.js"; import type { WindowState, WindowStateStore } from "../settings/window-state.js";
const WINDOW_STATE_SAVE_DEBOUNCE_MS = 400; const WINDOW_STATE_SAVE_DEBOUNCE_MS = 400;
const MAC_TRAFFIC_LIGHT_POSITION = { x: 16, y: 14 } as const;
const MAX_TRAFFIC_LIGHT_OFFSET_Y = 10;
export function readBadgeCount(input: unknown): number { export function readBadgeCount(input: unknown): number {
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) { if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
@@ -27,6 +29,7 @@ export interface WindowControlsOverlayUpdate {
height?: number; height?: number;
backgroundColor?: string; backgroundColor?: string;
foregroundColor?: string; foregroundColor?: string;
trafficLightOffsetY?: number;
} }
export interface WindowControlsOverlayState { export interface WindowControlsOverlayState {
@@ -79,7 +82,7 @@ export function getMainWindowChromeOptions(input: {
return { return {
titleBarStyle: "hidden", titleBarStyle: "hidden",
titleBarOverlay: true, titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 14 }, trafficLightPosition: MAC_TRAFFIC_LIGHT_POSITION,
}; };
} }
@@ -128,6 +131,14 @@ function readOverlayColor(input: unknown): string | null {
return input; return input;
} }
function readTrafficLightOffsetY(input: unknown): number | null {
if (typeof input !== "number" || !Number.isFinite(input)) {
return null;
}
return Math.abs(input) <= MAX_TRAFFIC_LIGHT_OFFSET_Y ? input : null;
}
export function readWindowControlsOverlayUpdate( export function readWindowControlsOverlayUpdate(
input: unknown, input: unknown,
): WindowControlsOverlayUpdate | null { ): WindowControlsOverlayUpdate | null {
@@ -139,8 +150,14 @@ export function readWindowControlsOverlayUpdate(
const height = readFiniteOverlayHeight(candidate.height); const height = readFiniteOverlayHeight(candidate.height);
const backgroundColor = readOverlayColor(candidate.backgroundColor); const backgroundColor = readOverlayColor(candidate.backgroundColor);
const foregroundColor = readOverlayColor(candidate.foregroundColor); const foregroundColor = readOverlayColor(candidate.foregroundColor);
const trafficLightOffsetY = readTrafficLightOffsetY(candidate.trafficLightOffsetY);
if (height === null && backgroundColor === null && foregroundColor === null) { if (
height === null &&
backgroundColor === null &&
foregroundColor === null &&
trafficLightOffsetY === null
) {
return null; return null;
} }
@@ -148,6 +165,7 @@ export function readWindowControlsOverlayUpdate(
...(height !== null ? { height } : {}), ...(height !== null ? { height } : {}),
...(backgroundColor !== null ? { backgroundColor } : {}), ...(backgroundColor !== null ? { backgroundColor } : {}),
...(foregroundColor !== null ? { foregroundColor } : {}), ...(foregroundColor !== null ? { foregroundColor } : {}),
...(trafficLightOffsetY !== null ? { trafficLightOffsetY } : {}),
}; };
} }
@@ -176,6 +194,20 @@ export function applyWindowControlsOverlayUpdate(input: {
return next; return next;
} }
export function applyMacWindowControlsUpdate(input: {
win: Pick<BrowserWindow, "setWindowButtonPosition">;
update: WindowControlsOverlayUpdate;
}): void {
if (input.update.trafficLightOffsetY === undefined) {
return;
}
input.win.setWindowButtonPosition({
x: MAC_TRAFFIC_LIGHT_POSITION.x,
y: MAC_TRAFFIC_LIGHT_POSITION.y + input.update.trafficLightOffsetY,
});
}
export function registerWindowManager(): void { export function registerWindowManager(): void {
const overlayStateByWindow = new WeakMap<BrowserWindow, WindowControlsOverlayState>(); const overlayStateByWindow = new WeakMap<BrowserWindow, WindowControlsOverlayState>();
@@ -230,6 +262,7 @@ export function registerWindowManager(): void {
} }
if (process.platform === "darwin") { if (process.platform === "darwin") {
applyMacWindowControlsUpdate({ win, update: nextUpdate });
return; return;
} }