mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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.
|
||||
|
||||
51
packages/app/e2e/workspace-focus-mode.spec.ts
Normal file
51
packages/app/e2e/workspace-focus-mode.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export interface DesktopWindowControlsOverlayUpdate {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
trafficLightOffsetY?: number;
|
||||
}
|
||||
|
||||
export interface DesktopWindowBridge {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -499,6 +499,7 @@ export const ar: TranslationResources = {
|
||||
preparingTerminal: "إعداد علامة التبويب المحطة الطرفية",
|
||||
preparingTerminalTooltip: "جارٍ تحضير المحطة...",
|
||||
newBrowser: "متصفح جديد",
|
||||
exitFocusMode: "إنهاء وضع التركيز",
|
||||
splitRight: "تقسيم الجزء الأيمن",
|
||||
splitDown: "تقسيم الجزء لأسفل",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -504,6 +504,7 @@ export const ja: TranslationResources = {
|
||||
preparingTerminal: "ターミナルタブを準備中",
|
||||
preparingTerminalTooltip: "ターミナルを準備中...",
|
||||
newBrowser: "新しいブラウザ",
|
||||
exitFocusMode: "フォーカスモードを終了",
|
||||
splitRight: "右にペインを分割",
|
||||
splitDown: "下にペインを分割",
|
||||
terminalProfilesMenu: "ターミナルプロファイル",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -503,6 +503,7 @@ export const ru: TranslationResources = {
|
||||
preparingTerminal: "Подготовка вкладки терминала",
|
||||
preparingTerminalTooltip: "Подготовка терминала...",
|
||||
newBrowser: "Новый браузер",
|
||||
exitFocusMode: "Выйти из режима фокусировки",
|
||||
splitRight: "Разделить панель справа",
|
||||
splitDown: "Разделить панель вниз",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
|
||||
@@ -499,6 +499,7 @@ export const zhCN: TranslationResources = {
|
||||
preparingTerminal: "正在准备 Terminal 标签",
|
||||
preparingTerminalTooltip: "正在准备 Terminal...",
|
||||
newBrowser: "新建浏览器",
|
||||
exitFocusMode: "退出专注模式",
|
||||
splitRight: "向右拆分窗格",
|
||||
splitDown: "向下拆分窗格",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user