diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 99ede8afe7c4a..311d01f90dc1e 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -204,6 +204,7 @@ export function HomeRouteScreen() { onRegenerateThreadTitle={regenerateThreadTitle} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} + hideOfflineEnvironments={listOptions.hideOfflineEnvironments} onOpenSettings={() => selectTab("settings")} onSearchQueryChange={setSearchQuery} onSelectThread={handleSelectThread} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index dac883341cc10..ccecf9ada32af 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -102,6 +102,7 @@ interface HomeScreenProps { readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; + readonly hideOfflineEnvironments: boolean; readonly projectSortOrder: HomeProjectSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; @@ -465,6 +466,7 @@ export function HomeScreen(props: HomeScreenProps) { threads: props.threads, pendingTasks: props.pendingTasks, environmentId: props.selectedEnvironmentId, + hideOfflineEnvironments: props.hideOfflineEnvironments, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, searchMatches: threadSearchMatchByKey, @@ -740,6 +742,7 @@ export function HomeScreen(props: HomeScreenProps) { selectedProjectKey={props.selectedProjectKey} onEnvironmentChange={props.onEnvironmentChange} onProjectChange={props.onProjectChange} + hideOfflineEnvironments={props.hideOfflineEnvironments} /> ) : null} @@ -752,6 +755,12 @@ export function HomeScreen(props: HomeScreenProps) { detail={`No threads matching "${props.searchQuery}".`} variant={Platform.OS === "android" ? "plain" : undefined} /> + ) : props.hideOfflineEnvironments ? ( + ) : v2ScopedProjectGroup !== null ? ( ; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; + readonly hideOfflineEnvironments: boolean; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; /** List padding the scroller bleeds through so pills start where row content does. */ @@ -31,9 +32,12 @@ export function ThreadListFilters(props: { contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 2, paddingBottom: 10, gap: 8 }} > { act(() => { controls!.setSelectedEnvironmentId(ENVIRONMENT); controls!.setSelectedProjectKey("env-1:project-1"); + controls!.setHideOfflineEnvironments(true); }); expect(harness.saved.at(-1)).toEqual({ - threadListFilters: { environmentId: ENVIRONMENT, projectKey: "env-1:project-1" }, + threadListFilters: { + environmentId: ENVIRONMENT, + projectKey: "env-1:project-1", + hideOfflineEnvironments: true, + }, }); expect(controls!.options.selectedEnvironmentId).toBe(ENVIRONMENT); expect(controls!.options.selectedProjectKey).toBe("env-1:project-1"); + expect(controls!.options.hideOfflineEnvironments).toBe(true); + const savedFilters = controls!.options; act(() => root.unmount()); + const restored = createRoot(document.createElement("div")); + await act(async () => + restored.render( + + + , + ), + ); + expect(controls!.options.hideOfflineEnvironments).toBe(true); + act(() => controls!.setHideOfflineEnvironments(false)); + expect(controls!.options.hideOfflineEnvironments).toBe(false); + expect(harness.saved.at(-1)).toEqual({ + threadListFilters: { + environmentId: ENVIRONMENT, + projectKey: "env-1:project-1", + hideOfflineEnvironments: false, + }, + }); + act(() => restored.unmount()); }); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 09c8be2c9a7c1..e92b931293421 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -9,6 +9,7 @@ import { updateMobilePreferencesAtom } from "../../state/preferences"; import type { HomeProjectSortOrder } from "./homeThreadList"; export interface HomeListOptions { + readonly hideOfflineEnvironments: boolean; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; @@ -20,6 +21,7 @@ export interface ResolvedHomeListOptions extends HomeListOptions { function defaultHomeListOptions(filters?: ThreadListFilters): HomeListOptions { return { + hideOfflineEnvironments: filters?.hideOfflineEnvironments ?? false, selectedEnvironmentId: filters?.environmentId ?? null, selectedProjectKey: filters?.projectKey ?? null, projectSortOrder: @@ -49,7 +51,7 @@ export function HomeListOptionsProvider({ const [optionsAtom] = useState(() => Atom.make(defaultHomeListOptions(savedFilters))); const options = useAtomValue(optionsAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); - // Only the scope persists; the sort order is per session. Updates read the + // Only the filters persist; the sort order is per session. Updates read the // registry, so several in one event each build on the last and save it. const updateOptions = (patch: Partial) => { const current = registry.get(optionsAtom); @@ -57,12 +59,14 @@ export function HomeListOptionsProvider({ registry.set(optionsAtom, next); if ( next.selectedEnvironmentId !== current.selectedEnvironmentId || - next.selectedProjectKey !== current.selectedProjectKey + next.selectedProjectKey !== current.selectedProjectKey || + next.hideOfflineEnvironments !== current.hideOfflineEnvironments ) { savePreferences({ threadListFilters: { environmentId: next.selectedEnvironmentId, projectKey: next.selectedProjectKey, + hideOfflineEnvironments: next.hideOfflineEnvironments, }, }); } @@ -98,6 +102,9 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { updateOptions({ selectedProjectKey: value }); }; + const setHideOfflineEnvironments = (value: boolean) => { + updateOptions({ hideOfflineEnvironments: value }); + }; const setProjectSortOrder = (value: HomeProjectSortOrder) => { updateOptions({ projectSortOrder: value }); }; @@ -106,5 +113,6 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet environment.environmentId)), + ); const environmentMenu = useSettingsEnvironmentMenu(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); @@ -310,6 +316,17 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { contentContainerClassName="gap-6 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > + {props.page === "zack" ? ( + + + + ) : null} {!hasConnectedSelection || reference === null ? ( {projectSelected diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index 3cf9665d4da8a..1d1b6af1a06d3 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -99,6 +99,7 @@ const SETTINGS_SEARCH_TERMS = { "start from remote", ], SettingsLabs: [ + "hide offline environments", "legacy", "plan mode", "beta", diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index dce389c836669..a29cfb5885eaf 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -268,6 +268,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { threads, pendingTasks, environmentId: options.selectedEnvironmentId, + hideOfflineEnvironments: options.hideOfflineEnvironments, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, searchMatches: threadSearchMatchByKey, @@ -568,9 +569,11 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { ? threadSearch.isPending ? "Searching thread messages…" : "No matching threads" - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} + : options.hideOfflineEnvironments + ? "No visible threads. Turn off Hide offline environments in Settings → Labs → Zack settings, or choose another filter." + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} ); diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 7eac8955efa14..48a3aefe408d6 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -8,6 +8,8 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { Atom, AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; +import { offlineEnvironmentIdsAtom } from "../../state/presentation"; +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { olderSettledThreads } from "../../state/threads"; import { makeRawThreadShell } from "../../test-fixtures"; import { useThreadListV2 } from "./useThreadListV2"; @@ -16,6 +18,10 @@ vi.mock("../../state/threads", () => ({ olderSettledThreads: { shelfAtom: vi.fn() }, threadFamilies: { mountRows: () => () => {} }, })); +vi.mock("../../state/presentation", async () => { + const { Atom } = await import("effect/reactivity"); + return { offlineEnvironmentIdsAtom: Atom.make>([]) }; +}); vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: undefined })); it("reuses a large thread list across tab renders and updates when its inputs change", async () => { @@ -23,6 +29,7 @@ it("reuses a large thread list across tab renders and updates when its inputs ch using _globals = { [Symbol.dispose]: () => vi.unstubAllGlobals() }; const shelfAtom = Atom.make>(new Map()); vi.mocked(olderSettledThreads.shelfAtom).mockReturnValue(shelfAtom); + const offlineIds = offlineEnvironmentIdsAtom as Atom.Writable>; const registry = AtomRegistry.make(); using _registry = { [Symbol.dispose]: () => registry.dispose() }; const environmentId = EnvironmentId.make("environment-1"); @@ -43,6 +50,7 @@ it("reuses a large thread list across tab renders and updates when its inputs ch threads, pendingTasks: [], environmentId: null, + hideOfflineEnvironments: false, projectRefs: null, searchQuery: "", searchMatches: new Map(), @@ -109,6 +117,45 @@ it("reuses a large thread list across tab renders and updates when its inputs ch seen.at(-1)!.layout.items.every((item) => item.thread.environmentId === environmentId), ).toBe(true); + await act(async () => registry.set(offlineIds, [otherEnvironmentId])); + const pendingTasks: ReadonlyArray = [environmentId, otherEnvironmentId].map( + (id) => ({ + kind: "draft", + key: `draft-${id}`, + environmentId: id, + projectId: ProjectId.make("project-test"), + projectTitle: undefined, + projectCwd: undefined, + title: "Unsent draft", + createdAt: input.now, + draftKey: `draft-${id}`, + draft: { text: "Unsent draft", attachments: [] }, + }), + ); + const hiddenInput = { ...input, pendingTasks, hideOfflineEnvironments: true }; + await act(async () => root.render()); + expect(seen.at(-1)!.layout.items).toHaveLength(85); + expect(seen.at(-1)!.layout.settledCount).toBe(525); + expect( + seen + .at(-1)! + .items.filter((item) => item.type === "v2-pending") + .map((item) => item.pendingTask.environmentId), + ).toEqual([environmentId]); + 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), + ).toEqual([environmentId]); + await act(async () => root.render()); + expect(seen.at(-1)!.layout.items).toHaveLength(160); + await act(async () => root.render()); + await act(async () => registry.set(offlineIds, [])); + expect(seen.at(-1)!.layout.items).toHaveLength(160); + const queuedThreadKeys = new Set([`${environmentId}:thread-150`]); await act(async () => root.render()); const queued = seen.at(-1)!; @@ -241,4 +288,12 @@ it("reuses a large thread list across tab renders and updates when its inputs ch expect(loaded.layout.items).toHaveLength(160); expect(loaded.olderSettledShelf.isListed(threads[150]!)).toBe(true); expect(loaded.items).not.toBe(loading.items); + await act(async () => registry.set(offlineIds, [environmentId])); + await act(async () => root.render()); + expect(seen.at(-1)!.layout.settledCount).toBe(525); + expect( + seen.at(-1)!.layout.items.every((item) => item.thread.environmentId === otherEnvironmentId), + ).toBe(true); + 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 fcf1048d2b6af..61c82bdbc9680 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,7 +1,9 @@ +import { useAtomValue } from "@effect/atom-react"; import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId } from "@t3tools/contracts"; import { useMemo, useSyncExternalStore } from "react"; +import { offlineEnvironmentIdsAtom } from "../../state/presentation"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -46,6 +48,7 @@ function observeInbox( export function useThreadListV2(input: { readonly threads: LayoutInput["threads"]; readonly pendingTasks: ReadonlyArray; + readonly hideOfflineEnvironments: boolean; readonly environmentId: EnvironmentId | null; readonly projectRefs: LayoutInput["projectRefs"]; readonly searchQuery: string; @@ -66,7 +69,19 @@ export function useThreadListV2(input: { readonly settledShelfExpanded: boolean; readonly selectedThreadKey: string | null; }) { - const { threads, workingShelfEnabled } = 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], + ); const layoutTime = useMemo( () => threadListV2LayoutTime({ threads, now: input.now, searchQuery: input.searchQuery }), [threads, input.now, input.searchQuery], @@ -121,8 +136,8 @@ export function useThreadListV2(input: { environmentId: input.environmentId, projectRefs: input.projectRefs, settlementEnvironmentIds: input.settlementEnvironmentIds, - }), - [input.environmentId, input.projectRefs, input.settlementEnvironmentIds], + }).filter((scope) => !hiddenEnvironmentIds.has(scope.environmentId)), + [input.environmentId, input.projectRefs, input.settlementEnvironmentIds, hiddenEnvironmentIds], ); const olderSettledShelf = useOlderSettledShelf({ scopes, @@ -201,6 +216,7 @@ export function useThreadListV2(input: { const searchQuery = input.searchQuery.trim().toLocaleLowerCase(); return input.pendingTasks.filter( (pendingTask) => + !hiddenEnvironmentIds.has(pendingTask.environmentId) && (input.environmentId === null || pendingTask.environmentId === input.environmentId) && (scopedProjectKeys === null || scopedProjectKeys.has( @@ -208,7 +224,13 @@ export function useThreadListV2(input: { )) && (searchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(searchQuery)), ); - }, [input.projectRefs, input.searchQuery, input.pendingTasks, input.environmentId]); + }, [ + input.projectRefs, + input.searchQuery, + input.pendingTasks, + input.environmentId, + hiddenEnvironmentIds, + ]); const items = useMemo( () => buildThreadListV2ListItems({ diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 436f3b172cdae..80b884ea75776 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -39,6 +39,7 @@ export const PullRequestListControls = Schema.Struct({ export type PullRequestListControls = typeof PullRequestListControls.Type; const ThreadListFilters = Schema.Struct({ + hideOfflineEnvironments: Schema.optional(Schema.Boolean), environmentId: Schema.NullOr(EnvironmentId), projectKey: Schema.NullOr(Schema.String), }); diff --git a/apps/mobile/src/state/presentation.ts b/apps/mobile/src/state/presentation.ts index 68b2d278cd95b..93195a407a5d5 100644 --- a/apps/mobile/src/state/presentation.ts +++ b/apps/mobile/src/state/presentation.ts @@ -17,10 +17,11 @@ export const environmentPresentations = createEnvironmentPresentationAtoms({ serverConfigValueAtom: serverEnvironment.configValueAtom, }); -export const { connectedEnvironmentIdsAtom } = createEnvironmentSummaryAtoms({ - catalogValueAtom: environmentCatalog.catalogValueAtom, - presentationAtom: environmentPresentations.presentationAtom, -}); +export const { connectedEnvironmentIdsAtom, offlineEnvironmentIdsAtom } = + createEnvironmentSummaryAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + presentationAtom: environmentPresentations.presentationAtom, + }); export const threadListEnvironmentsAtom = createThreadListEnvironmentsAtom( environmentServerConfigsAtom,