Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -443,7 +447,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
{legacySidebarEnabled ? (
<LegacyThreadSidebar routeReveal={sidebarRouteReveal} />
) : (
<ThreadSidebar routeReveal={sidebarRouteReveal} />
<ThreadSidebar
routeReveal={sidebarRouteReveal}
projectScopeSession={projectScopeSession}
/>
)}
<SidebarRail onDoubleClick={resetSidebarWidth} />
</Sidebar>
Expand Down
157 changes: 157 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {
resolveThreadStatusPill,
resolveWorkingStartedAt,
searchSidebarThreads,
createSidebarProjectScopeSession,
markSidebarProjectScopeChosen,
shouldClearPersistedSidebarProjectScope,
shouldRecedeSidebarThread,
sidebarProjectScopeWasRestored,
sortInboxThreadsByReturn,
resolveSidebarDropTarget,
planSidebarThreadDrop,
Expand Down Expand Up @@ -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);
});
});
54 changes: 54 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -560,6 +561,59 @@ export function isSidebarSubagentThread(thread: Pick<SidebarThreadSummary, "line
return thread.lineage.relationshipToParent === "subagent";
}

// The thread sidebar remounts when the viewport crosses the mobile breakpoint.
// AppSidebarLayout holds this, so a Scratch pick in the page session is not
// read back as restored state and cleared.
export interface SidebarProjectScopeSession {
userChose: boolean;
}

export function createSidebarProjectScopeSession(): SidebarProjectScopeSession {
return { userChose: false };
}

export function markSidebarProjectScopeChosen(session: SidebarProjectScopeSession): void {
session.userChose = true;
}

export function sidebarProjectScopeWasRestored(session: SidebarProjectScopeSession): boolean {
return !session.userChose;
}

// Scratch is a real catalog project, so a key that points only at it never
// looks missing and keeps filtering the sidebar. A pick in this session stays;
// a missing group is cleared once snapshots are ready.
export function shouldClearPersistedSidebarProjectScope(input: {
readonly scopeKey: string | null;
readonly snapshotsReady: boolean;
/** False when the user chose this scope during the current page session. */
readonly restored: boolean;
readonly scopedGroup: {
readonly memberProjects: ReadonlyArray<{
readonly environmentId: EnvironmentId;
readonly workspaceRoot: string;
}>;
} | 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.
Expand Down
45 changes: 43 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,11 @@ import {
resolveSidebarDropVerb,
resolveSidebarThreadSection,
searchSidebarThreads,
markSidebarProjectScopeChosen,
shouldClearPersistedSidebarProjectScope,
shouldCreateNewThreadInCurrentProject,
sidebarProjectScopeWasRestored,
type SidebarProjectScopeSession,
shouldNavigateAfterThreadPark,
sidebarListItemId,
sidebarThreadKeyAtY,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLDivElement | null>(null);
// Safari may click after Ctrl+click opens settings; suppress that selection until the picker reopens.
const suppressNextScopeChangeRef = useRef(false);
Expand Down Expand Up @@ -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);
};
Expand All @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
Loading