authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-06 05:37:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 02:37:27-07:00
log1ffba7093a83c99ef41dd486b10df75f2b9974e1
tree7d329ef53d5133b08a7ea20803e125c8aa41aa47
parent3da315e7b5c4537cbc7280f33dadb3f5f0e3baf0
signature Signed by PGP key B5690EEEBB952194

fix(web): closed plan sidebar stays closed when returning to a thread (#5484)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 41 insertions(+), 15 deletions(-)

apps/web/src/components/ChatView.tsx+20-15
......@@ -117,6 +117,11 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
117117import { isCommandPaletteOpen } from "../commandPaletteBus";
118118import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
119119import { useMediaQuery } from "../hooks/useMediaQuery";
120import {
121 clearPlanSidebarDismissal,
122 dismissPlanSidebarForTurn,
123 isPlanSidebarDismissedForTurn,
124} from "../planSidebarDismissal";
120125import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
121126import {
122127 selectActiveRightPanel,
......@@ -1310,8 +1315,6 @@ function ChatViewContent(props: ChatViewProps) {
13101315 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
13111316 useState<Record<string, number>>({});
13121317 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
1313 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
1314 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
13151318 // When set, the thread-change reset effect will open the sidebar instead of closing it.
13161319 // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation.
13171320 const planSidebarOpenOnNextThreadRef = useRef(false);
......@@ -3110,18 +3113,21 @@ function ChatViewContent(props: ChatViewProps) {
31103113 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
31113114 }, [handleInteractionModeChange, interactionMode]);
31123115 const dismissPlanSidebarForCurrentTurn = useCallback(() => {
3113 planSidebarDismissedForTurnRef.current =
3114 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
3115 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
3116 if (!activeThreadKey) return;
3117 dismissPlanSidebarForTurn(
3118 activeThreadKey,
3119 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__",
3120 );
3121 }, [activeThreadKey, activePlan?.turnId, sidebarProposedPlan?.turnId]);
31163122 const togglePlanSidebar = useCallback(() => {
31173123 if (!activeThreadRef) return;
31183124 if (planSidebarOpen) {
31193125 dismissPlanSidebarForCurrentTurn();
3120 } else {
3121 planSidebarDismissedForTurnRef.current = null;
3126 } else if (activeThreadKey) {
3127 clearPlanSidebarDismissal(activeThreadKey);
31223128 }
31233129 useRightPanelStore.getState().toggle(activeThreadRef, "plan");
3124 }, [activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]);
3130 }, [activeThreadKey, activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]);
31253131 const closePlanSidebar = useCallback(() => {
31263132 if (!activeThreadRef) return;
31273133 setMaximizedRightPanelThreadKey(null);
......@@ -3283,7 +3289,7 @@ function ChatViewContent(props: ChatViewProps) {
32833289 (surface: RightPanelSurface) => {
32843290 if (!activeThreadRef) return;
32853291 if (surface.kind === "plan") {
3286 planSidebarDismissedForTurnRef.current = null;
3292 clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef));
32873293 } else if (planSidebarOpen) {
32883294 dismissPlanSidebarForCurrentTurn();
32893295 }
......@@ -3852,10 +3858,10 @@ function ChatViewContent(props: ChatViewProps) {
38523858 if (planSidebarOpenOnNextThreadRef.current) {
38533859 planSidebarOpenOnNextThreadRef.current = false;
38543860 if (activeThreadRef) {
3861 clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef));
38553862 useRightPanelStore.getState().open(activeThreadRef, "plan");
38563863 }
38573864 }
3858 planSidebarDismissedForTurnRef.current = null;
38593865 // activeThreadRef resets transitively with the active thread.
38603866 }, [activeThread?.id]);
38613867
......@@ -3868,10 +3874,9 @@ function ChatViewContent(props: ChatViewProps) {
38683874 const latestTurnId = activeLatestTurn?.turnId ?? null;
38693875 if (latestTurnId && activePlan.turnId !== latestTurnId) return;
38703876 const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
3871 if (planSidebarDismissedForTurnRef.current === turnKey) return;
3872 if (activeThreadRef) {
3873 useRightPanelStore.getState().open(activeThreadRef, "plan");
3874 }
3877 if (!activeThreadRef) return;
3878 if (isPlanSidebarDismissedForTurn(scopedThreadKey(activeThreadRef), turnKey)) return;
3879 useRightPanelStore.getState().open(activeThreadRef, "plan");
38753880 }, [
38763881 activePlan,
38773882 activeLatestTurn?.turnId,
......@@ -5412,8 +5417,8 @@ function ChatViewContent(props: ChatViewProps) {
54125417 // "default" mode here means the agent is executing the plan, which produces
54135418 // step-tracking activities that the sidebar will display.
54145419 if (nextInteractionMode === "default" && autoOpenPlanSidebar) {
5415 planSidebarDismissedForTurnRef.current = null;
54165420 if (activeThreadRef) {
5421 clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef));
54175422 useRightPanelStore.getState().open(activeThreadRef, "plan");
54185423 }
54195424 }
apps/web/src/planSidebarDismissal.ts created+21
......@@ -0,0 +1,21 @@
1/**
2 * Tracks which turn's plan sidebar the user explicitly dismissed, per thread.
3 *
4 * Kept outside React state so a dismissal survives leaving and re-entering a
5 * thread (ChatView resets its per-thread refs on navigation). Dismissals are
6 * keyed by turn, so when a new turn produces fresh plan steps the sidebar
7 * still auto-opens.
8 */
9const dismissedTurnByThreadKey = new Map<string, string>();
10
11export function dismissPlanSidebarForTurn(threadKey: string, turnKey: string): void {
12 dismissedTurnByThreadKey.set(threadKey, turnKey);
13}
14
15export function clearPlanSidebarDismissal(threadKey: string): void {
16 dismissedTurnByThreadKey.delete(threadKey);
17}
18
19export function isPlanSidebarDismissedForTurn(threadKey: string, turnKey: string): boolean {
20 return dismissedTurnByThreadKey.get(threadKey) === turnKey;
21}