diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index adbc2d9..7745527 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -233,7 +233,7 @@ export interface GitMeta { // v1.9: 'settings' is an ephemeral pane kind — never persisted, always // singleton per workspace. The pane hook filters it out before writing to -// localStorage and dedupes on insertion via openOrFocusSettingsPane(). +// localStorage and dedupes on insertion via toggleSettingsPane(). export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings'; export interface WorkspacePane { diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index 52068e1..34fceb8 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -81,14 +81,27 @@ export function Workspace({ const [maximized, setMaximized] = useState(false); const settingsIdx = panes.findIndex((p) => p.kind === 'settings'); + // Esc semantics: maximized → restore; otherwise → close settings pane (only + // when it's the active pane). Bail when the user is typing in a field or + // inside an open dialog so we don't eat their cancel keystroke. useEffect(() => { - if (!maximized) return; + if (settingsIdx < 0) return; function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') setMaximized(false); + if (e.key !== 'Escape') return; + const t = e.target; + if (t instanceof HTMLElement) { + if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return; + if (t.closest('[role="dialog"]')) return; + } + if (maximized) { + setMaximized(false); + } else if (activePaneIdx === settingsIdx) { + removePane(settingsIdx); + } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [maximized]); + }, [maximized, settingsIdx, activePaneIdx, removePane]); // If the settings pane was closed (no longer in panes) while maximized, // clear the maximize state so the grid renders normally. @@ -210,6 +223,7 @@ export function Workspace({ project={project} maximized={maximized} onToggleMaximize={() => setMaximized((v) => !v)} + onClose={() => removePane(idx)} isMobile={isMobile} /> ) : pane.kind === 'chat' && pane.chatId ? ( diff --git a/apps/web/src/components/panes/SettingsPane.tsx b/apps/web/src/components/panes/SettingsPane.tsx index ab1c2e9..1fcda97 100644 --- a/apps/web/src/components/panes/SettingsPane.tsx +++ b/apps/web/src/components/panes/SettingsPane.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Archive, Maximize2, Minimize2 } from 'lucide-react'; +import { Archive, Maximize2, Minimize2, X } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import type { Project, Session } from '@/api/types'; @@ -24,6 +24,7 @@ interface Props { project: Project; maximized: boolean; onToggleMaximize: () => void; + onClose: () => void; isMobile: boolean; } @@ -65,7 +66,7 @@ function Switch({ ); } -export function SettingsPane({ session, project, maximized, onToggleMaximize, isMobile }: Props) { +export function SettingsPane({ session, project, maximized, onToggleMaximize, onClose, isMobile }: Props) { const [activeSection, setActiveSection] = useState
('session'); return ( @@ -99,6 +100,15 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, is {maximized ? : } )} +
diff --git a/apps/web/src/hooks/sessionEvents.ts b/apps/web/src/hooks/sessionEvents.ts index 243fac5..7b43482 100644 --- a/apps/web/src/hooks/sessionEvents.ts +++ b/apps/web/src/hooks/sessionEvents.ts @@ -62,10 +62,10 @@ export interface OpenChatInActivePaneEvent { chat_id: string; } -// v1.9: client-side event fired by the sidebar Settings button when a -// session is currently mounted. Session.tsx subscribes and calls -// panesHook.openOrFocusSettingsPane(). Sidebar handles the no-session case -// by navigating to /settings (themes page) directly. +// Client-side event fired by the sidebar Settings button when a session is +// currently mounted. Session.tsx subscribes and calls +// panesHook.toggleSettingsPane() (open on first click, close on second). +// Sidebar handles the no-session case by navigating to /settings directly. export interface OpenSettingsPaneEvent { type: 'open_settings_pane'; } diff --git a/apps/web/src/hooks/useSidebar.ts b/apps/web/src/hooks/useSidebar.ts index 27dbc0c..9e7a016 100644 --- a/apps/web/src/hooks/useSidebar.ts +++ b/apps/web/src/hooks/useSidebar.ts @@ -152,8 +152,8 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess // Consumed by Workspace; sidebar has no business with pane state. return prev; case 'open_settings_pane': - // v1.9: consumed by Session.tsx (calls openOrFocusSettingsPane on its - // panesHook). Sidebar data is untouched. + // Consumed by Session.tsx (calls toggleSettingsPane on its panesHook). + // Sidebar data is untouched. return prev; case 'session_archived': { let changed = false; diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts index 5d03c8d..4bea4ac 100644 --- a/apps/web/src/hooks/useWorkspacePanes.ts +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -73,10 +73,10 @@ export interface UseWorkspacePanesResult { closeAllTabs: (paneIdx: number) => void; showLandingPage: (paneIdx: number) => void; addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void; - // v1.9: idempotent open-or-focus for the settings pane singleton. Appends - // a new settings pane if none exists, otherwise just focuses the existing - // one. Always succeeds — settings panes don't count toward MAX_PANES. - openOrFocusSettingsPane: () => void; + // Open-on-first-click, close-on-second-click. Singleton — settings panes + // don't count toward MAX_PANES. Closing the only remaining pane (edge case) + // falls back to an empty pane to preserve the "always one pane" invariant. + toggleSettingsPane: () => void; removePane: (idx: number) => void; removeChatFromPanes: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void; @@ -254,22 +254,35 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { }); }, []); - const openOrFocusSettingsPane = useCallback(() => { + const toggleSettingsPane = useCallback(() => { setPanes((prev) => { const existingIdx = prev.findIndex((p) => p.kind === 'settings'); - if (existingIdx >= 0) { - setActivePaneIdx(existingIdx); - return prev; + if (existingIdx < 0) { + const next = [...prev, settingsPane()]; + setActivePaneIdx(next.length - 1); + return next; } - const next = [...prev, settingsPane()]; - setActivePaneIdx(next.length - 1); + if (prev.length <= 1) { + setActivePaneIdx(0); + return [emptyPane()]; + } + const next = prev.filter((_, i) => i !== existingIdx); + setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); return next; }); }, []); const removePane = useCallback((idx: number) => { setPanes((prev) => { - if (prev.length <= 1) return prev; + if (prev.length <= 1) { + // Settings is the only kind that can be the last pane and still need + // closing (X / Esc / sidebar toggle). Fall back to empty. + if (prev[idx]?.kind === 'settings') { + setActivePaneIdx(0); + return [emptyPane()]; + } + return prev; + } const next = prev.filter((_, i) => i !== idx); setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); return next; @@ -359,7 +372,6 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { setActivePaneIdx, activePaneIdxRef, openChatInPane, - openOrFocusSettingsPane, switchTab, removeTab, closeOtherTabs, @@ -367,6 +379,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { closeAllTabs, showLandingPage, addSplitPane, + toggleSettingsPane, removePane, removeChatFromPanes, initializeFirstChatIfEmpty, diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 19c3aba..8eb58e6 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -134,11 +134,10 @@ function SessionInner({ sessionId }: { sessionId: string }) { void api.projects.get(project.id).then(setProject).catch(() => {}); return; } - // v1.9: sidebar Settings button broadcasts this when a session is - // mounted; we own the workspace pane state, so we open/focus the - // singleton settings pane here. + // Sidebar Settings button broadcasts this when a session is mounted; + // toggleSettingsPane opens on first click, closes on second. if (event.type === 'open_settings_pane') { - panesHook.openOrFocusSettingsPane(); + panesHook.toggleSettingsPane(); } }); }, [sessionId, editingName, navigate, project, panesHook]);