Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 16 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,21 @@ 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, appVariant })).toBe(true);
});

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

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

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand Down
21 changes: 21 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,27 @@ 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".
*/
export function resolveThreadListV2Enabled(input: {
readonly preference: boolean | undefined;
readonly appVariant: unknown;
}): boolean {
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.
return resolveThreadListV2Enabled({
preference: AsyncResult.isSuccess(preferencesResult)
? preferencesResult.value.threadListV2Enabled
: undefined,
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
13 changes: 13 additions & 0 deletions apps/web/src/branding.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ 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";
}

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

const originalWindow = globalThis.window;
Expand Down Expand Up @@ -114,3 +115,13 @@ 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);
});
});
4 changes: 2 additions & 2 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import { cn, isMacPlatform } from "../lib/utils";
import { primaryServerKeybindingsAtom } from "../state/server";
import { useClientSettings } from "../hooks/useSettings";
import { useSidebarV2Enabled } from "../hooks/useSettings";
import ThreadSidebar from "./Sidebar";
import ThreadSidebarV2 from "./SidebarV2";
import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
Expand Down Expand Up @@ -100,7 +100,7 @@ function SidebarControl() {

export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
const sidebarV2Enabled = useSidebarV2Enabled();
// Settings routes render the settings nav, which lives in the v1 component
// and is identical for both sidebars — so v1 stays mounted there.
const pathname = useLocation({ select: (location) => location.pathname });
Expand Down
17 changes: 14 additions & 3 deletions apps/web/src/components/settings/BetaSettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useEffect, useState } from "react";

import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";
import {
useClientSettings,
useSidebarV2Enabled,
useUpdateClientSettings,
} from "../../hooks/useSettings";
import { Input } from "../ui/input";
import { Switch } from "../ui/switch";
import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
Expand Down Expand Up @@ -51,7 +55,7 @@ function AutoSettleDaysInput({
}

export function BetaSettingsPanel() {
const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
const sidebarV2Enabled = useSidebarV2Enabled();
const sidebarAutoSettleAfterDays = useClientSettings(
(settings) => settings.sidebarAutoSettleAfterDays,
);
Expand All @@ -66,7 +70,14 @@ export function BetaSettingsPanel() {
control={
<Switch
checked={sidebarV2Enabled}
onCheckedChange={(checked) => updateSettings({ sidebarV2Enabled: Boolean(checked) })}
// Touching the switch pins the choice, so a nightly build that
// defaults v2 on does not flip it back after the user opts out.
onCheckedChange={(checked) =>
updateSettings({
sidebarV2Enabled: Boolean(checked),
sidebarV2ConfiguredByUser: true,
})
}
aria-label="Enable the sidebar v2 beta"
/>
}
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
type UnifiedSettings,
} from "@t3tools/contracts/settings";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import { APP_STAGE_LABEL } from "~/branding";
import { resolveSidebarV2Default } from "~/branding.logic";
import { ensureLocalApi } from "~/localApi";
import * as Struct from "effect/Struct";
import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
Expand Down Expand Up @@ -218,6 +220,21 @@ export function useClientSettings<T = ClientSettings>(
return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
}

/**
* Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user
* has made one, otherwise the default for this build stage (on for nightly and
* dev, off for production). Every consumer must read through this rather than
* `settings.sidebarV2Enabled`, which is only meaningful alongside
* `sidebarV2ConfiguredByUser`.
*/
export function useSidebarV2Enabled(): boolean {
return useClientSettings((settings) =>
settings.sidebarV2ConfiguredByUser
? settings.sidebarV2Enabled
: resolveSidebarV2Default(APP_STAGE_LABEL),
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

/** Read current settings for one environment, merged with client-local preferences. */
export function useEnvironmentSettings<T = UnifiedSettings>(
environmentId: EnvironmentId,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useAtomValue } from "@effect/atom-react";
import { useEffect, useMemo } from "react";

import { isCommandPaletteOpen } from "../commandPaletteBus";
import { useClientSettings } from "../hooks/useSettings";
import { useClientSettings, useSidebarV2Enabled } from "../hooks/useSettings";
import { openCommandPalette } from "../commandPaletteBus";
import { useProjects } from "../state/entities";
import { usePrimaryEnvironmentId } from "../state/environments";
Expand All @@ -28,7 +28,7 @@ function ChatRouteGlobalShortcuts() {
const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
useHandleNewThread();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
const sidebarV2Enabled = useSidebarV2Enabled();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const projects = useProjects();
const primaryEnvironmentId = usePrimaryEnvironmentId();
Expand Down
16 changes: 16 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ describe("ClientSettings sidebar v2", () => {
expect(settings.sidebarAutoSettleAfterDays).toBe(3);
});

it("treats settings written before the beta had a per-channel default as unconfigured", () => {
// The stored blob always carries `sidebarV2Enabled`, so only the companion
// flag can distinguish "user opted out" from "never touched it".
expect(decodeClientSettings({ sidebarV2Enabled: false }).sidebarV2ConfiguredByUser).toBe(false);
expect(decodeClientSettings({ sidebarV2Enabled: true }).sidebarV2ConfiguredByUser).toBe(false);
});

it("preserves an explicit beta choice", () => {
const settings = decodeClientSettings({
sidebarV2Enabled: false,
sidebarV2ConfiguredByUser: true,
});
expect(settings.sidebarV2Enabled).toBe(false);
expect(settings.sidebarV2ConfiguredByUser).toBe(true);
});

it("allows auto-settle by inactivity to be disabled", () => {
expect(
decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays,
Expand Down
Loading
Loading