Skip to content
Open
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/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
sidebarV2Enabled: false,
sidebarV2ConfiguredByUser: false,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
24 changes: 9 additions & 15 deletions apps/mobile/src/features/home/HomeHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { useCallback, useMemo, useRef } from "react";
import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";
Expand All @@ -12,7 +10,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
import { SymbolView } from "../../components/AppSymbol";
import { T3Wordmark } from "../../components/T3Wordmark";
import { useThemeColor } from "../../lib/useThemeColor";
import { mobilePreferencesAtom } from "../../state/preferences";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
Expand Down Expand Up @@ -59,21 +57,14 @@ function checkedMenuState(checked: boolean) {
return checked ? ("on" as const) : undefined;
}

/** Thread List v2 lays the list out in fixed creation order, so the
sort/group filter controls would be silently ignored — hide them and
key the "customized" icon state off the environment filter alone. */
function useThreadListV2FilterGate() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
return (
AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true
);
}

function AndroidHomeHeader(props: HomeHeaderProps) {
const insets = useSafeAreaInsets();
const iconColor = useThemeColor("--color-icon");
const mutedColor = useThemeColor("--color-foreground-muted");
const threadListV2Enabled = useThreadListV2FilterGate();
// Thread List v2 lays the list out in fixed creation order, so the
// sort/group filter controls would be silently ignored — hide them and
// key the "customized" icon state off the environment filter alone.
const threadListV2Enabled = useThreadListV2Enabled();
const hasCustomListOptions = threadListV2Enabled
? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
: hasCustomHomeListOptions(props);
Expand Down Expand Up @@ -291,7 +282,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
function IosHomeHeader(props: HomeHeaderProps) {
const searchBarRef = useRef<SearchBarCommands>(null);
const iconColor = useThemeColor("--color-icon");
const threadListV2Enabled = useThreadListV2FilterGate();
// Thread List v2 lays the list out in fixed creation order, so the
// sort/group filter controls would be silently ignored — hide them and
// key the "customized" icon state off the environment filter alone.
const threadListV2Enabled = useThreadListV2Enabled();
const hasCustomListOptions = threadListV2Enabled
? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
: hasCustomHomeListOptions(props);
Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { SavedRemoteConnection } from "../../lib/connection";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand Down Expand Up @@ -181,9 +182,7 @@ export function HomeScreen(props: HomeScreenProps) {
ReadonlyMap<string, HomeGroupDisplayState>
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled =
AsyncResult.isSuccess(preferencesResult) &&
preferencesResult.value.threadListV2Enabled === true;
const threadListV2Enabled = useThreadListV2Enabled();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand Down
6 changes: 2 additions & 4 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { runtime } from "../../lib/runtime";
import { useThemeColor } from "../../lib/useThemeColor";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";
Expand Down Expand Up @@ -546,11 +547,8 @@ function GeneralSettingsSection() {
* the counterpart of web's Settings → Beta backed by mobile preferences.
*/
function BetaSettingsSection() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult)
? preferencesResult.value.threadListV2Enabled === true
: false;
const threadListV2Enabled = useThreadListV2Enabled();

return (
<View className="gap-3">
Expand Down
8 changes: 2 additions & 6 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type {
import { LegendList } from "@legendapp/list/react-native";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
Expand All @@ -25,7 +24,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useThemeColor } from "../../lib/useThemeColor";
import { useProjects, useThreadShells } from "../../state/entities";
import { mobilePreferencesAtom } from "../../state/preferences";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
Expand Down Expand Up @@ -196,10 +195,7 @@ function ThreadNavigationSidebarPane(
const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
useThreadListActions();
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled =
AsyncResult.isSuccess(preferencesResult) &&
preferencesResult.value.threadListV2Enabled === true;
const threadListV2Enabled = useThreadListV2Enabled();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
buildThreadListV2Items,
resolveThreadListV2Enabled,
resolveThreadListV2Status,
sortThreadsForListV2,
} from "./threadListV2";
Expand Down Expand Up @@ -38,6 +39,47 @@ function makeThread(

const NOW = "2026-06-02T00:00:00.000Z";

describe("resolveThreadListV2Enabled", () => {
it.each(["development", "preview"])("defaults on for the %s variant", (appVariant) => {
expect(
resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }),
).toBe(true);
});

it.each(["production", undefined])("defaults off for the %s variant", (appVariant) => {
expect(
resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }),
).toBe(false);
});

it("prefers an explicit device choice over the variant default", () => {
expect(
resolveThreadListV2Enabled({
preference: false,
preferencesLoaded: true,
appVariant: "preview",
}),
).toBe(false);
expect(
resolveThreadListV2Enabled({
preference: true,
preferencesLoaded: true,
appVariant: "production",
}),
).toBe(true);
});

it("holds v1 while preferences are still loading so the list does not remount", () => {
expect(
resolveThreadListV2Enabled({
preference: undefined,
preferencesLoaded: false,
appVariant: "development",
}),
).toBe(false);
});
});

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand Down
31 changes: 31 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "
export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;

/**
* Whether Thread List v2 is on by default for an app variant. The `development`
* and `preview` variants are mobile's nightly equivalents and opt in;
* `production` stays on v1. Counterpart of web's `resolveSidebarV2Default`.
*/
export function resolveThreadListV2Default(appVariant: unknown): boolean {
return appVariant === "development" || appVariant === "preview";
}

/**
* Resolved Thread List v2 state: the device-local preference if the user has
* set one, otherwise the default for this app variant. Preferences persist as
* sparse patches, so `undefined` genuinely means "never chosen".
*
* `preferencesLoaded` guards the startup window: preferences load
* asynchronously, and treating "still loading" as "never chosen" would mount
* v2 on a development build and then flip to v1 once a stored opt-out arrives,
* remounting the whole list. While loading, hold v1 — the state both variants
* already start from.
*/
export function resolveThreadListV2Enabled(input: {
readonly preference: boolean | undefined;
readonly preferencesLoaded: boolean;
readonly appVariant: unknown;
}): boolean {
if (!input.preferencesLoaded) {
return false;
}
return input.preference ?? resolveThreadListV2Default(input.appVariant);
}

export function resolveThreadListV2Status(
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
): ThreadListV2Status {
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import Constants from "expo-constants";

import { mobilePreferencesAtom } from "../../state/preferences";
import { resolveThreadListV2Enabled } from "./threadListV2";

/**
* Resolved Thread List v2 state: the device-local preference if the user has
* set one, otherwise the default for this app variant (on for development and
* preview, off for production). Every consumer must read through this rather
* than the raw preference, which is undefined until explicitly chosen.
*
* Kept out of `state/preferences.ts` so that module stays importable from node
* test environments, which have no `__DEV__` for expo-constants.
*/
export function useThreadListV2Enabled(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveThreadListV2Enabled({
preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined,
preferencesLoaded: loaded,
appVariant: Constants.expoConfig?.extra?.appVariant,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}
3 changes: 2 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export interface Preferences {
/**
* Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no
* client-settings sync, so the flat v2 thread list is opted into per
* device.
* device. Undefined means the user has never chosen, in which case the app
* variant decides — see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
}
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/branding.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,51 @@ export function formatAppDisplayName(input: {
return `${input.baseName} (${input.stageLabel})`;
}

/**
* Whether the sidebar v2 beta is on by default for a build stage.
*
* Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved
* from the client's own stage label rather than the connected server's version:
* v2 only exists in the client, so a stable client on a nightly server has
* nothing to turn on.
*/
export function resolveSidebarV2Default(stageLabel: string): boolean {
const stage = stageLabel.trim().toLowerCase();
return stage === "nightly" || stage === "dev";
}

/**
* Resolved sidebar v2 state: an explicit choice if the user has made one,
* otherwise the default for this build stage.
*
* A stored `enabled: true` counts as an explicit choice even without the
* companion flag. `true` was never the schema default, so it can only have come
* from the Settings → Beta toggle — settings written before that flag existed
* would otherwise lose the opt-in and drop such users back to v1 on production.
* Mirrors how `normalizeDesktopSettingsDocument` treats a legacy stored
* `updateChannel: "nightly"` as user-configured.
*
* `settingsHydrated` guards the startup window: client settings load
* asynchronously and the pre-hydration snapshot is just the schema defaults, so
* resolving against it would mount one sidebar and swap it out a tick later,
* remounting the tree. While hydrating, hold v1 — where both paths already
* start.
*/
export function resolveSidebarV2Enabled(input: {
readonly enabled: boolean;
readonly configuredByUser: boolean;
readonly settingsHydrated: boolean;
readonly stageLabel: string;
}): boolean {
if (!input.settingsHydrated) {
return false;
}

return input.configuredByUser || input.enabled
? input.enabled
: resolveSidebarV2Default(input.stageLabel);
}

export function resolveServerBackedAppStageLabel(input: {
readonly primaryServerVersion: string | null | undefined;
readonly fallbackStageLabel: string;
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/branding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import {
resolveServerBackedAppDisplayName,
resolveServerBackedAppStageLabel,
resolveSidebarV2Default,
resolveSidebarV2Enabled,
} from "./branding.logic";

const originalWindow = globalThis.window;
Expand Down Expand Up @@ -114,3 +116,74 @@ describe("branding logic", () => {
).toBe("T3 Code (Alpha)");
});
});

describe("resolveSidebarV2Default", () => {
it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => {
expect(resolveSidebarV2Default(stage)).toBe(true);
});

it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => {
expect(resolveSidebarV2Default(stage)).toBe(false);
});
});

describe("resolveSidebarV2Enabled", () => {
const hydrated = { settingsHydrated: true } as const;

it.each(["Alpha", "Latest"])(
"keeps a legacy opt-in on %s builds even without the companion flag",
(stageLabel) => {
// `true` was never the schema default, so it can only be an explicit
// opt-in from settings written before `sidebarV2ConfiguredByUser` existed.
expect(
resolveSidebarV2Enabled({
...hydrated,
enabled: true,
configuredByUser: false,
stageLabel,
}),
).toBe(true);
},
);

it("applies the stage default when the beta was never enabled or configured", () => {
expect(
resolveSidebarV2Enabled({
...hydrated,
enabled: false,
configuredByUser: false,
stageLabel: "Nightly",
}),
).toBe(true);
expect(
resolveSidebarV2Enabled({
...hydrated,
enabled: false,
configuredByUser: false,
stageLabel: "Latest",
}),
).toBe(false);
});

it("honors an explicit opt-out over the stage default", () => {
expect(
resolveSidebarV2Enabled({
...hydrated,
enabled: false,
configuredByUser: true,
stageLabel: "Nightly",
}),
).toBe(false);
});

it("holds v1 until settings hydrate so the sidebar does not remount", () => {
expect(
resolveSidebarV2Enabled({
enabled: true,
configuredByUser: true,
settingsHydrated: false,
stageLabel: "Nightly",
}),
).toBe(false);
});
});
Loading
Loading