diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f8828..a740fbafeee5 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -30,6 +30,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 36cead9f8cc3..4265107912b8 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -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"; @@ -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"; @@ -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); @@ -291,7 +282,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(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); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 54e242c21661..22c0a31bda2f 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -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 { @@ -181,9 +182,7 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => 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(null); const listRef = useRef(null); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index ab167d505501..d58d4799432e 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -46,6 +46,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"; @@ -555,11 +556,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 ( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 799d969da3e1..ea8d4f07955e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -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"; @@ -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"; @@ -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( diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index e62b9ceda343..b3e9f73bfe17 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadListV2Items, + resolveThreadListV2Enabled, resolveThreadListV2Status, sortThreadsForListV2, } from "./threadListV2"; @@ -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({ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index efa68153a3f9..62c0b39aeb8d 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -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, ): ThreadListV2Status { diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts new file mode 100644 index 000000000000..bb03b5aa9ad0 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -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); + const loaded = AsyncResult.isSuccess(preferencesResult); + return resolveThreadListV2Enabled({ + preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, + preferencesLoaded: loaded, + appVariant: Constants.expoConfig?.extra?.appVariant, + }); +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 4e576bb2fe13..bbcf4131f31d 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -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; } diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e6ab..06d663ca0b4a 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -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; diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e1c87bcf0595..e517d40b04f3 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -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; @@ -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); + }); +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 53a9ae487844..90a45e8e25f0 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,7 +14,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"; @@ -115,7 +115,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 }); diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index d4d31af36828..532d546df9a3 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label} {(item) => item.disabled ? ( @@ -133,13 +133,13 @@ function CommandPaletteResultRow(props: { )} {props.item.titleTrailingContent} {props.item.timestamp ? ( - + {props.item.timestamp} ) : null} {shortcutLabel ? {shortcutLabel} : null} {props.item.kind === "submenu" ? ( - + ) : null} ); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc3e8ee832ff..201241731fae 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -4,6 +4,7 @@ import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; +import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Set(); @@ -40,7 +41,7 @@ function ProjectFaviconFallback({ readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; }) { - return ; + return ; } function ProjectFaviconImage({ @@ -64,7 +65,11 @@ function ProjectFaviconImage({ { loadedProjectFaviconSrcs.add(src); setStatus("loaded"); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b05b3a39d904..98b5dcf84edb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2218,9 +2218,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
- - - - - } - > - - Search - {commandPaletteShortcutLabel ? ( - - {commandPaletteShortcutLabel} - - ) : null} - - - - + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + } + > {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ed9eadd907b7..3d314fd1ac2a 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -242,38 +242,40 @@ function SidebarV2ThreadTooltip({ -
-
{thread.title}
-
+
+
+ {thread.title} +
+
{projectTitle ? (
-
{projectTitle}
+
{projectTitle}
) : null} {environmentLabel ? (
- -
{environmentLabel}
+ +
{environmentLabel}
) : null} {thread.branch ? (
- -
{thread.branch}
+ +
{thread.branch}
) : null} {branchMismatch ? (
- +
You're currently checked out on another branch.
@@ -284,15 +286,15 @@ function SidebarV2ThreadTooltip({ -
{modelLabel}
+
{modelLabel}
) : null} {thread.session?.lastError ? (
- -
{thread.session.lastError}
+ +
Error occurred
) : null}
@@ -815,9 +817,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Un-settle thread" onClick={handleUnsettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > - + ) : ( ) : null} @@ -2220,151 +2222,158 @@ export default function SidebarV2() { return ( <> - - -
-
- - } - > - -
Search
- {commandPaletteShortcutLabel ? ( - - {commandPaletteShortcutLabel} - - ) : null} -
-
-
- - +
+
+ } > - -
-
- - {projectGroups.length > 0 ? ( - -
- - - {scopedProjectGroup ? ( - +
Search
+ {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + +
+
+ + + } + > + + + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + +
+
+ {projectGroups.length > 0 ? ( +
+ + } > - + {scopedProjectGroup ? ( + + ) : ( - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; - return ( - - - {project.displayName} - - - ); - })} - - - - - + {project.displayName} + + + ); + })} + + + + + + } + > + + - New project - -
+ + New project + +
+ ) : null}
- ) : null} - + } + > + + Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more - - ({hiddenSettledCount} settled hidden) - ) : null} @@ -2547,7 +2554,7 @@ export default function SidebarV2() { onClick={openAddProjectCommandPalette} className="inline-flex items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-[11px] font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground" > - + Add project @@ -2567,48 +2574,52 @@ export default function SidebarV2() { }} > - + Project settings - - {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 - ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.` - : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`} + + Manage project names, grouping rules, and environments. +
+ {projectActionsTarget?.memberProjects.map((member) => ( +
+ + + {member.workspaceRoot} + + + + + + {member.environmentLabel ?? "Current environment"} + + +
+ ))} +
{projectActionsTarget?.memberProjects.map((member) => (
-
- -
-
- -

- {member.environmentLabel ?? "Current environment"} -

-
-

- {member.workspaceRoot} -

-
-
-
+
-
- - -
+ {projectActionsTarget.memberProjects.length > 1 ? ( +
+ +
+ ) : null}
))}
@@ -2727,8 +2728,26 @@ export default function SidebarV2() {
) : null} - - + + {projectActionsTarget?.memberProjects.length === 1 ? ( + + ) : null} + diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 866ed0c9b8cc..fa9a75dd3234 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -95,6 +95,7 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { @@ -167,7 +168,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ); } import { Button } from "../ui/button"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Select, SelectItem, SelectPopup, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { @@ -303,15 +304,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {props.interactionMode === "plan" ? ( - + ) : ( - + )} {props.interactionMode === "plan" ? "Plan" : "Build"} @@ -342,16 +341,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onValueChange={(value) => props.onRuntimeModeChange(value!)} > - } + render={} > - + {runtimeModeOption.label} @@ -387,22 +379,21 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop } > - {props.planSidebarLabel} diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 3f8f56f041a6..699c9cfd9c49 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -93,7 +93,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro {showCollapsedStackCap ? (
{item.icon} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 9021b6b46091..73fc63489056 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -139,7 +139,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ); }} > -
+
{props.items.length > 0 ? ( {groups.map((group, groupIndex) => ( diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx new file mode 100644 index 000000000000..8eab75171c82 --- /dev/null +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -0,0 +1,71 @@ +import type { ComponentProps } from "react"; +import { ChevronDownIcon, type LucideIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { SelectTrigger } from "../ui/select"; + +const composerControlClassName = + "h-7 min-h-7 gap-1.5 px-2.5 text-muted-foreground/70 transition-none hover:text-foreground/80 [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; + +export function ComposerControl({ + className, + size = "sm", + variant = "ghost", + ...props +}: ComponentProps) { + return ( + diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 2ec4be70994b..e86435561a2a 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -50,7 +50,7 @@ export const ModelListRow = memo(function ModelListRow(props: { disabled={Boolean(props.disabledReason)} contentClassName="flex w-full items-center gap-3" className={cn( - "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]", + "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2 transition-[background-color,box-shadow,color]", "hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-highlighted:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))]", props.disabledReason && "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent", diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index bbdbd8bd9d96..d317ee6061c6 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -43,6 +43,10 @@ type ModelPickerItem = { const EMPTY_MODEL_JUMP_LABELS = new Map(); +function ModelListSeparator() { + return
; +} + // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs // can contain colons (e.g. some vendor model ids), so we only split on the // first colon — anything after that is the slug. @@ -521,7 +525,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -571,12 +575,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Search bar */} -
-
+
+
+ } value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} @@ -618,8 +622,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Model list */} -
- +
+ ref={modelListRef} data={filteredModelKeys} @@ -656,12 +660,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { estimatedItemSize={60} drawDistance={480} recycleItems + contentContainerClassName="pl-2 pr-px" + ItemSeparatorComponent={ModelListSeparator} onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( - "scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", - showTopScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", - showBottomScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + "model-picker-list h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", + showTopScrollFade && "model-picker-list-scroll-fade-top", + showBottomScrollFade && "model-picker-list-scroll-fade-bottom", )} /> diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 36a608888b2e..24ec66cd6142 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -78,34 +78,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { if (!content) { return; } - const selectedButton = Array.from( + const selectedItem = Array.from( content.querySelectorAll("[data-model-picker-provider]"), - ).find((button) => button.dataset.modelPickerProvider === props.selectedInstanceId); - if (!selectedButton) { + ).find((item) => item.dataset.modelPickerProvider === props.selectedInstanceId); + if (!selectedItem) { setSelectedIndicatorTop(null); return; } - const contentRect = content.getBoundingClientRect(); - const selectedButtonRect = selectedButton.getBoundingClientRect(); - setSelectedIndicatorTop( - selectedButtonRect.top - - contentRect.top + - content.scrollTop + - selectedButtonRect.height / 2 - - 10, - ); + setSelectedIndicatorTop(selectedItem.offsetTop + selectedItem.offsetHeight / 2 - 10); }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); return ( -
+
-
+
{selectedIndicatorTop !== null ? (
-
+ <> +
handleSelect("favorites")} type="button" - data-model-picker-provider="favorites" aria-label="Favorites" > @@ -146,7 +131,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
-
+