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,