From 1c9cc95bebbf4308ef97255675b421a479e30da3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 17 Jan 2026 11:08:34 +0100 Subject: [PATCH 01/18] feat: add layout presets and slot hotkeys Change-Id: I10152520fced6d45a8fd2e361c7a8a086689d900 Signed-off-by: Thomas Kosiewski --- src/browser/App.tsx | 93 +++- src/browser/components/RightSidebar.tsx | 4 +- .../components/Settings/SettingsModal.tsx | 19 +- .../Settings/sections/LayoutsSection.tsx | 409 ++++++++++++++ src/browser/contexts/UILayoutsContext.tsx | 244 +++++++++ src/browser/hooks/useResizableSidebar.ts | 47 +- src/browser/utils/commandIds.ts | 4 + src/browser/utils/commands/sources.ts | 108 ++++ src/browser/utils/ui/keybinds.test.ts | 3 +- src/browser/utils/ui/keybinds.ts | 20 +- src/browser/utils/uiLayouts.ts | 502 ++++++++++++++++++ src/common/orpc/schemas.ts | 12 + src/common/orpc/schemas/api.ts | 17 + src/common/orpc/schemas/uiLayouts.ts | 106 ++++ src/common/types/keybind.ts | 55 ++ src/common/types/project.ts | 3 + src/common/types/uiLayouts.ts | 305 +++++++++++ src/node/config.ts | 15 + src/node/orpc/router.ts | 26 + 19 files changed, 1953 insertions(+), 39 deletions(-) create mode 100644 src/browser/components/Settings/sections/LayoutsSection.tsx create mode 100644 src/browser/contexts/UILayoutsContext.tsx create mode 100644 src/browser/utils/uiLayouts.ts create mode 100644 src/common/orpc/schemas/uiLayouts.ts create mode 100644 src/common/types/keybind.ts create mode 100644 src/common/types/uiLayouts.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index c37e5198ec..8f5915af89 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -13,6 +13,7 @@ import { updatePersistedState, readPersistedState, } from "./hooks/usePersistedState"; +import { getEffectiveSlotKeybind } from "@/browser/utils/uiLayouts"; import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; import { useResumeManager } from "./hooks/useResumeManager"; @@ -56,6 +57,7 @@ import { SplashScreenProvider } from "./components/splashScreens/SplashScreenPro import { TutorialProvider } from "./contexts/TutorialContext"; import { TooltipProvider } from "./components/ui/tooltip"; import { useFeatureFlags } from "./contexts/FeatureFlagsContext"; +import { UILayoutsProvider, useUILayouts } from "@/browser/contexts/UILayoutsContext"; import { FeatureFlagsProvider } from "./contexts/FeatureFlagsContext"; import { ExperimentsProvider } from "./contexts/ExperimentsContext"; import { getWorkspaceSidebarKey } from "./utils/workspace"; @@ -76,13 +78,20 @@ function AppInner() { beginWorkspaceCreation, } = useWorkspaceContext(); const { theme, setTheme, toggleTheme } = useTheme(); - const { open: openSettings } = useSettings(); + const { open: openSettings, isOpen: isSettingsOpen } = useSettings(); const setThemePreference = useCallback( (nextTheme: ThemeMode) => { setTheme(nextTheme); }, [setTheme] ); + const { + layoutPresets, + applySlotToWorkspace, + applyPresetToWorkspace, + saveCurrentWorkspaceAsPreset, + setSlotPreset, + } = useUILayouts(); const { api, status, error, authenticate } = useAPI(); const { @@ -96,7 +105,9 @@ function AppInner() { // Auto-collapse sidebar on mobile by default const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; - const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile); + const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile, { + listener: true, + }); // Sync sidebar collapse state to root element for CSS-based titlebar insets useEffect(() => { @@ -457,6 +468,27 @@ function AppInner() { onToggleTheme: toggleTheme, onSetTheme: setThemePreference, onOpenSettings: openSettings, + layoutPresets, + onApplyLayoutSlot: (workspaceId, slot) => { + void applySlotToWorkspace(workspaceId, slot).catch(() => { + // Best-effort only. + }); + }, + onApplyLayoutPreset: (workspaceId, presetId) => { + void applyPresetToWorkspace(workspaceId, presetId).catch(() => { + // Best-effort only. + }); + }, + onSaveLayoutPreset: async (workspaceId, name, slot) => { + try { + const preset = await saveCurrentWorkspaceAsPreset(workspaceId, name); + if (slot) { + await setSlotPreset(slot, preset.id); + } + } catch { + // Best-effort only. + } + }, onClearTimingStats: (workspaceId: string) => workspaceStore.clearTimingStats(workspaceId), api, }; @@ -529,6 +561,39 @@ function AppInner() { openSettings, ]); + // Layout slot hotkeys (Ctrl/Cmd+Alt+1..9 by default) + useEffect(() => { + const handleKeyDownCapture = (e: KeyboardEvent) => { + if (isCommandPaletteOpen || isSettingsOpen) { + return; + } + + if (!selectedWorkspace) { + return; + } + + for (const slot of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) { + const keybind = getEffectiveSlotKeybind(layoutPresets, slot); + if (matchesKeybind(e, keybind)) { + e.preventDefault(); + void applySlotToWorkspace(selectedWorkspace.workspaceId, slot).catch(() => { + // Best-effort only. + }); + return; + } + } + }; + + window.addEventListener("keydown", handleKeyDownCapture, { capture: true }); + return () => window.removeEventListener("keydown", handleKeyDownCapture, { capture: true }); + }, [ + isCommandPaletteOpen, + isSettingsOpen, + selectedWorkspace, + layoutPresets, + applySlotToWorkspace, + ]); + // Subscribe to menu bar "Open Settings" (macOS Cmd+, from app menu) useEffect(() => { if (!api) return; @@ -789,17 +854,19 @@ function App() { return ( - - - - - - - - - - - + + + + + + + + + + + + + ); diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index eee316189e..44a2d7ef79 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -587,7 +587,9 @@ const RightSidebarComponent: React.FC = ({ ); // Manual collapse state (persisted globally) - const [collapsed, setCollapsed] = usePersistedState(RIGHT_SIDEBAR_COLLAPSED_KEY, false); + const [collapsed, setCollapsed] = usePersistedState(RIGHT_SIDEBAR_COLLAPSED_KEY, false, { + listener: true, + }); // Stats tab feature flag const { statsTabState } = useFeatureFlags(); diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index dcf45493b4..7d07399971 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -1,5 +1,15 @@ import React from "react"; -import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot, Keyboard } from "lucide-react"; +import { + Settings, + Key, + Cpu, + X, + Briefcase, + FlaskConical, + Bot, + Keyboard, + Layout, +} from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog"; import { GeneralSection } from "./sections/GeneralSection"; @@ -8,6 +18,7 @@ import { ProvidersSection } from "./sections/ProvidersSection"; import { ModelsSection } from "./sections/ModelsSection"; import { Button } from "@/browser/components/ui/button"; import { ProjectSettingsSection } from "./sections/ProjectSettingsSection"; +import { LayoutsSection } from "./sections/LayoutsSection"; import { ExperimentsSection } from "./sections/ExperimentsSection"; import { KeybindsSection } from "./sections/KeybindsSection"; import type { SettingsSection } from "./types"; @@ -43,6 +54,12 @@ const SECTIONS: SettingsSection[] = [ icon: , component: ModelsSection, }, + { + id: "layouts", + label: "Layouts", + icon: , + component: LayoutsSection, + }, { id: "experiments", label: "Experiments", diff --git a/src/browser/components/Settings/sections/LayoutsSection.tsx b/src/browser/components/Settings/sections/LayoutsSection.tsx new file mode 100644 index 0000000000..7d2352feb5 --- /dev/null +++ b/src/browser/components/Settings/sections/LayoutsSection.tsx @@ -0,0 +1,409 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Button } from "@/browser/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; +import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; +import { useUILayouts } from "@/browser/contexts/UILayoutsContext"; +import { getEffectiveSlotKeybind, getPresetForSlot } from "@/browser/utils/uiLayouts"; +import { stopKeyboardPropagation } from "@/browser/utils/events"; +import { formatKeybind, isMac, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; +import type { Keybind } from "@/common/types/keybind"; +import type { LayoutSlotNumber } from "@/common/types/uiLayouts"; + +function isModifierOnlyKey(key: string): boolean { + return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"; +} + +function normalizeCapturedKeybind(e: KeyboardEvent): Keybind | null { + if (!e.key || isModifierOnlyKey(e.key)) { + return null; + } + + // On macOS, we represent Cmd as ctrl=true so bindings remain cross-platform. + const onMac = isMac(); + const ctrl = e.ctrlKey ? true : onMac ? e.metaKey : false; + const meta = !onMac ? e.metaKey : false; + + return { + key: e.key, + ctrl: ctrl ? true : undefined, + alt: e.altKey ? true : undefined, + shift: e.shiftKey ? true : undefined, + meta: meta ? true : undefined, + }; +} + +function keybindConflicts(a: Keybind, b: Keybind): boolean { + if (a.key.toLowerCase() !== b.key.toLowerCase()) { + return false; + } + + for (const ctrlKey of [false, true]) { + for (const altKey of [false, true]) { + for (const shiftKey of [false, true]) { + for (const metaKey of [false, true]) { + const ev = new KeyboardEvent("keydown", { + key: a.key, + ctrlKey, + altKey, + shiftKey, + metaKey, + }); + + if (matchesKeybind(ev, a) && matchesKeybind(ev, b)) { + return true; + } + } + } + } + } + + return false; +} + +function validateSlotKeybindOverride(params: { + slot: LayoutSlotNumber; + keybind: Keybind; + existing: Array<{ slot: LayoutSlotNumber; keybind: Keybind }>; +}): string | null { + const hasModifier = [ + params.keybind.ctrl, + params.keybind.alt, + params.keybind.shift, + params.keybind.meta, + ].some((v) => v === true); + if (!hasModifier) { + return "Keybind must include at least one modifier key."; + } + + for (const core of Object.values(KEYBINDS)) { + if (keybindConflicts(params.keybind, core)) { + return `Conflicts with an existing mux shortcut (${formatKeybind(core)}).`; + } + } + + for (const entry of params.existing) { + if (entry.slot === params.slot) { + continue; + } + if (keybindConflicts(params.keybind, entry.keybind)) { + return `Conflicts with Slot ${entry.slot} (${formatKeybind(entry.keybind)}).`; + } + } + + return null; +} + +export function LayoutsSection() { + const { + layoutPresets, + loaded, + loadFailed, + refresh, + applySlotToWorkspace, + applyPresetToWorkspace, + saveCurrentWorkspaceAsPreset, + setSlotPreset, + setSlotKeybindOverride, + deletePreset, + renamePreset, + updatePresetFromCurrentWorkspace, + } = useUILayouts(); + const { selectedWorkspace } = useWorkspaceContext(); + + const [actionError, setActionError] = useState(null); + const [capturingSlot, setCapturingSlot] = useState(null); + const [captureError, setCaptureError] = useState(null); + + const effectiveSlotKeybinds = useMemo(() => { + return ([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map((slot) => ({ + slot, + keybind: getEffectiveSlotKeybind(layoutPresets, slot), + })); + }, [layoutPresets]); + + useEffect(() => { + if (!capturingSlot) { + return; + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + stopKeyboardPropagation(e); + setCapturingSlot(null); + setCaptureError(null); + return; + } + + const captured = normalizeCapturedKeybind(e); + if (!captured) { + return; + } + + e.preventDefault(); + stopKeyboardPropagation(e); + + const error = validateSlotKeybindOverride({ + slot: capturingSlot, + keybind: captured, + existing: effectiveSlotKeybinds, + }); + + if (error) { + setCaptureError(error); + return; + } + + void setSlotKeybindOverride(capturingSlot, captured).catch(() => { + setCaptureError("Failed to save keybind override."); + }); + setCapturingSlot(null); + setCaptureError(null); + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }, [capturingSlot, effectiveSlotKeybinds, setSlotKeybindOverride]); + + const workspaceId = selectedWorkspace?.workspaceId ?? null; + + const handleSavePreset = async (): Promise => { + setActionError(null); + + if (!workspaceId) { + setActionError("Select a workspace to save its layout."); + return; + } + + const name = window.prompt("Preset name:", ""); + const trimmed = name?.trim(); + if (!trimmed) { + return; + } + + try { + await saveCurrentWorkspaceAsPreset(workspaceId, trimmed); + } catch { + setActionError("Failed to save preset."); + } + }; + + return ( +
+
+
+

Layout Presets

+
+ Save and re-apply sidebar layouts per workspace. +
+
+
+ + +
+
+ + {!loaded ?
Loading…
: null} + {loadFailed ? ( +
+ Failed to load presets from config. Using defaults. +
+ ) : null} + {actionError ?
{actionError}
: null} + +
+

Slots (1–9)

+
+ {([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map((slot) => { + const slotConfig = layoutPresets.slots.find((s) => s.slot === slot); + const assignedPreset = getPresetForSlot(layoutPresets, slot); + const effectiveKeybind = getEffectiveSlotKeybind(layoutPresets, slot); + + return ( +
+
+
+
Slot {slot}
+
+ {assignedPreset ? assignedPreset.name : "Empty"} +
+
+ +
+ + {formatKeybind(effectiveKeybind)} + + +
+
+ +
+ + + {capturingSlot === slot ? ( +
+ Press a key combo (Esc to cancel) + {captureError ? ( +
{captureError}
+ ) : null} +
+ ) : ( + <> + + {slotConfig?.keybindOverride ? ( + + ) : null} + + )} +
+
+ ); + })} +
+
+ +
+

Presets

+
+ {layoutPresets.presets.length === 0 ? ( +
No presets yet.
+ ) : null} + + {layoutPresets.presets.map((preset) => ( +
+
+
+
{preset.name}
+
ID: {preset.id}
+
+ +
+ + + + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/browser/contexts/UILayoutsContext.tsx b/src/browser/contexts/UILayoutsContext.tsx new file mode 100644 index 0000000000..89e0df9ea6 --- /dev/null +++ b/src/browser/contexts/UILayoutsContext.tsx @@ -0,0 +1,244 @@ +import React, { + useCallback, + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useAPI } from "@/browser/contexts/API"; +import assert from "@/common/utils/assert"; +import { + DEFAULT_LAYOUT_PRESETS_CONFIG, + normalizeLayoutPresetsConfig, + type LayoutPreset, + type LayoutPresetsConfig, + type LayoutSlotNumber, +} from "@/common/types/uiLayouts"; +import { + applyLayoutPresetToWorkspace, + createPresetFromCurrentWorkspace, + getLayoutsConfigOrDefault, + getPresetById, + getPresetForSlot, + updateSlotAssignment, + updateSlotKeybindOverride, + upsertPreset, +} from "@/browser/utils/uiLayouts"; +import type { Keybind } from "@/common/types/keybind"; + +interface UILayoutsContextValue { + layoutPresets: LayoutPresetsConfig; + loaded: boolean; + loadFailed: boolean; + refresh: () => Promise; + saveAll: (next: LayoutPresetsConfig) => Promise; + + applySlotToWorkspace: (workspaceId: string, slot: LayoutSlotNumber) => Promise; + applyPresetToWorkspace: (workspaceId: string, presetId: string) => Promise; + saveCurrentWorkspaceAsPreset: (workspaceId: string, name: string) => Promise; + + setSlotPreset: (slot: LayoutSlotNumber, presetId: string | undefined) => Promise; + setSlotKeybindOverride: (slot: LayoutSlotNumber, keybind: Keybind | undefined) => Promise; + deletePreset: (presetId: string) => Promise; + renamePreset: (presetId: string, newName: string) => Promise; + updatePresetFromCurrentWorkspace: (workspaceId: string, presetId: string) => Promise; +} + +const UILayoutsContext = createContext(null); + +export function useUILayouts(): UILayoutsContextValue { + const ctx = useContext(UILayoutsContext); + if (!ctx) { + throw new Error("useUILayouts must be used within UILayoutsProvider"); + } + return ctx; +} + +export function UILayoutsProvider(props: { children: ReactNode }) { + const { api } = useAPI(); + + const [layoutPresets, setLayoutPresets] = useState( + DEFAULT_LAYOUT_PRESETS_CONFIG + ); + const [loaded, setLoaded] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + + const refresh = useCallback(async (): Promise => { + if (!api) { + setLayoutPresets(DEFAULT_LAYOUT_PRESETS_CONFIG); + setLoaded(true); + setLoadFailed(false); + return; + } + + try { + const remote = await api.uiLayouts.getAll(); + setLayoutPresets(getLayoutsConfigOrDefault(remote)); + setLoaded(true); + setLoadFailed(false); + } catch { + setLayoutPresets(DEFAULT_LAYOUT_PRESETS_CONFIG); + setLoaded(true); + setLoadFailed(true); + } + }, [api]); + + const saveAll = useCallback( + async (next: LayoutPresetsConfig): Promise => { + const normalized = normalizeLayoutPresetsConfig(next); + setLayoutPresets(normalized); + + if (!api) { + throw new Error("ORPC client not initialized"); + } + + await api.uiLayouts.saveAll({ layoutPresets: normalized }); + }, + [api] + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const applyPresetToWorkspace = useCallback( + async (workspaceId: string, presetId: string): Promise => { + const preset = getPresetById(layoutPresets, presetId); + if (!preset) { + return; + } + + await applyLayoutPresetToWorkspace(api ?? null, workspaceId, preset); + }, + [api, layoutPresets] + ); + + const applySlotToWorkspace = useCallback( + async (workspaceId: string, slot: LayoutSlotNumber): Promise => { + const preset = getPresetForSlot(layoutPresets, slot); + if (!preset) { + return; + } + + await applyLayoutPresetToWorkspace(api ?? null, workspaceId, preset); + }, + [api, layoutPresets] + ); + + const saveCurrentWorkspaceAsPreset = useCallback( + async (workspaceId: string, name: string): Promise => { + assert( + typeof workspaceId === "string" && workspaceId.length > 0, + "workspaceId must be non-empty" + ); + const preset = createPresetFromCurrentWorkspace(workspaceId, name); + await saveAll(upsertPreset(layoutPresets, preset)); + return preset; + }, + [layoutPresets, saveAll] + ); + + const updatePresetFromCurrentWorkspace = useCallback( + async (workspaceId: string, presetId: string): Promise => { + const existing = getPresetById(layoutPresets, presetId); + if (!existing) { + return; + } + + const next = createPresetFromCurrentWorkspace(workspaceId, existing.name, presetId); + await saveAll(upsertPreset(layoutPresets, next)); + }, + [layoutPresets, saveAll] + ); + + const renamePreset = useCallback( + async (presetId: string, newName: string): Promise => { + const trimmed = newName.trim(); + if (!trimmed) { + return; + } + + const existing = getPresetById(layoutPresets, presetId); + if (!existing) { + return; + } + + await saveAll( + upsertPreset(layoutPresets, { + ...existing, + name: trimmed, + }) + ); + }, + [layoutPresets, saveAll] + ); + + const deletePreset = useCallback( + async (presetId: string): Promise => { + const nextPresets = layoutPresets.presets.filter((p) => p.id !== presetId); + const nextSlots = layoutPresets.slots.map((s) => + s.presetId === presetId ? { ...s, presetId: undefined } : s + ); + + await saveAll( + normalizeLayoutPresetsConfig({ + version: 1, + presets: nextPresets, + slots: nextSlots, + }) + ); + }, + [layoutPresets, saveAll] + ); + + const setSlotPreset = useCallback( + async (slot: LayoutSlotNumber, presetId: string | undefined): Promise => { + await saveAll(updateSlotAssignment(layoutPresets, slot, presetId)); + }, + [layoutPresets, saveAll] + ); + + const setSlotKeybindOverride = useCallback( + async (slot: LayoutSlotNumber, keybind: Keybind | undefined): Promise => { + await saveAll(updateSlotKeybindOverride(layoutPresets, slot, keybind)); + }, + [layoutPresets, saveAll] + ); + + const value: UILayoutsContextValue = useMemo( + () => ({ + layoutPresets, + loaded, + loadFailed, + refresh, + saveAll, + applySlotToWorkspace, + applyPresetToWorkspace, + saveCurrentWorkspaceAsPreset, + setSlotPreset, + setSlotKeybindOverride, + deletePreset, + renamePreset, + updatePresetFromCurrentWorkspace, + }), + [ + layoutPresets, + loaded, + loadFailed, + refresh, + saveAll, + applySlotToWorkspace, + applyPresetToWorkspace, + saveCurrentWorkspaceAsPreset, + setSlotPreset, + setSlotKeybindOverride, + deletePreset, + renamePreset, + updatePresetFromCurrentWorkspace, + ] + ); + + return {props.children}; +} diff --git a/src/browser/hooks/useResizableSidebar.ts b/src/browser/hooks/useResizableSidebar.ts index 93a53ad563..bc58ca0a4d 100644 --- a/src/browser/hooks/useResizableSidebar.ts +++ b/src/browser/hooks/useResizableSidebar.ts @@ -23,6 +23,8 @@ */ import { useState, useEffect, useCallback, useRef } from "react"; +import { getStorageChangeEvent } from "@/common/constants/events"; +import { readPersistedString, updatePersistedState } from "@/browser/hooks/usePersistedState"; interface UseResizableSidebarOptions { /** Enable/disable resize functionality (typically tied to tab state) */ @@ -81,13 +83,48 @@ export function useResizableSidebar({ // Persist width changes to localStorage useEffect(() => { if (!enabled) return; - try { - localStorage.setItem(storageKey, width.toString()); - } catch { - // Ignore storage errors (private browsing, quota exceeded, etc.) - } + updatePersistedState(storageKey, width); }, [width, storageKey, enabled]); + // Keep width in sync when updated externally (e.g., layout presets) + useEffect(() => { + if (typeof window === "undefined") return; + + const handleExternalUpdate = () => { + if (isResizing) { + return; + } + + const stored = readPersistedString(storageKey); + if (!stored) { + return; + } + + const parsed = parseInt(stored, 10); + if (!Number.isFinite(parsed)) { + return; + } + + const clamped = Math.max(minWidth, Math.min(maxWidth, parsed)); + setWidth((prev) => (prev === clamped ? prev : clamped)); + }; + + const eventName = getStorageChangeEvent(storageKey); + window.addEventListener(eventName, handleExternalUpdate as EventListener); + + const handleStorage = (e: StorageEvent) => { + if (e.key === storageKey) { + handleExternalUpdate(); + } + }; + window.addEventListener("storage", handleStorage); + + return () => { + window.removeEventListener(eventName, handleExternalUpdate as EventListener); + window.removeEventListener("storage", handleStorage); + }; + }, [storageKey, minWidth, maxWidth, isResizing]); + /** * Handle mouse movement during drag * Calculates new width based on horizontal mouse delta from start position diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 400c705403..771d6a2489 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -60,6 +60,10 @@ export const CommandIds = { themeToggle: () => "appearance:theme:toggle" as const, themeSet: (theme: string) => `appearance:theme:set:${theme}` as const, + // Layout commands + layoutApplySlot: (slot: number) => `layout:apply-slot:${slot}` as const, + layoutApplyPreset: (presetId: string) => `layout:apply-preset:${presetId}` as const, + layoutSavePreset: () => "layout:save-preset" as const, // Settings commands settingsOpen: () => "settings:open" as const, settingsOpenSection: (section: string) => `settings:open:${section}` as const, diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index d69a9d6dc2..47a9b8b055 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -12,6 +12,12 @@ import { import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { CommandIds } from "@/browser/utils/commandIds"; import { isTabType, type TabType } from "@/browser/types/rightSidebar"; +import { + getEffectiveSlotKeybind, + getLayoutsConfigOrDefault, + getPresetForSlot, +} from "@/browser/utils/uiLayouts"; +import type { LayoutPresetsConfig, LayoutSlotNumber } from "@/common/types/uiLayouts"; import { addToolToFocusedTabset, getDefaultRightSidebarLayoutState, @@ -66,6 +72,16 @@ export interface BuildSourcesParams { onToggleTheme: () => void; onSetTheme: (theme: ThemeMode) => void; onOpenSettings?: (section?: string) => void; + + // Layout presets + layoutPresets?: LayoutPresetsConfig | null; + onApplyLayoutSlot?: (workspaceId: string, slot: LayoutSlotNumber) => void; + onApplyLayoutPreset?: (workspaceId: string, presetId: string) => void; + onSaveLayoutPreset?: ( + workspaceId: string, + name: string, + slot?: LayoutSlotNumber | null + ) => Promise; onClearTimingStats?: (workspaceId: string) => void; } @@ -75,6 +91,7 @@ export interface BuildSourcesParams { */ export const COMMAND_SECTIONS = { WORKSPACES: "Workspaces", + LAYOUTS: "Layouts", NAVIGATION: "Navigation", CHAT: "Chat", MODE: "Modes & Model", @@ -85,6 +102,7 @@ export const COMMAND_SECTIONS = { } as const; const section = { + layouts: COMMAND_SECTIONS.LAYOUTS, workspaces: COMMAND_SECTIONS.WORKSPACES, navigation: COMMAND_SECTIONS.NAVIGATION, chat: COMMAND_SECTIONS.CHAT, @@ -479,6 +497,96 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi return list; }); + // Layout presets + actions.push(() => { + const list: CommandAction[] = []; + const selected = p.selectedWorkspace; + if (!selected) { + return list; + } + + const config = getLayoutsConfigOrDefault(p.layoutPresets); + + for (const slot of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) { + const preset = getPresetForSlot(config, slot); + const shortcutHint = formatKeybind(getEffectiveSlotKeybind(config, slot)); + + list.push({ + id: CommandIds.layoutApplySlot(slot), + title: `Layout: Apply Slot ${slot}`, + subtitle: preset ? preset.name : "Empty", + section: section.layouts, + shortcutHint, + enabled: () => Boolean(preset) && Boolean(p.onApplyLayoutSlot), + run: () => { + if (!preset) return; + void p.onApplyLayoutSlot?.(selected.workspaceId, slot); + }, + }); + } + + if (p.onSaveLayoutPreset) { + list.push({ + id: CommandIds.layoutSavePreset(), + title: "Layout: Save current as preset…", + section: section.layouts, + run: () => undefined, + prompt: { + title: "Save Layout Preset", + fields: [ + { + type: "text", + name: "name", + label: "Name", + placeholder: "Enter preset name", + validate: (v) => (!v.trim() ? "Name is required" : null), + }, + { + type: "select", + name: "slot", + label: "Assign to slot (optional)", + placeholder: "Don't assign", + getOptions: () => [ + { id: "none", label: "Don't assign" }, + ...([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map((slot) => { + const assigned = getPresetForSlot(config, slot); + return { + id: String(slot), + label: `Slot ${slot}${assigned ? ` • ${assigned.name}` : ""}`, + keywords: assigned ? [assigned.name] : [], + }; + }), + ], + }, + ], + onSubmit: async (vals) => { + const name = vals.name.trim(); + const slotRaw = vals.slot; + const slot = + slotRaw && slotRaw !== "none" ? (Number(slotRaw) as LayoutSlotNumber) : null; + await p.onSaveLayoutPreset?.(selected.workspaceId, name, slot); + }, + }, + }); + } + + if (p.onApplyLayoutPreset && config.presets.length > 0) { + for (const preset of config.presets) { + list.push({ + id: CommandIds.layoutApplyPreset(preset.id), + title: `Layout: Apply Preset ${preset.name}`, + section: section.layouts, + keywords: [preset.name], + run: () => { + void p.onApplyLayoutPreset?.(selected.workspaceId, preset.id); + }, + }); + } + } + + return list; + }); + // Appearance actions.push(() => { const list: CommandAction[] = [ diff --git a/src/browser/utils/ui/keybinds.test.ts b/src/browser/utils/ui/keybinds.test.ts index 7e9c6cb6a1..dd350829ce 100644 --- a/src/browser/utils/ui/keybinds.test.ts +++ b/src/browser/utils/ui/keybinds.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "bun:test"; -import { matchesKeybind, type Keybind } from "./keybinds"; +import { matchesKeybind } from "./keybinds"; +import type { Keybind } from "@/common/types/keybind"; // Helper to create a minimal keyboard event function createEvent(overrides: Partial = {}): KeyboardEvent { diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index e8e06c9a0a..89fe9c08c4 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -8,25 +8,9 @@ */ import { stopKeyboardPropagation } from "@/browser/utils/events"; +import type { Keybind } from "@/common/types/keybind"; -/** - * Keybind definition type - */ -export interface Keybind { - key: string; - ctrl?: boolean; - shift?: boolean; - alt?: boolean; - meta?: boolean; - /** - * On macOS, Ctrl-based shortcuts traditionally use Cmd instead. - * Use this field to control that behavior: - * - "either" (default): accept Ctrl or Cmd - * - "command": require Cmd specifically - * - "control": require the Control key specifically - */ - macCtrlBehavior?: "either" | "command" | "control"; -} +export type { Keybind }; /** * Detect if running on macOS diff --git a/src/browser/utils/uiLayouts.ts b/src/browser/utils/uiLayouts.ts new file mode 100644 index 0000000000..a5d4b2cebf --- /dev/null +++ b/src/browser/utils/uiLayouts.ts @@ -0,0 +1,502 @@ +import assert from "@/common/utils/assert"; +import { + normalizeLayoutPresetsConfig, + type LayoutPreset, + type LayoutPresetsConfig, + type LayoutSlotNumber, + type RightSidebarLayoutPresetNode, + type RightSidebarLayoutPresetState, + type RightSidebarPresetTabType, + type RightSidebarWidthPreset, +} from "@/common/types/uiLayouts"; +import type { Keybind } from "@/common/types/keybind"; +import { + getRightSidebarLayoutKey, + RIGHT_SIDEBAR_COLLAPSED_KEY, + RIGHT_SIDEBAR_TAB_KEY, + RIGHT_SIDEBAR_WIDTH_KEY, +} from "@/common/constants/storage"; +import { + readPersistedState, + readPersistedString, + updatePersistedState, +} from "@/browser/hooks/usePersistedState"; +import { + findFirstTabsetId, + findTabset, + getDefaultRightSidebarLayoutState, + parseRightSidebarLayoutState, + type RightSidebarLayoutNode, + type RightSidebarLayoutState, +} from "@/browser/utils/rightSidebarLayout"; +import { isTabType, type TabType } from "@/browser/types/rightSidebar"; +import { createTerminalSession } from "@/browser/utils/terminal"; +import type { APIClient } from "@/browser/contexts/API"; + +const LEFT_SIDEBAR_COLLAPSED_KEY = "sidebarCollapsed"; + +export function createLayoutPresetId(): string { + const maybeCrypto = globalThis.crypto; + if (maybeCrypto && typeof maybeCrypto.randomUUID === "function") { + const id = maybeCrypto.randomUUID(); + assert(typeof id === "string" && id.length > 0, "randomUUID() must return a non-empty string"); + return id; + } + + const id = `layout_preset_${Date.now()}_${Math.random().toString(16).slice(2)}`; + assert(id.length > 0, "generated id must be non-empty"); + return id; +} + +export function getDefaultSlotKeybind(slot: LayoutSlotNumber): Keybind { + return { key: String(slot), ctrl: true, alt: true }; +} + +export function getEffectiveSlotKeybind( + config: LayoutPresetsConfig, + slot: LayoutSlotNumber +): Keybind { + const override = config.slots.find((s) => s.slot === slot)?.keybindOverride; + return override ?? getDefaultSlotKeybind(slot); +} + +export function getPresetById( + config: LayoutPresetsConfig, + presetId: string +): LayoutPreset | undefined { + return config.presets.find((p) => p.id === presetId); +} + +export function getPresetForSlot( + config: LayoutPresetsConfig, + slot: LayoutSlotNumber +): LayoutPreset | undefined { + const presetId = config.slots.find((s) => s.slot === slot)?.presetId; + if (!presetId) return undefined; + return getPresetById(config, presetId); +} + +function clampInt(value: number, min: number, max: number): number { + const rounded = Math.floor(value); + return Math.max(min, Math.min(max, rounded)); +} + +export function resolveRightSidebarWidthPx(width: RightSidebarWidthPreset): number { + if (width.mode === "px") { + return clampInt(width.value, 300, 1200); + } + + const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1200; + return clampInt(viewportWidth * width.value, 300, 1200); +} + +function getRightSidebarTabFallback(): TabType { + const raw = readPersistedState(RIGHT_SIDEBAR_TAB_KEY, "costs"); + return isTabType(raw) ? raw : "costs"; +} + +function readCurrentRightSidebarLayoutState(workspaceId: string): RightSidebarLayoutState { + const fallback = getRightSidebarTabFallback(); + const defaultLayout = getDefaultRightSidebarLayoutState(fallback); + const raw = readPersistedState(getRightSidebarLayoutKey(workspaceId), defaultLayout); + return parseRightSidebarLayoutState(raw, fallback); +} + +function readCurrentRightSidebarCollapsed(): boolean { + return readPersistedState(RIGHT_SIDEBAR_COLLAPSED_KEY, false); +} + +function readCurrentRightSidebarWidthPx(): number { + const raw = readPersistedString(RIGHT_SIDEBAR_WIDTH_KEY); + if (!raw) return 400; + const parsed = parseInt(raw, 10); + if (!Number.isFinite(parsed)) return 400; + return clampInt(parsed, 300, 1200); +} + +function readCurrentLeftSidebarCollapsed(): boolean { + return readPersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, false); +} + +function createTerminalPlaceholder(counter: number): RightSidebarPresetTabType { + const id = `t${counter}`; + return `terminal_new:${id}`; +} + +function toPresetTab( + tab: TabType, + ctx: { terminalCounter: number } +): RightSidebarPresetTabType | null { + if (tab.startsWith("file:")) { + return null; + } + + if (tab === "terminal" || tab.startsWith("terminal:")) { + ctx.terminalCounter += 1; + return createTerminalPlaceholder(ctx.terminalCounter); + } + + // Base tabs are already compatible. + if (tab === "costs" || tab === "review" || tab === "explorer" || tab === "stats") { + return tab; + } + + return null; +} + +function convertNodeToPreset( + node: RightSidebarLayoutNode, + ctx: { terminalCounter: number } +): RightSidebarLayoutPresetNode | null { + if (node.type === "tabset") { + const tabs: RightSidebarPresetTabType[] = []; + let resolvedActiveTab: RightSidebarPresetTabType | null = null; + + for (const tab of node.tabs) { + const converted = toPresetTab(tab, ctx); + if (!converted) { + continue; + } + + tabs.push(converted); + if (tab === node.activeTab) { + resolvedActiveTab = converted; + } + } + + if (tabs.length === 0) { + return null; + } + + return { + type: "tabset", + id: node.id, + tabs, + activeTab: resolvedActiveTab ?? tabs[0], + }; + } + + const left = convertNodeToPreset(node.children[0], ctx); + const right = convertNodeToPreset(node.children[1], ctx); + + if (!left && !right) { + return null; + } + if (!left) return right; + if (!right) return left; + + return { + type: "split", + id: node.id, + direction: node.direction, + sizes: node.sizes, + children: [left, right], + }; +} + +function findPresetTabset( + root: RightSidebarLayoutPresetNode, + tabsetId: string +): Extract | null { + if (root.type === "tabset") { + return root.id === tabsetId ? root : null; + } + + return ( + findPresetTabset(root.children[0], tabsetId) ?? findPresetTabset(root.children[1], tabsetId) + ); +} + +function findFirstPresetTabsetId(root: RightSidebarLayoutPresetNode): string | null { + if (root.type === "tabset") return root.id; + return findFirstPresetTabsetId(root.children[0]) ?? findFirstPresetTabsetId(root.children[1]); +} + +function convertLayoutStateToPreset(state: RightSidebarLayoutState): RightSidebarLayoutPresetState { + const ctx = { terminalCounter: 0 }; + const root = convertNodeToPreset(state.root, ctx); + + if (!root) { + // Fallback to default layout without terminals. + const fallback = getDefaultRightSidebarLayoutState("costs"); + const fallbackRoot = convertNodeToPreset(fallback.root, { terminalCounter: 0 }); + assert(fallbackRoot !== null, "default right sidebar layout must convert"); + return { + version: 1, + nextId: fallback.nextId, + focusedTabsetId: fallback.focusedTabsetId, + root: fallbackRoot, + }; + } + + const focusedTabsetId = + findPresetTabset(root, state.focusedTabsetId)?.id ?? + findFirstPresetTabsetId(root) ?? + "tabset-1"; + + return { + version: 1, + nextId: state.nextId, + focusedTabsetId, + root, + }; +} + +export function createPresetFromCurrentWorkspace( + workspaceId: string, + name: string, + existingPresetId?: string +): LayoutPreset { + const trimmedName = name.trim(); + assert(trimmedName.length > 0, "preset name must be non-empty"); + + const leftSidebarCollapsed = readCurrentLeftSidebarCollapsed(); + const rightSidebarCollapsed = readCurrentRightSidebarCollapsed(); + const rightSidebarWidthPx = readCurrentRightSidebarWidthPx(); + const rightSidebarLayout = readCurrentRightSidebarLayoutState(workspaceId); + + const presetLayout = convertLayoutStateToPreset(rightSidebarLayout); + + const preset: LayoutPreset = { + id: existingPresetId ?? createLayoutPresetId(), + name: trimmedName, + leftSidebarCollapsed, + rightSidebar: { + collapsed: rightSidebarCollapsed, + width: { mode: "px", value: rightSidebarWidthPx }, + layout: presetLayout, + }, + }; + + return preset; +} + +function collectTerminalTabs( + root: RightSidebarLayoutPresetNode, + out: RightSidebarPresetTabType[] +): void { + if (root.type === "tabset") { + for (const tab of root.tabs) { + if (tab.startsWith("terminal_new:")) { + out.push(tab); + } + } + return; + } + + collectTerminalTabs(root.children[0], out); + collectTerminalTabs(root.children[1], out); +} + +async function resolveTerminalSessions( + api: APIClient, + workspaceId: string, + terminalTabs: RightSidebarPresetTabType[] +): Promise> { + const mapping = new Map(); + + const existing = await api.terminal.listSessions({ workspaceId }).catch(() => []); + let existingIndex = 0; + + for (const tab of terminalTabs) { + let sessionId: string | undefined = existing[existingIndex]; + if (sessionId) { + existingIndex += 1; + } else { + try { + const session = await createTerminalSession(api, workspaceId); + sessionId = session.sessionId; + } catch { + sessionId = undefined; + } + } + + if (sessionId) { + mapping.set(tab, sessionId); + } + } + + return mapping; +} + +function isTerminalPlaceholderTab(tab: RightSidebarPresetTabType): tab is `terminal_new:${string}` { + return tab.startsWith("terminal_new:"); +} + +function resolvePresetTab( + tab: RightSidebarPresetTabType, + mapping: Map +): TabType | null { + if (isTerminalPlaceholderTab(tab)) { + const sessionId = mapping.get(tab); + return sessionId ? (`terminal:${sessionId}` as const) : null; + } + + return tab as TabType; +} + +function resolvePresetNodeToLayout( + node: RightSidebarLayoutPresetNode, + mapping: Map +): RightSidebarLayoutNode | null { + if (node.type === "tabset") { + const tabs = node.tabs + .map((t) => resolvePresetTab(t, mapping)) + .filter((t): t is TabType => !!t && isTabType(t)); + + if (tabs.length === 0) { + return null; + } + + const resolvedActive = resolvePresetTab(node.activeTab, mapping); + const activeTab = resolvedActive && tabs.includes(resolvedActive) ? resolvedActive : tabs[0]; + + return { + type: "tabset", + id: node.id, + tabs, + activeTab, + }; + } + + const left = resolvePresetNodeToLayout(node.children[0], mapping); + const right = resolvePresetNodeToLayout(node.children[1], mapping); + + if (!left && !right) { + return null; + } + if (!left) return right; + if (!right) return left; + + return { + type: "split", + id: node.id, + direction: node.direction, + sizes: node.sizes, + children: [left, right], + }; +} + +function resolvePresetLayoutToLayoutState( + preset: RightSidebarLayoutPresetState, + mapping: Map +): RightSidebarLayoutState { + const root = resolvePresetNodeToLayout(preset.root, mapping); + if (!root) { + return getDefaultRightSidebarLayoutState("costs"); + } + + const focusedTabsetId = + findTabset(root, preset.focusedTabsetId)?.id ?? findFirstTabsetId(root) ?? "tabset-1"; + + return { + version: 1, + nextId: preset.nextId, + focusedTabsetId, + root, + }; +} + +export async function applyLayoutPresetToWorkspace( + api: APIClient | null, + workspaceId: string, + preset: LayoutPreset +): Promise { + assert( + typeof workspaceId === "string" && workspaceId.length > 0, + "workspaceId must be non-empty" + ); + + // Apply global UI keys first so the UI immediately reflects a partially-applied preset + // even if terminal creation fails. + updatePersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, preset.leftSidebarCollapsed); + updatePersistedState(RIGHT_SIDEBAR_COLLAPSED_KEY, preset.rightSidebar.collapsed); + updatePersistedState( + RIGHT_SIDEBAR_WIDTH_KEY, + resolveRightSidebarWidthPx(preset.rightSidebar.width) + ); + + if (!api) { + return; + } + + const terminalTabs: RightSidebarPresetTabType[] = []; + collectTerminalTabs(preset.rightSidebar.layout.root, terminalTabs); + + const terminalMapping = await resolveTerminalSessions(api, workspaceId, terminalTabs); + + const layout = resolvePresetLayoutToLayoutState(preset.rightSidebar.layout, terminalMapping); + + updatePersistedState(getRightSidebarLayoutKey(workspaceId), layout); +} + +export function upsertPreset( + config: LayoutPresetsConfig, + preset: LayoutPreset +): LayoutPresetsConfig { + const normalized = normalizeLayoutPresetsConfig(config); + + const nextPresets = normalized.presets.filter((p) => p.id !== preset.id); + nextPresets.push(preset); + + return { + ...normalized, + presets: nextPresets, + }; +} + +export function updateSlotAssignment( + config: LayoutPresetsConfig, + slot: LayoutSlotNumber, + presetId: string | undefined +): LayoutPresetsConfig { + const normalized = normalizeLayoutPresetsConfig(config); + const nextSlots = normalized.slots.filter((s) => s.slot !== slot); + + if (presetId) { + nextSlots.push({ + slot, + presetId, + keybindOverride: normalized.slots.find((s) => s.slot === slot)?.keybindOverride, + }); + } else { + const existingOverride = normalized.slots.find((s) => s.slot === slot)?.keybindOverride; + if (existingOverride) { + nextSlots.push({ slot, keybindOverride: existingOverride }); + } + } + + return { + ...normalized, + slots: nextSlots.sort((a, b) => a.slot - b.slot), + }; +} + +export function updateSlotKeybindOverride( + config: LayoutPresetsConfig, + slot: LayoutSlotNumber, + keybindOverride: Keybind | undefined +): LayoutPresetsConfig { + const normalized = normalizeLayoutPresetsConfig(config); + const existing = normalized.slots.find((s) => s.slot === slot); + const presetId = existing?.presetId; + + const nextSlots = normalized.slots.filter((s) => s.slot !== slot); + + if (presetId || keybindOverride) { + nextSlots.push({ + slot, + presetId, + keybindOverride, + }); + } + + return { + ...normalized, + slots: nextSlots.sort((a, b) => a.slot - b.slot), + }; +} + +export function getLayoutsConfigOrDefault(value: unknown): LayoutPresetsConfig { + return normalizeLayoutPresetsConfig(value); +} diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index ecda7ac27c..b8b9d26084 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -79,6 +79,17 @@ export { MCPTestResultSchema, } from "./schemas/mcp"; +// UI Layouts schemas +export { + KeybindSchema, + LayoutPresetSchema, + LayoutPresetsConfigSchema, + LayoutSlotSchema, + RightSidebarLayoutPresetNodeSchema, + RightSidebarLayoutPresetStateSchema, + RightSidebarPresetTabSchema, + RightSidebarWidthPresetSchema, +} from "./schemas/uiLayouts"; // Terminal schemas export { TerminalCreateParamsSchema, @@ -142,6 +153,7 @@ export { CoderWorkspaceSchema, CoderWorkspaceStatusSchema, config, + uiLayouts, debug, features, general, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index c9c89d4cb0..b8279655b3 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -9,6 +9,7 @@ import { ResultSchema } from "./result"; import { RuntimeConfigSchema, RuntimeModeSchema } from "./runtime"; import { SecretSchema } from "./secrets"; import { SendMessageOptionsSchema, UpdateStatusSchema, WorkspaceChatMessageSchema } from "./stream"; +import { LayoutPresetsConfigSchema } from "./uiLayouts"; import { TerminalCreateParamsSchema, TerminalResizeParamsSchema, @@ -976,6 +977,22 @@ export const config = { }, }; +// UI Layouts (global settings) +export const uiLayouts = { + getAll: { + input: z.void(), + output: LayoutPresetsConfigSchema, + }, + saveAll: { + input: z + .object({ + layoutPresets: LayoutPresetsConfigSchema, + }) + .strict(), + output: z.void(), + }, +}; + // Splash screens export const splashScreens = { getViewedSplashScreens: { diff --git a/src/common/orpc/schemas/uiLayouts.ts b/src/common/orpc/schemas/uiLayouts.ts new file mode 100644 index 0000000000..13ade7a25e --- /dev/null +++ b/src/common/orpc/schemas/uiLayouts.ts @@ -0,0 +1,106 @@ +import type { + RightSidebarLayoutPresetNode, + RightSidebarPresetTabType, +} from "@/common/types/uiLayouts"; +import { z } from "zod"; + +export const KeybindSchema = z + .object({ + key: z.string().min(1), + ctrl: z.boolean().optional(), + shift: z.boolean().optional(), + alt: z.boolean().optional(), + meta: z.boolean().optional(), + macCtrlBehavior: z.enum(["either", "command", "control"]).optional(), + }) + .strict(); + +const RightSidebarPresetBaseTabSchema = z.enum(["costs", "review", "explorer", "stats"]); + +export const RightSidebarPresetTabSchema: z.ZodType = z.union([ + RightSidebarPresetBaseTabSchema, + z + .string() + .min("terminal_new:".length + 1) + .regex(/^terminal_new:.+$/), +]) as z.ZodType; + +export const RightSidebarLayoutPresetNodeSchema: z.ZodType = z.lazy( + () => { + const tabset = z + .object({ + type: z.literal("tabset"), + id: z.string().min(1), + tabs: z.array(RightSidebarPresetTabSchema), + activeTab: RightSidebarPresetTabSchema, + }) + .strict(); + + const split = z + .object({ + type: z.literal("split"), + id: z.string().min(1), + direction: z.enum(["horizontal", "vertical"]), + sizes: z.tuple([z.number(), z.number()]), + children: z.tuple([RightSidebarLayoutPresetNodeSchema, RightSidebarLayoutPresetNodeSchema]), + }) + .strict(); + + return z.union([split, tabset]); + } +); + +export const RightSidebarLayoutPresetStateSchema = z + .object({ + version: z.literal(1), + nextId: z.number().int(), + focusedTabsetId: z.string().min(1), + root: RightSidebarLayoutPresetNodeSchema, + }) + .strict(); + +export const RightSidebarWidthPresetSchema = z.discriminatedUnion("mode", [ + z + .object({ + mode: z.literal("px"), + value: z.number().int(), + }) + .strict(), + z + .object({ + mode: z.literal("fraction"), + value: z.number(), + }) + .strict(), +]); + +export const LayoutPresetSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + leftSidebarCollapsed: z.boolean(), + rightSidebar: z + .object({ + collapsed: z.boolean(), + width: RightSidebarWidthPresetSchema, + layout: RightSidebarLayoutPresetStateSchema, + }) + .strict(), + }) + .strict(); + +export const LayoutSlotSchema = z + .object({ + slot: z.number().int().min(1).max(9), + presetId: z.string().min(1).optional(), + keybindOverride: KeybindSchema.optional(), + }) + .strict(); + +export const LayoutPresetsConfigSchema = z + .object({ + version: z.literal(1), + presets: z.array(LayoutPresetSchema), + slots: z.array(LayoutSlotSchema), + }) + .strict(); diff --git a/src/common/types/keybind.ts b/src/common/types/keybind.ts new file mode 100644 index 0000000000..5005dd7bb2 --- /dev/null +++ b/src/common/types/keybind.ts @@ -0,0 +1,55 @@ +import assert from "@/common/utils/assert"; + +export interface Keybind { + key: string; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; + meta?: boolean; + /** + * On macOS, Ctrl-based shortcuts traditionally use Cmd instead. + * Use this field to control that behavior: + * - "either" (default): accept Ctrl or Cmd + * - "command": require Cmd specifically + * - "control": require the Control key specifically + */ + macCtrlBehavior?: "either" | "command" | "control"; +} + +export function normalizeKeybind(raw: unknown): Keybind | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + + const record = raw as Record; + + const key = typeof record.key === "string" ? record.key.trim() : ""; + if (!key) { + return undefined; + } + + const ctrl = typeof record.ctrl === "boolean" ? record.ctrl : undefined; + const shift = typeof record.shift === "boolean" ? record.shift : undefined; + const alt = typeof record.alt === "boolean" ? record.alt : undefined; + const meta = typeof record.meta === "boolean" ? record.meta : undefined; + + const macCtrlBehavior = + record.macCtrlBehavior === "either" || + record.macCtrlBehavior === "command" || + record.macCtrlBehavior === "control" + ? record.macCtrlBehavior + : undefined; + + const result: Keybind = { + key, + ctrl, + shift, + alt, + meta, + macCtrlBehavior, + }; + + assert(typeof result.key === "string" && result.key.length > 0, "Keybind.key must be non-empty"); + + return result; +} diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 4e34c06bf6..d4f94af68e 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -11,6 +11,7 @@ import type { } from "../orpc/schemas"; import type { TaskSettings, SubagentAiDefaults } from "./tasks"; import type { ModeAiDefaults } from "./modeAiDefaults"; +import type { LayoutPresetsConfig } from "./uiLayouts"; import type { AgentAiDefaults } from "./agentAiDefaults"; export type Workspace = z.infer; @@ -58,6 +59,8 @@ export interface ProjectsConfig { featureFlagOverrides?: Record; /** Global task settings (agent sub-workspaces, queue limits, nesting depth) */ taskSettings?: TaskSettings; + /** UI layout presets + hotkeys (shared via ~/.mux/config.json). */ + layoutPresets?: LayoutPresetsConfig; /** Default model + thinking overrides per agentId (applies to UI agents and subagents). */ agentAiDefaults?: AgentAiDefaults; /** @deprecated Legacy per-subagent default model + thinking overrides. */ diff --git a/src/common/types/uiLayouts.ts b/src/common/types/uiLayouts.ts new file mode 100644 index 0000000000..040c61bece --- /dev/null +++ b/src/common/types/uiLayouts.ts @@ -0,0 +1,305 @@ +import assert from "@/common/utils/assert"; +import type { Keybind } from "@/common/types/keybind"; +import { normalizeKeybind } from "@/common/types/keybind"; + +export type LayoutSlotNumber = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + +export interface LayoutSlot { + slot: LayoutSlotNumber; + presetId?: string; + keybindOverride?: Keybind; +} + +export type RightSidebarPresetBaseTabType = "costs" | "review" | "explorer" | "stats"; +export type RightSidebarPresetTabType = RightSidebarPresetBaseTabType | `terminal_new:${string}`; + +export type RightSidebarLayoutPresetNode = + | { + type: "split"; + id: string; + direction: "horizontal" | "vertical"; + sizes: [number, number]; + children: [RightSidebarLayoutPresetNode, RightSidebarLayoutPresetNode]; + } + | { + type: "tabset"; + id: string; + tabs: RightSidebarPresetTabType[]; + activeTab: RightSidebarPresetTabType; + }; + +export interface RightSidebarLayoutPresetState { + version: 1; + nextId: number; + focusedTabsetId: string; + root: RightSidebarLayoutPresetNode; +} + +export type RightSidebarWidthPreset = + | { + mode: "px"; + value: number; + } + | { + mode: "fraction"; + value: number; + }; + +export interface LayoutPreset { + id: string; + name: string; + leftSidebarCollapsed: boolean; + rightSidebar: { + collapsed: boolean; + width: RightSidebarWidthPreset; + layout: RightSidebarLayoutPresetState; + }; +} + +export interface LayoutPresetsConfig { + version: 1; + presets: LayoutPreset[]; + slots: LayoutSlot[]; +} + +export const DEFAULT_LAYOUT_PRESETS_CONFIG: LayoutPresetsConfig = { + version: 1, + presets: [], + slots: [], +}; + +function isLayoutSlotNumber(value: unknown): value is LayoutSlotNumber { + return ( + value === 1 || + value === 2 || + value === 3 || + value === 4 || + value === 5 || + value === 6 || + value === 7 || + value === 8 || + value === 9 + ); +} + +function normalizeOptionalNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeRightSidebarWidthPreset(raw: unknown): RightSidebarWidthPreset { + if (!raw || typeof raw !== "object") { + return { mode: "px", value: 400 }; + } + + const record = raw as Record; + const mode = record.mode; + + if (mode === "fraction") { + const value = + typeof record.value === "number" && Number.isFinite(record.value) ? record.value : 0.3; + // Keep in a sensible range (avoid 0px or >100% layouts) + const clamped = Math.min(0.9, Math.max(0.1, value)); + return { mode: "fraction", value: clamped }; + } + + const value = + typeof record.value === "number" && Number.isFinite(record.value) ? record.value : 400; + const rounded = Math.floor(value); + const clamped = Math.min(1200, Math.max(300, rounded)); + return { mode: "px", value: clamped }; +} + +function isPresetTabType(value: unknown): value is RightSidebarPresetTabType { + if (typeof value !== "string") return false; + if (value === "costs" || value === "review" || value === "explorer" || value === "stats") { + return true; + } + return value.startsWith("terminal_new:") && value.length > "terminal_new:".length; +} + +function isLayoutNode(value: unknown): value is RightSidebarLayoutPresetNode { + if (!value || typeof value !== "object") return false; + const v = value as Record; + + if (v.type === "tabset") { + return ( + typeof v.id === "string" && + Array.isArray(v.tabs) && + v.tabs.every((t) => isPresetTabType(t)) && + isPresetTabType(v.activeTab) + ); + } + + if (v.type === "split") { + if (typeof v.id !== "string") return false; + if (v.direction !== "horizontal" && v.direction !== "vertical") return false; + if (!Array.isArray(v.sizes) || v.sizes.length !== 2) return false; + if (typeof v.sizes[0] !== "number" || typeof v.sizes[1] !== "number") return false; + if (!Array.isArray(v.children) || v.children.length !== 2) return false; + return isLayoutNode(v.children[0]) && isLayoutNode(v.children[1]); + } + + return false; +} + +function findTabset( + root: RightSidebarLayoutPresetNode, + tabsetId: string +): RightSidebarLayoutPresetNode | null { + if (root.type === "tabset") { + return root.id === tabsetId ? root : null; + } + return findTabset(root.children[0], tabsetId) ?? findTabset(root.children[1], tabsetId); +} + +function isRightSidebarLayoutPresetState(value: unknown): value is RightSidebarLayoutPresetState { + if (!value || typeof value !== "object") return false; + const v = value as Record; + if (v.version !== 1) return false; + if (typeof v.nextId !== "number") return false; + if (typeof v.focusedTabsetId !== "string") return false; + if (!isLayoutNode(v.root)) return false; + return findTabset(v.root, v.focusedTabsetId) !== null; +} + +function normalizeLayoutSlot(raw: unknown): LayoutSlot | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + + const record = raw as Record; + + if (!isLayoutSlotNumber(record.slot)) { + return undefined; + } + + const presetId = normalizeOptionalNonEmptyString(record.presetId); + const keybindOverride = normalizeKeybind(record.keybindOverride); + + if (!presetId && !keybindOverride) { + return undefined; + } + + return { + slot: record.slot, + presetId, + keybindOverride, + }; +} + +function normalizeLayoutPreset(raw: unknown): LayoutPreset | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + + const record = raw as Record; + + const id = normalizeOptionalNonEmptyString(record.id); + const name = normalizeOptionalNonEmptyString(record.name); + if (!id || !name) { + return undefined; + } + + const leftSidebarCollapsed = + typeof record.leftSidebarCollapsed === "boolean" ? record.leftSidebarCollapsed : false; + + if (!record.rightSidebar || typeof record.rightSidebar !== "object") { + return undefined; + } + + const rightSidebarRecord = record.rightSidebar as Record; + const collapsed = + typeof rightSidebarRecord.collapsed === "boolean" ? rightSidebarRecord.collapsed : false; + const width = normalizeRightSidebarWidthPreset(rightSidebarRecord.width); + + const layoutRaw = rightSidebarRecord.layout; + if (!isRightSidebarLayoutPresetState(layoutRaw)) { + return undefined; + } + + const layout: RightSidebarLayoutPresetState = layoutRaw; + + return { + id, + name, + leftSidebarCollapsed, + rightSidebar: { + collapsed, + width, + layout, + }, + }; +} + +export function normalizeLayoutPresetsConfig(raw: unknown): LayoutPresetsConfig { + if (!raw || typeof raw !== "object") { + return DEFAULT_LAYOUT_PRESETS_CONFIG; + } + + const record = raw as Record; + if (record.version !== 1) { + return DEFAULT_LAYOUT_PRESETS_CONFIG; + } + + const presetsArray = Array.isArray(record.presets) ? record.presets : []; + const presetsById = new Map(); + for (const entry of presetsArray) { + const preset = normalizeLayoutPreset(entry); + if (!preset) continue; + presetsById.set(preset.id, preset); + } + + const slotsArray = Array.isArray(record.slots) ? record.slots : []; + const slotsByNumber = new Map(); + for (const entry of slotsArray) { + const slot = normalizeLayoutSlot(entry); + if (!slot) continue; + + // Drop presetId references to missing presets (self-healing) + if (slot.presetId && !presetsById.has(slot.presetId)) { + if (!slot.keybindOverride) { + continue; + } + slotsByNumber.set(slot.slot, { slot: slot.slot, keybindOverride: slot.keybindOverride }); + continue; + } + + slotsByNumber.set(slot.slot, slot); + } + + const presets = Array.from(presetsById.values()); + const slots = Array.from(slotsByNumber.values()).sort((a, b) => a.slot - b.slot); + + const result: LayoutPresetsConfig = { + version: 1, + presets, + slots, + }; + + assert(result.version === 1, "normalizeLayoutPresetsConfig: version must be 1"); + assert(Array.isArray(result.presets), "normalizeLayoutPresetsConfig: presets must be an array"); + assert(Array.isArray(result.slots), "normalizeLayoutPresetsConfig: slots must be an array"); + + return result; +} + +export function isLayoutPresetsConfigEmpty(value: LayoutPresetsConfig): boolean { + assert(value.version === 1, "isLayoutPresetsConfigEmpty: version must be 1"); + + if (value.presets.length > 0) { + return false; + } + + for (const slot of value.slots) { + if (slot.presetId || slot.keybindOverride) { + return false; + } + } + + return true; +} diff --git a/src/node/config.ts b/src/node/config.ts index e4fefd2adf..3e555d70c7 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -18,6 +18,7 @@ import { normalizeTaskSettings, } from "@/common/types/tasks"; import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; +import { isLayoutPresetsConfigEmpty, normalizeLayoutPresetsConfig } from "@/common/types/uiLayouts"; import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; @@ -120,6 +121,7 @@ export class Config { serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: Record; + layoutPresets?: unknown; taskSettings?: unknown; agentAiDefaults?: unknown; subagentAiDefaults?: unknown; @@ -157,6 +159,11 @@ export class Config { ...(legacyModeAiDefaults as Record), }); + const layoutPresetsRaw = normalizeLayoutPresetsConfig(parsed.layoutPresets); + const layoutPresets = isLayoutPresetsConfigEmpty(layoutPresetsRaw) + ? undefined + : layoutPresetsRaw; + return { projects: projectsMap, apiServerBindHost: parseOptionalNonEmptyString(parsed.apiServerBindHost), @@ -168,6 +175,7 @@ export class Config { mdnsServiceName: parseOptionalNonEmptyString(parsed.mdnsServiceName), serverSshHost: parsed.serverSshHost, viewedSplashScreens: parsed.viewedSplashScreens, + layoutPresets, taskSettings, agentAiDefaults, // Legacy fields are still parsed and returned for downgrade compatibility. @@ -206,6 +214,7 @@ export class Config { mdnsServiceName?: string; serverSshHost?: string; viewedSplashScreens?: string[]; + layoutPresets?: ProjectsConfig["layoutPresets"]; featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; taskSettings?: ProjectsConfig["taskSettings"]; agentAiDefaults?: ProjectsConfig["agentAiDefaults"]; @@ -246,6 +255,12 @@ export class Config { if (config.featureFlagOverrides) { data.featureFlagOverrides = config.featureFlagOverrides; } + if (config.layoutPresets) { + const normalized = normalizeLayoutPresetsConfig(config.layoutPresets); + if (!isLayoutPresetsConfigEmpty(normalized)) { + data.layoutPresets = normalized; + } + } if (config.viewedSplashScreens) { data.viewedSplashScreens = config.viewedSplashScreens; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index e6d6081294..8adec623cf 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -20,6 +20,11 @@ import { readPlanFile } from "@/node/utils/runtime/helpers"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator"; +import { + DEFAULT_LAYOUT_PRESETS_CONFIG, + isLayoutPresetsConfigEmpty, + normalizeLayoutPresetsConfig, +} from "@/common/types/uiLayouts"; import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; import { @@ -448,6 +453,27 @@ export const router = (authToken?: string) => { await context.taskService.maybeStartQueuedTasks(); }), }, + uiLayouts: { + getAll: t + .input(schemas.uiLayouts.getAll.input) + .output(schemas.uiLayouts.getAll.output) + .handler(({ context }) => { + const config = context.config.loadConfigOrDefault(); + return config.layoutPresets ?? DEFAULT_LAYOUT_PRESETS_CONFIG; + }), + saveAll: t + .input(schemas.uiLayouts.saveAll.input) + .output(schemas.uiLayouts.saveAll.output) + .handler(async ({ context, input }) => { + await context.config.editConfig((config) => { + const normalized = normalizeLayoutPresetsConfig(input.layoutPresets); + return { + ...config, + layoutPresets: isLayoutPresetsConfigEmpty(normalized) ? undefined : normalized, + }; + }); + }), + }, agents: { list: t .input(schemas.agents.list.input) From 831a4c63a0c977db9a79f887fbecf75306a04614 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 17 Jan 2026 11:20:49 +0100 Subject: [PATCH 02/18] fix: only update layout presets after successful save Change-Id: Ic4baec7d12a95ebffd8f3d990b54102b78fd0cd3 Signed-off-by: Thomas Kosiewski --- src/browser/contexts/UILayoutsContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/contexts/UILayoutsContext.tsx b/src/browser/contexts/UILayoutsContext.tsx index 89e0df9ea6..e5e69a57a6 100644 --- a/src/browser/contexts/UILayoutsContext.tsx +++ b/src/browser/contexts/UILayoutsContext.tsx @@ -88,13 +88,13 @@ export function UILayoutsProvider(props: { children: ReactNode }) { const saveAll = useCallback( async (next: LayoutPresetsConfig): Promise => { const normalized = normalizeLayoutPresetsConfig(next); - setLayoutPresets(normalized); if (!api) { throw new Error("ORPC client not initialized"); } await api.uiLayouts.saveAll({ layoutPresets: normalized }); + setLayoutPresets(normalized); }, [api] ); From be8474130eb53d8af513cbb40aae7200f79d3920 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 17 Jan 2026 15:36:05 +0100 Subject: [PATCH 03/18] fix: only consume slot hotkeys when slot is assigned Change-Id: I3f2da4aaf32ac1e974bc48b74509c9837fe913dc Signed-off-by: Thomas Kosiewski --- src/browser/App.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 8f5915af89..4cc670dcab 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -13,7 +13,7 @@ import { updatePersistedState, readPersistedState, } from "./hooks/usePersistedState"; -import { getEffectiveSlotKeybind } from "@/browser/utils/uiLayouts"; +import { getEffectiveSlotKeybind, getPresetForSlot } from "@/browser/utils/uiLayouts"; import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; import { useResumeManager } from "./hooks/useResumeManager"; @@ -573,14 +573,21 @@ function AppInner() { } for (const slot of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) { + const preset = getPresetForSlot(layoutPresets, slot); + if (!preset) { + continue; + } + const keybind = getEffectiveSlotKeybind(layoutPresets, slot); - if (matchesKeybind(e, keybind)) { - e.preventDefault(); - void applySlotToWorkspace(selectedWorkspace.workspaceId, slot).catch(() => { - // Best-effort only. - }); - return; + if (!matchesKeybind(e, keybind)) { + continue; } + + e.preventDefault(); + void applySlotToWorkspace(selectedWorkspace.workspaceId, slot).catch(() => { + // Best-effort only. + }); + return; } }; From d2c7a08224a1ef4c494543b71e7021efd0086b83 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 09:11:45 +0100 Subject: [PATCH 04/18] fix: drop modifier-less slot hotkey overrides from config Change-Id: Idc4c883f7614bed57ced8718c964e086d63a0dc6 Signed-off-by: Thomas Kosiewski --- src/common/types/keybind.ts | 4 ++++ src/common/types/uiLayouts.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/common/types/keybind.ts b/src/common/types/keybind.ts index 5005dd7bb2..3c8789602c 100644 --- a/src/common/types/keybind.ts +++ b/src/common/types/keybind.ts @@ -16,6 +16,10 @@ export interface Keybind { macCtrlBehavior?: "either" | "command" | "control"; } +export function hasModifierKeybind(keybind: Keybind): boolean { + return [keybind.ctrl, keybind.shift, keybind.alt, keybind.meta].some((v) => v === true); +} + export function normalizeKeybind(raw: unknown): Keybind | undefined { if (!raw || typeof raw !== "object") { return undefined; diff --git a/src/common/types/uiLayouts.ts b/src/common/types/uiLayouts.ts index 040c61bece..321d6a1976 100644 --- a/src/common/types/uiLayouts.ts +++ b/src/common/types/uiLayouts.ts @@ -1,6 +1,6 @@ import assert from "@/common/utils/assert"; import type { Keybind } from "@/common/types/keybind"; -import { normalizeKeybind } from "@/common/types/keybind"; +import { hasModifierKeybind, normalizeKeybind } from "@/common/types/keybind"; export type LayoutSlotNumber = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; @@ -179,7 +179,12 @@ function normalizeLayoutSlot(raw: unknown): LayoutSlot | undefined { } const presetId = normalizeOptionalNonEmptyString(record.presetId); - const keybindOverride = normalizeKeybind(record.keybindOverride); + const keybindOverrideRaw = normalizeKeybind(record.keybindOverride); + const keybindOverride = keybindOverrideRaw + ? hasModifierKeybind(keybindOverrideRaw) + ? keybindOverrideRaw + : undefined + : undefined; if (!presetId && !keybindOverride) { return undefined; From bc40fafa863dbef0f00cc9bf87e8e67fb72d8442 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 09:40:24 +0100 Subject: [PATCH 05/18] fix: avoid slot hotkeys in inputs and save+assign races Change-Id: I9ccc39546f476f8ace1232ba3dfa2d42bf7a3f87 Signed-off-by: Thomas Kosiewski --- src/browser/App.tsx | 23 +++++++++++++++++------ src/browser/contexts/UILayoutsContext.tsx | 19 ++++++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 4cc670dcab..79da5e4996 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -14,7 +14,12 @@ import { readPersistedState, } from "./hooks/usePersistedState"; import { getEffectiveSlotKeybind, getPresetForSlot } from "@/browser/utils/uiLayouts"; -import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; +import { + matchesKeybind, + KEYBINDS, + isEditableElement, + isTerminalFocused, +} from "./utils/ui/keybinds"; import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; @@ -90,7 +95,6 @@ function AppInner() { applySlotToWorkspace, applyPresetToWorkspace, saveCurrentWorkspaceAsPreset, - setSlotPreset, } = useUILayouts(); const { api, status, error, authenticate } = useAPI(); @@ -481,10 +485,7 @@ function AppInner() { }, onSaveLayoutPreset: async (workspaceId, name, slot) => { try { - const preset = await saveCurrentWorkspaceAsPreset(workspaceId, name); - if (slot) { - await setSlotPreset(slot, preset.id); - } + await saveCurrentWorkspaceAsPreset(workspaceId, name, slot); } catch { // Best-effort only. } @@ -572,6 +573,16 @@ function AppInner() { return; } + // Don't let global slot hotkeys fire while the user is typing. + if (isEditableElement(e.target) || isTerminalFocused(e.target)) { + return; + } + + // AltGr is commonly implemented as Ctrl+Alt; avoid treating it as our shortcut. + if (typeof e.getModifierState === "function" && e.getModifierState("AltGraph")) { + return; + } + for (const slot of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) { const preset = getPresetForSlot(layoutPresets, slot); if (!preset) { diff --git a/src/browser/contexts/UILayoutsContext.tsx b/src/browser/contexts/UILayoutsContext.tsx index e5e69a57a6..826cf87ce7 100644 --- a/src/browser/contexts/UILayoutsContext.tsx +++ b/src/browser/contexts/UILayoutsContext.tsx @@ -37,7 +37,11 @@ interface UILayoutsContextValue { applySlotToWorkspace: (workspaceId: string, slot: LayoutSlotNumber) => Promise; applyPresetToWorkspace: (workspaceId: string, presetId: string) => Promise; - saveCurrentWorkspaceAsPreset: (workspaceId: string, name: string) => Promise; + saveCurrentWorkspaceAsPreset: ( + workspaceId: string, + name: string, + slot?: LayoutSlotNumber | null + ) => Promise; setSlotPreset: (slot: LayoutSlotNumber, presetId: string | undefined) => Promise; setSlotKeybindOverride: (slot: LayoutSlotNumber, keybind: Keybind | undefined) => Promise; @@ -128,13 +132,22 @@ export function UILayoutsProvider(props: { children: ReactNode }) { ); const saveCurrentWorkspaceAsPreset = useCallback( - async (workspaceId: string, name: string): Promise => { + async ( + workspaceId: string, + name: string, + slot?: LayoutSlotNumber | null + ): Promise => { assert( typeof workspaceId === "string" && workspaceId.length > 0, "workspaceId must be non-empty" ); + const preset = createPresetFromCurrentWorkspace(workspaceId, name); - await saveAll(upsertPreset(layoutPresets, preset)); + let next = upsertPreset(layoutPresets, preset); + if (slot != null) { + next = updateSlotAssignment(next, slot, preset.id); + } + await saveAll(next); return preset; }, [layoutPresets, saveAll] From a2aec1535b7fe726f2bf5848bd1b335e0b370d0a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 09:51:11 +0100 Subject: [PATCH 06/18] fix: preserve mobile sidebar state and space keybinds Change-Id: Iecc135a3a4c5f7ee9e48c69b61f7eaaeb706db34 Signed-off-by: Thomas Kosiewski --- src/browser/utils/uiLayouts.ts | 5 ++++- src/common/types/keybind.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/browser/utils/uiLayouts.ts b/src/browser/utils/uiLayouts.ts index a5d4b2cebf..edf7b2fc77 100644 --- a/src/browser/utils/uiLayouts.ts +++ b/src/browser/utils/uiLayouts.ts @@ -115,7 +115,10 @@ function readCurrentRightSidebarWidthPx(): number { } function readCurrentLeftSidebarCollapsed(): boolean { - return readPersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, false); + // Match App.tsx's default: auto-collapse on mobile-ish widths unless the user has an explicit + // persisted preference yet. + const defaultCollapsed = typeof window !== "undefined" && window.innerWidth <= 768; + return readPersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, defaultCollapsed); } function createTerminalPlaceholder(counter: number): RightSidebarPresetTabType { diff --git a/src/common/types/keybind.ts b/src/common/types/keybind.ts index 3c8789602c..9bfa26bc78 100644 --- a/src/common/types/keybind.ts +++ b/src/common/types/keybind.ts @@ -27,7 +27,8 @@ export function normalizeKeybind(raw: unknown): Keybind | undefined { const record = raw as Record; - const key = typeof record.key === "string" ? record.key.trim() : ""; + const rawKey = typeof record.key === "string" ? record.key : ""; + const key = rawKey === " " ? rawKey : rawKey.trim(); if (!key) { return undefined; } From 3a46ce949985666cb5c9e1eccbdab1a60055c6de Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 10:08:06 +0100 Subject: [PATCH 07/18] fix: avoid overwriting layouts config before initial load Change-Id: If0ddbb0182e2cb258cb4e72ae8abb5c7053f5e64 Signed-off-by: Thomas Kosiewski --- src/browser/contexts/UILayoutsContext.tsx | 59 +++++++++++++++++------ 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/src/browser/contexts/UILayoutsContext.tsx b/src/browser/contexts/UILayoutsContext.tsx index 826cf87ce7..6f2dbdde2c 100644 --- a/src/browser/contexts/UILayoutsContext.tsx +++ b/src/browser/contexts/UILayoutsContext.tsx @@ -89,6 +89,26 @@ export function UILayoutsProvider(props: { children: ReactNode }) { } }, [api]); + const getConfigForWrite = useCallback(async (): Promise => { + if (!api) { + return layoutPresets; + } + + if (loaded && !loadFailed) { + return layoutPresets; + } + + // Avoid overwriting an existing config with defaults before the initial load completes. + const remote = await api.uiLayouts.getAll(); + const normalized = getLayoutsConfigOrDefault(remote); + + setLayoutPresets(normalized); + setLoaded(true); + setLoadFailed(false); + + return normalized; + }, [api, layoutPresets, loaded, loadFailed]); + const saveAll = useCallback( async (next: LayoutPresetsConfig): Promise => { const normalized = normalizeLayoutPresetsConfig(next); @@ -142,28 +162,32 @@ export function UILayoutsProvider(props: { children: ReactNode }) { "workspaceId must be non-empty" ); + const base = await getConfigForWrite(); + const preset = createPresetFromCurrentWorkspace(workspaceId, name); - let next = upsertPreset(layoutPresets, preset); + let next = upsertPreset(base, preset); if (slot != null) { next = updateSlotAssignment(next, slot, preset.id); } await saveAll(next); return preset; }, - [layoutPresets, saveAll] + [getConfigForWrite, saveAll] ); const updatePresetFromCurrentWorkspace = useCallback( async (workspaceId: string, presetId: string): Promise => { - const existing = getPresetById(layoutPresets, presetId); + const base = await getConfigForWrite(); + + const existing = getPresetById(base, presetId); if (!existing) { return; } const next = createPresetFromCurrentWorkspace(workspaceId, existing.name, presetId); - await saveAll(upsertPreset(layoutPresets, next)); + await saveAll(upsertPreset(base, next)); }, - [layoutPresets, saveAll] + [getConfigForWrite, saveAll] ); const renamePreset = useCallback( @@ -173,25 +197,28 @@ export function UILayoutsProvider(props: { children: ReactNode }) { return; } - const existing = getPresetById(layoutPresets, presetId); + const base = await getConfigForWrite(); + const existing = getPresetById(base, presetId); if (!existing) { return; } await saveAll( - upsertPreset(layoutPresets, { + upsertPreset(base, { ...existing, name: trimmed, }) ); }, - [layoutPresets, saveAll] + [getConfigForWrite, saveAll] ); const deletePreset = useCallback( async (presetId: string): Promise => { - const nextPresets = layoutPresets.presets.filter((p) => p.id !== presetId); - const nextSlots = layoutPresets.slots.map((s) => + const base = await getConfigForWrite(); + + const nextPresets = base.presets.filter((p) => p.id !== presetId); + const nextSlots = base.slots.map((s) => s.presetId === presetId ? { ...s, presetId: undefined } : s ); @@ -203,21 +230,23 @@ export function UILayoutsProvider(props: { children: ReactNode }) { }) ); }, - [layoutPresets, saveAll] + [getConfigForWrite, saveAll] ); const setSlotPreset = useCallback( async (slot: LayoutSlotNumber, presetId: string | undefined): Promise => { - await saveAll(updateSlotAssignment(layoutPresets, slot, presetId)); + const base = await getConfigForWrite(); + await saveAll(updateSlotAssignment(base, slot, presetId)); }, - [layoutPresets, saveAll] + [getConfigForWrite, saveAll] ); const setSlotKeybindOverride = useCallback( async (slot: LayoutSlotNumber, keybind: Keybind | undefined): Promise => { - await saveAll(updateSlotKeybindOverride(layoutPresets, slot, keybind)); + const base = await getConfigForWrite(); + await saveAll(updateSlotKeybindOverride(base, slot, keybind)); }, - [layoutPresets, saveAll] + [getConfigForWrite, saveAll] ); const value: UILayoutsContextValue = useMemo( From 6ea51dde9f318856bb263124bc9ef9db9b7022f2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 12:25:56 +0100 Subject: [PATCH 08/18] fix: keep extra terminal sessions reachable after applying preset Change-Id: I56966116ee4c8fd8957f1a8404f4e5de0f8483ea Signed-off-by: Thomas Kosiewski --- src/browser/utils/uiLayouts.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/browser/utils/uiLayouts.ts b/src/browser/utils/uiLayouts.ts index edf7b2fc77..0117a223de 100644 --- a/src/browser/utils/uiLayouts.ts +++ b/src/browser/utils/uiLayouts.ts @@ -22,6 +22,8 @@ import { updatePersistedState, } from "@/browser/hooks/usePersistedState"; import { + addTabToFocusedTabset, + collectAllTabs, findFirstTabsetId, findTabset, getDefaultRightSidebarLayoutState, @@ -29,7 +31,7 @@ import { type RightSidebarLayoutNode, type RightSidebarLayoutState, } from "@/browser/utils/rightSidebarLayout"; -import { isTabType, type TabType } from "@/browser/types/rightSidebar"; +import { isTabType, makeTerminalTabType, type TabType } from "@/browser/types/rightSidebar"; import { createTerminalSession } from "@/browser/utils/terminal"; import type { APIClient } from "@/browser/contexts/API"; @@ -428,7 +430,27 @@ export async function applyLayoutPresetToWorkspace( const terminalMapping = await resolveTerminalSessions(api, workspaceId, terminalTabs); - const layout = resolvePresetLayoutToLayoutState(preset.rightSidebar.layout, terminalMapping); + let layout = resolvePresetLayoutToLayoutState(preset.rightSidebar.layout, terminalMapping); + + // Preserve any extra backend terminal sessions that aren't referenced by the preset. + // Otherwise those sessions remain running but have no tabs until the workspace is re-mounted. + const backendSessionIds = await api.terminal.listSessions({ workspaceId }).catch(() => []); + + if (backendSessionIds.length > 0) { + const currentTabs = collectAllTabs(layout.root); + const currentTerminalSessionIds = new Set( + currentTabs.filter((t) => t.startsWith("terminal:")).map((t) => t.slice("terminal:".length)) + ); + + for (const sessionId of backendSessionIds) { + if (currentTerminalSessionIds.has(sessionId)) { + continue; + } + + layout = addTabToFocusedTabset(layout, makeTerminalTabType(sessionId), false); + currentTerminalSessionIds.add(sessionId); + } + } updatePersistedState(getRightSidebarLayoutKey(workspaceId), layout); } From 43eaf0079f000d1f2605f9d3454fe7a5a3a5e5f1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 13:02:49 +0100 Subject: [PATCH 09/18] tests: add Layouts settings story Change-Id: Ida9e598183242034f2f8f3fef0f2e0069f1ff86e Signed-off-by: Thomas Kosiewski --- docs/AGENTS.md | 1 + src/browser/stories/App.settings.stories.tsx | 57 ++++++++++++++++++++ src/browser/stories/mocks/orpc.ts | 20 +++++++ 3 files changed, 78 insertions(+) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index bf70886464..8eb39d6bfa 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -100,6 +100,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior. ### Storybook +- **Settings UI coverage:** if you add a new Settings modal section (or materially change an existing one), add/update an `App.settings.*.stories.tsx` story that navigates to that section so Chromatic catches regressions. - **Only** add full-app stories (`App.*.stories.tsx`). Do not add isolated component stories, even for small UI changes (they are not used/accepted in this repo). - Use play functions with `@storybook/test` utilities (`within`, `userEvent`, `waitFor`) to interact with the UI and set up the desired visual state. Do not add props to production components solely for storybook convenience. - Keep story data deterministic: avoid `Math.random()`, `Date.now()`, or other non-deterministic values in story setup. Pass explicit values when ordering or timing matters for visual stability. diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index 47ec4bc071..770b3293bb 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -23,6 +23,7 @@ import { within, userEvent, waitFor } from "@storybook/test"; import { getExperimentKey, EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; import type { TaskSettings } from "@/common/types/tasks"; +import type { LayoutPresetsConfig } from "@/common/types/uiLayouts"; export default { ...appMeta, @@ -35,6 +36,7 @@ export default { /** Setup basic workspace for settings stories */ function setupSettingsStory(options: { + layoutPresets?: LayoutPresetsConfig; providersConfig?: Record< string, { apiKeySet: boolean; isConfigured: boolean; baseUrl?: string; models?: string[] } @@ -64,6 +66,7 @@ function setupSettingsStory(options: { agentAiDefaults: options.agentAiDefaults, providersList: options.providersList ?? ["anthropic", "openai", "xai"], taskSettings: options.taskSettings, + layoutPresets: options.layoutPresets, }); } @@ -187,6 +190,60 @@ export const ProvidersConfigured: AppStory = { }, }; +// ═══════════════════════════════════════════════════════════════════════════════ +// Layouts +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Layouts section - with a preset assigned to a slot */ +export const LayoutsConfigured: AppStory = { + render: () => ( + + setupSettingsStory({ + layoutPresets: { + version: 1, + presets: [ + { + id: "preset-1", + name: "My Layout", + leftSidebarCollapsed: false, + rightSidebar: { + collapsed: false, + width: { mode: "px", value: 420 }, + layout: { + version: 1, + nextId: 2, + focusedTabsetId: "tabset-1", + root: { + type: "tabset", + id: "tabset-1", + tabs: ["costs", "review", "terminal_new:t1"], + activeTab: "review", + }, + }, + }, + }, + ], + slots: [{ slot: 1, presetId: "preset-1" }], + }, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "layouts"); + + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole("dialog"); + const dialogCanvas = within(dialog); + + await dialogCanvas.findByRole("heading", { name: /layout presets/i }); + await dialogCanvas.findByText(/Slots \(1–9\)/i); + + // Wait for the async config load from the UILayoutsProvider. + await dialogCanvas.findByText(/My Layout/i); + }, +}; /** Providers section - expanded to show quick links (docs + get API key) */ export const ProvidersExpanded: AppStory = { render: () => ( diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 5a5e48c49f..210468182b 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -10,6 +10,11 @@ import type { } from "@/common/types/agentDefinition"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { ProjectConfig } from "@/node/config"; +import { + DEFAULT_LAYOUT_PRESETS_CONFIG, + normalizeLayoutPresetsConfig, + type LayoutPresetsConfig, +} from "@/common/types/uiLayouts"; import type { WorkspaceChatMessage, ProvidersConfigMap, @@ -66,6 +71,8 @@ export interface MockSessionUsage { } export interface MockORPCClientOptions { + /** Layout presets config for Settings → Layouts stories */ + layoutPresets?: LayoutPresetsConfig; projects?: Map; workspaces?: FrontendWorkspaceMetadata[]; /** Initial task settings for config.getConfig (e.g., Settings → Tasks section) */ @@ -235,6 +242,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl coderTemplates = [], coderPresets = new Map(), coderWorkspaces = [], + layoutPresets: initialLayoutPresets, } = options; // Feature flags @@ -322,6 +330,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl return normalizeSubagentAiDefaults(raw); }; + let layoutPresets = initialLayoutPresets ?? DEFAULT_LAYOUT_PRESETS_CONFIG; let modeAiDefaults = deriveModeAiDefaults(); let subagentAiDefaults = deriveSubagentAiDefaults(); @@ -378,6 +387,17 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl getSshHost: () => Promise.resolve(null), setSshHost: () => Promise.resolve(undefined), }, + // Settings → Layouts (layout presets) + // Stored in-memory for Storybook only. + // Frontend code normalizes the response defensively, but we normalize here too so + // stories remain stable even if they mutate the config. + uiLayouts: { + getAll: () => Promise.resolve(layoutPresets), + saveAll: (input: { layoutPresets: unknown }) => { + layoutPresets = normalizeLayoutPresetsConfig(input.layoutPresets); + return Promise.resolve(undefined); + }, + }, config: { getConfig: () => Promise.resolve({ taskSettings, agentAiDefaults, subagentAiDefaults, modeAiDefaults }), From 8589ee3ad5741c8eacf3e34d9f365b910b062f93 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 13:08:58 +0100 Subject: [PATCH 10/18] tests: fix Layouts settings story assertion Change-Id: I961835bc183c617bb347c9f2e55c525138a6ac26 Signed-off-by: Thomas Kosiewski --- src/browser/stories/App.settings.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index 770b3293bb..1e60147574 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -241,7 +241,7 @@ export const LayoutsConfigured: AppStory = { await dialogCanvas.findByText(/Slots \(1–9\)/i); // Wait for the async config load from the UILayoutsProvider. - await dialogCanvas.findByText(/My Layout/i); + await dialogCanvas.findByText(/ID: preset-1/i); }, }; /** Providers section - expanded to show quick links (docs + get API key) */ From f55fec5527fd3a5965d17e3d81b5abb6cc74a0ee Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 14:09:41 +0100 Subject: [PATCH 11/18] fix: make layout slots store layouts directly Change-Id: I0755a3340a6aa01b9c22a0e3f4aaad1f640a13cc Signed-off-by: Thomas Kosiewski --- src/browser/App.tsx | 16 +- .../Settings/sections/LayoutsSection.tsx | 278 ++++++++++-------- src/browser/contexts/UILayoutsContext.tsx | 132 +++------ src/browser/stories/App.settings.stories.tsx | 42 +-- src/browser/utils/commandIds.ts | 3 +- src/browser/utils/commands/sources.ts | 85 ++---- src/browser/utils/uiLayouts.ts | 52 +--- src/common/orpc/schemas/uiLayouts.ts | 5 +- src/common/types/uiLayouts.ts | 114 +++++-- 9 files changed, 351 insertions(+), 376 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 79da5e4996..8d1ce27769 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -90,12 +90,7 @@ function AppInner() { }, [setTheme] ); - const { - layoutPresets, - applySlotToWorkspace, - applyPresetToWorkspace, - saveCurrentWorkspaceAsPreset, - } = useUILayouts(); + const { layoutPresets, applySlotToWorkspace, saveCurrentWorkspaceToSlot } = useUILayouts(); const { api, status, error, authenticate } = useAPI(); const { @@ -478,14 +473,9 @@ function AppInner() { // Best-effort only. }); }, - onApplyLayoutPreset: (workspaceId, presetId) => { - void applyPresetToWorkspace(workspaceId, presetId).catch(() => { - // Best-effort only. - }); - }, - onSaveLayoutPreset: async (workspaceId, name, slot) => { + onCaptureLayoutSlot: async (workspaceId, slot, name) => { try { - await saveCurrentWorkspaceAsPreset(workspaceId, name, slot); + await saveCurrentWorkspaceToSlot(workspaceId, slot, name); } catch { // Best-effort only. } diff --git a/src/browser/components/Settings/sections/LayoutsSection.tsx b/src/browser/components/Settings/sections/LayoutsSection.tsx index 7d2352feb5..552d9be127 100644 --- a/src/browser/components/Settings/sections/LayoutsSection.tsx +++ b/src/browser/components/Settings/sections/LayoutsSection.tsx @@ -1,12 +1,15 @@ import React, { useEffect, useMemo, useState } from "react"; import { Button } from "@/browser/components/ui/button"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/browser/components/ui/select"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/browser/components/ui/dialog"; +import { Input } from "@/browser/components/ui/input"; +import { Label } from "@/browser/components/ui/label"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useUILayouts } from "@/browser/contexts/UILayoutsContext"; import { getEffectiveSlotKeybind, getPresetForSlot } from "@/browser/utils/uiLayouts"; @@ -106,18 +109,22 @@ export function LayoutsSection() { loadFailed, refresh, applySlotToWorkspace, - applyPresetToWorkspace, - saveCurrentWorkspaceAsPreset, - setSlotPreset, + saveCurrentWorkspaceToSlot, + renameSlot, + clearSlot, setSlotKeybindOverride, - deletePreset, - renamePreset, - updatePresetFromCurrentWorkspace, } = useUILayouts(); const { selectedWorkspace } = useWorkspaceContext(); const [actionError, setActionError] = useState(null); const [capturingSlot, setCapturingSlot] = useState(null); + + const [nameDialog, setNameDialog] = useState<{ + mode: "capture" | "rename"; + slot: LayoutSlotNumber; + } | null>(null); + const [nameValue, setNameValue] = useState(""); + const [nameError, setNameError] = useState(null); const [captureError, setCaptureError] = useState(null); const effectiveSlotKeybinds = useMemo(() => { @@ -172,25 +179,50 @@ export function LayoutsSection() { }, [capturingSlot, effectiveSlotKeybinds, setSlotKeybindOverride]); const workspaceId = selectedWorkspace?.workspaceId ?? null; + const selectedWorkspaceLabel = selectedWorkspace + ? `${selectedWorkspace.projectName}/${selectedWorkspace.namedWorkspacePath.split("/").pop() ?? selectedWorkspace.namedWorkspacePath}` + : null; - const handleSavePreset = async (): Promise => { + const openNameDialog = (mode: "capture" | "rename", slot: LayoutSlotNumber) => { setActionError(null); + setNameError(null); + + const existingPreset = getPresetForSlot(layoutPresets, slot); + const initialName = existingPreset?.name ?? `Slot ${slot}`; - if (!workspaceId) { - setActionError("Select a workspace to save its layout."); + setNameDialog({ mode, slot }); + setNameValue(initialName); + }; + + const handleNameSubmit = async (): Promise => { + if (!nameDialog) { return; } - const name = window.prompt("Preset name:", ""); - const trimmed = name?.trim(); + const trimmed = nameValue.trim(); if (!trimmed) { + setNameError("Name is required."); return; } + setNameError(null); + setActionError(null); + try { - await saveCurrentWorkspaceAsPreset(workspaceId, trimmed); + if (nameDialog.mode === "capture") { + if (!workspaceId) { + setActionError("Select a workspace to capture its layout."); + return; + } + + await saveCurrentWorkspaceToSlot(workspaceId, nameDialog.slot, trimmed); + } else { + await renameSlot(nameDialog.slot, trimmed); + } + + setNameDialog(null); } catch { - setActionError("Failed to save preset."); + setNameError("Failed to save."); } }; @@ -198,25 +230,31 @@ export function LayoutsSection() {
-

Layout Presets

+

Layout Slots

- Save and re-apply sidebar layouts per workspace. + Each slot stores a layout snapshot. Apply with Ctrl/Cmd+Alt+1..9 (customizable).
+ {selectedWorkspaceLabel ? ( +
+ Selected workspace: {selectedWorkspaceLabel} +
+ ) : ( +
+ Select a workspace to capture/apply layouts. +
+ )}
-
{!loaded ?
Loading…
: null} {loadFailed ? (
- Failed to load presets from config. Using defaults. + Failed to load layouts from config. Using defaults.
) : null} {actionError ?
{actionError}
: null} @@ -263,28 +301,40 @@ export function LayoutsSection() {
- + Clear + +
+
{capturingSlot === slot ? (
Press a key combo (Esc to cancel) @@ -314,7 +364,7 @@ export function LayoutsSection() { }); }} > - Clear Hotkey + Reset Hotkey ) : null} @@ -326,84 +376,72 @@ export function LayoutsSection() {
-
-

Presets

-
- {layoutPresets.presets.length === 0 ? ( -
No presets yet.
- ) : null} - - {layoutPresets.presets.map((preset) => ( -
-
-
-
{preset.name}
-
ID: {preset.id}
-
- -
- - - - -
+ { + if (!open) { + setNameDialog(null); + setNameError(null); + } + }} + > + + + + {nameDialog?.mode === "capture" ? "Capture Layout" : "Rename Layout"} + + + {nameDialog?.mode === "capture" ? ( + <>Capture the current layout into Slot {nameDialog?.slot}. + ) : ( + <>Rename Slot {nameDialog?.slot}. + )} + + + +
{ + e.preventDefault(); + void handleNameSubmit(); + }} + className="space-y-4" + > + {nameDialog?.mode === "capture" ? ( +
+ Source workspace: {selectedWorkspaceLabel ?? "(none selected)"}
+ ) : null} + +
+ + setNameValue(e.target.value)} + placeholder="Enter layout name" + autoFocus + /> + {nameError ?
{nameError}
: null}
- ))} -
-
+ + + + + + + +
); } diff --git a/src/browser/contexts/UILayoutsContext.tsx b/src/browser/contexts/UILayoutsContext.tsx index 6f2dbdde2c..a6e4bbfc99 100644 --- a/src/browser/contexts/UILayoutsContext.tsx +++ b/src/browser/contexts/UILayoutsContext.tsx @@ -20,11 +20,9 @@ import { applyLayoutPresetToWorkspace, createPresetFromCurrentWorkspace, getLayoutsConfigOrDefault, - getPresetById, getPresetForSlot, - updateSlotAssignment, updateSlotKeybindOverride, - upsertPreset, + updateSlotPreset, } from "@/browser/utils/uiLayouts"; import type { Keybind } from "@/common/types/keybind"; @@ -36,18 +34,17 @@ interface UILayoutsContextValue { saveAll: (next: LayoutPresetsConfig) => Promise; applySlotToWorkspace: (workspaceId: string, slot: LayoutSlotNumber) => Promise; - applyPresetToWorkspace: (workspaceId: string, presetId: string) => Promise; - saveCurrentWorkspaceAsPreset: ( + + /** Capture the currently-selected workspace's layout into the given slot. */ + saveCurrentWorkspaceToSlot: ( workspaceId: string, - name: string, - slot?: LayoutSlotNumber | null + slot: LayoutSlotNumber, + name?: string | null ) => Promise; - setSlotPreset: (slot: LayoutSlotNumber, presetId: string | undefined) => Promise; + renameSlot: (slot: LayoutSlotNumber, newName: string) => Promise; + clearSlot: (slot: LayoutSlotNumber) => Promise; setSlotKeybindOverride: (slot: LayoutSlotNumber, keybind: Keybind | undefined) => Promise; - deletePreset: (presetId: string) => Promise; - renamePreset: (presetId: string, newName: string) => Promise; - updatePresetFromCurrentWorkspace: (workspaceId: string, presetId: string) => Promise; } const UILayoutsContext = createContext(null); @@ -127,18 +124,6 @@ export function UILayoutsProvider(props: { children: ReactNode }) { void refresh(); }, [refresh]); - const applyPresetToWorkspace = useCallback( - async (workspaceId: string, presetId: string): Promise => { - const preset = getPresetById(layoutPresets, presetId); - if (!preset) { - return; - } - - await applyLayoutPresetToWorkspace(api ?? null, workspaceId, preset); - }, - [api, layoutPresets] - ); - const applySlotToWorkspace = useCallback( async (workspaceId: string, slot: LayoutSlotNumber): Promise => { const preset = getPresetForSlot(layoutPresets, slot); @@ -151,11 +136,11 @@ export function UILayoutsProvider(props: { children: ReactNode }) { [api, layoutPresets] ); - const saveCurrentWorkspaceAsPreset = useCallback( + const saveCurrentWorkspaceToSlot = useCallback( async ( workspaceId: string, - name: string, - slot?: LayoutSlotNumber | null + slot: LayoutSlotNumber, + name?: string | null ): Promise => { assert( typeof workspaceId === "string" && workspaceId.length > 0, @@ -163,80 +148,47 @@ export function UILayoutsProvider(props: { children: ReactNode }) { ); const base = await getConfigForWrite(); - - const preset = createPresetFromCurrentWorkspace(workspaceId, name); - let next = upsertPreset(base, preset); - if (slot != null) { - next = updateSlotAssignment(next, slot, preset.id); - } - await saveAll(next); + const existingPreset = getPresetForSlot(base, slot); + + const trimmedName = name?.trim(); + const resolvedName = + trimmedName && trimmedName.length > 0 + ? trimmedName + : (existingPreset?.name ?? `Slot ${slot}`); + + const preset = createPresetFromCurrentWorkspace( + workspaceId, + resolvedName, + existingPreset?.id + ); + await saveAll(updateSlotPreset(base, slot, preset)); return preset; }, [getConfigForWrite, saveAll] ); - const updatePresetFromCurrentWorkspace = useCallback( - async (workspaceId: string, presetId: string): Promise => { - const base = await getConfigForWrite(); - - const existing = getPresetById(base, presetId); - if (!existing) { - return; - } - - const next = createPresetFromCurrentWorkspace(workspaceId, existing.name, presetId); - await saveAll(upsertPreset(base, next)); - }, - [getConfigForWrite, saveAll] - ); - - const renamePreset = useCallback( - async (presetId: string, newName: string): Promise => { + const renameSlot = useCallback( + async (slot: LayoutSlotNumber, newName: string): Promise => { const trimmed = newName.trim(); if (!trimmed) { return; } const base = await getConfigForWrite(); - const existing = getPresetById(base, presetId); - if (!existing) { + const existingPreset = getPresetForSlot(base, slot); + if (!existingPreset) { return; } - await saveAll( - upsertPreset(base, { - ...existing, - name: trimmed, - }) - ); - }, - [getConfigForWrite, saveAll] - ); - - const deletePreset = useCallback( - async (presetId: string): Promise => { - const base = await getConfigForWrite(); - - const nextPresets = base.presets.filter((p) => p.id !== presetId); - const nextSlots = base.slots.map((s) => - s.presetId === presetId ? { ...s, presetId: undefined } : s - ); - - await saveAll( - normalizeLayoutPresetsConfig({ - version: 1, - presets: nextPresets, - slots: nextSlots, - }) - ); + await saveAll(updateSlotPreset(base, slot, { ...existingPreset, name: trimmed })); }, [getConfigForWrite, saveAll] ); - const setSlotPreset = useCallback( - async (slot: LayoutSlotNumber, presetId: string | undefined): Promise => { + const clearSlot = useCallback( + async (slot: LayoutSlotNumber): Promise => { const base = await getConfigForWrite(); - await saveAll(updateSlotAssignment(base, slot, presetId)); + await saveAll(updateSlotPreset(base, slot, undefined)); }, [getConfigForWrite, saveAll] ); @@ -257,13 +209,10 @@ export function UILayoutsProvider(props: { children: ReactNode }) { refresh, saveAll, applySlotToWorkspace, - applyPresetToWorkspace, - saveCurrentWorkspaceAsPreset, - setSlotPreset, + saveCurrentWorkspaceToSlot, + renameSlot, + clearSlot, setSlotKeybindOverride, - deletePreset, - renamePreset, - updatePresetFromCurrentWorkspace, }), [ layoutPresets, @@ -272,13 +221,10 @@ export function UILayoutsProvider(props: { children: ReactNode }) { refresh, saveAll, applySlotToWorkspace, - applyPresetToWorkspace, - saveCurrentWorkspaceAsPreset, - setSlotPreset, + saveCurrentWorkspaceToSlot, + renameSlot, + clearSlot, setSlotKeybindOverride, - deletePreset, - renamePreset, - updatePresetFromCurrentWorkspace, ] ); diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index 1e60147574..2d12bc1ec6 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -201,30 +201,32 @@ export const LayoutsConfigured: AppStory = { setup={() => setupSettingsStory({ layoutPresets: { - version: 1, - presets: [ + version: 2, + slots: [ { - id: "preset-1", - name: "My Layout", - leftSidebarCollapsed: false, - rightSidebar: { - collapsed: false, - width: { mode: "px", value: 420 }, - layout: { - version: 1, - nextId: 2, - focusedTabsetId: "tabset-1", - root: { - type: "tabset", - id: "tabset-1", - tabs: ["costs", "review", "terminal_new:t1"], - activeTab: "review", + slot: 1, + preset: { + id: "preset-1", + name: "My Layout", + leftSidebarCollapsed: false, + rightSidebar: { + collapsed: false, + width: { mode: "px", value: 420 }, + layout: { + version: 1, + nextId: 2, + focusedTabsetId: "tabset-1", + root: { + type: "tabset", + id: "tabset-1", + tabs: ["costs", "review", "terminal_new:t1"], + activeTab: "review", + }, }, }, }, }, ], - slots: [{ slot: 1, presetId: "preset-1" }], }, }) } @@ -237,11 +239,11 @@ export const LayoutsConfigured: AppStory = { const dialog = await body.findByRole("dialog"); const dialogCanvas = within(dialog); - await dialogCanvas.findByRole("heading", { name: /layout presets/i }); + await dialogCanvas.findByRole("heading", { name: /layout slots/i }); await dialogCanvas.findByText(/Slots \(1–9\)/i); // Wait for the async config load from the UILayoutsProvider. - await dialogCanvas.findByText(/ID: preset-1/i); + await dialogCanvas.findByText(/My Layout/i); }, }; /** Providers section - expanded to show quick links (docs + get API key) */ diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 771d6a2489..9b86a083a8 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -62,8 +62,7 @@ export const CommandIds = { // Layout commands layoutApplySlot: (slot: number) => `layout:apply-slot:${slot}` as const, - layoutApplyPreset: (presetId: string) => `layout:apply-preset:${presetId}` as const, - layoutSavePreset: () => "layout:save-preset" as const, + layoutCaptureSlot: (slot: number) => `layout:capture-slot:${slot}` as const, // Settings commands settingsOpen: () => "settings:open" as const, settingsOpenSection: (section: string) => `settings:open:${section}` as const, diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 47a9b8b055..95f99ad790 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -73,14 +73,13 @@ export interface BuildSourcesParams { onSetTheme: (theme: ThemeMode) => void; onOpenSettings?: (section?: string) => void; - // Layout presets + // Layout slots layoutPresets?: LayoutPresetsConfig | null; onApplyLayoutSlot?: (workspaceId: string, slot: LayoutSlotNumber) => void; - onApplyLayoutPreset?: (workspaceId: string, presetId: string) => void; - onSaveLayoutPreset?: ( + onCaptureLayoutSlot?: ( workspaceId: string, - name: string, - slot?: LayoutSlotNumber | null + slot: LayoutSlotNumber, + name: string ) => Promise; onClearTimingStats?: (workspaceId: string) => void; } @@ -497,7 +496,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi return list; }); - // Layout presets + // Layout slots actions.push(() => { const list: CommandAction[] = []; const selected = p.selectedWorkspace; @@ -523,62 +522,30 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi void p.onApplyLayoutSlot?.(selected.workspaceId, slot); }, }); - } - - if (p.onSaveLayoutPreset) { - list.push({ - id: CommandIds.layoutSavePreset(), - title: "Layout: Save current as preset…", - section: section.layouts, - run: () => undefined, - prompt: { - title: "Save Layout Preset", - fields: [ - { - type: "text", - name: "name", - label: "Name", - placeholder: "Enter preset name", - validate: (v) => (!v.trim() ? "Name is required" : null), - }, - { - type: "select", - name: "slot", - label: "Assign to slot (optional)", - placeholder: "Don't assign", - getOptions: () => [ - { id: "none", label: "Don't assign" }, - ...([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map((slot) => { - const assigned = getPresetForSlot(config, slot); - return { - id: String(slot), - label: `Slot ${slot}${assigned ? ` • ${assigned.name}` : ""}`, - keywords: assigned ? [assigned.name] : [], - }; - }), - ], - }, - ], - onSubmit: async (vals) => { - const name = vals.name.trim(); - const slotRaw = vals.slot; - const slot = - slotRaw && slotRaw !== "none" ? (Number(slotRaw) as LayoutSlotNumber) : null; - await p.onSaveLayoutPreset?.(selected.workspaceId, name, slot); - }, - }, - }); - } - if (p.onApplyLayoutPreset && config.presets.length > 0) { - for (const preset of config.presets) { + if (p.onCaptureLayoutSlot) { list.push({ - id: CommandIds.layoutApplyPreset(preset.id), - title: `Layout: Apply Preset ${preset.name}`, + id: CommandIds.layoutCaptureSlot(slot), + title: `Layout: Capture current to Slot ${slot}…`, + subtitle: preset ? preset.name : "Empty", section: section.layouts, - keywords: [preset.name], - run: () => { - void p.onApplyLayoutPreset?.(selected.workspaceId, preset.id); + run: () => undefined, + prompt: { + title: `Capture Layout Slot ${slot}`, + fields: [ + { + type: "text", + name: "name", + label: "Name", + placeholder: `Slot ${slot}`, + initialValue: preset ? preset.name : `Slot ${slot}`, + getInitialValue: () => getPresetForSlot(config, slot)?.name ?? `Slot ${slot}`, + validate: (v) => (!v.trim() ? "Name is required" : null), + }, + ], + onSubmit: async (vals) => { + await p.onCaptureLayoutSlot?.(selected.workspaceId, slot, vals.name.trim()); + }, }, }); } diff --git a/src/browser/utils/uiLayouts.ts b/src/browser/utils/uiLayouts.ts index 0117a223de..4f5353c21b 100644 --- a/src/browser/utils/uiLayouts.ts +++ b/src/browser/utils/uiLayouts.ts @@ -62,20 +62,11 @@ export function getEffectiveSlotKeybind( return override ?? getDefaultSlotKeybind(slot); } -export function getPresetById( - config: LayoutPresetsConfig, - presetId: string -): LayoutPreset | undefined { - return config.presets.find((p) => p.id === presetId); -} - export function getPresetForSlot( config: LayoutPresetsConfig, slot: LayoutSlotNumber ): LayoutPreset | undefined { - const presetId = config.slots.find((s) => s.slot === slot)?.presetId; - if (!presetId) return undefined; - return getPresetById(config, presetId); + return config.slots.find((s) => s.slot === slot)?.preset; } function clampInt(value: number, min: number, max: number): number { @@ -455,40 +446,23 @@ export async function applyLayoutPresetToWorkspace( updatePersistedState(getRightSidebarLayoutKey(workspaceId), layout); } -export function upsertPreset( - config: LayoutPresetsConfig, - preset: LayoutPreset -): LayoutPresetsConfig { - const normalized = normalizeLayoutPresetsConfig(config); - - const nextPresets = normalized.presets.filter((p) => p.id !== preset.id); - nextPresets.push(preset); - - return { - ...normalized, - presets: nextPresets, - }; -} - -export function updateSlotAssignment( +export function updateSlotPreset( config: LayoutPresetsConfig, slot: LayoutSlotNumber, - presetId: string | undefined + preset: LayoutPreset | undefined ): LayoutPresetsConfig { const normalized = normalizeLayoutPresetsConfig(config); + const existing = normalized.slots.find((s) => s.slot === slot); + const nextSlots = normalized.slots.filter((s) => s.slot !== slot); - if (presetId) { + const keybindOverride = existing?.keybindOverride; + if (preset || keybindOverride) { nextSlots.push({ slot, - presetId, - keybindOverride: normalized.slots.find((s) => s.slot === slot)?.keybindOverride, + ...(preset ? { preset } : {}), + ...(keybindOverride ? { keybindOverride } : {}), }); - } else { - const existingOverride = normalized.slots.find((s) => s.slot === slot)?.keybindOverride; - if (existingOverride) { - nextSlots.push({ slot, keybindOverride: existingOverride }); - } } return { @@ -504,15 +478,15 @@ export function updateSlotKeybindOverride( ): LayoutPresetsConfig { const normalized = normalizeLayoutPresetsConfig(config); const existing = normalized.slots.find((s) => s.slot === slot); - const presetId = existing?.presetId; + const preset = existing?.preset; const nextSlots = normalized.slots.filter((s) => s.slot !== slot); - if (presetId || keybindOverride) { + if (preset || keybindOverride) { nextSlots.push({ slot, - presetId, - keybindOverride, + ...(preset ? { preset } : {}), + ...(keybindOverride ? { keybindOverride } : {}), }); } diff --git a/src/common/orpc/schemas/uiLayouts.ts b/src/common/orpc/schemas/uiLayouts.ts index 13ade7a25e..d6d8bc47b2 100644 --- a/src/common/orpc/schemas/uiLayouts.ts +++ b/src/common/orpc/schemas/uiLayouts.ts @@ -92,15 +92,14 @@ export const LayoutPresetSchema = z export const LayoutSlotSchema = z .object({ slot: z.number().int().min(1).max(9), - presetId: z.string().min(1).optional(), + preset: LayoutPresetSchema.optional(), keybindOverride: KeybindSchema.optional(), }) .strict(); export const LayoutPresetsConfigSchema = z .object({ - version: z.literal(1), - presets: z.array(LayoutPresetSchema), + version: z.literal(2), slots: z.array(LayoutSlotSchema), }) .strict(); diff --git a/src/common/types/uiLayouts.ts b/src/common/types/uiLayouts.ts index 321d6a1976..8bb82a6d64 100644 --- a/src/common/types/uiLayouts.ts +++ b/src/common/types/uiLayouts.ts @@ -6,7 +6,9 @@ export type LayoutSlotNumber = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; export interface LayoutSlot { slot: LayoutSlotNumber; - presetId?: string; + /** The layout stored in this slot, if any. */ + preset?: LayoutPreset; + /** Optional keybind override for applying this slot. */ keybindOverride?: Keybind; } @@ -57,14 +59,12 @@ export interface LayoutPreset { } export interface LayoutPresetsConfig { - version: 1; - presets: LayoutPreset[]; + version: 2; slots: LayoutSlot[]; } export const DEFAULT_LAYOUT_PRESETS_CONFIG: LayoutPresetsConfig = { - version: 1, - presets: [], + version: 2, slots: [], }; @@ -178,6 +178,39 @@ function normalizeLayoutSlot(raw: unknown): LayoutSlot | undefined { return undefined; } + const preset = normalizeLayoutPreset(record.preset); + + const keybindOverrideRaw = normalizeKeybind(record.keybindOverride); + const keybindOverride = keybindOverrideRaw + ? hasModifierKeybind(keybindOverrideRaw) + ? keybindOverrideRaw + : undefined + : undefined; + + if (!preset && !keybindOverride) { + return undefined; + } + + return { + slot: record.slot, + preset: preset ?? undefined, + keybindOverride, + }; +} + +function normalizeLayoutSlotV1( + raw: unknown +): { slot: LayoutSlotNumber; presetId?: string; keybindOverride?: Keybind } | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + + const record = raw as Record; + + if (!isLayoutSlotNumber(record.slot)) { + return undefined; + } + const presetId = normalizeOptionalNonEmptyString(record.presetId); const keybindOverrideRaw = normalizeKeybind(record.keybindOverride); const keybindOverride = keybindOverrideRaw @@ -247,12 +280,45 @@ export function normalizeLayoutPresetsConfig(raw: unknown): LayoutPresetsConfig } const record = raw as Record; - if (record.version !== 1) { - return DEFAULT_LAYOUT_PRESETS_CONFIG; + + if (record.version === 2) { + return normalizeLayoutPresetsConfigV2(record); } + if (record.version === 1) { + return migrateLayoutPresetsConfigV1(record); + } + + return DEFAULT_LAYOUT_PRESETS_CONFIG; +} + +function normalizeLayoutPresetsConfigV2(record: Record): LayoutPresetsConfig { + const slotsArray = Array.isArray(record.slots) ? record.slots : []; + const slotsByNumber = new Map(); + + for (const entry of slotsArray) { + const slot = normalizeLayoutSlot(entry); + if (!slot) continue; + slotsByNumber.set(slot.slot, slot); + } + + const slots = Array.from(slotsByNumber.values()).sort((a, b) => a.slot - b.slot); + + const result: LayoutPresetsConfig = { + version: 2, + slots, + }; + + assert(result.version === 2, "normalizeLayoutPresetsConfig: version must be 2"); + assert(Array.isArray(result.slots), "normalizeLayoutPresetsConfig: slots must be an array"); + + return result; +} + +function migrateLayoutPresetsConfigV1(record: Record): LayoutPresetsConfig { const presetsArray = Array.isArray(record.presets) ? record.presets : []; const presetsById = new Map(); + for (const entry of presetsArray) { const preset = normalizeLayoutPreset(entry); if (!preset) continue; @@ -261,47 +327,41 @@ export function normalizeLayoutPresetsConfig(raw: unknown): LayoutPresetsConfig const slotsArray = Array.isArray(record.slots) ? record.slots : []; const slotsByNumber = new Map(); + for (const entry of slotsArray) { - const slot = normalizeLayoutSlot(entry); + const slot = normalizeLayoutSlotV1(entry); if (!slot) continue; - // Drop presetId references to missing presets (self-healing) - if (slot.presetId && !presetsById.has(slot.presetId)) { - if (!slot.keybindOverride) { - continue; - } - slotsByNumber.set(slot.slot, { slot: slot.slot, keybindOverride: slot.keybindOverride }); + const preset = slot.presetId ? presetsById.get(slot.presetId) : undefined; + if (!preset && !slot.keybindOverride) { continue; } - slotsByNumber.set(slot.slot, slot); + slotsByNumber.set(slot.slot, { + slot: slot.slot, + preset, + keybindOverride: slot.keybindOverride, + }); } - const presets = Array.from(presetsById.values()); const slots = Array.from(slotsByNumber.values()).sort((a, b) => a.slot - b.slot); const result: LayoutPresetsConfig = { - version: 1, - presets, + version: 2, slots, }; - assert(result.version === 1, "normalizeLayoutPresetsConfig: version must be 1"); - assert(Array.isArray(result.presets), "normalizeLayoutPresetsConfig: presets must be an array"); - assert(Array.isArray(result.slots), "normalizeLayoutPresetsConfig: slots must be an array"); + assert(result.version === 2, "migrateLayoutPresetsConfigV1: version must be 2"); + assert(Array.isArray(result.slots), "migrateLayoutPresetsConfigV1: slots must be an array"); return result; } export function isLayoutPresetsConfigEmpty(value: LayoutPresetsConfig): boolean { - assert(value.version === 1, "isLayoutPresetsConfigEmpty: version must be 1"); - - if (value.presets.length > 0) { - return false; - } + assert(value.version === 2, "isLayoutPresetsConfigEmpty: version must be 2"); for (const slot of value.slots) { - if (slot.presetId || slot.keybindOverride) { + if (slot.preset || slot.keybindOverride) { return false; } } From 357f173ef54306bd997823c3a9b02b8da348a019 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 19 Jan 2026 11:15:49 +0100 Subject: [PATCH 12/18] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20simplify=20lay?= =?UTF-8?q?out=20slots=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I6b90ee9e8ccc61bbdbf2cc7caa1fbefb050bd82a Signed-off-by: Thomas Kosiewski --- .../Settings/sections/LayoutsSection.tsx | 535 ++++++++++-------- src/browser/stories/App.settings.stories.tsx | 38 +- 2 files changed, 335 insertions(+), 238 deletions(-) diff --git a/src/browser/components/Settings/sections/LayoutsSection.tsx b/src/browser/components/Settings/sections/LayoutsSection.tsx index 552d9be127..968f787d77 100644 --- a/src/browser/components/Settings/sections/LayoutsSection.tsx +++ b/src/browser/components/Settings/sections/LayoutsSection.tsx @@ -1,18 +1,11 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; +import { Plus } from "lucide-react"; import { Button } from "@/browser/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/browser/components/ui/dialog"; -import { Input } from "@/browser/components/ui/input"; -import { Label } from "@/browser/components/ui/label"; +import { KebabMenu, type KebabMenuItem } from "@/browser/components/KebabMenu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useUILayouts } from "@/browser/contexts/UILayoutsContext"; -import { getEffectiveSlotKeybind, getPresetForSlot } from "@/browser/utils/uiLayouts"; +import { getEffectiveSlotKeybind } from "@/browser/utils/uiLayouts"; import { stopKeyboardPropagation } from "@/browser/utils/events"; import { formatKeybind, isMac, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; import type { Keybind } from "@/common/types/keybind"; @@ -117,16 +110,21 @@ export function LayoutsSection() { const { selectedWorkspace } = useWorkspaceContext(); const [actionError, setActionError] = useState(null); - const [capturingSlot, setCapturingSlot] = useState(null); - - const [nameDialog, setNameDialog] = useState<{ - mode: "capture" | "rename"; + const [editingName, setEditingName] = useState<{ slot: LayoutSlotNumber; + value: string; + original: string; } | null>(null); - const [nameValue, setNameValue] = useState(""); const [nameError, setNameError] = useState(null); + + const [capturingSlot, setCapturingSlot] = useState(null); const [captureError, setCaptureError] = useState(null); + const workspaceId = selectedWorkspace?.workspaceId ?? null; + const selectedWorkspaceLabel = selectedWorkspace + ? `${selectedWorkspace.projectName}/${selectedWorkspace.namedWorkspacePath.split("/").pop() ?? selectedWorkspace.namedWorkspacePath}` + : null; + const effectiveSlotKeybinds = useMemo(() => { return ([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map((slot) => ({ slot, @@ -134,96 +132,111 @@ export function LayoutsSection() { })); }, [layoutPresets]); - useEffect(() => { - if (!capturingSlot) { - return; - } - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - stopKeyboardPropagation(e); - setCapturingSlot(null); - setCaptureError(null); - return; - } + const visibleSlots = useMemo(() => { + return layoutPresets.slots + .filter( + (slot): slot is typeof slot & { preset: NonNullable<(typeof slot)["preset"]> } => + slot.preset !== undefined + ) + .sort((a, b) => a.slot - b.slot); + }, [layoutPresets]); - const captured = normalizeCapturedKeybind(e); - if (!captured) { - return; + const nextFreeSlot = useMemo((): LayoutSlotNumber | null => { + const used = new Set(); + for (const slot of layoutPresets.slots) { + if (slot.preset) { + used.add(slot.slot); } + } - e.preventDefault(); - stopKeyboardPropagation(e); - - const error = validateSlotKeybindOverride({ - slot: capturingSlot, - keybind: captured, - existing: effectiveSlotKeybinds, - }); - - if (error) { - setCaptureError(error); - return; + for (const slot of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) { + if (!used.has(slot)) { + return slot; } + } - void setSlotKeybindOverride(capturingSlot, captured).catch(() => { - setCaptureError("Failed to save keybind override."); - }); - setCapturingSlot(null); - setCaptureError(null); - }; + return null; + }, [layoutPresets]); - window.addEventListener("keydown", handleKeyDown, { capture: true }); - return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }, [capturingSlot, effectiveSlotKeybinds, setSlotKeybindOverride]); + const submitRename = async (slot: LayoutSlotNumber, nextName: string): Promise => { + const trimmed = nextName.trim(); + if (!trimmed) { + setNameError("Name cannot be empty."); + return; + } - const workspaceId = selectedWorkspace?.workspaceId ?? null; - const selectedWorkspaceLabel = selectedWorkspace - ? `${selectedWorkspace.projectName}/${selectedWorkspace.namedWorkspacePath.split("/").pop() ?? selectedWorkspace.namedWorkspacePath}` - : null; + try { + await renameSlot(slot, trimmed); + setEditingName(null); + setNameError(null); + } catch { + setNameError("Failed to rename."); + } + }; - const openNameDialog = (mode: "capture" | "rename", slot: LayoutSlotNumber) => { + const handleAddLayout = async (): Promise => { setActionError(null); - setNameError(null); - const existingPreset = getPresetForSlot(layoutPresets, slot); - const initialName = existingPreset?.name ?? `Slot ${slot}`; + if (!workspaceId) { + setActionError("Select a workspace to capture its layout."); + return; + } + + if (!nextFreeSlot) { + setActionError("All 9 layout slots are used."); + return; + } - setNameDialog({ mode, slot }); - setNameValue(initialName); + try { + const preset = await saveCurrentWorkspaceToSlot( + workspaceId, + nextFreeSlot, + `Layout ${nextFreeSlot}` + ); + setEditingName({ slot: nextFreeSlot, value: preset.name, original: preset.name }); + setNameError(null); + } catch { + setActionError("Failed to add layout."); + } }; - const handleNameSubmit = async (): Promise => { - if (!nameDialog) { + const handleCaptureKeyDown = ( + slot: LayoutSlotNumber, + e: React.KeyboardEvent + ): void => { + if (e.key === "Escape") { + e.preventDefault(); + stopKeyboardPropagation(e); + setCapturingSlot(null); + setCaptureError(null); return; } - const trimmed = nameValue.trim(); - if (!trimmed) { - setNameError("Name is required."); + const captured = normalizeCapturedKeybind(e.nativeEvent); + if (!captured) { return; } - setNameError(null); - setActionError(null); + e.preventDefault(); + stopKeyboardPropagation(e); - try { - if (nameDialog.mode === "capture") { - if (!workspaceId) { - setActionError("Select a workspace to capture its layout."); - return; - } - - await saveCurrentWorkspaceToSlot(workspaceId, nameDialog.slot, trimmed); - } else { - await renameSlot(nameDialog.slot, trimmed); - } + const error = validateSlotKeybindOverride({ + slot, + keybind: captured, + existing: effectiveSlotKeybinds, + }); - setNameDialog(null); - } catch { - setNameError("Failed to save."); + if (error) { + setCaptureError(error); + return; } + + void setSlotKeybindOverride(slot, captured).catch(() => { + setCaptureError("Failed to save keybind override."); + }); + + setCapturingSlot(null); + setCaptureError(null); }; return ( @@ -244,7 +257,23 @@ export function LayoutsSection() {
)} +
+ + + + + Add layout + + @@ -259,189 +288,221 @@ export function LayoutsSection() { ) : null} {actionError ?
{actionError}
: null} -
-

Slots (1–9)

+ {visibleSlots.length > 0 ? (
- {([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map((slot) => { - const slotConfig = layoutPresets.slots.find((s) => s.slot === slot); - const assignedPreset = getPresetForSlot(layoutPresets, slot); + {visibleSlots.map((slotConfig) => { + const slot = slotConfig.slot; + const preset = slotConfig.preset; const effectiveKeybind = getEffectiveSlotKeybind(layoutPresets, slot); + const isEditingName = editingName?.slot === slot; + const isCapturing = capturingSlot === slot; + + const menuItems: KebabMenuItem[] = [ + { + label: "Update from current workspace", + emoji: "📸", + disabled: !workspaceId, + tooltip: workspaceId ? undefined : "Select a workspace to capture its layout.", + onClick: () => { + setActionError(null); + if (!workspaceId) { + setActionError("Select a workspace to capture its layout."); + return; + } + + void saveCurrentWorkspaceToSlot(workspaceId, slot).catch(() => { + setActionError("Failed to update layout."); + }); + }, + }, + ...(slotConfig.keybindOverride + ? ([ + { + label: "Reset hotkey to default", + emoji: "↩️", + onClick: () => { + void setSlotKeybindOverride(slot, undefined).catch(() => { + setActionError("Failed to reset hotkey."); + }); + }, + }, + ] as const) + : []), + { + label: "Delete layout", + emoji: "🗑️", + onClick: () => { + const ok = confirm(`Delete layout "${preset.name}"?`); + if (!ok) return; + + setActionError(null); + + void (async () => { + let failed = false; + + try { + await clearSlot(slot); + } catch { + failed = true; + } + + try { + await setSlotKeybindOverride(slot, undefined); + } catch { + failed = true; + } + + if (failed) { + setActionError("Failed to delete layout."); + } + })(); + }, + }, + ]; + return (
-
-
-
Slot {slot}
-
- {assignedPreset ? assignedPreset.name : "Empty"} +
+
+
Slot {slot}
+ +
+ {isEditingName ? ( + + setEditingName({ ...editingName, value: e.target.value }) + } + onKeyDown={(e) => { + stopKeyboardPropagation(e); + + if (e.key === "Enter") { + e.preventDefault(); + void submitRename(slot, editingName.value); + } else if (e.key === "Escape") { + e.preventDefault(); + setEditingName(null); + setNameError(null); + } + }} + onBlur={() => void submitRename(slot, editingName.value)} + autoFocus + aria-label={`Rename layout Slot ${slot}`} + /> + ) : ( + + + { + e.stopPropagation(); + setActionError(null); + setCapturingSlot(null); + setCaptureError(null); + setEditingName({ + slot, + value: preset.name, + original: preset.name, + }); + setNameError(null); + }} + title="Double-click to rename" + > + {preset.name} + + + Double-click to rename + + )}
-
- - {formatKeybind(effectiveKeybind)} - +
+ {isCapturing ? ( +
+ + Press keys… + + handleCaptureKeyDown(slot, e)} + aria-label={`Set hotkey for Slot ${slot}`} + /> +
+ ) : ( + + + { + e.preventDefault(); + e.stopPropagation(); + + setActionError(null); + setEditingName(null); + setNameError(null); + setCapturingSlot(slot); + setCaptureError(null); + }} + > + {formatKeybind(effectiveKeybind)} + + + Double-click to change hotkey + + )} + + +
-
- - - -
+ {isCapturing ? ( +
+ Press a key combo (Esc to cancel) + {captureError ?
{captureError}
: null} +
+ ) : null} -
- {capturingSlot === slot ? ( -
- Press a key combo (Esc to cancel) - {captureError ? ( -
{captureError}
- ) : null} -
- ) : ( - <> - - {slotConfig?.keybindOverride ? ( - - ) : null} - - )} -
+ {isEditingName && nameError ? ( +
{nameError}
+ ) : null}
); })}
-
+ ) : null} - { - if (!open) { - setNameDialog(null); - setNameError(null); - } - }} + - - - - - + + Add layout +
); } diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index 2d12bc1ec6..df234468be 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -194,6 +194,37 @@ export const ProvidersConfigured: AppStory = { // Layouts // ═══════════════════════════════════════════════════════════════════════════════ +/** Layouts section - empty state (no layouts configured) */ +export const LayoutsEmpty: AppStory = { + render: () => ( + + setupSettingsStory({ + layoutPresets: { + version: 2, + slots: [], + }, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "layouts"); + + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole("dialog"); + const dialogCanvas = within(dialog); + + await dialogCanvas.findByRole("heading", { name: /layout slots/i }); + + // Empty state should render no slot rows. + await dialogCanvas.findByText(/^Add layout$/i); + if (dialogCanvas.queryByText(/Slot 1/i)) { + throw new Error("Expected no slot rows to be rendered in the empty state"); + } + }, +}; + /** Layouts section - with a preset assigned to a slot */ export const LayoutsConfigured: AppStory = { render: () => ( @@ -240,10 +271,15 @@ export const LayoutsConfigured: AppStory = { const dialogCanvas = within(dialog); await dialogCanvas.findByRole("heading", { name: /layout slots/i }); - await dialogCanvas.findByText(/Slots \(1–9\)/i); // Wait for the async config load from the UILayoutsProvider. await dialogCanvas.findByText(/My Layout/i); + await dialogCanvas.findByText(/Slot 1/i); + await dialogCanvas.findByText(/^Add layout$/i); + + if (dialogCanvas.queryByText(/Slot 2/i)) { + throw new Error("Expected only configured layouts to render"); + } }, }; /** Providers section - expanded to show quick links (docs + get API key) */ From 66e6a337de5dd9a2961ff559c7fdd7c0a4722895 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 19 Jan 2026 11:28:39 +0100 Subject: [PATCH 13/18] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20remove=20redun?= =?UTF-8?q?dant=20layouts=20header=20add=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I5774f8022e81ac174bbf79570a18344d971aad57 Signed-off-by: Thomas Kosiewski --- .../Settings/sections/LayoutsSection.tsx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/browser/components/Settings/sections/LayoutsSection.tsx b/src/browser/components/Settings/sections/LayoutsSection.tsx index 968f787d77..8187ec3d9c 100644 --- a/src/browser/components/Settings/sections/LayoutsSection.tsx +++ b/src/browser/components/Settings/sections/LayoutsSection.tsx @@ -259,21 +259,6 @@ export function LayoutsSection() {
- - - - - Add layout - - From d0f910c72c1fda8715c2df0e7b0ec392c0aaf98e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 19 Jan 2026 11:41:03 +0100 Subject: [PATCH 14/18] =?UTF-8?q?=F0=9F=A4=96=20fix:=20make=20kebab=20menu?= =?UTF-8?q?=20clickable=20in=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove emojis from Layout Slots overflow menu items\n- Ensure KebabMenu dropdown captures pointer events when a Radix Dialog disables outside pointer events Change-Id: Icf844f7edd4501d32b05f4ef52a1676f046e6d14 Signed-off-by: Thomas Kosiewski --- src/browser/components/KebabMenu.tsx | 2 +- src/browser/components/Settings/sections/LayoutsSection.tsx | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/browser/components/KebabMenu.tsx b/src/browser/components/KebabMenu.tsx index 81aeacde66..2b51161ac2 100644 --- a/src/browser/components/KebabMenu.tsx +++ b/src/browser/components/KebabMenu.tsx @@ -97,7 +97,7 @@ export const KebabMenu: React.FC = ({ items, className }) => { createPortal(
{ @@ -305,7 +304,6 @@ export function LayoutsSection() { ? ([ { label: "Reset hotkey to default", - emoji: "↩️", onClick: () => { void setSlotKeybindOverride(slot, undefined).catch(() => { setActionError("Failed to reset hotkey."); @@ -316,7 +314,6 @@ export function LayoutsSection() { : []), { label: "Delete layout", - emoji: "🗑️", onClick: () => { const ok = confirm(`Delete layout "${preset.name}"?`); if (!ok) return; From 6ab67519069decd60721b18e9e00ab6eb532ed87 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 19 Jan 2026 12:04:32 +0100 Subject: [PATCH 15/18] =?UTF-8?q?=F0=9F=A4=96=20feat:=20allow=20unlimited?= =?UTF-8?q?=20layout=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Allow layout slots beyond 9 (1–9 keep default hotkeys; 10+ support custom overrides)\n- Fix delete reappearing by fetching latest config before writes\n- Move Apply into overflow menu and add inline reset control while capturing hotkeys Change-Id: Id57a08f5ac2581757f788bbdd422c2f815c9ef31 Signed-off-by: Thomas Kosiewski --- src/browser/App.tsx | 21 ++- .../Settings/sections/LayoutsSection.tsx | 161 +++++++++++------- src/browser/contexts/UILayoutsContext.tsx | 28 +-- src/browser/stories/App.settings.stories.tsx | 25 +++ src/browser/utils/commands/sources.ts | 5 +- src/browser/utils/uiLayouts.ts | 11 +- src/common/orpc/schemas/uiLayouts.ts | 2 +- src/common/types/uiLayouts.ts | 19 +-- 8 files changed, 180 insertions(+), 92 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 8d1ce27769..11b6dfe65a 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -580,7 +580,7 @@ function AppInner() { } const keybind = getEffectiveSlotKeybind(layoutPresets, slot); - if (!matchesKeybind(e, keybind)) { + if (!keybind || !matchesKeybind(e, keybind)) { continue; } @@ -590,6 +590,25 @@ function AppInner() { }); return; } + + // Custom overrides for additional slots (10+). + for (const slotConfig of layoutPresets.slots) { + if (slotConfig.slot <= 9) { + continue; + } + if (!slotConfig.preset || !slotConfig.keybindOverride) { + continue; + } + if (!matchesKeybind(e, slotConfig.keybindOverride)) { + continue; + } + + e.preventDefault(); + void applySlotToWorkspace(selectedWorkspace.workspaceId, slotConfig.slot).catch(() => { + // Best-effort only. + }); + return; + } }; window.addEventListener("keydown", handleKeyDownCapture, { capture: true }); diff --git a/src/browser/components/Settings/sections/LayoutsSection.tsx b/src/browser/components/Settings/sections/LayoutsSection.tsx index d6d7a18148..6966aab9b8 100644 --- a/src/browser/components/Settings/sections/LayoutsSection.tsx +++ b/src/browser/components/Settings/sections/LayoutsSection.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; -import { Plus } from "lucide-react"; +import { Plus, X } from "lucide-react"; import { Button } from "@/browser/components/ui/button"; +import assert from "@/common/utils/assert"; import { KebabMenu, type KebabMenuItem } from "@/browser/components/KebabMenu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; @@ -125,11 +126,29 @@ export function LayoutsSection() { ? `${selectedWorkspace.projectName}/${selectedWorkspace.namedWorkspacePath.split("/").pop() ?? selectedWorkspace.namedWorkspacePath}` : null; - const effectiveSlotKeybinds = useMemo(() => { - return ([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map((slot) => ({ - slot, - keybind: getEffectiveSlotKeybind(layoutPresets, slot), - })); + const existingKeybinds = useMemo(() => { + const existing: Array<{ slot: LayoutSlotNumber; keybind: Keybind }> = []; + + // Built-in defaults for Slots 1–9 are treated as "reserved" regardless of whether a preset + // is assigned (so users don't accidentally create conflicts for later). + for (const slot of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) { + const keybind = getEffectiveSlotKeybind(layoutPresets, slot); + assert(keybind, `Slot ${slot} must have a default keybind`); + existing.push({ slot, keybind }); + } + + // Additional slots only participate in conflict detection if they have a custom override. + for (const slotConfig of layoutPresets.slots) { + if (slotConfig.slot <= 9) { + continue; + } + if (!slotConfig.keybindOverride) { + continue; + } + existing.push({ slot: slotConfig.slot, keybind: slotConfig.keybindOverride }); + } + + return existing; }, [layoutPresets]); const visibleSlots = useMemo(() => { @@ -141,21 +160,20 @@ export function LayoutsSection() { .sort((a, b) => a.slot - b.slot); }, [layoutPresets]); - const nextFreeSlot = useMemo((): LayoutSlotNumber | null => { - const used = new Set(); + const nextSlotNumber = useMemo((): LayoutSlotNumber => { + const used = new Set(); for (const slot of layoutPresets.slots) { if (slot.preset) { used.add(slot.slot); } } - for (const slot of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) { - if (!used.has(slot)) { - return slot; - } + let candidate = 1; + while (used.has(candidate)) { + candidate += 1; } - return null; + return candidate; }, [layoutPresets]); const submitRename = async (slot: LayoutSlotNumber, nextName: string): Promise => { @@ -182,18 +200,13 @@ export function LayoutsSection() { return; } - if (!nextFreeSlot) { - setActionError("All 9 layout slots are used."); - return; - } - try { const preset = await saveCurrentWorkspaceToSlot( workspaceId, - nextFreeSlot, - `Layout ${nextFreeSlot}` + nextSlotNumber, + `Layout ${nextSlotNumber}` ); - setEditingName({ slot: nextFreeSlot, value: preset.name, original: preset.name }); + setEditingName({ slot: nextSlotNumber, value: preset.name, original: preset.name }); setNameError(null); } catch { setActionError("Failed to add layout."); @@ -223,7 +236,7 @@ export function LayoutsSection() { const error = validateSlotKeybindOverride({ slot, keybind: captured, - existing: effectiveSlotKeybinds, + existing: existingKeybinds, }); if (error) { @@ -245,7 +258,8 @@ export function LayoutsSection() {

Layout Slots

- Each slot stores a layout snapshot. Apply with Ctrl/Cmd+Alt+1..9 (customizable). + Slots 1–9 have default Ctrl/Cmd+Alt+1..9 hotkeys. Additional layouts can be added and + assigned custom hotkeys.
{selectedWorkspaceLabel ? (
@@ -284,6 +298,18 @@ export function LayoutsSection() { const isCapturing = capturingSlot === slot; const menuItems: KebabMenuItem[] = [ + { + label: "Apply", + disabled: !workspaceId, + tooltip: workspaceId ? undefined : "Select a workspace to apply layouts.", + onClick: () => { + setActionError(null); + if (!workspaceId) return; + void applySlotToWorkspace(workspaceId, slot).catch(() => { + setActionError("Failed to apply layout."); + }); + }, + }, { label: "Update from current workspace", disabled: !workspaceId, @@ -300,18 +326,6 @@ export function LayoutsSection() { }); }, }, - ...(slotConfig.keybindOverride - ? ([ - { - label: "Reset hotkey to default", - onClick: () => { - void setSlotKeybindOverride(slot, undefined).catch(() => { - setActionError("Failed to reset hotkey."); - }); - }, - }, - ] as const) - : []), { label: "Delete layout", onClick: () => { @@ -406,16 +420,54 @@ export function LayoutsSection() {
{isCapturing ? ( -
- - Press keys… - - handleCaptureKeyDown(slot, e)} - aria-label={`Set hotkey for Slot ${slot}`} - /> +
+
+ + Press keys… + + handleCaptureKeyDown(slot, e)} + aria-label={`Set hotkey for Slot ${slot}`} + /> +
+ + {slotConfig.keybindOverride ? ( + + + + + + {slot <= 9 ? "Reset to default" : "Clear hotkey"} + + + ) : null}
) : ( @@ -433,28 +485,13 @@ export function LayoutsSection() { setCaptureError(null); }} > - {formatKeybind(effectiveKeybind)} + {effectiveKeybind ? formatKeybind(effectiveKeybind) : "No hotkey"} Double-click to change hotkey )} - -
@@ -479,7 +516,7 @@ export function LayoutsSection() { variant="secondary" size="lg" className="w-full" - disabled={!workspaceId || !nextFreeSlot} + disabled={!workspaceId} onClick={() => void handleAddLayout()} > diff --git a/src/browser/contexts/UILayoutsContext.tsx b/src/browser/contexts/UILayoutsContext.tsx index a6e4bbfc99..d5e2832904 100644 --- a/src/browser/contexts/UILayoutsContext.tsx +++ b/src/browser/contexts/UILayoutsContext.tsx @@ -91,20 +91,24 @@ export function UILayoutsProvider(props: { children: ReactNode }) { return layoutPresets; } - if (loaded && !loadFailed) { - return layoutPresets; - } - - // Avoid overwriting an existing config with defaults before the initial load completes. - const remote = await api.uiLayouts.getAll(); - const normalized = getLayoutsConfigOrDefault(remote); + // Always fetch the latest config right before a write. + // + // This prevents stale in-memory state (captured by closures) from accidentally overwriting a + // newer config when multiple writes happen in sequence (e.g., delete layout → clear hotkey). + try { + const remote = await api.uiLayouts.getAll(); + const normalized = getLayoutsConfigOrDefault(remote); - setLayoutPresets(normalized); - setLoaded(true); - setLoadFailed(false); + setLayoutPresets(normalized); + setLoaded(true); + setLoadFailed(false); - return normalized; - }, [api, layoutPresets, loaded, loadFailed]); + return normalized; + } catch { + // Best-effort fallback: don't block writes if the config fetch fails. + return layoutPresets; + } + }, [api, layoutPresets]); const saveAll = useCallback( async (next: LayoutPresetsConfig): Promise => { diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index df234468be..88095a186f 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -257,6 +257,29 @@ export const LayoutsConfigured: AppStory = { }, }, }, + { + slot: 10, + preset: { + id: "preset-10", + name: "Extra Layout", + leftSidebarCollapsed: false, + rightSidebar: { + collapsed: true, + width: { mode: "px", value: 400 }, + layout: { + version: 1, + nextId: 2, + focusedTabsetId: "tabset-1", + root: { + type: "tabset", + id: "tabset-1", + tabs: ["costs"], + activeTab: "costs", + }, + }, + }, + }, + }, ], }, }) @@ -274,7 +297,9 @@ export const LayoutsConfigured: AppStory = { // Wait for the async config load from the UILayoutsProvider. await dialogCanvas.findByText(/My Layout/i); + await dialogCanvas.findByText(/Extra Layout/i); await dialogCanvas.findByText(/Slot 1/i); + await dialogCanvas.findByText(/Slot 10/i); await dialogCanvas.findByText(/^Add layout$/i); if (dialogCanvas.queryByText(/Slot 2/i)) { diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 95f99ad790..763cbb17d5 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -3,6 +3,7 @@ import type { CommandAction } from "@/browser/contexts/CommandRegistryContext"; import type { APIClient } from "@/browser/contexts/API"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { THINKING_LEVELS, type ThinkingLevel } from "@/common/types/thinking"; +import assert from "@/common/utils/assert"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { getAutoRetryKey, @@ -508,7 +509,9 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi for (const slot of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) { const preset = getPresetForSlot(config, slot); - const shortcutHint = formatKeybind(getEffectiveSlotKeybind(config, slot)); + const keybind = getEffectiveSlotKeybind(config, slot); + assert(keybind, `Slot ${slot} must have a default keybind`); + const shortcutHint = formatKeybind(keybind); list.push({ id: CommandIds.layoutApplySlot(slot), diff --git a/src/browser/utils/uiLayouts.ts b/src/browser/utils/uiLayouts.ts index 4f5353c21b..e0afa6b06d 100644 --- a/src/browser/utils/uiLayouts.ts +++ b/src/browser/utils/uiLayouts.ts @@ -50,14 +50,19 @@ export function createLayoutPresetId(): string { return id; } -export function getDefaultSlotKeybind(slot: LayoutSlotNumber): Keybind { - return { key: String(slot), ctrl: true, alt: true }; +export function getDefaultSlotKeybind(slot: LayoutSlotNumber): Keybind | undefined { + // Reserve 1–9 for the built-in Ctrl/Cmd+Alt+[1-9] slot hotkeys. + if (slot >= 1 && slot <= 9) { + return { key: String(slot), ctrl: true, alt: true }; + } + + return undefined; } export function getEffectiveSlotKeybind( config: LayoutPresetsConfig, slot: LayoutSlotNumber -): Keybind { +): Keybind | undefined { const override = config.slots.find((s) => s.slot === slot)?.keybindOverride; return override ?? getDefaultSlotKeybind(slot); } diff --git a/src/common/orpc/schemas/uiLayouts.ts b/src/common/orpc/schemas/uiLayouts.ts index d6d8bc47b2..1e08028989 100644 --- a/src/common/orpc/schemas/uiLayouts.ts +++ b/src/common/orpc/schemas/uiLayouts.ts @@ -91,7 +91,7 @@ export const LayoutPresetSchema = z export const LayoutSlotSchema = z .object({ - slot: z.number().int().min(1).max(9), + slot: z.number().int().min(1), preset: LayoutPresetSchema.optional(), keybindOverride: KeybindSchema.optional(), }) diff --git a/src/common/types/uiLayouts.ts b/src/common/types/uiLayouts.ts index 8bb82a6d64..09db396a0b 100644 --- a/src/common/types/uiLayouts.ts +++ b/src/common/types/uiLayouts.ts @@ -2,7 +2,12 @@ import assert from "@/common/utils/assert"; import type { Keybind } from "@/common/types/keybind"; import { hasModifierKeybind, normalizeKeybind } from "@/common/types/keybind"; -export type LayoutSlotNumber = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; +/** + * Layout slots are 1-indexed and unbounded. + * + * Slots 1–9 are reserved for the default Ctrl/Cmd+Alt+1..9 hotkeys. + */ +export type LayoutSlotNumber = number; export interface LayoutSlot { slot: LayoutSlotNumber; @@ -69,17 +74,7 @@ export const DEFAULT_LAYOUT_PRESETS_CONFIG: LayoutPresetsConfig = { }; function isLayoutSlotNumber(value: unknown): value is LayoutSlotNumber { - return ( - value === 1 || - value === 2 || - value === 3 || - value === 4 || - value === 5 || - value === 6 || - value === 7 || - value === 8 || - value === 9 - ); + return typeof value === "number" && Number.isInteger(value) && value >= 1; } function normalizeOptionalNonEmptyString(value: unknown): string | undefined { From bd743b509c9798e156d55b2e425240c0e784ae8c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 19 Jan 2026 12:21:52 +0100 Subject: [PATCH 16/18] =?UTF-8?q?=F0=9F=A4=96=20fix:=20shift=20layout=20sl?= =?UTF-8?q?ots=20on=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I0c5aae82599d33c5c9f6ae28021086e074d19177 Signed-off-by: Thomas Kosiewski --- .../Settings/sections/LayoutsSection.tsx | 64 +++++++------------ src/browser/contexts/UILayoutsContext.tsx | 11 ++-- src/browser/utils/uiLayouts.ts | 33 ++++++++++ 3 files changed, 61 insertions(+), 47 deletions(-) diff --git a/src/browser/components/Settings/sections/LayoutsSection.tsx b/src/browser/components/Settings/sections/LayoutsSection.tsx index 6966aab9b8..034f60086e 100644 --- a/src/browser/components/Settings/sections/LayoutsSection.tsx +++ b/src/browser/components/Settings/sections/LayoutsSection.tsx @@ -101,11 +101,10 @@ export function LayoutsSection() { layoutPresets, loaded, loadFailed, - refresh, applySlotToWorkspace, saveCurrentWorkspaceToSlot, renameSlot, - clearSlot, + deleteSlot, setSlotKeybindOverride, } = useUILayouts(); const { selectedWorkspace } = useWorkspaceContext(); @@ -254,29 +253,22 @@ export function LayoutsSection() { return (
-
-
-

Layout Slots

-
- Slots 1–9 have default Ctrl/Cmd+Alt+1..9 hotkeys. Additional layouts can be added and - assigned custom hotkeys. -
- {selectedWorkspaceLabel ? ( -
- Selected workspace: {selectedWorkspaceLabel} -
- ) : ( -
- Select a workspace to capture/apply layouts. -
- )} +
+

Layout Slots

+
+ Layouts are saved globally and can be applied to any workspace.
- -
- +
+ Slots 1–9 have default Ctrl/Cmd+Alt+1..9 hotkeys. Additional layouts can be added and + assigned custom hotkeys.
+ {selectedWorkspaceLabel ? ( +
Target workspace: {selectedWorkspaceLabel}
+ ) : ( +
+ Select a workspace to capture or apply layouts. +
+ )}
{!loaded ?
Loading…
: null} @@ -334,25 +326,13 @@ export function LayoutsSection() { setActionError(null); - void (async () => { - let failed = false; - - try { - await clearSlot(slot); - } catch { - failed = true; - } - - try { - await setSlotKeybindOverride(slot, undefined); - } catch { - failed = true; - } - - if (failed) { - setActionError("Failed to delete layout."); - } - })(); + setEditingName(null); + setCapturingSlot(null); + setCaptureError(null); + + void deleteSlot(slot).catch(() => { + setActionError("Failed to delete layout."); + }); }, }, ]; diff --git a/src/browser/contexts/UILayoutsContext.tsx b/src/browser/contexts/UILayoutsContext.tsx index d5e2832904..f9b23bb09c 100644 --- a/src/browser/contexts/UILayoutsContext.tsx +++ b/src/browser/contexts/UILayoutsContext.tsx @@ -19,6 +19,7 @@ import { import { applyLayoutPresetToWorkspace, createPresetFromCurrentWorkspace, + deleteSlotAndShiftFollowingSlots, getLayoutsConfigOrDefault, getPresetForSlot, updateSlotKeybindOverride, @@ -43,7 +44,7 @@ interface UILayoutsContextValue { ) => Promise; renameSlot: (slot: LayoutSlotNumber, newName: string) => Promise; - clearSlot: (slot: LayoutSlotNumber) => Promise; + deleteSlot: (slot: LayoutSlotNumber) => Promise; setSlotKeybindOverride: (slot: LayoutSlotNumber, keybind: Keybind | undefined) => Promise; } @@ -189,10 +190,10 @@ export function UILayoutsProvider(props: { children: ReactNode }) { [getConfigForWrite, saveAll] ); - const clearSlot = useCallback( + const deleteSlot = useCallback( async (slot: LayoutSlotNumber): Promise => { const base = await getConfigForWrite(); - await saveAll(updateSlotPreset(base, slot, undefined)); + await saveAll(deleteSlotAndShiftFollowingSlots(base, slot)); }, [getConfigForWrite, saveAll] ); @@ -215,7 +216,7 @@ export function UILayoutsProvider(props: { children: ReactNode }) { applySlotToWorkspace, saveCurrentWorkspaceToSlot, renameSlot, - clearSlot, + deleteSlot, setSlotKeybindOverride, }), [ @@ -227,7 +228,7 @@ export function UILayoutsProvider(props: { children: ReactNode }) { applySlotToWorkspace, saveCurrentWorkspaceToSlot, renameSlot, - clearSlot, + deleteSlot, setSlotKeybindOverride, ] ); diff --git a/src/browser/utils/uiLayouts.ts b/src/browser/utils/uiLayouts.ts index e0afa6b06d..3bfee8ef9c 100644 --- a/src/browser/utils/uiLayouts.ts +++ b/src/browser/utils/uiLayouts.ts @@ -504,3 +504,36 @@ export function updateSlotKeybindOverride( export function getLayoutsConfigOrDefault(value: unknown): LayoutPresetsConfig { return normalizeLayoutPresetsConfig(value); } + +export function deleteSlotAndShiftFollowingSlots( + config: LayoutPresetsConfig, + slot: LayoutSlotNumber +): LayoutPresetsConfig { + assert( + Number.isInteger(slot) && slot >= 1, + "deleteSlotAndShiftFollowingSlots: slot must be a positive integer" + ); + + const normalized = normalizeLayoutPresetsConfig(config); + const hasSlot = normalized.slots.some((s) => s.slot === slot); + if (!hasSlot) { + return normalized; + } + + const nextSlots = normalized.slots + .filter((s) => s.slot !== slot) + .map((s) => { + if (s.slot > slot) { + return { + ...s, + slot: s.slot - 1, + }; + } + return s; + }); + + return normalizeLayoutPresetsConfig({ + ...normalized, + slots: nextSlots, + }); +} From c9dd69f5791f916a651c7df5609bc0e1fcd0e210 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 19 Jan 2026 12:29:42 +0100 Subject: [PATCH 17/18] =?UTF-8?q?=F0=9F=A4=96=20tests:=20fix=20layouts=20s?= =?UTF-8?q?tory=20slot=20text=20match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I7f8abd865e5615c3abcd24dab696f73e8cdc53b8 Signed-off-by: Thomas Kosiewski --- src/browser/stories/App.settings.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index 88095a186f..4a18d4950f 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -298,8 +298,8 @@ export const LayoutsConfigured: AppStory = { // Wait for the async config load from the UILayoutsProvider. await dialogCanvas.findByText(/My Layout/i); await dialogCanvas.findByText(/Extra Layout/i); - await dialogCanvas.findByText(/Slot 1/i); - await dialogCanvas.findByText(/Slot 10/i); + await dialogCanvas.findByText(/^Slot 1$/i); + await dialogCanvas.findByText(/^Slot 10$/i); await dialogCanvas.findByText(/^Add layout$/i); if (dialogCanvas.queryByText(/Slot 2/i)) { From a6efccc9554f0acc20bfe59d26f33d6e59faff3c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 19 Jan 2026 12:59:09 +0100 Subject: [PATCH 18/18] =?UTF-8?q?=F0=9F=A4=96=20fix:=20hide=20target=20wor?= =?UTF-8?q?kspace=20label=20when=20selected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I59c1c2f88b94c23ff5670dc925b223b579c92f1c Signed-off-by: Thomas Kosiewski --- src/browser/components/Settings/sections/LayoutsSection.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/browser/components/Settings/sections/LayoutsSection.tsx b/src/browser/components/Settings/sections/LayoutsSection.tsx index 034f60086e..6b55484495 100644 --- a/src/browser/components/Settings/sections/LayoutsSection.tsx +++ b/src/browser/components/Settings/sections/LayoutsSection.tsx @@ -262,9 +262,7 @@ export function LayoutsSection() { Slots 1–9 have default Ctrl/Cmd+Alt+1..9 hotkeys. Additional layouts can be added and assigned custom hotkeys.
- {selectedWorkspaceLabel ? ( -
Target workspace: {selectedWorkspaceLabel}
- ) : ( + {selectedWorkspaceLabel ? null : (
Select a workspace to capture or apply layouts.