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

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.
`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.
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.

View File

@@ -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();
});

View File

@@ -40,6 +40,7 @@ import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import {
getIsElectronRuntime,
getIsElectronRuntimeMac,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
@@ -108,7 +109,11 @@ import {
WindowChromeRegion,
WindowChromeSafeArea,
} 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 { navigateToAgent } from "@/utils/navigate-to-agent";
import {
@@ -425,7 +430,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
@@ -442,6 +446,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isCompactLayout = useIsCompactFormFactor();
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
const isWorkspaceFocusModeEnabled = isWorkspaceRoute && isFocusModeEnabled;
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
@@ -470,7 +476,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
isMobile: isCompactLayout,
toggleAgentList,
toggleBothSidebars: toggleDesktopSidebars,
toggleFocusMode,
cycleTheme,
});
@@ -479,11 +484,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
isSettingsRoute: pathname.includes("/settings"),
isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen,
isWorkspaceExplorerOpen: isWorkspaceRoute && isDesktopFileExplorerOpen,
requestedExplorerWidth: explorerWidth,
viewportWidth,
});
const desktopSidebarMounted = chromeEnabled && !isFocusModeEnabled;
const desktopSidebarMounted = chromeEnabled && !isWorkspaceFocusModeEnabled;
const desktopSidebarVisible =
!isCompactLayout &&
desktopSidebarMounted &&
@@ -497,7 +502,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const appChromeLayout = resolveDesktopAppChromeLayout({
desktopSidebarRendered: desktopSidebarVisible,
hasTopLeftWindowControls,
sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled,
sidebarControlsEnabled: chromeEnabled && !isWorkspaceFocusModeEnabled,
});
const sidebarChrome = (
<SidebarChrome
@@ -659,16 +664,23 @@ function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
const pathname = usePathname();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const liftTrafficLights =
getIsElectronRuntimeMac() &&
isFocusModeEnabled &&
parseHostWorkspaceRouteFromPathname(pathname) !== null;
useEffect(() => {
if (!enabled || isNative) return;
void updateDesktopWindowControls({
backgroundColor: surface0,
foregroundColor: foreground,
trafficLightOffsetY: liftTrafficLights ? -5 : 0.5,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [enabled, surface0, foreground]);
}, [enabled, surface0, foreground, liftTrafficLights]);
return null;
}
@@ -987,7 +999,7 @@ const layoutStyles = StyleSheet.create((theme) => ({
},
windowSidebarToggle: {
position: "absolute",
top: 0,
top: 1,
left: 0,
zIndex: 20,
height: HEADER_INNER_HEIGHT,

View File

@@ -45,12 +45,14 @@ function MobileMenuIcon({ color }: { color: string }) {
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { theme } = useUnistyles();
@@ -83,7 +85,12 @@ function SidebarMenuToggleButton({
accessibilityState={accessibilityState}
>
{({ 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 ? (
<MobileMenuIcon color={color} />
) : (
@@ -121,7 +128,14 @@ export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps =
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
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) {

View File

@@ -123,6 +123,7 @@ interface SplitContainerProps {
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
focusModeEnabled?: boolean;
onExitFocusMode: () => void;
}
interface WorkspaceTabDragData {
@@ -390,6 +391,7 @@ export function SplitContainer({
onReorderTabsInPane,
renderPaneEmptyState = () => null,
focusModeEnabled,
onExitFocusMode,
}: SplitContainerProps) {
const inheritedWindowChromeCorners = useWindowChromeCorners();
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
@@ -611,6 +613,8 @@ export function SplitContainer({
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
@@ -755,6 +759,8 @@ function SplitNodeView({
dropPreview,
tabDropPreview,
windowChromeCorners,
focusModeEnabled,
onExitFocusMode,
}: SplitNodeViewProps) {
const groupId = node.kind === "group" ? node.group.id : null;
const groupDirection = node.kind === "group" ? node.group.direction : null;
@@ -808,6 +814,8 @@ function SplitNodeView({
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeRegion>
);
@@ -857,6 +865,8 @@ function SplitNodeView({
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={windowChromeCorners}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
</SplitGroupChild>
{index < node.group.children.length - 1 ? (
@@ -908,6 +918,8 @@ function SplitPaneView({
showDropZones,
dropPreview,
tabDropPreview,
focusModeEnabled,
onExitFocusMode,
}: SplitPaneViewProps) {
const { theme: _theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
@@ -1043,6 +1055,8 @@ function SplitPaneView({
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>

View File

@@ -95,6 +95,7 @@ export interface DesktopWindowControlsOverlayUpdate {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
trafficLightOffsetY?: number;
}
export interface DesktopWindowBridge {

View File

@@ -34,14 +34,12 @@ export function useKeyboardShortcuts({
isMobile,
toggleAgentList,
toggleBothSidebars,
toggleFocusMode,
cycleTheme,
}: {
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
toggleBothSidebars?: () => void;
toggleFocusMode?: () => void;
cycleTheme?: () => void;
}) {
const pathname = usePathname();
@@ -105,7 +103,6 @@ export function useKeyboardShortcuts({
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
"toggle-agent-list": toggleAgentList,
"toggle-both-sidebars": toggleBothSidebars,
"toggle-focus-mode": toggleFocusMode,
"cycle-theme": cycleTheme,
};
@@ -305,6 +302,5 @@ export function useKeyboardShortcuts({
router,
toggleAgentList,
toggleBothSidebars,
toggleFocusMode,
]);
}

View File

@@ -499,6 +499,7 @@ export const ar: TranslationResources = {
preparingTerminal: "إعداد علامة التبويب المحطة الطرفية",
preparingTerminalTooltip: "جارٍ تحضير المحطة...",
newBrowser: "متصفح جديد",
exitFocusMode: "إنهاء وضع التركيز",
splitRight: "تقسيم الجزء الأيمن",
splitDown: "تقسيم الجزء لأسفل",
terminalProfilesMenu: "Terminal profiles",

View File

@@ -498,6 +498,7 @@ export const en = {
preparingTerminal: "Preparing terminal tab",
preparingTerminalTooltip: "Preparing terminal...",
newBrowser: "New browser",
exitFocusMode: "Exit focus mode",
splitRight: "Split pane right",
splitDown: "Split pane down",
terminalProfilesMenu: "Terminal profiles",

View File

@@ -504,6 +504,7 @@ export const es: TranslationResources = {
preparingTerminal: "Preparando la pestaña del terminal",
preparingTerminalTooltip: "Preparando terminal...",
newBrowser: "Nuevo navegador",
exitFocusMode: "Salir del modo de concentración",
splitRight: "Panel dividido a la derecha",
splitDown: "Dividir panel hacia abajo",
terminalProfilesMenu: "Terminal profiles",

View File

@@ -503,6 +503,7 @@ export const fr: TranslationResources = {
preparingTerminal: "Préparation de l'onglet du terminal",
preparingTerminalTooltip: "Préparation du terminal...",
newBrowser: "Nouveau navigateur",
exitFocusMode: "Quitter le mode concentration",
splitRight: "Volet divisé à droite",
splitDown: "Diviser le volet vers le bas",
terminalProfilesMenu: "Terminal profiles",

View File

@@ -504,6 +504,7 @@ export const ja: TranslationResources = {
preparingTerminal: "ターミナルタブを準備中",
preparingTerminalTooltip: "ターミナルを準備中...",
newBrowser: "新しいブラウザ",
exitFocusMode: "フォーカスモードを終了",
splitRight: "右にペインを分割",
splitDown: "下にペインを分割",
terminalProfilesMenu: "ターミナルプロファイル",

View File

@@ -503,6 +503,7 @@ export const ptBR: TranslationResources = {
preparingTerminal: "Preparando aba de terminal",
preparingTerminalTooltip: "Preparando terminal...",
newBrowser: "Novo navegador",
exitFocusMode: "Sair do modo de foco",
splitRight: "Dividir painel à direita",
splitDown: "Dividir painel abaixo",
terminalProfilesMenu: "Perfis de terminal",

View File

@@ -503,6 +503,7 @@ export const ru: TranslationResources = {
preparingTerminal: "Подготовка вкладки терминала",
preparingTerminalTooltip: "Подготовка терминала...",
newBrowser: "Новый браузер",
exitFocusMode: "Выйти из режима фокусировки",
splitRight: "Разделить панель справа",
splitDown: "Разделить панель вниз",
terminalProfilesMenu: "Terminal profiles",

View File

@@ -499,6 +499,7 @@ export const zhCN: TranslationResources = {
preparingTerminal: "正在准备 Terminal 标签",
preparingTerminalTooltip: "正在准备 Terminal...",
newBrowser: "新建浏览器",
exitFocusMode: "退出专注模式",
splitRight: "向右拆分窗格",
splitDown: "向下拆分窗格",
terminalProfilesMenu: "Terminal profiles",

View File

@@ -25,6 +25,7 @@ export type KeyboardActionId =
| "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down"
| "workspace.pane.close"
| "workspace.focus.toggle"
| "workspace.terminal.new"
| "sidebar.toggle.right"
| "workspace.new"
@@ -57,6 +58,7 @@ export type KeyboardActionDefinition =
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
| { id: "workspace.focus.toggle"; scope: KeyboardActionScope }
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "workspace.new"; scope: KeyboardActionScope }

View File

@@ -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.down", { id: "workspace.pane.move-tab.down", 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) => {
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({
kind: "dispatch",
@@ -333,7 +334,6 @@ describe("routeKeyboardShortcut — callbacks and pickers", () => {
it.each([
["sidebar.toggle.left", "toggle-agent-list"],
["sidebar.toggle.both", "toggle-both-sidebars"],
["view.toggle.focus", "toggle-focus-mode"],
["theme.cycle", "cycle-theme"],
] as const)("%s → callback %s", (action, name) => {
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual<ShortcutAction>({

View File

@@ -20,11 +20,7 @@ export interface ShortcutRoutingInput {
payload: KeyboardShortcutPayload;
}
export type ShortcutCallbackName =
| "toggle-agent-list"
| "toggle-both-sidebars"
| "toggle-focus-mode"
| "cycle-theme";
export type ShortcutCallbackName = "toggle-agent-list" | "toggle-both-sidebars" | "cycle-theme";
export type ShortcutAction =
| { 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.down": { id: "workspace.pane.move-tab.down", 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> = {
"sidebar.toggle.left": "toggle-agent-list",
"sidebar.toggle.both": "toggle-both-sidebars",
"view.toggle.focus": "toggle-focus-mode",
"theme.cycle": "cycle-theme",
};

View File

@@ -1081,7 +1081,12 @@ function SettingsSidebar({
);
return (
<View style={outerContainerStyle} testID="settings-sidebar">
<View
accessibilityLabel={t("settings.title")}
role="navigation"
style={outerContainerStyle}
testID="settings-sidebar"
>
{isDesktop ? (
<View style={innerContainerStyle}>
<View style={sidebarStyles.sidebarDragArea}>

View File

@@ -438,6 +438,8 @@ interface WorkspaceDesktopTabsRowProps {
activeDragTabId?: string | null;
tabDropPreviewIndex?: number | null;
showPaneSplitActions?: boolean;
focusModeEnabled: boolean;
onExitFocusMode: () => void;
}
function getFallbackTabLabel(
@@ -758,15 +760,19 @@ export function WorkspaceDesktopTabsRow({
activeDragTabId = null,
tabDropPreviewIndex = null,
showPaneSplitActions = true,
focusModeEnabled,
onExitFocusMode,
}: WorkspaceDesktopTabsRowProps) {
const { t } = useTranslation();
const router = useRouter();
const newTabKeys = useShortcutKeys("workspace-tab-new");
const focusModeKeys = useShortcutKeys("toggle-focus");
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setTabsContainerWidth, event);
@@ -780,12 +786,18 @@ export function WorkspaceDesktopTabsRow({
updateMeasuredWidth(setInlineAddButtonWidth, event);
}, []);
const handleExitFocusModeLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setExitFocusModeWidth, event);
}, []);
const layoutMetrics = useMemo(
() => ({
rowHorizontalInset: 0,
actionsReservedWidth: Math.max(
0,
tabsActionsWidth + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH),
tabsActionsWidth +
(inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) +
(focusModeEnabled ? exitFocusModeWidth : 0),
),
rowPaddingHorizontal: 0,
tabGap: 0,
@@ -795,7 +807,7 @@ export function WorkspaceDesktopTabsRow({
estimatedCharWidth: 7,
closeButtonWidth: 22,
}),
[inlineAddButtonWidth, tabsActionsWidth],
[exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, tabsActionsWidth],
);
const fallbackTabLabels = useMemo(
@@ -968,6 +980,31 @@ export function WorkspaceDesktopTabsRow({
testID="workspace-tabs-row"
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
horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback}
@@ -1184,6 +1221,14 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
paddingHorizontal: theme.spacing[2],
},
exitFocusModeSlot: {
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[1],
borderRightWidth: 1,
borderRightColor: theme.colors.border,
},
inlineAddButton: {
flexDirection: "row",
alignItems: "center",

View File

@@ -266,6 +266,9 @@ const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
@@ -1712,6 +1715,7 @@ function WorkspaceScreenContent({
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const normalizedServerId = useMemo(() => trimNonEmpty(decodeSegment(serverId)) ?? "", [serverId]);
@@ -2966,6 +2970,11 @@ function WorkspaceScreenContent({
const handleWorkspacePaneAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id === "workspace.focus.toggle") {
toggleFocusMode();
return true;
}
if (!persistenceKey || !workspaceLayout) {
return true;
}
@@ -3039,6 +3048,7 @@ function WorkspaceScreenContent({
persistenceKey,
focusedPaneTabState.activeTabId,
focusedPaneTabState.pane,
toggleFocusMode,
workspaceLayout,
],
);
@@ -3072,6 +3082,7 @@ function WorkspaceScreenContent({
"workspace.pane.move-tab.up",
"workspace.pane.move-tab.down",
"workspace.pane.close",
"workspace.focus.toggle",
] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
priority: 100,
@@ -3366,8 +3377,8 @@ function WorkspaceScreenContent({
style={explorerToggleStyle}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
const active = hovered || pressed;
const colorMapping = active ? foregroundColorMapping : extraMutedColorMapping;
return (
<>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
@@ -3412,9 +3423,9 @@ function WorkspaceScreenContent({
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
>
{({ hovered }) => {
{({ hovered, pressed }) => {
const colorMapping =
isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
hovered || pressed ? foregroundColorMapping : extraMutedColorMapping;
return <ThemedPanelRight size={16} uniProps={colorMapping} />;
}}
</HeaderToggleButton>
@@ -3502,6 +3513,7 @@ function WorkspaceScreenContent({
<SplitContainer
layout={workspaceLayout}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
workspaceKey={persistenceKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
@@ -3539,6 +3551,7 @@ function WorkspaceScreenContent({
workspaceLayout,
persistenceKey,
desktopFocusModeEnabled,
toggleFocusMode,
normalizedServerId,
normalizedWorkspaceId,
isRouteFocused,
@@ -3668,6 +3681,8 @@ function WorkspaceScreenContent({
onSplitRight={noop}
onSplitDown={noop}
showPaneSplitActions={false}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
/>
) : null}

View File

@@ -153,6 +153,7 @@ const lightSemanticColors = {
// Text
foreground: "#1a1a1e",
foregroundMuted: "#71717a",
foregroundExtraMuted: "#a1a1aa",
// Controls
scrollbarHandle: "#3f3f46", // zinc-700
@@ -231,6 +232,7 @@ interface DarkThemeConfig {
surfaceSidebar: string;
surfaceSidebarHover: string;
foregroundMuted: string;
foregroundExtraMuted: string;
scrollbarHandle: string;
border: string;
borderAccent: string;
@@ -271,6 +273,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
foreground: "#fafafa",
foregroundMuted: tint.foregroundMuted,
foregroundExtraMuted: tint.foregroundExtraMuted,
scrollbarHandle: tint.scrollbarHandle,
@@ -332,6 +335,7 @@ const paseoDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#141716",
surfaceSidebarHover: "#1c1f1e",
foregroundMuted: "#A1A5A4",
foregroundExtraMuted: "#717574",
scrollbarHandle: "#717574",
border: "#252B2A",
borderAccent: "#2F3534",
@@ -351,6 +355,7 @@ const zincDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#131316",
surfaceSidebarHover: "#1b1b1e",
foregroundMuted: "#a1a1aa",
foregroundExtraMuted: "#71717a",
scrollbarHandle: "#71717a",
border: "#27272a",
borderAccent: "#303036",
@@ -371,6 +376,7 @@ const midnightDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#121420",
surfaceSidebarHover: "#1a1c28",
foregroundMuted: "#9a9db0",
foregroundExtraMuted: "#6b6e82",
scrollbarHandle: "#6b6e82",
border: "#242636",
borderAccent: "#2e3040",
@@ -390,6 +396,7 @@ const claudeDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#1a1918",
surfaceSidebarHover: "#222120",
foregroundMuted: "#ada9a5",
foregroundExtraMuted: "#78746f",
scrollbarHandle: "#78746f",
border: "#2c2a27",
borderAccent: "#36332f",
@@ -409,6 +416,7 @@ const ghosttyDarkColors = buildDarkSemanticColors({
surfaceSidebar: "#21252d",
surfaceSidebarHover: "#292d36",
foregroundMuted: "#c8ccd8",
foregroundExtraMuted: "#a0a4b2",
scrollbarHandle: "#a0a4b2",
border: "#353a47",
borderAccent: "#3f4454",

View File

@@ -45,6 +45,7 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
trafficLightOffsetY?: number;
}) => ipcRenderer.invoke("paseo:window:updateWindowControls", update),
onResized: (handler: EventHandler): (() => void) => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
applyMacWindowControlsUpdate,
applyWindowControlsOverlayUpdate,
createWindowControlsOverlayState,
DEFAULT_WINDOW_HEIGHT,
@@ -70,10 +71,12 @@ describe("window-manager", () => {
readWindowControlsOverlayUpdate({
height: 48,
backgroundColor: "#181B1A",
trafficLightOffsetY: -5,
}),
).toEqual({
height: 48,
backgroundColor: "#181B1A",
trafficLightOffsetY: -5,
});
});
@@ -82,6 +85,13 @@ describe("window-manager", () => {
expect(readWindowControlsOverlayUpdate({})).toBeNull();
expect(readWindowControlsOverlayUpdate({ height: 0 })).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", () => {
it("uses frameless hidden title bars with overlay on windows", () => {
expect(

View File

@@ -13,6 +13,8 @@ import {
import type { WindowState, WindowStateStore } from "../settings/window-state.js";
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 {
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
@@ -27,6 +29,7 @@ export interface WindowControlsOverlayUpdate {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
trafficLightOffsetY?: number;
}
export interface WindowControlsOverlayState {
@@ -79,7 +82,7 @@ export function getMainWindowChromeOptions(input: {
return {
titleBarStyle: "hidden",
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 14 },
trafficLightPosition: MAC_TRAFFIC_LIGHT_POSITION,
};
}
@@ -128,6 +131,14 @@ function readOverlayColor(input: unknown): string | null {
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(
input: unknown,
): WindowControlsOverlayUpdate | null {
@@ -139,8 +150,14 @@ export function readWindowControlsOverlayUpdate(
const height = readFiniteOverlayHeight(candidate.height);
const backgroundColor = readOverlayColor(candidate.backgroundColor);
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;
}
@@ -148,6 +165,7 @@ export function readWindowControlsOverlayUpdate(
...(height !== null ? { height } : {}),
...(backgroundColor !== null ? { backgroundColor } : {}),
...(foregroundColor !== null ? { foregroundColor } : {}),
...(trafficLightOffsetY !== null ? { trafficLightOffsetY } : {}),
};
}
@@ -176,6 +194,20 @@ export function applyWindowControlsOverlayUpdate(input: {
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 {
const overlayStateByWindow = new WeakMap<BrowserWindow, WindowControlsOverlayState>();
@@ -230,6 +262,7 @@ export function registerWindowManager(): void {
}
if (process.platform === "darwin") {
applyMacWindowControlsUpdate({ win, update: nextUpdate });
return;
}