From c49d51b23fbd1a5a2cf64fd0e24aa6cb5a4fe9bf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 20:59:10 +0000 Subject: [PATCH] fix(web): opening a hidden chat adds its project to the sidebar filter When the routed thread was hidden by the sidebar project filter, the route reveal replaced the scope with all projects and saved that, so the first send in a draft from another project cleared the user's filter. The reveal now adds the thread's project group to the current scope, and widens to all projects only when no scopable project holds the thread. Fixes pingdotgg/t3code#33251 AI-assisted; verified with the tests and tools listed above. --- apps/web/src/components/Sidebar.logic.test.ts | 48 +++++++++++++++++-- apps/web/src/components/Sidebar.tsx | 25 +++++----- .../web/src/components/sidebar/routeReveal.ts | 38 +++++++++++++-- 3 files changed, 90 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0e3f47b7f9ed8..3c27c0fa14221 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1475,18 +1475,46 @@ describe("resolveSidebarRouteReveal", () => { const hidden = makeThreadFixture({ projectId: ProjectId.make("project-other") }); const hiddenKey = "environment-test:thread-test"; const findThread = (key: string) => (key === hiddenKey ? hidden : undefined); + const scopeKeys = ["scoped"]; + const scopeGroups = [ + { + projectKey: "scoped", + memberProjectRefs: [{ environmentId: "environment-test", projectId: "project-scoped" }], + }, + { + projectKey: "other", + memberProjectRefs: [{ environmentId: "environment-test", projectId: "project-other" }], + }, + ]; - it("widens the scope for a newly opened thread the scope hides", () => { + it("adds the project of a newly opened thread the scope hides", () => { expect( resolveSidebarRouteReveal({ reveal: { observedKey: "environment-test:other", pendingKey: null }, routeKey: hiddenKey, isSearching: false, + scopeKeys, scopedProjectKeys, + scopeGroups, delegatedUnderParent: new Set(), findThread, }), - ).toEqual({ pendingKey: hiddenKey, widensScope: true }); + ).toEqual({ pendingKey: hiddenKey, widensScope: true, scopeKeys: ["scoped", "other"] }); + }); + + it("widens to all projects for a hidden thread outside every scopable project", () => { + expect( + resolveSidebarRouteReveal({ + reveal: { observedKey: "environment-test:other", pendingKey: null }, + routeKey: hiddenKey, + isSearching: false, + scopeKeys, + scopedProjectKeys, + scopeGroups: scopeGroups.slice(0, 1), + delegatedUnderParent: new Set(), + findThread, + }), + ).toEqual({ pendingKey: hiddenKey, widensScope: true, scopeKeys: [] }); }); it("never widens for a thread whose reveal already completed", () => { @@ -1496,11 +1524,13 @@ describe("resolveSidebarRouteReveal", () => { reveal: { observedKey: hiddenKey, pendingKey: null }, routeKey: hiddenKey, isSearching: false, + scopeKeys, scopedProjectKeys, + scopeGroups, delegatedUnderParent: new Set(), findThread, }), - ).toEqual({ pendingKey: null, widensScope: false }); + ).toEqual({ pendingKey: null, widensScope: false, scopeKeys }); }); it("widens again for a thread reopened before the thread between them listed its row", () => { @@ -1512,12 +1542,18 @@ describe("resolveSidebarRouteReveal", () => { reveal, routeKey, isSearching: false, + scopeKeys, scopedProjectKeys, + scopeGroups, delegatedUnderParent: new Set(), findThread, }); resolve("environment-test:unlisted"); - expect(resolve(hiddenKey)).toEqual({ pendingKey: hiddenKey, widensScope: true }); + expect(resolve(hiddenKey)).toEqual({ + pendingKey: hiddenKey, + widensScope: true, + scopeKeys: ["scoped", "other"], + }); }); it("holds a pending reveal without widening while searching", () => { @@ -1526,11 +1562,13 @@ describe("resolveSidebarRouteReveal", () => { reveal: { observedKey: hiddenKey, pendingKey: hiddenKey }, routeKey: hiddenKey, isSearching: true, + scopeKeys, scopedProjectKeys, + scopeGroups, delegatedUnderParent: new Set(), findThread, }), - ).toEqual({ pendingKey: hiddenKey, widensScope: false }); + ).toEqual({ pendingKey: hiddenKey, widensScope: false, scopeKeys }); }); }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2b1a8403935c6..bd8d67d6acd43 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1122,15 +1122,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve // scrolled away; a remount starts at the top, so each mount scrolls to the open // thread once. Render decides the widening, so the first frame of the new route // already lists the row; the active row's ref callback scrolls to it. - const { widensScope: revealWidensScope } = resolveSidebarRouteReveal({ - reveal: routeReveal, - routeKey: routeRowThreadKey, - isSearching: isSearchingThreads, - scopedProjectKeys: chosenScopeProjectKeys, - delegatedUnderParent, - findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key), - }); - const projectScopeKeys = revealWidensScope ? [] : chosenProjectScopeKeys; + const { widensScope: revealWidensScope, scopeKeys: projectScopeKeys } = resolveSidebarRouteReveal( + { + reveal: routeReveal, + routeKey: routeRowThreadKey, + isSearching: isSearchingThreads, + scopeKeys: chosenProjectScopeKeys, + scopedProjectKeys: chosenScopeProjectKeys, + scopeGroups, + delegatedUnderParent, + findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key), + }, + ); const projectScopeKey = projectScopeKeys.length === 0 ? null : projectScopeKeys.join("\n"); const toggleProjectScopeKey = (key: string) => setProjectScopeKeys( @@ -1146,8 +1149,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve useEffect(() => { if (!revealWidensScope) return; clearSelection(); - setPersistedProjectScopeKeys([]); - }, [clearSelection, revealWidensScope, setPersistedProjectScopeKeys]); + setPersistedProjectScopeKeys(projectScopeKey === null ? [] : projectScopeKey.split("\n")); + }, [clearSelection, revealWidensScope, projectScopeKey, setPersistedProjectScopeKeys]); const revealActiveRow = useActiveRowReveal(routeReveal, routeRowThreadKey); const showProjectEnvironments = projectGroupsSpanEnvironments(projectGroups); const scratchMachineLabelFor = ( diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts index f88433577581b..b79a00e1a2aa9 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -49,17 +49,35 @@ type RevealableThread = Parameters[0][numb // Render records the route before deciding, so the first render of a new route // already widens the scope. Only a route reveal widens: the once-per-mount -// scroll never changes a scope the user chose. +// scroll never changes a scope the user chose. Widening adds the thread's project +// to the scope; only a thread outside every scopable project widens to all projects. export function resolveSidebarRouteReveal(input: { readonly reveal: SidebarRouteReveal; readonly routeKey: string | null; readonly isSearching: boolean; + readonly scopeKeys: readonly string[]; readonly scopedProjectKeys: ReadonlySet | null; + readonly scopeGroups: readonly { + readonly projectKey: string; + readonly memberProjectRefs: readonly { environmentId: string; projectId: string }[]; + }[]; readonly delegatedUnderParent: ReadonlySet; readonly findThread: (key: string) => T | undefined; -}): { readonly pendingKey: string | null; readonly widensScope: boolean } { - const { reveal, routeKey, isSearching, scopedProjectKeys, delegatedUnderParent, findThread } = - input; +}): { + readonly pendingKey: string | null; + readonly widensScope: boolean; + readonly scopeKeys: readonly string[]; +} { + const { + reveal, + routeKey, + isSearching, + scopeKeys, + scopedProjectKeys, + scopeGroups, + delegatedUnderParent, + findThread, + } = input; const pendingKey = pendingSidebarRouteReveal(reveal, routeKey); // Search results replace the thread list; the reveal waits for it to return. const thread = pendingKey === null || isSearching ? undefined : findThread(pendingKey); @@ -68,5 +86,15 @@ export function resolveSidebarRouteReveal(input: { scopedProjectKeys !== null && filterSidebarV2VisibleThreads([thread], scopedProjectKeys, delegatedUnderParent).length === 0 && filterSidebarV2VisibleThreads([thread], null, delegatedUnderParent).length === 1; - return { pendingKey, widensScope }; + if (!widensScope) return { pendingKey, widensScope, scopeKeys }; + const threadProjectKey = scopeGroups.find((group) => + group.memberProjectRefs.some( + (ref) => ref.environmentId === thread.environmentId && ref.projectId === thread.projectId, + ), + )?.projectKey; + return { + pendingKey, + widensScope, + scopeKeys: threadProjectKey === undefined ? [] : [...scopeKeys, threadProjectKey], + }; }