From 9029f36fd23dd79e19d40acc5211f70fda4aa86d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 7 Nov 2026 23:18:03 -0800 Subject: [PATCH 1/3] fix(mobile): keep selected offline environment threads visible Hide offline environments removed every thread from an environment the user had explicitly selected, so an active remote thread disappeared under its own ubuntu-main + blaze filter and the empty state blamed the offline setting. An explicitly selected environment is no longer hidden by the offline filter in the thread list, its settled shelf, or pending tasks, and the offline empty state only shows when no environment is selected. Verified with the useThreadListV2 regression (selected offline environment keeps its threads, pending task, and shelf scope) and vp run check:changed. Model: muse-wonder-1.0-contributor via Muse Code --- apps/mobile/src/features/home/HomeScreen.tsx | 2 +- .../threads/ThreadNavigationSidebar.tsx | 2 +- .../features/threads/useThreadListV2.test.tsx | 21 +++++++++++++++++++ .../src/features/threads/useThreadListV2.ts | 11 ++++++++-- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ccecf9ada32af..969421e6beb79 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -755,7 +755,7 @@ export function HomeScreen(props: HomeScreenProps) { detail={`No threads matching "${props.searchQuery}".`} variant={Platform.OS === "android" ? "plain" : undefined} /> - ) : props.hideOfflineEnvironments ? ( + ) : props.hideOfflineEnvironments && props.selectedEnvironmentId === null ? ( item.thread.environmentId === otherEnvironmentId), ).toBe(true); + // Explicitly selecting an offline environment keeps its threads listed, so a + // thread on that environment stays discoverable under its own filter. + await act(async () => + root.render(), + ); + expect(seen.at(-1)!.layout.items).toHaveLength(85); + expect(seen.at(-1)!.layout.settledCount).toBe(575); + expect( + seen.at(-1)!.layout.items.every((item) => item.thread.environmentId === environmentId), + ).toBe(true); + expect( + seen + .at(-1)! + .items.filter((item) => item.type === "v2-pending") + .map((item) => item.pendingTask.environmentId), + ).toEqual([environmentId]); + expect( + vi + .mocked(olderSettledThreads.shelfAtom) + .mock.lastCall?.[0].map(({ scope }) => scope.environmentId), + ).toEqual([environmentId]); await act(async () => root.render()); expect(seen.at(-1)!.layout.settledCount).toBe(1100); }); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 61c82bdbc9680..3adb8fd8b3a20 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -71,9 +71,16 @@ export function useThreadListV2(input: { }) { const { workingShelfEnabled } = input; const offlineEnvironmentIds = useAtomValue(offlineEnvironmentIdsAtom); + // An explicitly selected environment stays listed even when it counts as + // offline; hiding it would leave its own filter with nothing to open. const hiddenEnvironmentIds = useMemo( - () => new Set(input.hideOfflineEnvironments ? offlineEnvironmentIds : []), - [input.hideOfflineEnvironments, offlineEnvironmentIds], + () => + new Set( + input.hideOfflineEnvironments + ? offlineEnvironmentIds.filter((id) => id !== input.environmentId) + : [], + ), + [input.hideOfflineEnvironments, offlineEnvironmentIds, input.environmentId], ); const threads = useMemo( () => From f47c9ff8fc83d0d16a4663deb5c14b9bc7ada825 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 7 Nov 2026 23:29:19 -0800 Subject: [PATCH 2/3] fix(web,mobile): keep scoped offline projects visible in thread lists Desktop had the same contradiction as mobile: scoping the sidebar to a project on an offline environment hid all of its threads. filterSidebarV2VisibleThreads now keeps explicitly scoped threads, the older settled shelf still pages scoped offline environments, and mobile also exempts environments whose project is explicitly scoped (not only a selected environment). Empty states only blame the offline filter when nothing is explicitly selected or scoped. Verified: Sidebar.logic (87 tests) and useThreadListV2 regressions failed before and pass after; combined surface tests and vp run check:changed pass. A second-model review (opencode/deepseek/deepseek-flash) confirmed both surfaces with no blocking findings. Model: muse-wonder-1.0-contributor via Muse Code --- apps/mobile/src/features/home/HomeScreen.tsx | 4 ++- .../threads/ThreadNavigationSidebar.tsx | 4 ++- .../features/threads/useThreadListV2.test.tsx | 22 ++++++++++++++++ .../src/features/threads/useThreadListV2.ts | 26 ++++++++++++------- apps/web/src/components/Sidebar.logic.test.ts | 5 +++- apps/web/src/components/Sidebar.logic.ts | 4 +++ apps/web/src/components/Sidebar.tsx | 7 ++++- 7 files changed, 58 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 969421e6beb79..ccd89254bed3c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -755,7 +755,9 @@ export function HomeScreen(props: HomeScreenProps) { detail={`No threads matching "${props.searchQuery}".`} variant={Platform.OS === "android" ? "plain" : undefined} /> - ) : props.hideOfflineEnvironments && props.selectedEnvironmentId === null ? ( + ) : props.hideOfflineEnvironments && + props.selectedEnvironmentId === null && + v2ScopedProjectGroup === null ? ( scope.environmentId), ).toEqual([environmentId]); + // Scoping to a project on the offline environment (without selecting the + // environment itself) also keeps its threads listed. + await act(async () => + root.render( + , + ), + ); + expect(seen.at(-1)!.layout.items).toHaveLength(85); + expect( + seen.at(-1)!.layout.items.every((item) => item.thread.environmentId === environmentId), + ).toBe(true); + expect( + vi + .mocked(olderSettledThreads.shelfAtom) + .mock.lastCall?.[0].map(({ scope }) => scope.environmentId), + ).toContain(environmentId); await act(async () => root.render()); expect(seen.at(-1)!.layout.settledCount).toBe(1100); }); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 3adb8fd8b3a20..f5d9ed2e2bf27 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -71,17 +71,23 @@ export function useThreadListV2(input: { }) { const { workingShelfEnabled } = input; const offlineEnvironmentIds = useAtomValue(offlineEnvironmentIdsAtom); - // An explicitly selected environment stays listed even when it counts as - // offline; hiding it would leave its own filter with nothing to open. - const hiddenEnvironmentIds = useMemo( - () => - new Set( - input.hideOfflineEnvironments - ? offlineEnvironmentIds.filter((id) => id !== input.environmentId) - : [], + // An explicitly selected environment, or one whose project is explicitly + // scoped to, stays listed even when it counts as offline; hiding it would + // leave its own filter with nothing to open. + const hiddenEnvironmentIds = useMemo(() => { + if (!input.hideOfflineEnvironments) return new Set(); + const scopedEnvironmentIds = new Set(input.projectRefs?.map((ref) => ref.environmentId)); + return new Set( + offlineEnvironmentIds.filter( + (id) => id !== input.environmentId && !scopedEnvironmentIds.has(id), ), - [input.hideOfflineEnvironments, offlineEnvironmentIds, input.environmentId], - ); + ); + }, [ + input.hideOfflineEnvironments, + offlineEnvironmentIds, + input.environmentId, + input.projectRefs, + ]); const threads = useMemo( () => hiddenEnvironmentIds.size === 0 diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8868d5daba090..ee3f0d1323dd3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -869,7 +869,9 @@ describe("filterSidebarV2VisibleThreads", () => { expect(visibleIds(null, null)).toEqual(["local", "remote", "local-other"]); expect(visibleIds(null, hidden())).toEqual(["local", "local-other"]); expect(visibleIds(localScope, hidden())).toEqual(["local"]); - expect(visibleIds(remoteScope, hidden())).toEqual([]); + // Explicitly scoping to the offline environment's project keeps its + // threads listed, so they stay discoverable under that scope. + expect(visibleIds(remoteScope, hidden())).toEqual(["remote"]); // The open thread's row stays listed, but only within the project scope. expect(visibleIds(null, hidden(remoteKey))).toEqual(["local", "remote", "local-other"]); expect(visibleIds(localScope, hidden(remoteKey))).toEqual(["local"]); @@ -877,6 +879,7 @@ describe("filterSidebarV2VisibleThreads", () => { expect(hidesOffline(null, null)).toBe(false); expect(hidesOffline(null, hidden())).toBe(true); expect(hidesOffline(localScope, hidden())).toBe(false); + expect(hidesOffline(remoteScope, hidden())).toBe(false); expect(hidesOffline(null, hidden(remoteKey))).toBe(false); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5ae608cba2fd5..675f4611f78d1 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -625,6 +625,10 @@ export function filterSidebarV2VisibleThreads< thread.archivedAt === null && (hidden === null || !hidden.environmentIds.has(thread.environmentId) || + // Explicitly scoping to a project keeps its threads listed even when + // their environment counts as offline. + (scopedProjectKeys !== null && + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) || scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === hidden.keptThreadKey) && !isShownUnderParent( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3fa552da9c966..52d43874fd1db 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -501,7 +501,12 @@ function useSidebarThreads(input: { const olderSettledShelf = useOlderSettledShelf({ scopes: olderSettledScopes( [...serverConfigs.keys()].filter( - (environmentId) => hidden === null || !hidden.environmentIds.has(environmentId), + (environmentId) => + hidden === null || + !hidden.environmentIds.has(environmentId) || + // A scoped project on an offline environment still pages its shelf. + (scopedProjectKeys !== null && + [...scopedProjectKeys].some((key) => key.startsWith(`${environmentId}:`))), ), scopedProjectKeys, ), From 7b4a5bea78b5d157efe8b4fe54ca4d47acd784fd Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:57:44 -0800 Subject: [PATCH 3/3] fix(web,mobile): respect visible parents when grouping delegated tasks --- .../src/features/home/HomeRouteScreen.tsx | 5 +- .../threads/ThreadNavigationSidebar.tsx | 4 +- .../features/threads/useThreadListV2.test.tsx | 60 +++++++++++++++++++ .../src/features/threads/useThreadListV2.ts | 39 +++++++++--- apps/web/src/components/Sidebar.logic.test.ts | 23 ++++++- apps/web/src/components/Sidebar.logic.ts | 25 +++++++- apps/web/src/components/Sidebar.tsx | 10 +++- 7 files changed, 143 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 311d01f90dc1e..1232236f87acf 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -43,10 +43,7 @@ export function HomeRouteScreen() { const projects = useProjects(); // Stop rebuilding the streaming thread list while Thread covers Home. const visible = useHomeRouteVisible(); - const threads = useAtomValueWhileVisible( - environmentThreadShells.navigationThreadShellsAtom, - visible, - ); + const threads = useAtomValueWhileVisible(environmentThreadShells.quietThreadShellsAtom, visible); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index be2e81d7c924f..b2b51674ba33c 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -26,7 +26,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; -import { useProjects, useNavigationThreadShells } from "../../state/entities"; +import { useProjects, useVisibleThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { useThreadListNow } from "./use-thread-list-now"; @@ -117,7 +117,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { const insets = useSafeAreaInsets(); const { fabClearance, fabSize } = useAndroidControlSizing(); const projects = useProjects(); - const threads = useNavigationThreadShells(); + const threads = useVisibleThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const openSwipeableRef = useRef(null); diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 03e54f40b1c44..33ca09c2d37da 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -339,4 +339,64 @@ it("reuses a large thread list across tab renders and updates when its inputs ch ).toContain(environmentId); await act(async () => root.render()); expect(seen.at(-1)!.layout.settledCount).toBe(1100); + + const parent = presentThreadShell( + environmentId, + makeRawThreadShell({ + id: ThreadId.make("parent"), + projectId: ProjectId.make("parent-project"), + }), + ); + const task = presentThreadShell( + otherEnvironmentId, + makeRawThreadShell({ + id: ThreadId.make("task"), + delegatedFrom: { environmentId, threadId: parent.id, title: parent.title }, + }), + ); + const subagent = { + ...task, + id: ThreadId.make("subagent"), + lineage: { + parentThreadId: task.id, + rootThreadId: task.id, + relationshipToParent: "subagent" as const, + }, + }; + const archived = { ...task, id: ThreadId.make("archived"), archivedAt: input.now }; + const delegationInput = { + ...input, + threads: [parent, task, subagent, archived], + hideOfflineEnvironments: true, + }; + await act(async () => registry.set(offlineIds, [])); + await act(async () => root.render()); + expect(seen.at(-1)!.layout.items.map((item) => item.thread.id)).toEqual([parent.id]); + await act(async () => registry.set(offlineIds, [environmentId])); + expect(seen.at(-1)!.layout.items.map((item) => item.thread.id)).toEqual([task.id]); + const taskScope = { environmentId: otherEnvironmentId, projectId: task.projectId }; + await act(async () => + root.render( + , + ), + ); + expect(seen.at(-1)!.layout.items.map((item) => item.thread.id)).toEqual([parent.id]); + await act(async () => + root.render( + , + ), + ); + expect(seen.at(-1)!.layout.items.map((item) => item.thread.id)).toEqual([task.id]); }); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index f5d9ed2e2bf27..2008317698ef4 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { isListedThreadLineage, type EnvironmentId } from "@t3tools/contracts"; import { useMemo, useSyncExternalStore } from "react"; import { offlineEnvironmentIdsAtom } from "../../state/presentation"; @@ -88,13 +88,36 @@ export function useThreadListV2(input: { input.environmentId, input.projectRefs, ]); - const threads = useMemo( - () => - hiddenEnvironmentIds.size === 0 - ? input.threads - : input.threads.filter((thread) => !hiddenEnvironmentIds.has(thread.environmentId)), - [input.threads, hiddenEnvironmentIds], - ); + const threads = useMemo(() => { + const visible = input.threads.filter( + (thread) => + thread.archivedAt === null && + isListedThreadLineage(thread.lineage) && + !hiddenEnvironmentIds.has(thread.environmentId), + ); + const projectKeys = + input.projectRefs === null || input.projectRefs === undefined + ? null + : new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)); + const parentKeys = new Set( + visible + .filter( + (thread) => + (input.environmentId === null || thread.environmentId === input.environmentId) && + (projectKeys === null || + projectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ) + .map((thread) => `${thread.environmentId}:${thread.id}`), + ); + return visible.filter((thread) => { + const parent = thread.delegatedFrom; + return ( + parent === null || + parent === undefined || + !parentKeys.has(`${parent.environmentId}:${parent.threadId}`) + ); + }); + }, [input.threads, input.environmentId, input.projectRefs, hiddenEnvironmentIds]); const layoutTime = useMemo( () => threadListV2LayoutTime({ threads, now: input.now, searchQuery: input.searchQuery }), [threads, input.now, input.searchQuery], diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ee3f0d1323dd3..5623a7794c2cd 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -903,11 +903,14 @@ describe("filterSidebarV2VisibleThreads", () => { const delegated = new Set([ threadKey({ environmentId: task.environmentId, threadId: task.id }), ]); - const visibleIds = (hiddenEnvironmentIds: ReadonlySet | null) => + const visibleIds = ( + hiddenEnvironmentIds: ReadonlySet | null, + scope: ReadonlySet | null = null, + ) => filterSidebarV2VisibleThreads( threads, - null, - withoutHiddenDelegationParents(delegated, threads, hiddenEnvironmentIds), + scope, + withoutHiddenDelegationParents(delegated, threads, hiddenEnvironmentIds, scope), hiddenEnvironmentIds === null ? null : { environmentIds: hiddenEnvironmentIds, keptThreadKey: null }, @@ -916,6 +919,20 @@ describe("filterSidebarV2VisibleThreads", () => { expect(visibleIds(null)).toEqual(["thread-parent"]); expect(visibleIds(new Set([parentEnvironmentId]))).toEqual(["thread-task"]); expect(visibleIds(new Set([taskEnvironmentId]))).toEqual(["thread-parent"]); + const hiddenParents = new Set([parentEnvironmentId]); + const bothProjects = new Set([ + `${parentEnvironmentId}:${parent.projectId}`, + `${taskEnvironmentId}:${task.projectId}`, + ]); + expect(visibleIds(hiddenParents, bothProjects)).toEqual(["thread-parent"]); + expect( + visibleIds( + hiddenParents, + new Set([`${parentEnvironmentId}:other-project`, `${taskEnvironmentId}:${task.projectId}`]), + ), + ).toEqual(["thread-task"]); + // Widening a route back to all projects hides the offline parent again. + expect(visibleIds(hiddenParents, null)).toEqual(["thread-task"]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 675f4611f78d1..cb6dc4e0e8bc7 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -644,13 +644,32 @@ export function filterSidebarV2VisibleThreads< /** A task whose parent's environment is hidden lists on its own, as under an archived parent. */ export function withoutHiddenDelegationParents( delegatedUnderParent: ReadonlySet, - threads: readonly Pick[], + threads: readonly Pick< + SidebarThreadSummary, + "delegatedFrom" | "environmentId" | "id" | "projectId" + >[], hiddenEnvironmentIds: ReadonlySet | null, + scopedProjectKeys: ReadonlySet | null = null, ): ReadonlySet { if (hiddenEnvironmentIds === null) return delegatedUnderParent; + const scopedParentKeys = + scopedProjectKeys === null + ? null + : new Set( + threads + .filter((thread) => + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`), + ) + .map((thread) => + threadKey({ environmentId: thread.environmentId, threadId: thread.id }), + ), + ); const orphanKeys = threads.flatMap((thread) => { - const parentEnvironmentId = thread.delegatedFrom?.environmentId; - return parentEnvironmentId !== undefined && hiddenEnvironmentIds.has(parentEnvironmentId) + const parent = thread.delegatedFrom; + return parent !== null && + parent !== undefined && + hiddenEnvironmentIds.has(parent.environmentId) && + !scopedParentKeys?.has(threadKey(parent)) ? [threadKey({ environmentId: thread.environmentId, threadId: thread.id })] : []; }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 52d43874fd1db..36406a2fc1f77 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1138,15 +1138,16 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ), ); const threads = useQuietThreadShells(); - const delegatedUnderParent = withoutHiddenDelegationParents( + const scopedDelegatedUnderParent = withoutHiddenDelegationParents( allDelegatedUnderParent, threads, hiddenEnvironmentIds, + chosenScopeProjectKeys, ); const routeRowThreadRef = routeThreadRef === null ? null - : resolveSidebarRowThreadRef(routeThreadRef, threads, delegatedUnderParent); + : resolveSidebarRowThreadRef(routeThreadRef, threads, scopedDelegatedUnderParent); const routeRowThreadKey = routeRowThreadRef === null ? null : scopedThreadKey(routeRowThreadRef); // Only a hidden environment's open row is kept, so other navigation leaves the partition memoized. const keptThreadKey = @@ -1166,9 +1167,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve routeKey: routeRowThreadKey, isSearching: isSearchingThreads, scopedProjectKeys: chosenScopeProjectKeys, - delegatedUnderParent, + delegatedUnderParent: scopedDelegatedUnderParent, findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key), }); + const delegatedUnderParent = revealWidensScope + ? withoutHiddenDelegationParents(allDelegatedUnderParent, threads, hiddenEnvironmentIds) + : scopedDelegatedUnderParent; const projectScopeKeys = revealWidensScope ? [] : chosenProjectScopeKeys; const projectScopeKey = projectScopeKeys.length === 0 ? null : projectScopeKeys.join("\n"); const toggleProjectScopeKey = (key: string) =>