From 9b6fd563c13f1a50a78c5fef356f994724afa723 Mon Sep 17 00:00:00 2001 From: macodev00 Date: Sat, 17 Oct 2026 06:44:55 +0000 Subject: [PATCH 1/2] fix(web): clear a sidebar scope stuck on No project A persisted sidebarProjectScopeKey that resolves to a scratch workspace kept the new sidebar filtered to that one catalog entry across reloads. The snapshot reset only cleared a scope whose group was missing, and Scratch stays in the catalog. Clear a restored scratch scope once snapshots are ready. A scratch scope chosen during this session stays. --- apps/web/src/components/Sidebar.logic.test.ts | 124 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 41 ++++++ apps/web/src/components/Sidebar.tsx | 27 +++- 3 files changed, 188 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e8149ae0026e2..3c56807188c9e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -42,6 +42,7 @@ import { resolveWorkingStartedAt, pullRequestMatchesSearchQuery, searchSidebarThreads, + shouldClearPersistedSidebarProjectScope, shouldClearThreadSelectionOnMouseDown, shouldShowSidebarV2Duration, shouldRecedeSidebarThread, @@ -1066,6 +1067,129 @@ describe("searchSidebarThreads", () => { }); }); +describe("shouldClearPersistedSidebarProjectScope", () => { + const local = EnvironmentId.make("env-local"); + const remote = EnvironmentId.make("env-remote"); + const remoteScratchRoot = "/home/user/.t3/scratch"; + const scratchWorkspaceRootFor = (environmentId: EnvironmentId) => + environmentId === remote ? remoteScratchRoot : "/Users/me/.t3/scratch"; + const scratchGroup = { + memberProjects: [{ environmentId: remote, workspaceRoot: `${remoteScratchRoot}/` }], + }; + const repoGroup = { + memberProjects: [{ environmentId: remote, workspaceRoot: "/home/user/src/t3code" }], + }; + + it("keeps the scope until project snapshots are ready", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${remote}:${remoteScratchRoot}`, + snapshotsReady: false, + restored: true, + scopedGroup: null, + scratchWorkspaceRootFor, + }), + ).toBe(false); + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${remote}:${remoteScratchRoot}`, + snapshotsReady: false, + restored: true, + scopedGroup: scratchGroup, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); + + it("keeps an empty scope", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: null, + snapshotsReady: true, + restored: true, + scopedGroup: null, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); + + it("clears a scope whose catalog group is gone", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: "missing-project", + snapshotsReady: true, + restored: true, + scopedGroup: null, + scratchWorkspaceRootFor, + }), + ).toBe(true); + }); + + it("clears a restored scope that resolves to a scratch workspace", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${remote}:${remoteScratchRoot}`, + snapshotsReady: true, + restored: true, + scopedGroup: scratchGroup, + scratchWorkspaceRootFor, + }), + ).toBe(true); + }); + + it("keeps a scratch scope the user picked in this session", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${remote}:${remoteScratchRoot}`, + snapshotsReady: true, + restored: false, + scopedGroup: scratchGroup, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); + + it("keeps a scope that resolves to a normal project", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: "github.com/pingdotgg/t3code", + snapshotsReady: true, + restored: true, + scopedGroup: repoGroup, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); + + it("compares each member with its own environment scratch root", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${local}:${remoteScratchRoot}`, + snapshotsReady: true, + restored: true, + scopedGroup: { + memberProjects: [{ environmentId: local, workspaceRoot: remoteScratchRoot }], + }, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); + + it("keeps a group that still contains a non-scratch checkout", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: "github.com/pingdotgg/t3code", + snapshotsReady: true, + restored: true, + scopedGroup: { + memberProjects: [...scratchGroup.memberProjects, ...repoGroup.memberProjects], + }, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); +}); + describe("filterSidebarProjectScopeItems", () => { const items = [ { value: "all", label: "All projects" }, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 94db97c388a24..71a57ad918ca7 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; @@ -515,6 +516,46 @@ export function isSidebarSubagentThread(thread: Pick; + } | null; + readonly scratchWorkspaceRootFor: (environmentId: EnvironmentId) => string | null | undefined; +}): boolean { + if (input.scopeKey === null || !input.snapshotsReady) { + return false; + } + const group = input.scopedGroup; + if (group === null) { + return true; + } + if (!input.restored) { + return false; + } + return ( + group.memberProjects.length > 0 && + group.memberProjects.every((member) => + isScratchProject(member, input.scratchWorkspaceRootFor(member.environmentId)), + ) + ); +} + export function filterSidebarV2VisibleThreads< T extends Pick & { environmentId: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3558e1306f4c6..9ea5f33872e1a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -210,6 +210,7 @@ import { resolveSidebarThreadStatus, resolveThreadLastVisitedAt, searchSidebarThreads, + shouldClearPersistedSidebarProjectScope, shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, shouldRecedeSidebarThread, @@ -2698,6 +2699,7 @@ export default function Sidebar() { const projectScopeFilter = useComboboxFilter(); const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); const allProjectConfigsReady = useAtomValue(environmentProjects.projectConfigsReadyAtom); + const projectCatalogReady = allProjectSnapshotsReady && allProjectConfigsReady; const headerSearchRef = useRef(null); // Safari can send a click after Ctrl+click opens settings. Ignore that one // selection, then clear the guard when the picker opens again. @@ -2745,7 +2747,10 @@ export default function Sidebar() { const routeThreadKeyRef = useRef(routeThreadKey); // A selection made under one scope can hold threads the next scope hides. + // A scope loaded with the page can be cleared. A pick in this session is kept. + const userChoseProjectScopeRef = useRef(false); const setProjectScopeKey = (key: string | null) => { + userChoseProjectScopeRef.current = true; clearSelection(); setPersistedProjectScopeKey(key); }; @@ -2790,23 +2795,37 @@ export default function Sidebar() { ), ); useEffect(() => { - const nextKey = resolveSidebarProjectScopeKey({ + const resolvedKey = resolveSidebarProjectScopeKey({ groups: projectGroups, key: persistedProjectScopeKey, settings: projectGroupingSettings, - canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, + canClearMissingScope: projectCatalogReady, }); + const resolvedGroup = + resolvedKey === null + ? null + : (projectGroups.find((group) => group.projectKey === resolvedKey) ?? null); + const nextKey = shouldClearPersistedSidebarProjectScope({ + scopeKey: resolvedKey, + snapshotsReady: projectCatalogReady, + restored: !userChoseProjectScopeRef.current, + scopedGroup: resolvedGroup, + scratchWorkspaceRootFor: (environmentId) => + serverConfigs.get(environmentId)?.scratchWorkspaceRoot, + }) + ? null + : resolvedKey; if (nextKey !== persistedProjectScopeKey) { clearSelection(); setPersistedProjectScopeKey(nextKey); } }, [ - allProjectConfigsReady, - allProjectSnapshotsReady, clearSelection, persistedProjectScopeKey, + projectCatalogReady, projectGroups, projectGroupingSettings, + serverConfigs, setPersistedProjectScopeKey, ]); const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; From 46d7780dd4fd4f813b688a33c1e7f221665ed27c Mon Sep 17 00:00:00 2001 From: macodev00 Date: Mon, 26 Oct 2026 08:30:50 +0000 Subject: [PATCH 2/2] fix(web): keep a chosen No project scope across sidebar remounts Resizing across the mobile breakpoint remounts the sidebar and drops the in-session Scratch choice, so the restored-scope reset clears a filter the user just picked. The layout holds that choice. A Scratch scope restored from the previous page is still cleared. --- apps/web/src/components/AppSidebarLayout.tsx | 9 ++++- apps/web/src/components/Sidebar.logic.test.ts | 33 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 19 +++++++++++ apps/web/src/components/Sidebar.tsx | 17 +++++++--- 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index f7b3209f661ac..6c24323299c95 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -37,6 +37,7 @@ import { usePanelNavigationSuppression, } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; +import { createSidebarProjectScopeSession } from "./Sidebar.logic"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; @@ -379,6 +380,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { observedKey: null, pendingKey: null, })); + // The shell swaps its tree across the mobile breakpoint and remounts the + // sidebar. This outlives that so a Scratch scope picked this session stays. + const [projectScopeSession] = useState(() => createSidebarProjectScopeSession()); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const pathname = useLocation({ select: (location) => location.pathname }); @@ -443,7 +447,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {legacySidebarEnabled ? ( ) : ( - + )} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index cf16a4d016485..917d732d13c94 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,8 +20,11 @@ import { resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreads, + createSidebarProjectScopeSession, + markSidebarProjectScopeChosen, shouldClearPersistedSidebarProjectScope, shouldRecedeSidebarThread, + sidebarProjectScopeWasRestored, sortInboxThreadsByReturn, resolveSidebarDropTarget, planSidebarThreadDrop, @@ -1436,6 +1439,36 @@ describe("shouldClearPersistedSidebarProjectScope", () => { ).toBe(false); }); + it("clears a scratch scope restored into a new session", () => { + const session = createSidebarProjectScopeSession(); + expect(sidebarProjectScopeWasRestored(session)).toBe(true); + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${remote}:${remoteScratchRoot}`, + snapshotsReady: true, + restored: sidebarProjectScopeWasRestored(session), + scopedGroup: scratchGroup, + scratchWorkspaceRootFor, + }), + ).toBe(true); + }); + + it("keeps a scratch scope the user chose when the sidebar remounts", () => { + const session = createSidebarProjectScopeSession(); + markSidebarProjectScopeChosen(session); + // A remount reads the layout's session again; it does not start a new one. + expect(sidebarProjectScopeWasRestored(session)).toBe(false); + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${remote}:${remoteScratchRoot}`, + snapshotsReady: true, + restored: sidebarProjectScopeWasRestored(session), + scopedGroup: scratchGroup, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); + it("keeps a scope that resolves to a normal project", () => { expect( shouldClearPersistedSidebarProjectScope({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 194f737f57e5a..cfb284bcaba24 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -561,6 +561,25 @@ export function isSidebarSubagentThread(thread: Pick { - userChoseProjectScopeRef.current = true; + markSidebarProjectScopeChosen(projectScopeSession); clearSelection(); setPersistedProjectScopeKey(key); }; @@ -1022,7 +1030,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve !shouldClearPersistedSidebarProjectScope({ scopeKey: chosenProjectScopeKey, snapshotsReady: projectCatalogReady, - restored: !userChoseProjectScopeRef.current, + restored: sidebarProjectScopeWasRestored(projectScopeSession), scopedGroup: chosenProjectGroup, scratchWorkspaceRootFor: (environmentId) => serverConfigs.get(environmentId)?.scratchWorkspaceRoot, @@ -1037,6 +1045,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve chosenProjectScopeKey, clearSelection, projectCatalogReady, + projectScopeSession, serverConfigs, setPersistedProjectScopeKey, ]);