web: fix Settings pane unreachable on mobile (push ?pane= atomically)

Opening the settings pane on mobile set activePaneIdx, but the ?pane= URL-sync
effect snapped it back to the chat pane on the panes change, so the pane never
showed. toggleSettingsPane now returns the new pane id (id generated outside the
updater, strict-mode safe); Session's toggleSettingsAndSync pushes ?pane=<id> on
mobile when opening (and drops it on close) so the sync effect keeps it active —
mirrors the existing addPaneAndSwitch pattern. Desktop unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 20:20:24 +00:00
parent 920f8b75a6
commit 21384cce5b
2 changed files with 29 additions and 7 deletions

View File

@@ -50,8 +50,8 @@ export function activePaneChatId(pane: WorkspacePane): string | undefined {
// v1.9: settings pane factory. No chats, no state beyond identity — the // v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the // SettingsPane component renders Session/Project sections from the
// surrounding session/project. // surrounding session/project.
function settingsPane(): WorkspacePane { function settingsPane(id: string = generateId()): WorkspacePane {
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 }; return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 };
} }
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with // v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
@@ -135,7 +135,7 @@ export interface UseWorkspacePanesResult {
// Open-on-first-click, close-on-second-click. Singleton — settings panes // Open-on-first-click, close-on-second-click. Singleton — settings panes
// don't count toward MAX_PANES. Closing the only remaining pane (edge case) // 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. // falls back to an empty pane to preserve the "always one pane" invariant.
toggleSettingsPane: () => void; toggleSettingsPane: () => string | null;
removePane: (idx: number) => void; removePane: (idx: number) => void;
removeChatFromPanes: (chatId: string) => void; removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void;
@@ -492,14 +492,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return success ? newPaneId : null; return success ? newPaneId : null;
}, [seedPaneChat]); }, [seedPaneChat]);
const toggleSettingsPane = useCallback(() => { // Returns the new settings pane id when one is OPENED (so mobile callers can
// push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
// Id generated outside the updater so a strict-mode double-invoke agrees.
const toggleSettingsPane = useCallback((): string | null => {
const newPaneId = generateId();
let openedId: string | null = null;
setPanes((prev) => { setPanes((prev) => {
const existingIdx = prev.findIndex((p) => p.kind === 'settings'); const existingIdx = prev.findIndex((p) => p.kind === 'settings');
if (existingIdx < 0) { if (existingIdx < 0) {
const next = [...prev, settingsPane()]; const next = [...prev, settingsPane(newPaneId)];
setActivePaneIdx(next.length - 1); setActivePaneIdx(next.length - 1);
openedId = newPaneId;
return next; return next;
} }
openedId = null;
if (prev.length <= 1) { if (prev.length <= 1) {
setActivePaneIdx(0); setActivePaneIdx(0);
return [emptyPane()]; return [emptyPane()];
@@ -508,6 +515,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next; return next;
}); });
return openedId;
}, []); }, []);
const removePane = useCallback((idx: number) => { const removePane = useCallback((idx: number) => {

View File

@@ -123,6 +123,20 @@ function SessionInner({ sessionId }: { sessionId: string }) {
}; };
}, [sessionId]); }, [sessionId]);
// v2.3: opening the settings pane on mobile must push ?pane= atomically, or
// the URL-sync effect below snaps activePaneIdx back to the chat pane and the
// settings pane never shows (same fix as addPaneAndSwitch). toggleSettingsPane
// returns the new pane id when it opens (null when it closes → drop ?pane= so
// the effect falls back to pane 0). Desktop has no URL pane state — no-op.
const toggleSettingsAndSync = useCallback(() => {
const openedId = panesHook.toggleSettingsPane();
if (!isMobile) return;
const params = new URLSearchParams(location.search);
if (openedId) params.set('pane', openedId);
else params.delete('pane');
navigate(`${location.pathname}?${params.toString()}`);
}, [panesHook, isMobile, navigate, location.pathname, location.search]);
useEffect(() => { useEffect(() => {
return sessionEvents.subscribe((event) => { return sessionEvents.subscribe((event) => {
if (event.type === 'session_renamed' && event.session_id === sessionId) { if (event.type === 'session_renamed' && event.session_id === sessionId) {
@@ -156,10 +170,10 @@ function SessionInner({ sessionId }: { sessionId: string }) {
// Sidebar Settings button broadcasts this when a session is mounted; // Sidebar Settings button broadcasts this when a session is mounted;
// toggleSettingsPane opens on first click, closes on second. // toggleSettingsPane opens on first click, closes on second.
if (event.type === 'open_settings_pane') { if (event.type === 'open_settings_pane') {
panesHook.toggleSettingsPane(); toggleSettingsAndSync();
} }
}); });
}, [sessionId, editingName, navigate, project, panesHook]); }, [sessionId, editingName, navigate, project, toggleSettingsAndSync]);
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so // v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
// MobileTabSwitcher's onSwitchPane can push the same URL state and the // MobileTabSwitcher's onSwitchPane can push the same URL state and the