From 21384cce5b94dc235829dfcdd360c3377b0ad5e8 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 29 May 2026 20:20:24 +0000 Subject: [PATCH] web: fix Settings pane unreachable on mobile (push ?pane= atomically) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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) --- apps/web/src/hooks/useWorkspacePanes.ts | 18 +++++++++++++----- apps/web/src/pages/Session.tsx | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts index 8a0f72d..101193f 100644 --- a/apps/web/src/hooks/useWorkspacePanes.ts +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -50,8 +50,8 @@ export function activePaneChatId(pane: WorkspacePane): string | undefined { // v1.9: settings pane factory. No chats, no state beyond identity — the // SettingsPane component renders Session/Project sections from the // surrounding session/project. -function settingsPane(): WorkspacePane { - return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 }; +function settingsPane(id: string = generateId()): WorkspacePane { + return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 }; } // 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 // 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; + toggleSettingsPane: () => string | null; removePane: (idx: number) => void; removeChatFromPanes: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void; @@ -492,14 +492,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { return success ? newPaneId : null; }, [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) => { const existingIdx = prev.findIndex((p) => p.kind === 'settings'); if (existingIdx < 0) { - const next = [...prev, settingsPane()]; + const next = [...prev, settingsPane(newPaneId)]; setActivePaneIdx(next.length - 1); + openedId = newPaneId; return next; } + openedId = null; if (prev.length <= 1) { setActivePaneIdx(0); return [emptyPane()]; @@ -508,6 +515,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); return next; }); + return openedId; }, []); const removePane = useCallback((idx: number) => { diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 3a4353c..ffc335b 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -123,6 +123,20 @@ function SessionInner({ sessionId }: { sessionId: string }) { }; }, [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(() => { return sessionEvents.subscribe((event) => { 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; // toggleSettingsPane opens on first click, closes on second. 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 // MobileTabSwitcher's onSwitchPane can push the same URL state and the