Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export function HomeRouteScreen() {
onRegenerateThreadTitle={regenerateThreadTitle}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
hideOfflineEnvironments={listOptions.hideOfflineEnvironments}
onOpenSettings={() => selectTab("settings")}
onSearchQueryChange={setSearchQuery}
onSelectThread={handleSelectThread}
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -740,6 +742,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedProjectKey={props.selectedProjectKey}
onEnvironmentChange={props.onEnvironmentChange}
onProjectChange={props.onProjectChange}
hideOfflineEnvironments={props.hideOfflineEnvironments}
/>
) : null}
</>
Expand All @@ -752,6 +755,12 @@ export function HomeScreen(props: HomeScreenProps) {
detail={`No threads matching "${props.searchQuery}".`}
variant={Platform.OS === "android" ? "plain" : undefined}
/>
) : props.hideOfflineEnvironments ? (
<EmptyState
title="No visible threads"
detail="Turn off Hide offline environments in Settings → Labs → Zack settings, or choose another filter."
variant={Platform.OS === "android" ? "plain" : undefined}
/>
) : v2ScopedProjectGroup !== null ? (
<EmptyState
title={`No threads in ${v2ScopedProjectGroup.title}`}
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/features/home/ThreadListFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function ThreadListFilters(props: {
readonly projects: ReadonlyArray<HomeListFilterMenuProject>;
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. */
Expand All @@ -31,9 +32,12 @@ export function ThreadListFilters(props: {
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 2, paddingBottom: 10, gap: 8 }}
>
<ListFilterMenu
label={selectedEnvironment?.label ?? "All environments"}
label={
selectedEnvironment?.label ??
(props.hideOfflineEnvironments ? "Online environments" : "All environments")
}
accessibilityLabel="Filter threads by environment"
active={selectedEnvironment !== undefined}
active={selectedEnvironment !== undefined || props.hideOfflineEnvironments}
actions={[
{
id: "all",
Expand Down
35 changes: 34 additions & 1 deletion apps/mobile/src/features/home/home-list-options.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,45 @@ it("saves the scope from every update made in one event", async () => {
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(
<HomeListOptionsProvider
projectGroupingMode="repository"
savedFilters={{
environmentId: savedFilters.selectedEnvironmentId,
projectKey: savedFilters.selectedProjectKey,
hideOfflineEnvironments: savedFilters.hideOfflineEnvironments,
}}
>
<Probe />
</HomeListOptionsProvider>,
),
);
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());
});
12 changes: 10 additions & 2 deletions apps/mobile/src/features/home/home-list-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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:
Expand Down Expand Up @@ -49,20 +51,22 @@ 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<HomeListOptions>) => {
const current = registry.get(optionsAtom);
const next = { ...current, ...patch };
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,
},
});
}
Expand Down Expand Up @@ -98,6 +102,9 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<Environm
const setSelectedProjectKey = (value: string | null) => {
updateOptions({ selectedProjectKey: value });
};
const setHideOfflineEnvironments = (value: boolean) => {
updateOptions({ hideOfflineEnvironments: value });
};
const setProjectSortOrder = (value: HomeProjectSortOrder) => {
updateOptions({ projectSortOrder: value });
};
Expand All @@ -106,5 +113,6 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<Environm
setSelectedEnvironmentId,
setSelectedProjectKey,
setProjectSortOrder,
setHideOfflineEnvironments,
} as const;
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import { ModelEvaluationSettings } from "./ModelEvaluationSettings";
import { SkillsLibrarySettings } from "./SkillsLibrarySettings";
import { AutoSettleSettingsRows, ThreadSplitSettingsSection } from "./ThreadSettingsSections";
import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
import { useHomeListOptions } from "../home/home-list-options";
import { useEnvironments } from "../../state/environments";

type SettingsPage = "threads" | "source-control" | "worktrees" | "maintenance" | "zack";

Expand Down Expand Up @@ -182,6 +184,10 @@ export function SettingsZackRouteScreen() {
}

function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
const { environments } = useEnvironments();
const { options, setHideOfflineEnvironments } = useHomeListOptions(
new Set(environments.map((environment) => environment.environmentId)),
);
const environmentMenu = useSettingsEnvironmentMenu();
const insets = useSafeAreaInsets();
const navigation = useNavigation();
Expand Down Expand Up @@ -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" ? (
<SettingsSection title="Threads">
<SettingsSwitchRow
icon="wifi.slash"
label="Hide offline environments"
subtitle="Hide threads from offline environments on this device."
value={options.hideOfflineEnvironments}
onValueChange={setHideOfflineEnvironments}
/>
</SettingsSection>
) : null}
{!hasConnectedSelection || reference === null ? (
<Text className="px-2 text-base text-foreground-muted">
{projectSelected
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/settings/settings-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const SETTINGS_SEARCH_TERMS = {
"start from remote",
],
SettingsLabs: [
"hide offline environments",
"legacy",
"plan mode",
"beta",
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"}
</Text>
);

Expand Down
55 changes: 55 additions & 0 deletions apps/mobile/src/features/threads/useThreadListV2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,13 +18,18 @@ 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<ReadonlyArray<EnvironmentId>>([]) };
});
vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: undefined }));

it("reuses a large thread list across tab renders and updates when its inputs change", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
using _globals = { [Symbol.dispose]: () => vi.unstubAllGlobals() };
const shelfAtom = Atom.make<ReadonlyMap<EnvironmentId, OlderSettledShelfEnvironment>>(new Map());
vi.mocked(olderSettledThreads.shelfAtom).mockReturnValue(shelfAtom);
const offlineIds = offlineEnvironmentIdsAtom as Atom.Writable<ReadonlyArray<EnvironmentId>>;
const registry = AtomRegistry.make();
using _registry = { [Symbol.dispose]: () => registry.dispose() };
const environmentId = EnvironmentId.make("environment-1");
Expand All @@ -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(),
Expand Down Expand Up @@ -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<PendingNewTask> = [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(<Probe input={hiddenInput} tab="chats" />));
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(<Probe input={input} tab="chats" />));
expect(seen.at(-1)!.layout.items).toHaveLength(160);
await act(async () => root.render(<Probe input={hiddenInput} tab="chats" />));
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(<Probe input={{ ...input, queuedThreadKeys }} tab="chats" />));
const queued = seen.at(-1)!;
Expand Down Expand Up @@ -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(<Probe input={hiddenInput} tab="chats" />));
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(<Probe input={input} tab="chats" />));
expect(seen.at(-1)!.layout.settledCount).toBe(1100);
});
Loading
Loading