From 1ecccc112f7cd204484fd142a6fc09c94ddcff04 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 17 May 2026 20:50:25 +0000 Subject: [PATCH] fix: settings pane close affordance + sidebar toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.9 settings pane had no way to dismiss once opened. ChatTabBar (which owns the per-pane close X for chat panes) is skipped for settings panes, and the pane header itself only rendered the maximize toggle (desktop-only). Mobile users had zero controls beyond the section tabs. Add three close paths: - X button in SettingsPane header, visible on mobile + desktop, sits next to the maximize toggle. Tap-target sized per the v1.6 mobile convention (max-md:min-h-[44px]). - Esc when the settings pane is the active pane and no input/textarea/ dialog has focus. Maximize-restore still wins when maximized. - Sidebar Settings button is now a strict toggle: opens on first click, closes on second. Renamed openOrFocusSettingsPane → toggleSettingsPane in the panes hook. Edge case: removing the settings pane when it's the only pane left falls back to an empty pane to preserve the "always one pane" invariant. In normal flow this is unreachable (the toggle only appends), but defensive against future entry points. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/api/types.ts | 2 +- apps/web/src/components/Workspace.tsx | 20 ++++++++-- .../web/src/components/panes/SettingsPane.tsx | 14 ++++++- apps/web/src/hooks/sessionEvents.ts | 8 ++-- apps/web/src/hooks/useSidebar.ts | 4 +- apps/web/src/hooks/useWorkspacePanes.ts | 37 +++++++++++++------ apps/web/src/pages/Session.tsx | 7 ++-- 7 files changed, 64 insertions(+), 28 deletions(-) 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]);