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 a66540ff4fdf3..917d732d13c94 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,7 +20,11 @@ import { resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreads, + createSidebarProjectScopeSession, + markSidebarProjectScopeChosen, + shouldClearPersistedSidebarProjectScope, shouldRecedeSidebarThread, + sidebarProjectScopeWasRestored, sortInboxThreadsByReturn, resolveSidebarDropTarget, planSidebarThreadDrop, @@ -1352,3 +1356,156 @@ describe("resolveSidebarRouteReveal", () => { ).toEqual({ pendingKey: hiddenKey, widensScope: false }); }); }); + +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("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({ + 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); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 475ca41271143..cfb284bcaba24 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"; @@ -560,6 +561,59 @@ 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)), + ) + ); +} + // Subagent threads have no sidebar row, so an open subagent is represented by // its nearest non-subagent ancestor. A broken chain (missing parent, cycle, // other environment) resolves to the thread itself, which then matches no row. diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 72f90e27a8e3a..32dd958d31445 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -175,7 +175,11 @@ import { resolveSidebarDropVerb, resolveSidebarThreadSection, searchSidebarThreads, + markSidebarProjectScopeChosen, + shouldClearPersistedSidebarProjectScope, shouldCreateNewThreadInCurrentProject, + sidebarProjectScopeWasRestored, + type SidebarProjectScopeSession, shouldNavigateAfterThreadPark, sidebarListItemId, sidebarThreadKeyAtY, @@ -779,7 +783,13 @@ function readRouteThreadKey(router: AnyRouter): string | null { return ref === null ? null : scopedThreadKey(ref); } -export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { +export default function Sidebar({ + routeReveal, + projectScopeSession, +}: { + routeReveal: SidebarRouteReveal; + projectScopeSession: SidebarProjectScopeSession; +}) { const { projects, serverConfigs, @@ -895,6 +905,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const projectScopeFilter = useComboboxFilter(); const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); const allProjectConfigsReady = useAtomValue(environmentProjects.projectConfigsReadyAtom); + const projectCatalogReady = allProjectSnapshotsReady && allProjectConfigsReady; const headerSearchRef = useRef(null); // Safari may click after Ctrl+click opens settings; suppress that selection until the picker reopens. const suppressNextScopeChangeRef = useRef(false); @@ -947,7 +958,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; // 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 setProjectScopeKey = (key: string | null) => { + markSidebarProjectScopeChosen(projectScopeSession); clearSelection(); setPersistedProjectScopeKey(key); }; @@ -957,7 +970,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve groups: projectGroups, key: persistedProjectScopeKey, settings: projectGroupingSettings, - canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, + canClearMissingScope: projectCatalogReady, }); const chosenProjectGroup = chosenProjectScopeKey === null @@ -1008,6 +1021,34 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve clearSelection(); setPersistedProjectScopeKey(null); }, [clearSelection, revealWidensScope, setPersistedProjectScopeKey]); + // A restored Scratch scope is a real group, so the missing-project read leaves + // its stored key in place and the sidebar stays filtered. Clear that key once + // the catalog is ready. Pass the resolved key: a missing project stays stored + // until it returns. A Scratch scope picked in this session stays. + useEffect(() => { + if ( + !shouldClearPersistedSidebarProjectScope({ + scopeKey: chosenProjectScopeKey, + snapshotsReady: projectCatalogReady, + restored: sidebarProjectScopeWasRestored(projectScopeSession), + scopedGroup: chosenProjectGroup, + scratchWorkspaceRootFor: (environmentId) => + serverConfigs.get(environmentId)?.scratchWorkspaceRoot, + }) + ) { + return; + } + clearSelection(); + setPersistedProjectScopeKey(null); + }, [ + chosenProjectGroup, + chosenProjectScopeKey, + clearSelection, + projectCatalogReady, + projectScopeSession, + serverConfigs, + setPersistedProjectScopeKey, + ]); const revealActiveRow = useActiveRowReveal(routeReveal, routeRowThreadKey); const showProjectEnvironments = projectGroupsSpanEnvironments(projectGroups); const scratchMachineLabelFor = (