Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/__tests__/unit/panel-state-logic.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 2 additions & 3 deletions src/app/chat/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState('');
const titleInputRef = useRef<HTMLInputElement>(null);
const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel();
const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle } = usePanel();
const { t } = useTranslation();

const handleStartEditTitle = useCallback(() => {
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
18 changes: 15 additions & 3 deletions src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<PanelContent>("files");
const [workingDirectory, setWorkingDirectory] = useState("");
const [sessionId, setSessionId] = useState("");
Expand Down Expand Up @@ -148,6 +154,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const [splitSessions, setSplitSessions] = useState<SplitSession[]>(() => loadSplitSessions());
const [activeColumnId, setActiveColumnIdRaw] = useState<string>(() => 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
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions src/components/layout/SplitColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
34 changes: 34 additions & 0 deletions src/lib/panel-state-logic.ts
Original file line number Diff line number Diff line change
@@ -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;
}