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
3 changes: 1 addition & 2 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ const clientSettings: ClientSettings = {
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
sidebarV2Enabled: false,
sidebarV2ConfiguredByUser: false,
legacySidebarEnabled: false,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ function LocalSettingsRouteScreen() {
<SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
</SettingsSection>

<BetaSettingsSection />
<LegacySettingsSection />

<ArchivedThreadsSettingsSection />

Expand Down Expand Up @@ -519,7 +519,7 @@ function ConfiguredSettingsRouteScreen() {
<SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
</SettingsSection>

<BetaSettingsSection />
<LegacySettingsSection />

<ArchivedThreadsSettingsSection />

Expand All @@ -538,26 +538,27 @@ function GeneralSettingsSection() {
}

/**
* Device-local beta toggles. Mobile has no client-settings sync, so this is
* the counterpart of web's Settings → Beta backed by mobile preferences.
* Device-local legacy toggles. Mobile has no client-settings sync, so this is
* the counterpart of web's Settings → General → Legacy features backed by
* mobile preferences.
*/
function BetaSettingsSection() {
function LegacySettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();

return (
<View className="gap-3">
<SettingsSection title="Beta">
<SettingsSection title="Legacy">
<SettingsSwitchRow
icon="sidebar.left"
label="Thread List v2"
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
label="Legacy Thread List"
value={!threadListV2Enabled}
onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })}
/>
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
Brings back the original grouped thread list. The default list is flat, in creation order:
active work renders as cards; settled threads collapse to compact rows.
</Text>
</View>
);
Expand Down
22 changes: 13 additions & 9 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,24 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {

describe("resolveThreadListV2Enabled", () => {
it("defaults on when the device has never chosen", () => {
expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe(
true,
);
expect(
resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false);
expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true);
it("honors an explicit legacy opt-in", () => {
expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe(
false,
);
expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe(
true,
);
});

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

Expand Down
12 changes: 6 additions & 6 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,23 +103,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;

/**
* Thread List v2 is on by default on every app variant; the Settings → Beta
* toggle is an opt-out. Preferences persist as sparse patches, so `undefined`
* genuinely means "never chosen".
* The flat Thread List v2 is the default on every app variant; the Settings →
* Legacy toggle opts a device back into the grouped legacy list. Preferences
* persist as sparse patches, so `undefined` genuinely means "never chosen".
*
* `preferencesLoaded` guards the startup window: preferences load
* asynchronously, and rendering one list before the stored choice arrives would
* remount the whole thing a tick later. While loading, hold the default — that
* is where every device without an explicit opt-out lands anyway.
* is where every device without an explicit legacy opt-in lands anyway.
*/
export function resolveThreadListV2Enabled(input: {
readonly preference: boolean | undefined;
readonly legacyPreference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
return input.legacyPreference !== true;
}

export function resolveThreadListV2Status(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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 (on). Every consumer must read through this
* Resolved Thread List v2 state: on unless the device opted into the legacy
* grouped list (Settings → Legacy). Every consumer must read through this
* rather than the raw preference, which is undefined until explicitly chosen.
*/
export function useThreadListV2Enabled(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveThreadListV2Enabled({
preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined,
legacyPreference: loaded ? preferencesResult.value.legacyThreadListEnabled : undefined,
preferencesLoaded: loaded,
});
}
17 changes: 9 additions & 8 deletions apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ export interface Preferences {
readonly projectGroupingEnabled?: boolean;
readonly projectGroupingMode?: SidebarProjectGroupingMode;
/**
* Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no
* client-settings sync, so the flat v2 thread list is opted out of per
* device. Undefined means the user has never chosen, which resolves to on —
* see `resolveThreadListV2Enabled`.
* Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
* no client-settings sync, so the legacy grouped thread list is opted into
* per device. Deliberately a fresh key (was `threadListV2Enabled`, an
* opt-out): sanitizing drops the old key, so every device resets to the
* default flat list — see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
readonly legacyThreadListEnabled?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand Down Expand Up @@ -84,7 +85,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
collapsedProjectGroups?: readonly string[];
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
threadListV2Enabled?: boolean;
legacyThreadListEnabled?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand Down Expand Up @@ -121,8 +122,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
) {
preferences.projectGroupingMode = parsed.projectGroupingMode;
}
if (typeof parsed.threadListV2Enabled === "boolean") {
preferences.threadListV2Enabled = parsed.threadListV2Enabled;
if (typeof parsed.legacyThreadListEnabled === "boolean") {
preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
}
return preferences;
}
Expand Down
45 changes: 0 additions & 45 deletions apps/web/src/branding.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,51 +11,6 @@ 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: 0 additions & 73 deletions apps/web/src/branding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ 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 @@ -116,74 +114,3 @@ 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);
});
});
26 changes: 17 additions & 9 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import { cn, isMacPlatform } from "../lib/utils";
import { primaryServerKeybindingsAtom } from "../state/server";
import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings";
import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
import LegacyThreadSidebar from "./LegacySidebar";
import ThreadSidebar from "./Sidebar";
import ThreadSidebarV2 from "./SidebarV2";
import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
import {
resolveInitialThreadSidebarWidth,
Expand Down Expand Up @@ -118,13 +120,11 @@ function SidebarControl() {

export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
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 legacySidebarEnabled = useLegacySidebarEnabled();
// Settings routes show the settings nav in place of whichever thread
// sidebar is active.
const pathname = useLocation({ select: (location) => location.pathname });
const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/");
const useSidebarV2 = sidebarV2Enabled && !isOnSettings;
const useSidebarV2Theme = useSidebarV2 || isOnSettings;
const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
// Subscribed rather than read once: the clamp must track live window size,
Expand Down Expand Up @@ -188,7 +188,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
side="left"
collapsible="offcanvas"
data-app-sidebar=""
data-sidebar-version={useSidebarV2Theme ? "v2" : "v1"}
className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
resizable={{
maxWidth: sidebarMaximumWidth,
Expand All @@ -200,7 +199,16 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
onResize: setSidebarWidth,
}}
>
{useSidebarV2 ? <ThreadSidebarV2 /> : <ThreadSidebar />}
{isOnSettings ? (
<>
<SidebarChromeHeader isElectron={isElectron} />
<SettingsSidebarNav pathname={pathname} />
</>
) : legacySidebarEnabled ? (
<LegacyThreadSidebar />
) : (
<ThreadSidebar />
)}
<SidebarRail />
</Sidebar>
{children}
Expand Down
Loading
Loading