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/App.tsx b/src/browser/App.tsx index c37e5198ec..11b6dfe65a 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -13,7 +13,13 @@ import { updatePersistedState, readPersistedState, } from "./hooks/usePersistedState"; -import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; +import { getEffectiveSlotKeybind, getPresetForSlot } from "@/browser/utils/uiLayouts"; +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"; @@ -56,6 +62,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 +83,14 @@ 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, saveCurrentWorkspaceToSlot } = useUILayouts(); const { api, status, error, authenticate } = useAPI(); const { @@ -96,7 +104,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 +467,19 @@ function AppInner() { onToggleTheme: toggleTheme, onSetTheme: setThemePreference, onOpenSettings: openSettings, + layoutPresets, + onApplyLayoutSlot: (workspaceId, slot) => { + void applySlotToWorkspace(workspaceId, slot).catch(() => { + // Best-effort only. + }); + }, + onCaptureLayoutSlot: async (workspaceId, slot, name) => { + try { + await saveCurrentWorkspaceToSlot(workspaceId, slot, name); + } catch { + // Best-effort only. + } + }, onClearTimingStats: (workspaceId: string) => workspaceStore.clearTimingStats(workspaceId), api, }; @@ -529,6 +552,75 @@ 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; + } + + // 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) { + continue; + } + + const keybind = getEffectiveSlotKeybind(layoutPresets, slot); + if (!keybind || !matchesKeybind(e, keybind)) { + continue; + } + + e.preventDefault(); + void applySlotToWorkspace(selectedWorkspace.workspaceId, slot).catch(() => { + // Best-effort only. + }); + 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 }); + 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 +881,19 @@ function App() { return ( - - - - - - - - - - - + + + + + + + + + + + + + ); 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(
= ({ ); // 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..6b55484495 --- /dev/null +++ b/src/browser/components/Settings/sections/LayoutsSection.tsx @@ -0,0 +1,505 @@ +import React, { useMemo, useState } from "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"; +import { useUILayouts } from "@/browser/contexts/UILayoutsContext"; +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"; +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, + applySlotToWorkspace, + saveCurrentWorkspaceToSlot, + renameSlot, + deleteSlot, + setSlotKeybindOverride, + } = useUILayouts(); + const { selectedWorkspace } = useWorkspaceContext(); + + const [actionError, setActionError] = useState(null); + const [editingName, setEditingName] = useState<{ + slot: LayoutSlotNumber; + value: string; + original: string; + } | null>(null); + 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 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(() => { + 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 nextSlotNumber = useMemo((): LayoutSlotNumber => { + const used = new Set(); + for (const slot of layoutPresets.slots) { + if (slot.preset) { + used.add(slot.slot); + } + } + + let candidate = 1; + while (used.has(candidate)) { + candidate += 1; + } + + return candidate; + }, [layoutPresets]); + + const submitRename = async (slot: LayoutSlotNumber, nextName: string): Promise => { + const trimmed = nextName.trim(); + if (!trimmed) { + setNameError("Name cannot be empty."); + return; + } + + try { + await renameSlot(slot, trimmed); + setEditingName(null); + setNameError(null); + } catch { + setNameError("Failed to rename."); + } + }; + + const handleAddLayout = async (): Promise => { + setActionError(null); + + if (!workspaceId) { + setActionError("Select a workspace to capture its layout."); + return; + } + + try { + const preset = await saveCurrentWorkspaceToSlot( + workspaceId, + nextSlotNumber, + `Layout ${nextSlotNumber}` + ); + setEditingName({ slot: nextSlotNumber, value: preset.name, original: preset.name }); + setNameError(null); + } catch { + setActionError("Failed to add layout."); + } + }; + + const handleCaptureKeyDown = ( + slot: LayoutSlotNumber, + e: React.KeyboardEvent + ): void => { + if (e.key === "Escape") { + e.preventDefault(); + stopKeyboardPropagation(e); + setCapturingSlot(null); + setCaptureError(null); + return; + } + + const captured = normalizeCapturedKeybind(e.nativeEvent); + if (!captured) { + return; + } + + e.preventDefault(); + stopKeyboardPropagation(e); + + const error = validateSlotKeybindOverride({ + slot, + keybind: captured, + existing: existingKeybinds, + }); + + if (error) { + setCaptureError(error); + return; + } + + void setSlotKeybindOverride(slot, captured).catch(() => { + setCaptureError("Failed to save keybind override."); + }); + + setCapturingSlot(null); + setCaptureError(null); + }; + + return ( +
+
+

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 ? null : ( +
+ Select a workspace to capture or apply layouts. +
+ )} +
+ + {!loaded ?
Loading…
: null} + {loadFailed ? ( +
+ Failed to load layouts from config. Using defaults. +
+ ) : null} + {actionError ?
{actionError}
: null} + + {visibleSlots.length > 0 ? ( +
+ {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: "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, + 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."); + }); + }, + }, + { + label: "Delete layout", + onClick: () => { + const ok = confirm(`Delete layout "${preset.name}"?`); + if (!ok) return; + + setActionError(null); + + setEditingName(null); + setCapturingSlot(null); + setCaptureError(null); + + void deleteSlot(slot).catch(() => { + setActionError("Failed to delete layout."); + }); + }, + }, + ]; + + return ( +
+
+
+
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 + + )} +
+
+ +
+ {isCapturing ? ( +
+
+ + Press keys… + + handleCaptureKeyDown(slot, e)} + aria-label={`Set hotkey for Slot ${slot}`} + /> +
+ + {slotConfig.keybindOverride ? ( + + + + + + {slot <= 9 ? "Reset to default" : "Clear hotkey"} + + + ) : null} +
+ ) : ( + + + { + e.preventDefault(); + e.stopPropagation(); + + setActionError(null); + setEditingName(null); + setNameError(null); + setCapturingSlot(slot); + setCaptureError(null); + }} + > + {effectiveKeybind ? formatKeybind(effectiveKeybind) : "No hotkey"} + + + Double-click to change hotkey + + )} + + +
+
+ + {isCapturing ? ( +
+ Press a key combo (Esc to cancel) + {captureError ?
{captureError}
: null} +
+ ) : null} + + {isEditingName && nameError ? ( +
{nameError}
+ ) : null} +
+ ); + })} +
+ ) : null} + + +
+ ); +} diff --git a/src/browser/contexts/UILayoutsContext.tsx b/src/browser/contexts/UILayoutsContext.tsx new file mode 100644 index 0000000000..f9b23bb09c --- /dev/null +++ b/src/browser/contexts/UILayoutsContext.tsx @@ -0,0 +1,237 @@ +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, + deleteSlotAndShiftFollowingSlots, + getLayoutsConfigOrDefault, + getPresetForSlot, + updateSlotKeybindOverride, + updateSlotPreset, +} 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; + + /** Capture the currently-selected workspace's layout into the given slot. */ + saveCurrentWorkspaceToSlot: ( + workspaceId: string, + slot: LayoutSlotNumber, + name?: string | null + ) => Promise; + + renameSlot: (slot: LayoutSlotNumber, newName: string) => Promise; + deleteSlot: (slot: LayoutSlotNumber) => Promise; + setSlotKeybindOverride: (slot: LayoutSlotNumber, keybind: Keybind | undefined) => 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 getConfigForWrite = useCallback(async (): Promise => { + if (!api) { + return layoutPresets; + } + + // 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); + + 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 => { + const normalized = normalizeLayoutPresetsConfig(next); + + if (!api) { + throw new Error("ORPC client not initialized"); + } + + await api.uiLayouts.saveAll({ layoutPresets: normalized }); + setLayoutPresets(normalized); + }, + [api] + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + 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 saveCurrentWorkspaceToSlot = useCallback( + async ( + workspaceId: string, + slot: LayoutSlotNumber, + name?: string | null + ): Promise => { + assert( + typeof workspaceId === "string" && workspaceId.length > 0, + "workspaceId must be non-empty" + ); + + const base = await getConfigForWrite(); + 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 renameSlot = useCallback( + async (slot: LayoutSlotNumber, newName: string): Promise => { + const trimmed = newName.trim(); + if (!trimmed) { + return; + } + + const base = await getConfigForWrite(); + const existingPreset = getPresetForSlot(base, slot); + if (!existingPreset) { + return; + } + + await saveAll(updateSlotPreset(base, slot, { ...existingPreset, name: trimmed })); + }, + [getConfigForWrite, saveAll] + ); + + const deleteSlot = useCallback( + async (slot: LayoutSlotNumber): Promise => { + const base = await getConfigForWrite(); + await saveAll(deleteSlotAndShiftFollowingSlots(base, slot)); + }, + [getConfigForWrite, saveAll] + ); + + const setSlotKeybindOverride = useCallback( + async (slot: LayoutSlotNumber, keybind: Keybind | undefined): Promise => { + const base = await getConfigForWrite(); + await saveAll(updateSlotKeybindOverride(base, slot, keybind)); + }, + [getConfigForWrite, saveAll] + ); + + const value: UILayoutsContextValue = useMemo( + () => ({ + layoutPresets, + loaded, + loadFailed, + refresh, + saveAll, + applySlotToWorkspace, + saveCurrentWorkspaceToSlot, + renameSlot, + deleteSlot, + setSlotKeybindOverride, + }), + [ + layoutPresets, + loaded, + loadFailed, + refresh, + saveAll, + applySlotToWorkspace, + saveCurrentWorkspaceToSlot, + renameSlot, + deleteSlot, + setSlotKeybindOverride, + ] + ); + + 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/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index 47ec4bc071..4a18d4950f 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,123 @@ 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: () => ( + + setupSettingsStory({ + layoutPresets: { + version: 2, + slots: [ + { + 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", + }, + }, + }, + }, + }, + { + 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", + }, + }, + }, + }, + }, + ], + }, + }) + } + /> + ), + 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 }); + + // 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)) { + throw new Error("Expected only configured layouts to render"); + } + }, +}; /** 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 }), diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 400c705403..9b86a083a8 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -60,6 +60,9 @@ 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, + 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 d69a9d6dc2..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, @@ -12,6 +13,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 +73,15 @@ export interface BuildSourcesParams { onToggleTheme: () => void; onSetTheme: (theme: ThemeMode) => void; onOpenSettings?: (section?: string) => void; + + // Layout slots + layoutPresets?: LayoutPresetsConfig | null; + onApplyLayoutSlot?: (workspaceId: string, slot: LayoutSlotNumber) => void; + onCaptureLayoutSlot?: ( + workspaceId: string, + slot: LayoutSlotNumber, + name: string + ) => 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,66 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi return list; }); + // Layout slots + 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 keybind = getEffectiveSlotKeybind(config, slot); + assert(keybind, `Slot ${slot} must have a default keybind`); + const shortcutHint = formatKeybind(keybind); + + 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.onCaptureLayoutSlot) { + list.push({ + id: CommandIds.layoutCaptureSlot(slot), + title: `Layout: Capture current to Slot ${slot}…`, + subtitle: preset ? preset.name : "Empty", + section: section.layouts, + 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()); + }, + }, + }); + } + } + + 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..3bfee8ef9c --- /dev/null +++ b/src/browser/utils/uiLayouts.ts @@ -0,0 +1,539 @@ +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 { + addTabToFocusedTabset, + collectAllTabs, + findFirstTabsetId, + findTabset, + getDefaultRightSidebarLayoutState, + parseRightSidebarLayoutState, + type RightSidebarLayoutNode, + type RightSidebarLayoutState, +} from "@/browser/utils/rightSidebarLayout"; +import { isTabType, makeTerminalTabType, 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 | 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 | undefined { + const override = config.slots.find((s) => s.slot === slot)?.keybindOverride; + return override ?? getDefaultSlotKeybind(slot); +} + +export function getPresetForSlot( + config: LayoutPresetsConfig, + slot: LayoutSlotNumber +): LayoutPreset | undefined { + return config.slots.find((s) => s.slot === slot)?.preset; +} + +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 { + // 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 { + 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); + + 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); +} + +export function updateSlotPreset( + config: LayoutPresetsConfig, + slot: LayoutSlotNumber, + 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); + + const keybindOverride = existing?.keybindOverride; + if (preset || keybindOverride) { + nextSlots.push({ + slot, + ...(preset ? { preset } : {}), + ...(keybindOverride ? { keybindOverride } : {}), + }); + } + + 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 preset = existing?.preset; + + const nextSlots = normalized.slots.filter((s) => s.slot !== slot); + + if (preset || keybindOverride) { + nextSlots.push({ + slot, + ...(preset ? { preset } : {}), + ...(keybindOverride ? { keybindOverride } : {}), + }); + } + + return { + ...normalized, + slots: nextSlots.sort((a, b) => a.slot - b.slot), + }; +} + +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, + }); +} 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..1e08028989 --- /dev/null +++ b/src/common/orpc/schemas/uiLayouts.ts @@ -0,0 +1,105 @@ +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), + preset: LayoutPresetSchema.optional(), + keybindOverride: KeybindSchema.optional(), + }) + .strict(); + +export const LayoutPresetsConfigSchema = z + .object({ + version: z.literal(2), + 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..9bfa26bc78 --- /dev/null +++ b/src/common/types/keybind.ts @@ -0,0 +1,60 @@ +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 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; + } + + const record = raw as Record; + + const rawKey = typeof record.key === "string" ? record.key : ""; + const key = rawKey === " " ? rawKey : rawKey.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..09db396a0b --- /dev/null +++ b/src/common/types/uiLayouts.ts @@ -0,0 +1,365 @@ +import assert from "@/common/utils/assert"; +import type { Keybind } from "@/common/types/keybind"; +import { hasModifierKeybind, normalizeKeybind } from "@/common/types/keybind"; + +/** + * 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; + /** The layout stored in this slot, if any. */ + preset?: LayoutPreset; + /** Optional keybind override for applying this slot. */ + 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: 2; + slots: LayoutSlot[]; +} + +export const DEFAULT_LAYOUT_PRESETS_CONFIG: LayoutPresetsConfig = { + version: 2, + slots: [], +}; + +function isLayoutSlotNumber(value: unknown): value is LayoutSlotNumber { + return typeof value === "number" && Number.isInteger(value) && value >= 1; +} + +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 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 + ? hasModifierKeybind(keybindOverrideRaw) + ? keybindOverrideRaw + : undefined + : undefined; + + 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 === 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; + presetsById.set(preset.id, preset); + } + + const slotsArray = Array.isArray(record.slots) ? record.slots : []; + const slotsByNumber = new Map(); + + for (const entry of slotsArray) { + const slot = normalizeLayoutSlotV1(entry); + if (!slot) continue; + + const preset = slot.presetId ? presetsById.get(slot.presetId) : undefined; + if (!preset && !slot.keybindOverride) { + continue; + } + + slotsByNumber.set(slot.slot, { + slot: slot.slot, + preset, + keybindOverride: slot.keybindOverride, + }); + } + + const slots = Array.from(slotsByNumber.values()).sort((a, b) => a.slot - b.slot); + + const result: LayoutPresetsConfig = { + version: 2, + slots, + }; + + 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 === 2, "isLayoutPresetsConfigEmpty: version must be 2"); + + for (const slot of value.slots) { + if (slot.preset || 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)