diff --git a/src/__tests__/unit/panel-state-logic.test.ts b/src/__tests__/unit/panel-state-logic.test.ts new file mode 100644 index 00000000..22038a75 --- /dev/null +++ b/src/__tests__/unit/panel-state-logic.test.ts @@ -0,0 +1,77 @@ +/** + * Tests for panel open/close state logic on route changes. + * + * Bug: panel always re-opens when switching between chats, + * ignoring user's manual close. + * + * Run with: npx tsx --test src/__tests__/unit/panel-state-logic.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { computePanelOpenOnRouteChange } from '../../lib/panel-state-logic'; + +describe('computePanelOpenOnRouteChange', () => { + // --- Navigating away from chat --- + + it('closes panel when navigating away from chat detail route', () => { + const result = computePanelOpenOnRouteChange( + /* isChatDetailRoute */ false, + /* previousPanelOpen */ true, + /* wasChatDetailRoute */ true, + ); + assert.equal(result, false); + }); + + it('keeps panel closed when already closed and navigating away', () => { + const result = computePanelOpenOnRouteChange(false, false, true); + assert.equal(result, false); + }); + + // --- Entering chat from non-chat route --- + + it('opens panel when entering chat detail from non-chat route', () => { + const result = computePanelOpenOnRouteChange( + /* isChatDetailRoute */ true, + /* previousPanelOpen */ false, + /* wasChatDetailRoute */ false, + ); + assert.equal(result, true); + }); + + // --- Switching between chats (the bug scenario) --- + + it('preserves closed state when switching between chats', () => { + // User manually closed the panel, then clicks another chat + const result = computePanelOpenOnRouteChange( + /* isChatDetailRoute */ true, + /* previousPanelOpen */ false, + /* wasChatDetailRoute */ true, + ); + assert.equal(result, false, 'panel should stay closed when user closed it'); + }); + + it('preserves open state when switching between chats', () => { + // User has panel open, clicks another chat + const result = computePanelOpenOnRouteChange( + /* isChatDetailRoute */ true, + /* previousPanelOpen */ true, + /* wasChatDetailRoute */ true, + ); + assert.equal(result, true, 'panel should stay open when user left it open'); + }); + + // --- Edge cases --- + + it('opens panel when navigating from non-chat to chat even if previously closed', () => { + // From settings to a chat — panel should open regardless of previous state + const result = computePanelOpenOnRouteChange(true, false, false); + assert.equal(result, true); + }); + + it('keeps panel closed when on non-chat route regardless of previous state', () => { + const result = computePanelOpenOnRouteChange(false, false, false); + assert.equal(result, false); + }); +}); diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 7881593d..15452f12 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -32,7 +32,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [isEditingTitle, setIsEditingTitle] = useState(false); const [editTitle, setEditTitle] = useState(''); const titleInputRef = useRef(null); - const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel(); + const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle } = usePanel(); const { t } = useTranslation(); const handleStartEditTitle = useCallback(() => { @@ -102,7 +102,6 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { window.dispatchEvent(new Event('refresh-file-tree')); } setSessionId(id); - setPanelOpen(true); const title = data.session.title || t('chat.newConversation'); setSessionTitle(title); setPanelSessionTitle(title); @@ -121,7 +120,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { loadSession(); return () => { cancelled = true; }; - }, [id, setWorkingDirectory, setSessionId, setPanelSessionTitle, setPanelOpen, t]); + }, [id, setWorkingDirectory, setSessionId, setPanelSessionTitle, t]); useEffect(() => { // Reset state when switching sessions diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 33a29e15..3dad72ca 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -11,6 +11,7 @@ import { UpdateDialog } from "./UpdateDialog"; import { UpdateBanner } from "./UpdateBanner"; import { DocPreview } from "./DocPreview"; import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/usePanel"; +import { computePanelOpenOnRouteChange } from "@/lib/panel-state-logic"; import { UpdateContext } from "@/hooks/useUpdate"; import { useUpdateChecker } from "@/hooks/useUpdateChecker"; import { ImageGenContext, useImageGenState } from "@/hooks/useImageGen"; @@ -114,6 +115,11 @@ export function AppShell({ children }: { children: React.ReactNode }) { setChatListOpenRaw(open); }, []); const [panelOpenRaw, setPanelOpenRaw] = useState(false); + // Tracks whether the previous route was a chat detail page, so the route-change + // effect can distinguish "switching chats" (preserve panel state) from "entering + // chat from settings/etc." (auto-open). Initialised to false so cold-start on + // /chat/[id] is treated as "entering chat" and opens the panel. + const wasChatDetailRouteRef = useRef(false); const [panelContent, setPanelContent] = useState("files"); const [workingDirectory, setWorkingDirectory] = useState(""); const [sessionId, setSessionId] = useState(""); @@ -148,6 +154,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [splitSessions, setSplitSessions] = useState(() => loadSplitSessions()); const [activeColumnId, setActiveColumnIdRaw] = useState(() => loadActiveColumn()); const isSplitActive = splitSessions.length >= 2; + // Note: in split mode isChatDetailRoute is always true regardless of pathname, + // which means computePanelOpenOnRouteChange will preserve the panel state (correct). const isChatDetailRoute = pathname.startsWith("/chat/") || isSplitActive; // Persist split sessions to localStorage @@ -319,11 +327,15 @@ export function AppShell({ children }: { children: React.ReactNode }) { }); }, []); - // Sync panel state on route changes: open on chat detail, close otherwise. + // Sync panel state on route changes: preserve user's open/close preference + // when switching between chats; open when entering chat from non-chat route. // Reset doc preview when navigating between pages/sessions. useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setPanelOpenRaw(isChatDetailRoute); + + setPanelOpenRaw((prev) => + computePanelOpenOnRouteChange(isChatDetailRoute, prev, wasChatDetailRouteRef.current) + ); + wasChatDetailRouteRef.current = isChatDetailRoute; setPreviewFileRaw(null); }, [pathname, isChatDetailRoute]); const panelOpen = panelOpenRaw; diff --git a/src/components/layout/SplitColumn.tsx b/src/components/layout/SplitColumn.tsx index 10ee1ca5..e17b96aa 100644 --- a/src/components/layout/SplitColumn.tsx +++ b/src/components/layout/SplitColumn.tsx @@ -28,7 +28,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu const [sessionMode, setSessionMode] = useState(""); const [projectName, setProjectName] = useState(""); const [sessionWorkingDir, setSessionWorkingDir] = useState(""); - const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel(); + const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle } = usePanel(); const { t } = useTranslation(); // Load session metadata @@ -106,11 +106,10 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu setWorkingDirectory(''); } setSessionId(sessionId); - setPanelOpen(true); if (sessionTitle) { setPanelSessionTitle(sessionTitle); } - }, [isActive, sessionId, sessionWorkingDir, sessionTitle, setWorkingDirectory, setSessionId, setPanelSessionTitle, setPanelOpen]); + }, [isActive, sessionId, sessionWorkingDir, sessionTitle, setWorkingDirectory, setSessionId, setPanelSessionTitle]); const handleClose = useCallback((e: React.MouseEvent) => { e.stopPropagation(); diff --git a/src/lib/panel-state-logic.ts b/src/lib/panel-state-logic.ts new file mode 100644 index 00000000..fac06dc5 --- /dev/null +++ b/src/lib/panel-state-logic.ts @@ -0,0 +1,34 @@ +/** + * Pure logic for computing panel open/close state on route changes. + * + * Extracted from AppShell so it can be unit-tested without React. + */ + +/** + * Determines what the panel open state should be after a route change. + * + * Rules: + * - Navigating away from a chat detail route → close the panel + * - Navigating between chat detail routes (switching chats) → preserve user's preference + * - Navigating to a chat detail route from a non-chat route → open the panel + * + * @param isChatDetailRoute Whether the new route is a chat detail route (/chat/[id]) + * @param previousPanelOpen Whether the panel was open before the route change + * @param wasChatDetailRoute Whether the previous route was also a chat detail route + * @returns The new panelOpen value + */ +export function computePanelOpenOnRouteChange( + isChatDetailRoute: boolean, + previousPanelOpen: boolean, + wasChatDetailRoute: boolean, +): boolean { + if (!isChatDetailRoute) { + return false; + } + // Switching between chats → preserve user's preference + if (wasChatDetailRoute) { + return previousPanelOpen; + } + // Entering chat from non-chat route → open + return true; +}