From b907a785456dbbb1583a2c8cc8fc6287237c9f63 Mon Sep 17 00:00:00 2001 From: macodev00 Date: Thu, 8 Oct 2026 06:43:46 +0000 Subject: [PATCH] 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 that scope the same way once snapshots are ready. --- apps/web/src/components/Sidebar.logic.test.ts | 104 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 35 ++++++ apps/web/src/components/Sidebar.tsx | 27 ++++- 3 files changed, 161 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 616ff8c4ec05..538d7d5deaec 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, @@ -1070,6 +1071,109 @@ 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, + scopedGroup: null, + scratchWorkspaceRootFor, + }), + ).toBe(false); + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${remote}:${remoteScratchRoot}`, + snapshotsReady: false, + scopedGroup: scratchGroup, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); + + it("keeps an empty scope", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: null, + snapshotsReady: true, + scopedGroup: null, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); + + it("clears a scope whose catalog group is gone", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: "missing-project", + snapshotsReady: true, + scopedGroup: null, + scratchWorkspaceRootFor, + }), + ).toBe(true); + }); + + it("clears a scope that resolves to a scratch workspace", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${remote}:${remoteScratchRoot}`, + snapshotsReady: true, + scopedGroup: scratchGroup, + scratchWorkspaceRootFor, + }), + ).toBe(true); + }); + + it("keeps a scope that resolves to a normal project", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: "github.com/pingdotgg/t3code", + snapshotsReady: true, + scopedGroup: repoGroup, + scratchWorkspaceRootFor, + }), + ).toBe(false); + }); + + it("compares each member with its own environment scratch root", () => { + expect( + shouldClearPersistedSidebarProjectScope({ + scopeKey: `${local}:${remoteScratchRoot}`, + snapshotsReady: 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, + 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 3fe1881332a5..ac8d4fc4d890 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"; @@ -567,6 +568,40 @@ 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; + } + 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 56e51346b79e..3629b0614184 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -211,6 +211,7 @@ import { resolveSidebarThreadStatus, resolveThreadLastVisitedAt, searchSidebarThreads, + shouldClearPersistedSidebarProjectScope, shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, shouldRecedeSidebarThread, @@ -2723,25 +2724,41 @@ export default function Sidebar() { [scopedProjectGroup], ); // Configs identify scratch projects, so both they and live project snapshots - // must arrive before a persisted scope can be considered missing. + // must arrive before a persisted scope can be considered missing. Scratch + // ("No project") is a live catalog project, so the same reset drops a scope + // that resolves only to those workspaces. const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); const allProjectConfigsReady = useAtomValue(environmentProjects.projectConfigsReadyAtom); + const projectCatalogReady = allProjectSnapshotsReady && allProjectConfigsReady; 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, + scopedGroup: resolvedGroup, + scratchWorkspaceRootFor: (environmentId) => + serverConfigs.get(environmentId)?.scratchWorkspaceRoot, + }) + ? null + : resolvedKey; if (nextKey !== persistedProjectScopeKey) { setProjectScopeKey(nextKey); } }, [ - allProjectConfigsReady, - allProjectSnapshotsReady, persistedProjectScopeKey, + projectCatalogReady, projectGroups, projectGroupingSettings, + serverConfigs, setProjectScopeKey, ]); // Count-only subscription: the parent needs "are there draft rows" for the