fix: settings pane close affordance + sidebar toggle

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:50:25 +00:00
parent b6469055d8
commit 1ecccc112f
7 changed files with 64 additions and 28 deletions

View File

@@ -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 {

View File

@@ -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 ? (

View File

@@ -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<Section>('session');
return (
@@ -99,6 +100,15 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, is
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
)}
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close settings"
title="Close (Esc)"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto">

View File

@@ -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';
}

View File

@@ -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;

View File

@@ -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;
}
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,

View File

@@ -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]);