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/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ccecf9ada32af..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.hideOfflineEnvironments && + props.selectedEnvironmentId === null && + v2ScopedProjectGroup === null ? ( (null); @@ -569,7 +569,9 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { ? threadSearch.isPending ? "Searching thread messages…" : "No matching threads" - : options.hideOfflineEnvironments + : options.hideOfflineEnvironments && + options.selectedEnvironmentId === null && + selectedProjectScope === null ? "No visible threads. Turn off Hide offline environments in Settings → Labs → Zack settings, or choose another filter." : selectedProjectScope !== null ? `No threads in ${selectedProjectScope.title}` diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 48a3aefe408d6..33ca09c2d37da 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -294,6 +294,109 @@ it("reuses a large thread list across tab renders and updates when its inputs ch expect( seen.at(-1)!.layout.items.every((item) => 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]); + // 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); + + 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 61c82bdbc9680..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"; @@ -71,17 +71,53 @@ export function useThreadListV2(input: { }) { const { workingShelfEnabled } = input; const offlineEnvironmentIds = useAtomValue(offlineEnvironmentIdsAtom); - const hiddenEnvironmentIds = useMemo( - () => new Set(input.hideOfflineEnvironments ? offlineEnvironmentIds : []), - [input.hideOfflineEnvironments, offlineEnvironmentIds], - ); - const threads = useMemo( - () => - hiddenEnvironmentIds.size === 0 - ? input.threads - : input.threads.filter((thread) => !hiddenEnvironmentIds.has(thread.environmentId)), - [input.threads, hiddenEnvironmentIds], - ); + // 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.projectRefs, + ]); + 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 8868d5daba090..5623a7794c2cd 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); }); @@ -900,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 }, @@ -913,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 5ae608cba2fd5..cb6dc4e0e8bc7 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( @@ -640,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 3fa552da9c966..36406a2fc1f77 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, ), @@ -1133,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 = @@ -1161,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) =>