diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 3a0342a8214..cf9cf378edd 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -4,6 +4,8 @@ import type { 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"; @@ -14,6 +16,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 { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; @@ -21,6 +24,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, + type HomeListFilterMenuProject, } from "./home-list-filter-menu"; import { hasCustomHomeListOptions, @@ -33,13 +37,16 @@ export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; export function HomeHeader(props: { readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; @@ -59,11 +66,24 @@ 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 hasCustomListOptions = hasCustomHomeListOptions(props); + const threadListV2Enabled = useThreadListV2FilterGate(); + const hasCustomListOptions = threadListV2Enabled + ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + : hasCustomHomeListOptions(props); const menuActions = useMemo( () => [ { @@ -82,40 +102,67 @@ function AndroidHomeHeader(props: HomeHeaderProps) { })), ], }, - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectSortOrder === option.value), - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.threadSortOrder === option.value), - })), - }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectGroupingMode === option.value), - })), - }, + ...(props.projects.length === 0 + ? [] + : ([ + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + state: checkedMenuState(props.selectedProjectKey === null), + }, + ...props.projects.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: checkedMenuState(props.selectedProjectKey === project.key), + })), + ], + }, + ] satisfies MenuAction[])), + ...(threadListV2Enabled + ? [] + : ([ + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectSortOrder === option.value), + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.threadSortOrder === option.value), + })), + }, + { + id: "project-grouping", + title: "Group projects", + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ + id: `project-grouping:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectGroupingMode === option.value), + })), + }, + ] satisfies MenuAction[])), ], [ props.environments, props.projectGroupingMode, props.projectSortOrder, + props.projects, props.selectedEnvironmentId, + props.selectedProjectKey, props.threadSortOrder, + threadListV2Enabled, ], ); const handleMenuAction = useCallback( @@ -137,6 +184,19 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return; } + if (id === "project:all") { + props.onProjectChange(null); + return; + } + + if (id.startsWith("project:")) { + const projectKey = id.slice("project:".length); + if (props.projects.some((project) => project.key === projectKey)) { + props.onProjectChange(projectKey); + } + return; + } + const projectSort = PROJECT_SORT_OPTIONS.find( (option) => id === `project-sort:${option.value}`, ); @@ -255,17 +315,24 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const hasCustomListOptions = hasCustomHomeListOptions(props); + const threadListV2Enabled = useThreadListV2FilterGate(); + const hasCustomListOptions = threadListV2Enabled + ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + : hasCustomHomeListOptions(props); const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu(props); + const filterMenu = buildHomeListFilterMenu({ + ...props, + listOrganization: !threadListV2Enabled, + }); return ( <> + {props.projects.length > 0 ? ( + + Project + props.onProjectChange(null)} + subtitle="Show threads from every project" + > + All projects + + {props.projects.map((project) => ( + props.onProjectChange(project.key)} + > + {project.label} + + ))} + + ) : null} + Sort projects {PROJECT_SORT_OPTIONS.map((option) => ( diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 502d2bab1b5..d4b19cdb0fb 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,9 +1,10 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -58,6 +59,28 @@ export function HomeRouteScreen() { setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; + const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const projectFilterOptions = useMemo( + () => + projects + .filter( + (project) => + selectedEnvironmentId === null || project.environmentId === selectedEnvironmentId, + ) + .map((project) => ({ + key: scopedProjectKey(project.environmentId, project.id), + label: project.title, + })), + [projects, selectedEnvironmentId], + ); + useEffect(() => { + if ( + selectedProjectKey !== null && + !projectFilterOptions.some((project) => project.key === selectedProjectKey) + ) { + setSelectedProjectKey(null); + } + }, [projectFilterOptions, selectedProjectKey]); // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. @@ -90,12 +113,15 @@ export function HomeRouteScreen() { navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectGroupingModeChange={setProjectGroupingMode} onProjectSortOrderChange={setProjectSortOrder} @@ -115,6 +141,7 @@ export function HomeRouteScreen() { onSettleThread={settleThread} onUnsettleThread={unsettleThread} onEnvironmentChange={setSelectedEnvironmentId} + onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) } @@ -151,6 +178,7 @@ export function HomeRouteScreen() { savedConnectionsById={savedConnectionsById} searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} + selectedProjectKey={selectedProjectKey} threads={threads} threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d1339a9bb91..41180c48643 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -38,7 +38,12 @@ import { ThreadListShowMoreRow, } from "../threads/thread-list-items"; import { ThreadListV2Row } from "../threads/thread-list-v2-items"; -import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2"; +import { + buildThreadListV2Items, + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + THREAD_LIST_V2_SETTLED_PAGE_COUNT, + type ThreadListV2Item, +} from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, @@ -65,11 +70,13 @@ interface HomeScreenProps { readonly environments: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; @@ -91,10 +98,6 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; -// v2 settled-tail paging: recent history is the common lookup; the deep -// tail stays behind an explicit Show more. -const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; -const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -169,80 +172,6 @@ function HomeTopContentSpacer() { return ; } -function ThreadListV2ProjectScope(props: { - readonly projects: ReadonlyArray; - readonly selectedKey: string | null; - readonly onChange: (key: string | null) => void; -}) { - if (props.projects.length === 0) return null; - - return ( - - {props.projects.length > 1 ? ( - props.onChange(null)} - className={cn( - "min-h-8 items-center justify-center rounded-lg border px-3", - props.selectedKey === null - ? "border-border bg-subtle-strong" - : "border-black/15 dark:border-white/15", - )} - > - All - - ) : null} - {props.projects.map((project) => { - const key = scopedProjectKey(project.environmentId, project.id); - const selected = props.selectedKey === key; - return ( - props.onChange(selected ? null : key)} - className={cn( - "min-h-8 flex-row items-center gap-1.5 rounded-lg border py-1 pl-2 pr-3", - selected ? "border-border bg-subtle-strong" : "border-black/15 dark:border-white/15", - )} - > - - - {project.title} - - - ); - })} - - ); -} - /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { @@ -314,12 +243,51 @@ export function HomeScreen(props: HomeScreenProps) { onScrollBeginDrag: handleScrollBeginDrag, }); + const scopedProject = useMemo( + () => + props.selectedProjectKey === null + ? null + : (props.projects.find( + (project) => + scopedProjectKey(project.environmentId, project.id) === props.selectedProjectKey && + (props.selectedEnvironmentId === null || + project.environmentId === props.selectedEnvironmentId), + ) ?? null), + [props.projects, props.selectedEnvironmentId, props.selectedProjectKey], + ); + const scopedProjects = useMemo( + () => (scopedProject === null ? props.projects : [scopedProject]), + [props.projects, scopedProject], + ); + const scopedThreads = useMemo( + () => + scopedProject === null + ? props.threads + : props.threads.filter( + (thread) => + thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id, + ), + [props.threads, scopedProject], + ); + const scopedPendingTasks = useMemo( + () => + scopedProject === null + ? props.pendingTasks + : props.pendingTasks.filter( + (pendingTask) => + pendingTask.message.environmentId === scopedProject.environmentId && + pendingTask.creation.projectId === scopedProject.id, + ), + [props.pendingTasks, scopedProject], + ); + const projectGroups = useMemo( () => buildHomeThreadGroups({ - projects: props.projects, - threads: props.threads, - pendingTasks: props.pendingTasks, + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: props.projectSortOrder, @@ -327,14 +295,14 @@ export function HomeScreen(props: HomeScreenProps) { projectGroupingMode: props.projectGroupingMode, }), [ - props.pendingTasks, props.projectGroupingMode, - props.projects, props.projectSortOrder, props.searchQuery, props.selectedEnvironmentId, props.threadSortOrder, - props.threads, + scopedPendingTasks, + scopedProjects, + scopedThreads, ], ); @@ -365,7 +333,8 @@ export function HomeScreen(props: HomeScreenProps) { return map; }, [props.projects]); - const [v2ProjectScopeKey, setV2ProjectScopeKey] = useState(null); + const v2ProjectScopeKey = props.selectedProjectKey; + const setV2ProjectScopeKey = props.onProjectChange; const v2ScopeProjects = useMemo( () => props.selectedEnvironmentId === null @@ -382,12 +351,6 @@ export function HomeScreen(props: HomeScreenProps) { ) ?? null), [v2ProjectScopeKey, v2ScopeProjects], ); - useEffect(() => { - if (v2ProjectScopeKey !== null && v2ScopedProject === null) { - setV2ProjectScopeKey(null); - } - }, [v2ProjectScopeKey, v2ScopedProject]); - // Thread List v2 (beta): one flat list in creation order, no grouping. // Settled threads collapse into a recency tail below the card block. // Settled threads stay in the live shell stream (settled ≠ archived), so @@ -442,6 +405,10 @@ export function HomeScreen(props: HomeScreenProps) { const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); useEffect(() => { if (!threadListV2Enabled) return; + // Refresh immediately on enable: the mount-time value can be hours old + // by the time the beta is switched on, which would misclassify the + // inactivity auto-settle boundary until the first tick. + setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); }, [threadListV2Enabled]); @@ -509,6 +476,11 @@ export function HomeScreen(props: HomeScreenProps) { (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), )?.driver ?? null } + environmentLabel={ + Object.keys(props.savedConnectionsById).length > 1 + ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null) + : null + } onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} @@ -535,6 +507,7 @@ export function HomeScreen(props: HomeScreenProps) { projectCwdByKey, props.onArchiveThread, props.onSelectThread, + props.savedConnectionsById, serverConfigs, settlementEnvironmentIds, ], @@ -731,14 +704,11 @@ export function HomeScreen(props: HomeScreenProps) { pendingTask.creation.projectId === v2ScopedProject.id)) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); + // Project scoping lives in the header filter menu (no inline chip row on + // mobile — the menu is the one filter surface). const v2ListHeader = ( <> {listHeader} - {v2PendingTasks.map((pendingTask, index) => ( + ) : scopedProject !== null ? ( + ) : selectedEnvironmentLabel ? ( 0 ? ( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts new file mode 100644 index 00000000000..916e32671a1 --- /dev/null +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { buildHomeListFilterMenu } from "./home-list-filter-menu"; + +describe("buildHomeListFilterMenu", () => { + it("adds a project scope submenu that selects and clears the same scope as the chips", () => { + const onProjectChange = vi.fn(); + const menu = buildHomeListFilterMenu({ + environments: [], + projects: [ + { key: "environment-1:project-1", label: "Codething" }, + { key: "environment-1:project-2", label: "Website" }, + ], + selectedEnvironmentId: null, + selectedProjectKey: "environment-1:project-1", + projectSortOrder: "updated_at", + threadSortOrder: "updated_at", + projectGroupingMode: "repository", + onEnvironmentChange: vi.fn(), + onProjectChange, + onProjectSortOrderChange: vi.fn(), + onThreadSortOrderChange: vi.fn(), + onProjectGroupingModeChange: vi.fn(), + }); + + const projectMenu = menu.items.find( + (item) => item.type === "submenu" && item.title === "Project", + ); + expect(projectMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "All projects", state: "off" }, + { title: "Codething", state: "on" }, + { title: "Website", state: "off" }, + ], + }); + if (projectMenu?.type !== "submenu") throw new Error("Expected project submenu"); + + projectMenu.items[0]?.onPress(); + projectMenu.items[2]?.onPress(); + expect(onProjectChange).toHaveBeenNthCalledWith(1, null); + expect(onProjectChange).toHaveBeenNthCalledWith(2, "environment-1:project-2"); + }); +}); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index 46ea639677b..73fda2f5d03 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -16,6 +16,11 @@ export interface HomeListFilterMenuEnvironment { readonly label: string; } +export interface HomeListFilterMenuProject { + readonly key: string; + readonly label: string; +} + type HomeListFilterMenuAction = { readonly type: "action"; readonly title: string; @@ -37,15 +42,22 @@ export interface HomeListFilterMenu { export function buildHomeListFilterMenu(props: { readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; readonly onOpenSettings?: () => void; + /** False hides the sort/group submenus. Thread List v2 uses a fixed + creation-order layout, so offering those controls while it silently + ignores them would be a lie; the environment filter still applies. */ + readonly listOrganization?: boolean; }): HomeListFilterMenu { const items: Array = []; @@ -57,61 +69,86 @@ export function buildHomeListFilterMenu(props: { }); } - items.push( - { + items.push({ + type: "submenu", + title: "Environment", + items: [ + { + type: "action", + title: "All environments", + subtitle: "Show threads from every environment", + state: props.selectedEnvironmentId === null ? "on" : "off", + onPress: () => props.onEnvironmentChange(null), + }, + ...props.environments.map((environment) => ({ + type: "action" as const, + title: environment.label, + state: + props.selectedEnvironmentId === environment.environmentId + ? ("on" as const) + : ("off" as const), + onPress: () => props.onEnvironmentChange(environment.environmentId), + })), + ], + }); + + if (props.projects.length > 0) { + items.push({ type: "submenu", - title: "Environment", + title: "Project", items: [ { type: "action", - title: "All environments", - subtitle: "Show threads from every environment", - state: props.selectedEnvironmentId === null ? "on" : "off", - onPress: () => props.onEnvironmentChange(null), + title: "All projects", + subtitle: "Show threads from every project", + state: props.selectedProjectKey === null ? "on" : "off", + onPress: () => props.onProjectChange(null), }, - ...props.environments.map((environment) => ({ + ...props.projects.map((project) => ({ type: "action" as const, - title: environment.label, - state: - props.selectedEnvironmentId === environment.environmentId - ? ("on" as const) - : ("off" as const), - onPress: () => props.onEnvironmentChange(environment.environmentId), + title: project.label, + state: props.selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + onPress: () => props.onProjectChange(project.key), })), ], - }, - { - type: "submenu", - title: "Sort projects", - items: PROJECT_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.projectSortOrder === option.value ? "on" : "off", - onPress: () => props.onProjectSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Sort threads", - items: THREAD_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.threadSortOrder === option.value ? "on" : "off", - onPress: () => props.onThreadSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Group projects", - items: PROJECT_GROUPING_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - subtitle: option.subtitle, - state: props.projectGroupingMode === option.value ? "on" : "off", - onPress: () => props.onProjectGroupingModeChange(option.value), - })), - }, - ); + }); + } + + if (props.listOrganization !== false) { + items.push( + { + type: "submenu", + title: "Sort projects", + items: PROJECT_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.projectSortOrder === option.value ? "on" : "off", + onPress: () => props.onProjectSortOrderChange(option.value), + })), + }, + { + type: "submenu", + title: "Sort threads", + items: THREAD_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.threadSortOrder === option.value ? "on" : "off", + onPress: () => props.onThreadSortOrderChange(option.value), + })), + }, + { + type: "submenu", + title: "Group projects", + items: PROJECT_GROUPING_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + subtitle: option.subtitle, + state: props.projectGroupingMode === option.value ? "on" : "off", + onPress: () => props.onProjectGroupingModeChange(option.value), + })), + }, + ); + } return { title: "Thread list options", diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index 788be25906b..651a64f6f83 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -27,5 +27,8 @@ describe("home list options", () => { hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), ).toBe(true); expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true); + expect( + hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }), + ).toBe(true); }); }); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 64881034306..919cec55ef1 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -93,13 +93,16 @@ export function HomeListOptionsProvider({ children }: PropsWithChildren) { return createElement(HomeListOptionsContext, { value }, children); } -export function hasCustomHomeListOptions(options: HomeListOptions): boolean { +export function hasCustomHomeListOptions( + options: HomeListOptions & { readonly selectedProjectKey?: string | null }, +): boolean { const defaultProjectSortOrder = DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; return ( options.selectedEnvironmentId !== null || + (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || options.projectSortOrder !== defaultProjectSortOrder || options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 666169f3039..186c606ae8f 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -36,6 +36,8 @@ import { AppText as Text } from "../../components/AppText"; const ACTION_ITEM_WIDTH = 58; const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; +const COMPACT_ACTION_CIRCLE_SIZE = 28; +const COMPACT_ACTION_ICON_SIZE = 13; export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; export const THREAD_SWIPE_SPRING = { @@ -163,6 +165,9 @@ export function useSwipeableScrollGate(options?: { export function ThreadSwipeable(props: { readonly backgroundColor: ColorValue; readonly children: (close: () => void) => ReactNode; + /** Uses action visuals that fit inside compact 44pt rows. The press target + * still spans the row's full height and width. */ + readonly compactActions?: boolean; readonly containerStyle?: StyleProp; /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; @@ -258,6 +263,7 @@ export function ThreadSwipeable(props: { renderRightActions={(_progress, translation, methods) => ( ["name"]; @@ -293,6 +300,8 @@ function SwipeActionButton(props: { readonly stretchesOnFullSwipe: boolean; readonly translation: SharedValue; }) { + const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; + const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); @@ -324,7 +333,7 @@ function SwipeActionButton(props: { return { transform: [{ translateX: -stretch }], - width: ACTION_CIRCLE_SIZE + stretch, + width: circleSize + stretch, }; }); const iconStyle = useAnimatedStyle(() => { @@ -386,13 +395,13 @@ function SwipeActionButton(props: { width: "100%", })} > - + - + - + {props.label} @@ -434,6 +443,7 @@ function SwipeActionButton(props: { export function ThreadSwipeActions(props: { readonly backgroundColor: ColorValue; + readonly compact: boolean; readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeThreshold: number; readonly onDelete: () => void; @@ -466,6 +476,7 @@ export function ThreadSwipeActions(props: { (null); const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -193,19 +224,77 @@ function ThreadNavigationSidebarPane( setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); + const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const projectFilterOptions = useMemo( + () => + projects + .filter( + (project) => + options.selectedEnvironmentId === null || + project.environmentId === options.selectedEnvironmentId, + ) + .map((project) => ({ + key: scopedProjectKey(project.environmentId, project.id), + label: project.title, + })), + [options.selectedEnvironmentId, projects], + ); + const selectedProject = useMemo( + () => + selectedProjectKey === null + ? null + : (projects.find( + (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, + ) ?? null), + [projects, selectedProjectKey], + ); + useEffect(() => { + if ( + selectedProjectKey !== null && + !projectFilterOptions.some((project) => project.key === selectedProjectKey) + ) { + setSelectedProjectKey(null); + } + }, [projectFilterOptions, selectedProjectKey]); + const scopedProjects = useMemo( + () => (selectedProject === null ? projects : [selectedProject]), + [projects, selectedProject], + ); + const scopedThreads = useMemo( + () => + selectedProject === null + ? threads + : threads.filter( + (thread) => + thread.environmentId === selectedProject.environmentId && + thread.projectId === selectedProject.id, + ), + [selectedProject, threads], + ); + const scopedPendingTasks = useMemo( + () => + selectedProject === null + ? pendingTasks + : pendingTasks.filter( + (pendingTask) => + pendingTask.message.environmentId === selectedProject.environmentId && + pendingTask.creation.projectId === selectedProject.id, + ), + [pendingTasks, selectedProject], + ); const groups = useMemo( () => buildHomeThreadGroups({ - projects, - threads, - pendingTasks, + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), - [options, pendingTasks, projects, props.searchQuery, threads], + [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads], ); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap @@ -237,6 +326,153 @@ function ThreadNavigationSidebarPane( } return map; }, [projects]); + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [projects]); + + // Thread List v2 (beta) support — same model as the compact Home list + // (HomeScreen.tsx): flat creation-order card block + settled recency tail. + // PR states stream in per-row; merged/closed PRs auto-settle their thread + // on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + // The settled tail renders in pages; expansion resets when the filter + // context changes so environment/search flips never inherit a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); + } + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), + [], + ); + // now ticks per minute so the inactivity auto-settle boundary is actually + // crossed while the pane stays open; without a clock dependency the + // partition memoizes a frozen "now". + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + if (!threadListV2Enabled) return; + // Refresh immediately on enable: the mount-time value can be hours old + // by the time the beta is switched on, which would misclassify the + // inactivity auto-settle boundary until the first tick. + setNowMinute(new Date().toISOString().slice(0, 16)); + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]); + // Threads on servers without the settlement capability never classify as + // settled (the user could neither un-settle nor pin them). + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const threadListV2Layout = useMemo(() => { + if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + return buildThreadListV2Items({ + threads: threads.filter((thread) => thread.archivedAt === null), + environmentId: options.selectedEnvironmentId, + projectRef: + selectedProject === null + ? null + : { + environmentId: selectedProject.environmentId, + projectId: selectedProject.id, + }, + searchQuery: props.searchQuery, + changeRequestStateByKey, + settlementEnvironmentIds, + settledLimit: settledVisibleCount, + now: `${nowMinute}:00.000Z`, + }); + }, [ + changeRequestStateByKey, + nowMinute, + options.selectedEnvironmentId, + props.searchQuery, + settledVisibleCount, + settlementEnvironmentIds, + threadListV2Enabled, + threads, + selectedProject, + ]); + const listItems = useMemo(() => { + if (!threadListV2Enabled) return listLayout.items; + // Queued offline tasks render above the thread rows (mirrors the + // compact Home v2 list): they are not thread shells, so the v2 item + // builder never sees them, but they must stay visible and deletable + // while their environment is offline. Same environment scope and + // search filter as the list. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = pendingTasks.filter( + (pendingTask) => + (options.selectedEnvironmentId === null || + pendingTask.message.environmentId === options.selectedEnvironmentId) && + (selectedProject === null || + (pendingTask.message.environmentId === selectedProject.environmentId && + pendingTask.creation.projectId === selectedProject.id)) && + (v2SearchQuery.length === 0 || + pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ); + const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ + type: "v2-pending-task" as const, + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + isLast: index === v2PendingTasks.length - 1, + })); + for (const item of threadListV2Layout.items) { + items.push({ + type: "v2-thread" as const, + key: scopedThreadKey(item.thread.environmentId, item.thread.id), + item, + }); + } + if (threadListV2Layout.hiddenSettledCount > 0) { + items.push({ + type: "v2-show-more", + key: "v2-show-more", + hiddenCount: threadListV2Layout.hiddenSettledCount, + }); + } + return items; + }, [ + listLayout.items, + options.selectedEnvironmentId, + pendingTasks, + props.searchQuery, + selectedProject, + threadListV2Enabled, + threadListV2Layout, + ]); const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ @@ -260,36 +496,64 @@ function ThreadNavigationSidebarPane( })), ], }, - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: options.projectSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: options.threadSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - subtitle: option.subtitle, - state: options.projectGroupingMode === option.value ? "on" : "off", - })), - }, + ...(projectFilterOptions.length === 0 + ? [] + : ([ + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + subtitle: "Show threads from every project", + state: selectedProjectKey === null ? "on" : "off", + }, + ...projectFilterOptions.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + })), + ], + }, + ] satisfies MenuAction[])), + // v2 lays the list out in fixed creation order — offering sort/group + // controls it silently ignores would be a lie. Environment still + // scopes the v2 partition, so it stays. + ...(threadListV2Enabled + ? [] + : ([ + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: options.projectSortOrder === option.value ? "on" : "off", + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: options.threadSortOrder === option.value ? "on" : "off", + })), + }, + { + id: "project-grouping", + title: "Group projects", + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ + id: `project-grouping:${option.value}`, + title: option.label, + subtitle: option.subtitle, + state: options.projectGroupingMode === option.value ? "on" : "off", + })), + }, + ] satisfies MenuAction[])), ], - [environments, options], + [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -305,6 +569,17 @@ function ThreadNavigationSidebarPane( if (environment) setSelectedEnvironmentId(environment.environmentId); return; } + if (event === "project:all") { + setSelectedProjectKey(null); + return; + } + if (event.startsWith("project:")) { + const projectKey = event.slice("project:".length); + if (projectFilterOptions.some((project) => project.key === projectKey)) { + setSelectedProjectKey(projectKey); + } + return; + } const projectSort = PROJECT_SORT_OPTIONS.find( (option) => `project-sort:${option.value}` === event, ); @@ -326,6 +601,7 @@ function ThreadNavigationSidebarPane( }, [ environments, + projectFilterOptions, setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, @@ -382,7 +658,44 @@ function ThreadNavigationSidebarPane( onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); - const listExtraData = props.selectedThreadKey ?? ""; + const listExtraData = useMemo( + () => ({ + selectedThreadKey: props.selectedThreadKey ?? "", + savedConnectionsById, + serverConfigs, + }), + [props.selectedThreadKey, savedConnectionsById, serverConfigs], + ); + const sidebarItemsAreEqual = useCallback( + (previous: SidebarListItem, item: SidebarListItem): boolean => { + if (previous.type === "v2-thread" && item.type === "v2-thread") { + return ( + previous.key === item.key && + previous.item.thread === item.item.thread && + previous.item.variant === item.item.variant && + previous.item.showSettledDivider === item.item.showSettledDivider + ); + } + if (previous.type === "v2-show-more" && item.type === "v2-show-more") { + return previous.hiddenCount === item.hiddenCount; + } + if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { + return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; + } + if ( + previous.type === "v2-thread" || + previous.type === "v2-show-more" || + previous.type === "v2-pending-task" || + item.type === "v2-thread" || + item.type === "v2-show-more" || + item.type === "v2-pending-task" + ) { + return false; + } + return homeListItemsAreEqual(previous, item); + }, + [], + ); const focusSearch = useCallback(() => { const focus = () => { if (props.nativeChrome) { @@ -401,8 +714,78 @@ function ThreadNavigationSidebarPane( }, [props.nativeChrome, props.onRequestVisibility, props.visible]); useHardwareKeyboardCommand("focusSearch", focusSearch); const renderListItem = useCallback( - ({ item }: { readonly item: HomeListItem }) => { + ({ item }: { readonly item: SidebarListItem }) => { switch (item.type) { + case "v2-pending-task": + return ( + + ); + case "v2-thread": { + const thread = item.item.thread; + const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); + return ( + + provider.instanceId === + (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), + )?.driver ?? null + } + environmentLabel={ + Object.keys(savedConnectionsById).length > 1 + ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + : null + } + pane="sidebar" + selected={ + scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey + } + fullSwipeWidth={props.width - 20} + onSelectThread={handleSelectThread} + onDeleteThread={confirmDeleteThread} + onArchiveThread={archiveThread} + settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={projectCwdByKey.get(scopeKey) ?? null} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + simultaneousSwipeGesture={sidebarScrollGesture} + /> + ); + } + case "v2-show-more": + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({item.hiddenCount} settled hidden) + + + ); case "header": return ( buildHomeListFilterMenu({ environments, + projects: projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, + selectedProjectKey, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, onEnvironmentChange: setSelectedEnvironmentId, + onProjectChange: setSelectedProjectKey, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, onProjectGroupingModeChange: setProjectGroupingMode, + listOrganization: !threadListV2Enabled, }), [ environments, options, + projectFilterOptions, + selectedProjectKey, setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, + threadListV2Enabled, ], ); const nativeHeaderItems = useMemo( @@ -531,7 +933,9 @@ function ThreadNavigationSidebarPane( ? "Loading threads…" : props.searchQuery.trim().length > 0 ? "No matching threads" - : "No threads yet"} + : selectedProject !== null + ? `No threads in ${selectedProject.title}` + : "No threads yet"} ); @@ -539,6 +943,7 @@ function ThreadNavigationSidebarPane( return ( <> item.type} - itemsAreEqual={homeListItemsAreEqual} + itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} @@ -625,12 +1030,12 @@ function ThreadNavigationSidebarPane( item.type} - itemsAreEqual={homeListItemsAreEqual} + itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} contentContainerStyle={[ diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 9dd41f3cd60..63eb9a4d9f3 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -19,8 +19,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; /** - * Thread List v2 rows mirror the web sidebar's compact tonal cards and - * receded settled tail while retaining native swipe and long-press actions. + * Thread List v2 renders one flat native list: rich edge-to-edge rows for + * active work and a receded settled tail, all with native swipe and + * long-press actions. State reads through colored status labels and text + * hierarchy rather than card fills. */ const MONO_FONT = Platform.select({ @@ -29,12 +31,15 @@ const MONO_FONT = Platform.select({ default: "monospace", }); +// Status hues follow the system-wide convention set by sidebar v1 and the +// Live Activity/widgets (amber approval, indigo input, sky working) so a +// thread reads the same color everywhere it surfaces. const STATUS_LABEL_BY_STATUS: Partial< Record > = { approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, - input: { label: "Input", className: "text-amber-700 dark:text-amber-300" }, - working: { label: "Working", className: "text-blue-600 dark:text-blue-400" }, + input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, + working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, }; @@ -60,10 +65,20 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; -export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() { +/** Rounded-row radius shared with the v1 sidebar rows. */ +const SIDEBAR_V2_ROW_RADIUS = 12; + +export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider(props: { + readonly pane?: "screen" | "sidebar"; +}) { const borderColor = useThemeColor("--color-border"); return ( - + Settled @@ -76,6 +91,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly showSettledDivider: boolean; readonly project: EnvironmentProject | null; readonly providerDriver: string | null; + /** Which machine hosts the thread. Null when only one environment is + connected — repeating the same label on every row is noise. Mirrors + the web sidebar's remote-environment cloud icon, but as text since + phones have no hover tooltips. */ + readonly environmentLabel: string | null; + /** Hosting surface. "screen" (default) renders the compact Home idiom: + flat edge-to-edge rows on the screen background with inset hairlines. + "sidebar" renders the iPad split-view idiom: rounded rows blending + into the drawer surface, selection filled with the accent color — + matching the v1 sidebar rows. */ + readonly pane?: "screen" | "sidebar"; + /** Highlights the thread open in the detail pane (iPad split view). The + compact Home list never sets it — phones navigate away on select. */ + readonly selected?: boolean; + /** Override for narrow panes (iPad sidebar); defaults to window width. */ + readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => void; @@ -117,6 +148,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, [onChangeRequestState, prState, threadKey]); const screenColor = useThemeColor("--color-screen"); + const drawerColor = useThemeColor("--color-drawer"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const selectedBackgroundColor = useThemeColor("--color-user-bubble"); + const sidebarPane = props.pane === "sidebar"; + const selected = props.selected === true; const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; @@ -174,99 +210,184 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { thread.title, ]); + // The sidebar pane fills selected rows with the accent color (matching the + // v1 sidebar), so every piece of row text needs a white-on-accent variant. + const cardContent = ( + <> + + {props.project ? ( + + ) : null} + + {props.project?.title ?? ""} + + + {statusLabel?.label ?? timeLabel} + + + + {thread.title} + + + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : thread.branch || props.environmentLabel ? ( + /* "branch · machine" share one truncating line. The machine sits + last so a tight fit cuts the repetitive label, not the branch — + and machine-only fills the row for non-git projects. */ + + {thread.branch ? ( + + {thread.branch} + + ) : null} + {thread.branch && props.environmentLabel ? " · " : null} + {props.environmentLabel ? ( + + {props.environmentLabel} + + ) : null} + + ) : ( + + )} + {pr ? ( + + #{pr.label} + + ) : null} + {props.providerDriver ? ( + + + + ) : null} + + + ); + const rowContent = (close: () => void) => variant === "card" ? ( { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed + ? pressedBackgroundColor + : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + paddingHorizontal: 12, + paddingVertical: 10, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } > - - - - - {props.project ? ( - - ) : null} - - {props.project?.title ?? ""} - - - {statusLabel?.label ?? timeLabel} - - - - {thread.title} - - - {status === "failed" && thread.session?.lastError ? ( - - {thread.session.lastError} - - ) : thread.branch ? ( - - {thread.branch} - - ) : ( - - )} - {props.providerDriver ? ( - - - - ) : null} - - + {sidebarPane ? ( + cardContent + ) : ( + /* Flat native list rows: no tonal containers — colored status + labels and text hierarchy carry state, an inset hairline + separates rows. The opaque screen background stays so swipe + actions reveal behind the row. */ + + {cardContent} + - + )} ) : ( { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed + ? pressedBackgroundColor + : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } > {/* Settled history recedes: dimmed favicon + muted title. */} - + {props.project ? ( ) : null} - + {thread.title} {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} @@ -292,14 +422,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { return ( <> - {props.showSettledDivider ? : null} + {props.showSettledDivider ? : null} , ): ThreadListV2Status { diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 8a8c355b760..78c87119512 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -142,6 +142,13 @@ function stabilizeOptionFunctions( export function NativeStackScreenOptions(props: { readonly options?: AppNativeStackNavigationOptions; + /** + * Causes dynamic native header factories to be reapplied when their closed-over + * menu content changes. Factory functions are intentionally stabilized, so + * their source alone cannot capture a menu that was initially empty while + * asynchronous data was loading. + */ + readonly optionsVersion?: unknown; readonly listeners?: Record void>; readonly name?: string; }) { @@ -163,7 +170,7 @@ export function NativeStackScreenOptions(props: { if (!navigation || !stableOptions) { return; } - const signature = optionsSignature(stableOptions); + const signature = optionsSignature([stableOptions, props.optionsVersion]); // Avoid re-entering navigation state when semantically equal options are // reapplied every layout (common when callers pass unstable object literals). if (lastAppliedOptionsSignatureRef.current === signature) { @@ -171,7 +178,7 @@ export function NativeStackScreenOptions(props: { } lastAppliedOptionsSignatureRef.current = signature; navigation.setOptions(stableOptions); - }, [navigation, stableOptions]); + }, [navigation, props.optionsVersion, stableOptions]); useEffect(() => { if (!navigation || !props.listeners) { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 8a713fd9026..ef6b64be9c9 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -84,7 +84,7 @@ function SidebarControl() { "pointer-events-auto", isSidebarVisible && stageBackdropVariant && - "hover:bg-white/15 [&_svg]:text-white/85! [&_svg]:hover:text-white!", + "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!", )} aria-label="Toggle main sidebar" /> @@ -164,10 +164,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
{ + it("assigns positional jump shortcuts to the first nine displayed items", () => { + const items = Array.from({ length: 10 }, (_, index) => ({ + kind: "action" as const, + value: `project-${index + 1}`, + searchTerms: [], + title: `Project ${index + 1}`, + icon: null, + shortcutCommand: "chat.new" as const, + run: async () => undefined, + })); + + expect(enumerateCommandPaletteItems(items).map((item) => item.shortcutCommand)).toEqual([ + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9", + undefined, + ]); + }); +}); + const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ab53adbefb1..a217d53b5b7 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,8 @@ -import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts"; +import { + type KeybindingCommand, + type FilesystemBrowseEntry, + THREAD_JUMP_KEYBINDING_COMMANDS, +} from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -52,6 +56,18 @@ export interface CommandPaletteView { readonly initialQuery?: string; } +export function enumerateCommandPaletteItems( + items: ReadonlyArray, +): CommandPaletteActionItem[] { + return items.map((item, index) => { + const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index]; + if (shortcutCommand) return { ...item, shortcutCommand }; + + const { shortcutCommand: _shortcutCommand, ...itemWithoutShortcut } = item; + return itemWithoutShortcut; + }); +} + export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; export function filterBrowseEntries(input: { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8c7e6d287ca..bb83dfa5691 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -95,6 +95,7 @@ import { buildProjectActionItems, buildRootGroups, buildThreadActionItems, + enumerateCommandPaletteItems, type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, @@ -112,7 +113,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; -import { resolveShortcutCommand } from "../keybindings"; +import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { Command, CommandDialog, @@ -686,29 +687,30 @@ function OpenCommandPaletteDialog(props: { const projectThreadItems = useMemo( () => - buildProjectActionItems({ - projects, - valuePrefix: "new-thread-in", - shortcutCommand: "chat.new", - icon: (project) => ( - - ), - runProject: async (project) => { - await startNewThreadInProjectFromContext( - { - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }, - scopeProjectRef(project.environmentId, project.id), - ); - }, - }), + enumerateCommandPaletteItems( + buildProjectActionItems({ + projects, + valuePrefix: "new-thread-in", + icon: (project) => ( + + ), + runProject: async (project) => { + await startNewThreadInProjectFromContext( + { + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + }), + ), [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], ); @@ -997,7 +999,13 @@ function OpenCommandPaletteDialog(props: { : projectThreadItems; pushPaletteView({ addonIcon: , - groups: [{ value: "projects", label: "Projects", items: prioritized }], + groups: [ + { + value: "projects", + label: "Projects", + items: enumerateCommandPaletteItems(prioritized), + }, + ], }); }, [ clearOpenIntent, @@ -1545,6 +1553,22 @@ function OpenCommandPaletteDialog(props: { } function handleKeyDown(event: KeyboardEvent): void { + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false }, + }); + if (threadJumpIndexFromCommand(command ?? "") !== null) { + const matchingItem = displayedGroups + .flatMap((group) => group.items) + .find((item) => item.shortcutCommand === command); + if (matchingItem) { + event.preventDefault(); + event.stopPropagation(); + executeItem(matchingItem); + return; + } + } + if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { event.preventDefault(); void submitAddProjectCloneFlow(); @@ -1855,7 +1879,7 @@ function OpenCommandPaletteDialog(props: { {remoteProjectContext.title} - + {remoteProjectContext.description} @@ -1896,37 +1920,35 @@ function OpenCommandPaletteDialog(props: { - Navigate + Navigate {addProjectCloneFlow?.step === "repository" ? ( Enter - - {remoteProjectButtonLabel ?? "Continue"} - + {remoteProjectButtonLabel ?? "Continue"} ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( Enter - Select + Select ) : null} {isSubmenu ? ( Backspace - Back + Back ) : null} Esc - Close + Close
{canOpenProjectFromFileManager ? ( @@ -166,7 +166,7 @@ function RightPanelEmptyState(props: { const disabledCard = ( + ) : ( + + )} - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( - - ) : ( - - )} - - + + {detailsTooltip} + ); } @@ -460,144 +605,109 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { data-thread-item className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]" > -
-
-
- + - {props.projectTitle ? ( - - {props.projectTitle} - - ) : ( - - )} - - - {props.jumpLabel ? ( - props.jumpLabel - ) : topStatus ? ( - +
+
+ + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} + + + {props.jumpLabel ? ( + props.jumpLabel + ) : topStatus ? ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {topStatus.label} + + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported ? ( + + ) : null} - {props.settlementSupported ? ( - +
+
{title}
+
+ {thread.branch ? ( + {thread.branch} + ) : ( + + )} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + ) : null} - -
-
{title}
-
- {thread.branch ? ( - - {thread.branch} - - ) : ( - - )} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {driverKind ? ( - - } + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + - - {thread.modelSelection.model} - - ) : null} - {isRemote ? ( - - - } - > - - - - Running on {props.environmentLabel ?? "a remote environment"} - - - ) : null} - -
- {status === "failed" && thread.session?.lastError ? ( -
- {thread.session.lastError} + + ) : null} +
- ) : null} -
-
+
+ + {detailsTooltip} + ); }); @@ -711,9 +821,8 @@ export default function SidebarV2() { [], ); - // Project scope: chips above the list. Scoping filters the list AND - // becomes the new-thread target — one visible control doing both jobs the - // old per-project headers did. + // Project scope: one menu above the list. Scoping filters the list without + // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); const scopedProject = useMemo( () => @@ -794,7 +903,7 @@ export default function SidebarV2() { // filter context changes so a scope/search flip never inherits a deep // page state. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = `${projectScopeKey ?? "all"}`; + const settledResetKey = projectScopeKey ?? "all"; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -1322,243 +1431,245 @@ export default function SidebarV2() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"); - const projectScrollerRef = useRef(null); - const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false); - const updateProjectScrollFade = useCallback(() => { - const scroller = projectScrollerRef.current; - if (!scroller) return; - setCanScrollProjectsRight( - scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, - ); - }, []); - useEffect(() => { - const scroller = projectScrollerRef.current; - if (!scroller) return; - - updateProjectScrollFade(); - const resizeObserver = new ResizeObserver(updateProjectScrollFade); - resizeObserver.observe(scroller); - return () => resizeObserver.disconnect(); - }, [projects, updateProjectScrollFade]); - return ( <> - - +
+
} > - - Search + +
Search
{commandPaletteShortcutLabel ? ( - + {commandPaletteShortcutLabel} ) : null}
- - - - - - - +
+
+ + + } + > + + + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
+
{projects.length > 0 ? ( - -
-
- {projects.length > 1 ? ( - - ) : null} - {projects.map((project) => { - const scopeKey = `${project.environmentId}:${project.id}`; - const isScoped = projectScopeKey === scopeKey; - return ( - - ); - })} -
-
- - + +
+ + + {scopedProject ? ( + + ) : ( + + )} + + {scopedProject?.title ?? "All projects"} + + + + + + setProjectScopeKey(value === "all" ? null : (value as string)) } > - - - Add project - -
+ + + All projects + + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + return ( + + + {project.title} + + ); + })} + + + + + + } + > + + + New project +
) : null} -
    - {orderedThreads.flatMap((thread, threadIndex) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const isSettledRow = settledThreadKeys.has(threadKey); - // Settled is the ONLY thing that collapses a row: every - // not-settled thread is a full card. Density comes from users - // (or the auto rules) actually settling work, not from the - // sidebar second-guessing what still matters. - const isCard = !isSettledRow; - const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; - const previousWasCard = - previousThread != null && - !settledThreadKeys.has( - scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + +
      + {orderedThreads.flatMap((thread, threadIndex) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey( + scopeThreadRef(previousThread.environmentId, previousThread.id), + ), + ); + const showSettledGap = !isCard && previousWasCard; + const row = ( + ); - const showSettledGap = !isCard && previousWasCard; - const row = ( - - ); - if (!showSettledGap) return [row]; - // The divider is its own keyed list item (not part of the first - // settled row): it keeps one stable DOM node at the boundary, - // so settling a thread slides it instead of teleporting it - // along with whichever row happens to be first in the tail — - // and row heights stay independent of neighbor classification. - return [ -
    • -
      - - Settled + if (!showSettledGap) return [row]; + // The divider is its own keyed list item (not part of the first + // settled row): it keeps one stable DOM node at the boundary, + // so settling a thread slides it instead of teleporting it + // along with whichever row happens to be first in the tail — + // and row heights stay independent of neighbor classification. + return [ +
    • +
      + Settled + +
      +
    • , + row, + ]; + })} + {hiddenSettledCount > 0 ? ( +
    • +
- , - row, - ]; - })} - {hiddenSettledCount > 0 ? ( -
  • - -
  • - ) : null} - + + + ) : null} + + {orderedThreads.length === 0 ? (
    {projects.length === 0 ? ( @@ -1567,7 +1678,7 @@ export default function SidebarV2() {