Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
122 changes: 108 additions & 14 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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 {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -789,17 +881,19 @@ function App() {
return (
<ExperimentsProvider>
<FeatureFlagsProvider>
<TooltipProvider delayDuration={200}>
<SettingsProvider>
<SplashScreenProvider>
<TutorialProvider>
<CommandRegistryProvider>
<AppInner />
</CommandRegistryProvider>
</TutorialProvider>
</SplashScreenProvider>
</SettingsProvider>
</TooltipProvider>
<UILayoutsProvider>
<TooltipProvider delayDuration={200}>
<SettingsProvider>
<SplashScreenProvider>
<TutorialProvider>
<CommandRegistryProvider>
<AppInner />
</CommandRegistryProvider>
</TutorialProvider>
</SplashScreenProvider>
</SettingsProvider>
</TooltipProvider>
</UILayoutsProvider>
</FeatureFlagsProvider>
</ExperimentsProvider>
);
Expand Down
2 changes: 1 addition & 1 deletion src/browser/components/KebabMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export const KebabMenu: React.FC<KebabMenuProps> = ({ items, className }) => {
createPortal(
<div
ref={menuRef}
className="bg-dark border-border-light fixed z-[10000] min-w-40 overflow-hidden rounded-[3px] border shadow-[0_4px_16px_rgba(0,0,0,0.8)]"
className="bg-dark border-border-light pointer-events-auto fixed z-[10000] min-w-40 overflow-hidden rounded-[3px] border shadow-[0_4px_16px_rgba(0,0,0,0.8)]"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
Expand Down
4 changes: 3 additions & 1 deletion src/browser/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,9 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
);

// Manual collapse state (persisted globally)
const [collapsed, setCollapsed] = usePersistedState<boolean>(RIGHT_SIDEBAR_COLLAPSED_KEY, false);
const [collapsed, setCollapsed] = usePersistedState<boolean>(RIGHT_SIDEBAR_COLLAPSED_KEY, false, {
listener: true,
});

// Stats tab feature flag
const { statsTabState } = useFeatureFlags();
Expand Down
19 changes: 18 additions & 1 deletion src/browser/components/Settings/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -43,6 +54,12 @@ const SECTIONS: SettingsSection[] = [
icon: <Cpu className="h-4 w-4" />,
component: ModelsSection,
},
{
id: "layouts",
label: "Layouts",
icon: <Layout className="h-4 w-4" />,
component: LayoutsSection,
},
{
id: "experiments",
label: "Experiments",
Expand Down
Loading
Loading