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 = (