diff --git a/README.md b/README.md index fc5e14792ce..528c95a2acc 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ There's no public docs site yet, checkout the miscellaneous markdown files in [d ## Documentation - [Getting started](./docs/getting-started/quick-start.md) +- [Remote access](./docs/user/remote-access.md) +- [Keeping T3 Code in sync](./docs/user/server-updates.md) - [Architecture overview](./docs/architecture/overview.md) - [Provider guides](./docs/providers/codex.md) - [Operations](./docs/operations/ci.md) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 4a12c1b0f87..9b96a97bd93 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], + glassOpacity: 80, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index cc51b56ee70..b8a13137c88 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -33,7 +33,7 @@ import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; import { connectionStorageLayer } from "./storage"; function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { - if (state.isConnected === false || state.isInternetReachable === false) { + if (state.isConnected === false) { return "offline"; } if (state.isConnected === true) { 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/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 0e702ebc11b..f310b0fdbc4 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -204,6 +204,18 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))), ); + it.effect("exposes service lifecycle commands without T3 Connect configuration", () => + Effect.gen(function* () { + const { output } = yield* captureStdout(runCli(["service", "--help"], noConnectCli)); + + assert.include(output, "Manage the T3 Code background service."); + assert.include(output, "install"); + assert.include(output, "uninstall"); + assert.include(output, "update"); + assert.include(output, "status"); + }), + ); + it.effect("reports fresh headless connect state without requiring local configuration", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( @@ -307,7 +319,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { runConnectCli(["connect", "logout", "--base-dir", baseDir]), ); - assert.equal(output, "Signed out of T3 Connect locally."); + assert.equal( + output, + "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.", + ); assert.isFalse(NodeFS.existsSync(tokenPath)); }), ); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index ddfbf5e3ecc..a7767b50a15 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -13,6 +13,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; +import { serviceCommand } from "./cli/service.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -47,6 +48,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serveCommand, authCommand, projectCommand, + serviceCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index e63cbf331b5..cc52a50fe36 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -16,9 +16,9 @@ import { formatRelayClientReady, headlessSessionConfig, isPublishAgentActivityEnabledValue, - recoverBootServiceOffer, reportCloudDisconnectResults, } from "./connect.ts"; +import { recoverServiceOnboardingOffer } from "./service.ts"; it("explains how to complete headless authorization", () => { assert.equal( @@ -58,7 +58,7 @@ it.effect("detects headless operation from individual SSH config values", () => it.effect("treats cancelling optional background setup as a successful skip", () => Effect.gen(function* () { - const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({}))); + const result = yield* recoverServiceOnboardingOffer(Effect.fail(new Terminal.QuitError({}))); assert.isFalse(result); }), ); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 8b11fc73346..0369d47e4d7 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -6,7 +6,6 @@ import { } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; -import * as Terminal from "effect/Terminal"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; @@ -20,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; +import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient, @@ -29,7 +29,6 @@ import { } from "effect/unstable/http"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; -import packageJson from "../../package.json" with { type: "json" }; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BootService from "../cloud/bootService.ts"; @@ -45,9 +44,13 @@ import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; -import * as ProcessRunner from "../processRunner.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { + bootServiceLayer, + offerServiceDuringOnboarding, + recoverServiceOnboardingOffer, +} from "./service.ts"; const jsonFlag = Flag.boolean("json").pipe( Flag.withDescription("Emit JSON instead of human-readable output."), @@ -396,21 +399,6 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { if (options.clearAuthorization) { const tokens = yield* CliTokenManager.CloudCliTokenManager; yield* tokens.clear; - - // uninstall itself no-ops when nothing is installed (and on non-Linux), - // so no status pre-check that could mask a real removal failure. - const bootService = yield* BootService.BootService; - yield* bootService.uninstall.pipe( - Effect.tap((removed) => - removed ? Console.log("Removed the T3 Code background service.") : Effect.void, - ), - Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)), - Effect.catch((error) => - Console.warn(`Could not remove the background service: ${error.message}`).pipe( - Effect.as(false), - ), - ), - ); } yield* reportCloudDisconnectResults({ @@ -420,7 +408,9 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { }); if (options.clearAuthorization) { - yield* Console.log("Signed out of T3 Connect locally."); + yield* Console.log( + "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.", + ); } }); @@ -457,11 +447,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* ( - offer: Effect.Effect, -) => - offer.pipe( - Effect.catchTags({ - QuitError: () => Effect.succeed(false), - BootServiceUnsupportedError: (error) => - Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), - BootServiceCommandError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - BootServiceInstallError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - }), - ); - export const connectCommand = Command.make("connect", { ...projectLocationFlags, headless: headlessFlag, @@ -748,7 +690,7 @@ export const connectCommand = Command.make("connect", { // Connect itself already succeeded; a boot-service failure must not // fail the command, just tell the user what happened and move on. - const background = yield* recoverBootServiceOffer(offerBootService); + const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); yield* Console.log( background ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out." diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts new file mode 100644 index 00000000000..e91e10000b6 --- /dev/null +++ b/apps/server/src/cli/service.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; + +import { formatServiceStatus } from "./service.ts"; + +const status = { + supported: true, + installed: true, + current: true, + unitPath: "/home/me/.config/systemd/user/t3code.service", + logPath: "/home/me/.t3/userdata/logs/boot-service.log", +} as const; + +it("reports the installed service version and host paths", () => { + assert.equal( + formatServiceStatus(status, "0.0.29"), + [ + "T3 Code service", + " Status: installed · t3@0.0.29", + " Unit: /home/me/.config/systemd/user/t3code.service", + " Logs: /home/me/.t3/userdata/logs/boot-service.log", + ].join("\n"), + ); +}); + +it("gives a direct repair command for a stale service", () => { + assert.include( + formatServiceStatus({ ...status, current: false }, "0.0.29"), + "Next: Run `npx t3@latest service update`.", + ); +}); + +it("explains service availability without systemd", () => { + assert.include( + formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), + "Supported on: Linux with systemd", + ); +}); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts new file mode 100644 index 00000000000..bd846eeee34 --- /dev/null +++ b/apps/server/src/cli/service.ts @@ -0,0 +1,199 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Terminal from "effect/Terminal"; +import { Command, GlobalFlag, Prompt } from "effect/unstable/cli"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import type * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) => + BootService.layer({ + baseDir: config.baseDir, + logsDir: config.logsDir, + cliVersion: packageJson.version, + }).pipe(Layer.provide(ProcessRunner.layer)); + +export type ServiceReconcileResult = + | { + readonly changed: false; + readonly status: BootService.BootServiceStatus; + } + | { + readonly changed: true; + readonly previouslyInstalled: boolean; + readonly plan: BootService.BootServicePlan; + }; + +/** Install, update, or repair the service using the CLI version running this command. */ +export const reconcileService = Effect.fn("cli.service.reconcile")(function* () { + const service = yield* BootService.BootService; + const status = yield* service.status; + if (status.installed && status.current) { + return { changed: false, status } satisfies ServiceReconcileResult; + } + const plan = yield* service.install; + return { + changed: true, + previouslyInstalled: status.installed, + plan, + } satisfies ServiceReconcileResult; +}); + +export function formatServiceStatus( + status: BootService.BootServiceStatus, + cliVersion: string, +): string { + if (!status.supported) { + return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd"; + } + if (!status.installed) { + return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; + } + return [ + "T3 Code service", + ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`, + ` Unit: ${status.unitPath}`, + ` Logs: ${status.logPath}`, + ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]), + ].join("\n"); +} + +const runServiceCommand = Effect.fn("cli.service.run")(function* ( + flags: { readonly baseDir: Parameters[0]["baseDir"] }, + run: Effect.Effect, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* run.pipe(Effect.provide(bootServiceLayer(config))); +}); + +const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe( + Command.withDescription("Install T3 Code as a background service for this user."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const result = yield* reconcileService(); + if (!result.changed) { + yield* Console.log( + `T3 Code service is already installed with t3@${packageJson.version}.`, + ); + return; + } + yield* Console.log( + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + ); + }), + ), + ), +); + +const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe( + Command.withDescription( + "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.", + ), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const result = yield* reconcileService(); + if (!result.changed) { + yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); + return; + } + yield* Console.log( + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + ); + }), + ), + ), +); + +const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( + Command.withDescription("Stop and remove the T3 Code background service."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + const removed = yield* service.uninstall; + yield* Console.log( + removed ? "Removed the T3 Code service." : "T3 Code service is not installed.", + ); + }), + ), + ), +); + +const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( + Command.withDescription("Show whether the T3 Code background service is installed."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); + }), + ), + ), +); + +export const offerServiceDuringOnboarding = Effect.gen(function* () { + const service = yield* BootService.BootService; + const { supported, installed, current } = yield* service.status; + if (!supported) { + return false; + } + if (installed && current) { + yield* Console.log("T3 Code is already set up to run in the background on this machine."); + return true; + } + const wanted = yield* Prompt.run( + Prompt.confirm({ + message: installed + ? "The installed T3 Code service needs an update or repair. Update it now?" + : "Run T3 Code in the background whenever this machine boots? " + + "It stays reachable through T3 Connect even after you log out.", + initial: true, + }), + ); + if (!wanted) { + return false; + } + const result = yield* reconcileService(); + if (result.changed) { + yield* Console.log( + `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, + ); + } + return true; +}); + +export const recoverServiceOnboardingOffer = ( + offer: Effect.Effect, +) => + offer.pipe( + Effect.catchTags({ + QuitError: () => Effect.succeed(false), + BootServiceUnsupportedError: (error) => + Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), + BootServiceCommandError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + BootServiceInstallError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + }), + ); + +export const serviceCommand = Command.make("service").pipe( + Command.withDescription("Manage the T3 Code background service."), + Command.withSubcommands([ + serviceInstallCommand, + serviceUninstallCommand, + serviceUpdateCommand, + serviceStatusCommand, + ]), +); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index a24d54697d9..46e9a1da987 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -14,6 +14,7 @@ import { HostProcessPlatform, } from "@t3tools/shared/hostProcess"; +import { reconcileService } from "../cli/service.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as BootService from "./bootService.ts"; @@ -100,7 +101,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { unit, [ "[Unit]", - "Description=T3 Code server (T3 Connect)", + "Description=T3 Code server", "StartLimitIntervalSec=300", "StartLimitBurst=5", "", @@ -108,6 +109,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { "Type=simple", "WorkingDirectory=%h", "Environment=T3CODE_HOME=/home/theo/.t3", + "Environment=T3_BOOT_SERVICE_UNIT=t3code.service", "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", "Restart=always", "RestartSec=5", @@ -170,6 +172,33 @@ it("flags package-manager cache entry points as ephemeral", () => { }); it.layer(NodeServices.layer)("BootService", (it) => { + it.effect("reconciles the standalone service once and is then idempotent", () => + Effect.gen(function* () { + const { dirs } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const first = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + assert.isTrue(first.changed); + if (!first.changed) return; + assert.isFalse(first.previouslyInstalled); + + const commandCount = commands.length; + const second = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + assert.isFalse(second.changed); + assert.lengthOf(commands, commandCount); + }), + ); + it.effect("installs the unit, enables the service, and enables linger", () => Effect.gen(function* () { const { dirs, fs, path } = yield* makeTestContext(); @@ -311,7 +340,7 @@ it.layer(NodeServices.layer)("BootService", (it) => { }), ); - it.effect("reports an installed-but-stale unit so connect can offer a repair", () => + it.effect("reports an installed-but-stale unit so the lifecycle can offer a repair", () => Effect.gen(function* () { const { dirs, fs, path } = yield* makeTestContext(); const commands: Array = []; @@ -402,8 +431,8 @@ it.layer(NodeServices.layer)("BootService", (it) => { const error = yield* service.install.pipe(Effect.flip); assert.isTrue(isCommandError(error)); - // A leftover unit would make the next connect report "already set up" - // even though linger never happened. + // A leftover unit would make status report "installed" even though + // linger never happened. assert.isFalse( yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0662b367049..d7e13e834f4 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -16,21 +16,19 @@ import { } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../processRunner.ts"; +import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; /** - * Installs T3 Code as a per-user boot service so a connected machine stays - * reachable through T3 Connect after the SSH session ends. Linux-only for - * now: systemd user unit + loginctl enable-linger. The service runs a pinned - * runtime installed under /runtime — never `npx t3`, whose cache is - * ephemeral and whose registry fetch at boot would make startup depend on - * the network. + * Installs T3 Code as a per-user boot service. Linux-only for now: systemd + * user unit + loginctl enable-linger. The service runs a stable or pinned + * runtime — never an ephemeral `npx t3` cache whose eviction could break + * startup. */ const BOOT_SERVICE_NAME = "t3code"; -const BOOT_RUNTIME_DIR = "runtime"; -const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; -const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; const EPHEMERAL_CACHE_SEGMENTS = [ "/_npx/", // npx @@ -92,7 +90,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { // relay connection, and Restart=always covers early-boot failures. return [ "[Unit]", - "Description=T3 Code server (T3 Connect)", + "Description=T3 Code server", // Give up after 5 crashes in 5 minutes so a persistently broken install // (deleted runtime, broken workspace) stops instead of restarting every // 5s forever and growing the unrotated append log without bound. @@ -103,6 +101,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, + `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, "Restart=always", "RestartSec=5", @@ -206,14 +205,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const unitDir = path.join(homeDir, ".config", "systemd", "user"); const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); const logPath = path.join(input.logsDir, "boot-service.log"); - const runtimeVersionDir = path.join( - input.baseDir, - BOOT_RUNTIME_DIR, - "versions", - input.cliVersion, - ); - const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); - const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete"); + const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); const requireSystemdLinux = Effect.gen(function* () { if (platform !== "linux" || homeDir === "") { @@ -263,52 +255,41 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!isEphemeralCacheEntry(host.cliEntryPath)) { return; } - // The sentinel is written only after npm exits 0. Checking the entry - // file alone is not enough: npm extracts files before running native - // builds (node-pty), so a killed install leaves a plausible-looking but - // broken tree behind. - const alreadyPinned = yield* Effect.all([ - fs.exists(runtimeSentinelPath), - fs.exists(runtimeEntryPath), - ]).pipe( - Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - if (alreadyPinned) { - return; - } - yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe( - Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - yield* runStep( - "installing the pinned t3 runtime (this can take a few minutes)", - "npm", - [ - "install", - "--prefix", - runtimeVersionDir, - "--no-fund", - "--no-audit", - `t3@${input.cliVersion}`, - ], - // Native deps (node-pty) can compile from source on slow boxes; the - // ProcessRunner default of 60s would kill a healthy install. - { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT }, - ).pipe( - Effect.tapError(() => - fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore), + yield* ensurePinnedRuntimeInstalled({ + baseDir: input.baseDir, + version: input.cliVersion, + fs, + path, + runner, + }).pipe( + Effect.mapError((error) => + error.step.startsWith("installing") + ? new BootServiceCommandError({ + step: error.step, + exitCode: error.exitCode, + stdoutLength: error.stdoutLength, + stderrLength: error.stderrLength, + cause: error.cause, + }) + : new BootServiceInstallError({ cause: error }), + ), + Effect.tapError((error) => + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { + flag: "a", + }), + ), + Effect.ignore, + ), ), ); - yield* fs - .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); }); // Where the unit will point: derivable without touching the network, so // status can compare units purely; install materializes it first. const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) - ? runtimeEntryPath + ? runtimePaths.entryPath : host.cliEntryPath; const plan: BootServicePlan = { nodePath: host.execPath, @@ -341,8 +322,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); // If any activation step fails, remove the unit again: a leftover file - // would make the next `t3 connect` report the service as already set up - // even though it was never enabled or lingered. + // would make service status report it as installed even though it was + // never enabled or lingered. yield* Effect.gen(function* () { yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); yield* runStep("enabling the service", "systemctl", [ @@ -372,7 +353,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // fails), leave nothing behind: disable removes the enable symlink, remove // deletes the file, daemon-reload clears the stale definition — otherwise a // dangling wants/ symlink logs "Failed to load unit" at every boot and the - // next connect misreports the state. + // next lifecycle command misreports the state. const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* ( previousUnit: Option.Option, ) { diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts new file mode 100644 index 00000000000..9b46c0038ec --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -0,0 +1,129 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import { + ensurePinnedRuntimeInstalled, + pinnedRuntimePaths, + removePinnedRuntimeInstallation, +} from "./pinnedRuntime.ts"; + +it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { + it.effect("serializes concurrent installs of the same runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const installStarted = yield* Deferred.make(); + const allowInstallToFinish = yield* Deferred.make(); + const paths = pinnedRuntimePaths(path, baseDir, "0.0.29"); + let npmRuns = 0; + + const runner = ProcessRunner.ProcessRunner.of({ + run: (_input) => + Effect.gen(function* () { + npmRuns += 1; + yield* Deferred.succeed(installStarted, undefined); + yield* Deferred.await(allowInstallToFinish); + yield* fs + .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + const install = ensurePinnedRuntimeInstalled({ + baseDir, + version: "0.0.29", + fs, + path, + runner, + }); + + const first = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Deferred.await(installStarted); + const second = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Effect.yieldNow; + assert.equal(npmRuns, 1); + + yield* Deferred.succeed(allowInstallToFinish, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + assert.equal(npmRuns, 1); + assert.isTrue(yield* fs.exists(paths.sentinelPath)); + assert.isTrue(yield* fs.exists(paths.entryPath)); + }), + ); + + it.effect("waits for an active install before removing its runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const installStarted = yield* Deferred.make(); + const allowInstallToFinish = yield* Deferred.make(); + const paths = pinnedRuntimePaths(path, baseDir, "0.0.30"); + const runner = ProcessRunner.ProcessRunner.of({ + run: (_input) => + Effect.gen(function* () { + yield* Deferred.succeed(installStarted, undefined); + yield* Deferred.await(allowInstallToFinish); + yield* fs + .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + + const installFiber = yield* Effect.forkChild( + ensurePinnedRuntimeInstalled({ + baseDir, + version: "0.0.30", + fs, + path, + runner, + }), + { startImmediately: true }, + ); + yield* Deferred.await(installStarted); + const removeFiber = yield* Effect.forkChild( + removePinnedRuntimeInstallation({ + baseDir, + version: "0.0.30", + fs, + path, + }), + { startImmediately: true }, + ); + yield* Effect.yieldNow; + assert.isTrue(yield* fs.exists(paths.versionDir)); + + yield* Deferred.succeed(allowInstallToFinish, undefined); + yield* Fiber.join(installFiber); + yield* Fiber.join(removeFiber); + assert.isFalse(yield* fs.exists(paths.versionDir)); + }), + ); +}); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts new file mode 100644 index 00000000000..e3e095ce081 --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -0,0 +1,176 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * A pinned runtime is an exact `t3@` npm-installed into + * /runtime/versions/. The boot service points its systemd + * unit here, and server self-update installs the target version here before + * switching over — never `npx t3`, whose cache is ephemeral and whose + * registry fetch at boot would make startup depend on the network. + */ + +const PINNED_RUNTIME_DIR = "runtime"; +const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +// Boot-service setup and remote self-update share this module but can be +// constructed in separate layers. Serialize the complete check/install/ +// sentinel transaction across all callers in this process. +const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); + +export interface PinnedRuntimePaths { + readonly versionDir: string; + readonly entryPath: string; + readonly sentinelPath: string; +} + +export function pinnedRuntimePaths( + path: Path.Path, + baseDir: string, + version: string, +): PinnedRuntimePaths { + const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + return { + versionDir, + entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + sentinelPath: path.join(versionDir, ".install-complete"), + }; +} + +export class PinnedRuntimeInstallError extends Schema.TaggedErrorClass()( + "PinnedRuntimeInstallError", + { + step: Schema.String, + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.exitCode === undefined + ? `Pinned runtime install failed while ${this.step}.` + : `Pinned runtime install failed while ${this.step} (exit code ${this.exitCode}).`; + } +} + +/** + * Installs `t3@` into the pinned runtime directory unless a complete + * install is already there, and returns its paths. The sentinel is written + * only after npm exits 0; checking the entry file alone is not enough — npm + * extracts files before running native builds (node-pty), so a killed + * install leaves a plausible-looking but broken tree behind. + */ +export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensure_installed")( + function* (input: { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly runner: ProcessRunner.ProcessRunner["Service"]; + }) { + const { fs, runner } = input; + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + + return yield* pinnedRuntimeInstallLock.withPermit( + Effect.gen(function* () { + const alreadyPinned = yield* Effect.all([ + fs.exists(paths.sentinelPath), + fs.exists(paths.entryPath), + ]).pipe( + Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), + ), + ); + if (alreadyPinned) { + return paths; + } + + yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "preparing the pinned runtime directory", + cause, + }), + ), + ); + + const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + yield* runner + .run({ + command: "npm", + args: [ + "install", + "--prefix", + paths.versionDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ], + // Native deps (node-pty) can compile from source on slow boxes; the + // ProcessRunner default of 60s would kill a healthy install. + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: installStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + Effect.tapError(() => + fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + + yield* fs + .writeFileString(paths.sentinelPath, `${input.version}\n`) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), + ), + ); + + return paths; + }), + ); + }, +); + +/** Removes one pinned runtime while holding the same process-wide lock used + * by install/check/sentinel work, so cleanup cannot race another caller that + * is materializing or reusing the runtime tree. */ +export const removePinnedRuntimeInstallation = Effect.fn("cloud.pinned_runtime.remove")( + function* (input: { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + }) { + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + yield* pinnedRuntimeInstallLock.withPermit( + input.fs + .remove(paths.versionDir, { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "removing the pinned runtime", cause }), + ), + ), + ); + }, +); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts new file mode 100644 index 00000000000..28bcc0ffe62 --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -0,0 +1,589 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + renderBootServiceUnit, +} from "./bootService.ts"; +import * as SelfUpdate from "./selfUpdate.ts"; + +const NODE_PATH = "/usr/local/bin/node"; + +interface RecordedCommand { + readonly command: string; + readonly args: ReadonlyArray; +} + +const makeRecordingRunnerLayer = ( + commands: Array, + options?: { + readonly failWhen?: ((command: string, args: ReadonlyArray) => boolean) | undefined; + readonly stdoutFor?: + | ((command: string, args: ReadonlyArray) => string | undefined) + | undefined; + }, +) => + Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.sync(() => { + commands.push({ command: input.command, args: input.args }); + const failed = options?.failWhen?.(input.command, input.args) === true; + const versionFromPath = + input.command === NODE_PATH && input.args[1] === "--version" + ? /[/\\]runtime[/\\]versions[/\\]([^/\\]+)/.exec(input.args[0] ?? "")?.[1] + : undefined; + return { + stdout: + options?.stdoutFor?.(input.command, input.args) ?? + (versionFromPath === undefined ? "" : `${versionFromPath}\n`), + stderr: failed ? `${input.command} exploded` : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ); + +const provideHostRefs = (input: { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; + readonly entryPath: string; +}) => + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed(HostProcessEnvironment, input.env), + Layer.succeed(HostProcessExecutablePath, NODE_PATH), + Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]), + ), + ); + +it("recognizes published npm artifacts as swappable entry points", () => { + assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isTrue( + SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + ); + assert.isTrue( + SelfUpdate.isPublishedCliEntry( + "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + ), + ); + // Dev checkouts and the desktop bundle run apps/server/dist directly. + assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs")); + assert.isFalse(SelfUpdate.isPublishedCliEntry("")); +}); + +it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { + const makeHome = Effect.fn("test.makeHome")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + return { fs, path, home }; + }); + + const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* ( + home: string, + entryPath: string, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir: path.join(home, ".t3"), + logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + }); + + it.effect("reports boot-service for the systemd-spawned unit process", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, + entryPath, + }), + ); + assert.equal(method, "boot-service"); + }), + ); + + it.effect("does not claim a systemd process owned by another unit", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { HOME: home, INVOCATION_ID: "abc123" }, + entryPath, + }), + ); + assert.isNull(method); + }), + ); + + it.effect("reports respawn for a manual run of the pinned artifact", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + // Same unit on disk, but no INVOCATION_ID: restarting the unit would + // not replace this process, so it must respawn itself instead. + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath })); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports respawn for a foreground npx artifact on darwin", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`, + }), + ); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports desktop-managed for desktop-supervised backends", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + // Desktop ownership wins over every process-shape heuristic: even a + // systemd-looking pinned artifact belongs to the app that spawned it. + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: true, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, + entryPath, + }), + ); + assert.equal(method, "desktop-managed"); + }), + ); + + it.effect("reports no method for dev checkouts and Windows", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`, + }), + ); + assert.isNull(devMethod); + const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "win32", + env: { HOME: home }, + entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + }), + ); + assert.isNull(windowsMethod); + }), + ); +}); + +it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { + interface RecordedSpawn { + readonly command: string; + readonly args: ReadonlyArray; + } + + const makeContext = Effect.fn("test.makeContext")(function* (options?: { + readonly platform?: NodeJS.Platform; + readonly bootService?: boolean; + readonly desktopManaged?: boolean; + readonly entryPath?: string; + readonly failWhen?: (command: string, args: ReadonlyArray) => boolean; + readonly stdoutFor?: (command: string, args: ReadonlyArray) => string | undefined; + readonly failSpawn?: boolean; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + const baseDir = path.join(home, ".t3"); + const entryPath = + options?.entryPath ?? + path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + const env: NodeJS.ProcessEnv = + options?.bootService === true + ? { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + } + : { HOME: home }; + if (options?.bootService === true) { + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir, + logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + } + + const commands: Array = []; + const spawns: Array = []; + let exited = 0; + // layerTest always reports mode "web"; desktop-managed contexts overlay + // the mode the desktop app's bootstrap envelope would set. + const configLayer = + options?.desktopManaged === true + ? Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return { ...config, mode: "desktop" as const }; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir))) + : ServerConfig.layerTest(home, baseDir); + const service = yield* SelfUpdate.make({ + host: { + spawnDetached: (command, args) => + Effect.sync(() => spawns.push({ command, args })).pipe( + Effect.andThen( + options?.failSpawn === true + ? Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause: new Error("detached spawn failed"), + }), + ) + : Effect.void, + ), + ), + exitProcess: () => { + exited += 1; + }, + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeRecordingRunnerLayer(commands, { + failWhen: options?.failWhen, + stdoutFor: options?.stdoutFor, + }), + configLayer, + ), + ), + provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), + ); + return { + fs, + path, + home, + baseDir, + entryPath, + commands, + spawns, + exitCount: () => exited, + service, + }; + }); + + it.effect("rejects dist-tags and other non-exact versions", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip); + assert.include(error.reason, "not an exact t3 version"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("refuses to update a desktop-managed backend and points at the app", () => + Effect.gen(function* () { + const context = yield* makeContext({ desktopManaged: true, bootService: true }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "desktop app"); + assert.lengthOf(context.commands, 0); + assert.lengthOf(context.spawns, 0); + }), + ); + + it.effect("fails without touching anything when no update method applies", () => + Effect.gen(function* () { + const context = yield* makeContext({ + entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs", + }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "cannot update itself"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("surfaces a failed npm install and never schedules a restart", () => + Effect.gen(function* () { + const context = yield* makeContext({ failWhen: (command) => command === "npm" }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.equal(error.reason, "Could not install the requested t3 version."); + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("reinstalls the same version after a failed preflight", () => + Effect.gen(function* () { + let preflightAttempts = 0; + const context = yield* makeContext({ + failWhen: (command) => { + if (command !== NODE_PATH) return false; + preflightAttempts += 1; + return preflightAttempts === 1; + }, + }); + const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); + const entryPath = context.path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* context.fs.makeDirectory(context.path.dirname(entryPath), { recursive: true }); + yield* context.fs.writeFileString(entryPath, "export {};\n"); + yield* context.fs.writeFileString( + context.path.join(versionDir, ".install-complete"), + "0.0.29\n", + ); + + const firstError = yield* context.service + .update({ targetVersion: "0.0.29" }) + .pipe(Effect.flip); + assert.include(firstError.reason, "failed its version check"); + assert.isFalse(yield* context.fs.exists(versionDir)); + + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + assert.deepEqual( + context.commands.map((entry) => entry.command), + [NODE_PATH, "npm", NODE_PATH], + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rejects and removes an installed runtime that reports the wrong version", () => + Effect.gen(function* () { + const context = yield* makeContext({ + stdoutFor: (command, args) => + command === NODE_PATH && args[1] === "--version" ? "0.0.28\n" : undefined, + }); + const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); + + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + + assert.include(error.reason, "did not report the requested"); + assert.isFalse(yield* context.fs.exists(versionDir)); + assert.lengthOf(context.spawns, 0); + }), + ); + + it.effect("reports a detached replacement spawn failure and leaves updates retryable", () => + Effect.gen(function* () { + const context = yield* makeContext({ failSpawn: true }); + + const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(first.reason, "Could not start the replacement"); + + const second = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(second.reason, "Could not start the replacement"); + assert.notInclude(second.reason, "already in progress"); + assert.lengthOf(context.spawns, 2); + assert.equal(context.exitCount(), 0); + }), + ); + + it.effect("installs, preflights, and respawns a foreground server", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + assert.lengthOf(context.spawns, 1); + + const concurrentError = yield* context.service + .update({ targetVersion: "0.0.30" }) + .pipe(Effect.flip); + assert.include(concurrentError.reason, "already in progress"); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + assert.deepEqual( + context.commands.map((entry) => [entry.command, ...entry.args].join(" ")), + [ + `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`, + `${NODE_PATH} ${pinnedEntry} --version`, + ], + ); + + // The restart is deferred so the RPC acknowledgement flushes first. + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 1); + const spawn = context.spawns[0]; + assert.equal(spawn?.command, "/bin/sh"); + assert.include(spawn?.args ?? [], pinnedEntry); + // The replacement replays the original CLI arguments. + assert.include(spawn?.args ?? [], "serve"); + assert.equal(context.exitCount(), 1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rewrites the systemd unit and restarts the boot service", () => + Effect.gen(function* () { + const context = yield* makeContext({ bootService: true }); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" }); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + const unit = yield* context.fs.readFileString( + context.path.join(context.home, ".config", "systemd", "user", "t3code.service"), + ); + assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl", "systemctl"], + ); + assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]); + + assert.deepEqual(context.commands[3], { + command: "systemctl", + args: ["--user", "restart", "t3code.service"], + }); + assert.lengthOf(context.spawns, 0); + // systemd replaces the process; the server must not exit itself. + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restores the previous unit and permits a retry when systemd restart fails", () => + Effect.gen(function* () { + let failRestart = true; + const context = yield* makeContext({ + bootService: true, + failWhen: (command, args) => { + if (command !== "systemctl" || args[1] !== "restart" || !failRestart) { + return false; + } + failRestart = false; + return true; + }, + }); + const unitPath = context.path.join( + context.home, + ".config", + "systemd", + "user", + BOOT_SERVICE_UNIT_FILE, + ); + const previousUnit = yield* context.fs.readFileString(unitPath); + + const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(first.reason, "Restarting the systemd boot service failed"); + assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + assert.deepEqual( + context.commands.slice(-2).map((entry) => entry.args), + [ + ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + ["--user", "daemon-reload"], + ], + ); + + const retry = yield* context.service.update({ targetVersion: "0.0.30" }); + assert.deepEqual(retry, { targetVersion: "0.0.30", method: "boot-service" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restores the previous systemd unit when daemon-reload fails", () => + Effect.gen(function* () { + const context = yield* makeContext({ + bootService: true, + failWhen: (command) => command === "systemctl", + }); + const unitPath = context.path.join( + context.home, + ".config", + "systemd", + "user", + BOOT_SERVICE_UNIT_FILE, + ); + const previousUnit = yield* context.fs.readFileString(unitPath); + + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "Reloading systemd units failed"); + assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl", "systemctl"], + ); + + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts new file mode 100644 index 00000000000..2df49910bd5 --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.ts @@ -0,0 +1,428 @@ +// @effect-diagnostics nodeBuiltinImport:off +// node:child_process directly: the foreground-server replacement must be a +// detached fire-and-forget child that outlives this process, while Effect's +// ChildProcessSpawner ties every child to a scope that kills it. +import { + ServerSelfUpdateError, + type ServerSelfUpdateCapability, + type ServerSelfUpdateInput, + type ServerSelfUpdateResult, +} from "@t3tools/contracts"; +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as NodeChildProcess from "node:child_process"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import * as ServerConfig from "../config.ts"; +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + quoteSystemdValue, + renderBootServiceUnit, +} from "./bootService.ts"; +import { ensurePinnedRuntimeInstalled, removePinnedRuntimeInstallation } from "./pinnedRuntime.ts"; + +/** + * Lets a connected client replace this server with another published `t3` + * version over RPC — the only update path that works when the user is not at + * the machine (phone against a home server, relay-managed box). The target + * version is npm-installed into the pinned runtime and verified before + * anything restarts, so a failed install leaves the running server untouched. + */ + +const PREFLIGHT_TIMEOUT = Duration.seconds(30); +/** Grace between acknowledging the RPC and killing the process, so the + response (and its relay hop) flushes before the socket drops. */ +const RESTART_DELAY = Duration.seconds(2); + +/** Exact npm versions only — never dist-tags — so the acknowledgement names + the version that was actually installed. Also keeps the value safe to + pass to npm and embed in filesystem paths. */ +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export interface ServerSelfUpdateHost { + readonly execPath: string; + readonly cliEntryPath: string; + /** Original CLI arguments after the entry path, replayed on respawn. */ + readonly cliArgs: ReadonlyArray; + /** Resolves once the foreground replacement process has actually spawned. */ + readonly spawnDetached: ( + command: string, + args: ReadonlyArray, + ) => Effect.Effect; + readonly exitProcess: () => void; +} + +function normalizeEntryPath(entryPath: string): string { + return entryPath.replaceAll("\\", "/"); +} + +/** + * Only a published npm artifact can be swapped for another version: dev + * checkouts (apps/server/dist) and the desktop app's bundled backend have no + * npm identity, and the desktop manages its own updates. + */ +export function isPublishedCliEntry(entryPath: string): boolean { + return normalizeEntryPath(entryPath).includes("/node_modules/t3/dist/"); +} + +/** + * The update path this process can offer, or null when only a manual + * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this + * backend and owns its version; only updating the app updates it. + * "boot-service" — this is the systemd-supervised process from + * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a + * foreground POSIX process running a published artifact: replace it with a + * detached child. Windows foreground runs are unsupported for now (no + * equivalent of the detach-and-exec handoff below). + */ +export const resolveServerSelfUpdateCapability = Effect.fn( + "cloud.server_self_update.resolve_capability", +)(function* (input: { + /** True when the desktop app supervises this backend (mode "desktop"). */ + readonly desktopManaged: boolean; +}) { + if (input.desktopManaged) { + return "desktop-managed" as const; + } + + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; + const hostArguments = yield* HostProcessArguments; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const entryPath = hostArguments[1] ?? ""; + if (entryPath === "") { + return null; + } + + const homeDir = env.HOME ?? ""; + if (platform === "linux" && homeDir !== "") { + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe( + Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))), + Effect.orElseSucceed(() => false), + ); + // INVOCATION_ID only proves that some systemd unit launched us. The + // explicit marker written into t3code.service identifies this unit as the + // supervisor that will replace the current process when restarted. + if ( + unitReferencesEntry && + (env.INVOCATION_ID ?? "") !== "" && + env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_UNIT_FILE + ) { + return "boot-service" as const; + } + + // A process owned by another (or a legacy unmarked) systemd unit must not + // use the foreground respawn path: Restart=always could otherwise launch + // the old unit beside the detached replacement. + if ((env.INVOCATION_ID ?? "") !== "") { + return null; + } + } + + if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) { + return "respawn" as const; + } + + return null; +}); + +export class ServerSelfUpdate extends Context.Service< + ServerSelfUpdate, + { + readonly update: ( + input: ServerSelfUpdateInput, + ) => Effect.Effect; + } +>()("t3/cloud/selfUpdate/ServerSelfUpdate") {} + +export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: { + readonly host?: Partial; +}) { + const serverConfig = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const env = yield* HostProcessEnvironment; + const hostExecPath = yield* HostProcessExecutablePath; + const hostArguments = yield* HostProcessArguments; + const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); + + const host: ServerSelfUpdateHost = { + execPath: options?.host?.execPath ?? hostExecPath, + cliEntryPath: options?.host?.cliEntryPath ?? hostArguments[1] ?? "", + cliArgs: options?.host?.cliArgs ?? hostArguments.slice(2), + spawnDetached: + options?.host?.spawnDetached ?? + ((command, args) => + Effect.callback((resume) => { + const spawnError = (cause: unknown) => + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause, + }); + let child: NodeChildProcess.ChildProcess; + try { + child = NodeChildProcess.spawn(command, [...args], { + detached: true, + stdio: "ignore", + }); + } catch (cause) { + resume(Effect.fail(spawnError(cause))); + return; + } + + const onSpawnError = (cause: Error) => resume(Effect.fail(spawnError(cause))); + child.once("error", onSpawnError); + child.once("spawn", () => { + child.removeListener("error", onSpawnError); + // Keep asynchronous child errors from becoming uncaught after the + // successful spawn handoff has already been acknowledged. + child.on("error", () => undefined); + child.unref(); + resume(Effect.void); + }); + })), + exitProcess: options?.host?.exitProcess ?? (() => process.exit(0)), + }; + + const inFlight = yield* Ref.make(false); + + const failWith = (reason: string, cause?: unknown) => + cause === undefined + ? new ServerSelfUpdateError({ reason }) + : new ServerSelfUpdateError({ reason, cause }); + + /** Deferred so the RPC acknowledgement flushes before the process dies. + Detached from the request scope: the triggering connection is exactly + what the restart tears down. */ + const scheduleRestart = (restart: Effect.Effect) => + Effect.sleep(RESTART_DELAY).pipe( + Effect.andThen(restart), + Effect.forkDetach({ startImmediately: true }), + ); + const writeUnitAtomically = (filePath: string, contents: string) => + writeFileStringAtomically({ filePath, contents }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + + const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( + "cloud.server_self_update.update", + )(function* (input) { + if (capability === "desktop-managed") { + return yield* failWith( + "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", + ); + } + if (capability === null) { + return yield* failWith( + "This server cannot update itself; relaunch it manually with the new version.", + ); + } + const activeMethod = capability; + const targetVersion = input.targetVersion.trim(); + if (!EXACT_VERSION_PATTERN.test(targetVersion)) { + return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); + } + + const alreadyRunning = yield* Ref.getAndSet(inFlight, true); + if (alreadyRunning) { + return yield* failWith("A server update is already in progress."); + } + + return yield* Effect.gen(function* () { + const runtimePaths = yield* ensurePinnedRuntimeInstalled({ + baseDir: serverConfig.baseDir, + version: targetVersion, + fs, + path, + runner, + }).pipe( + Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)), + ); + + // A broken artifact (failed native build, incompatible node) must be + // caught while the current server is still alive to report it. + const preflight = yield* runner + .run({ + command: host.execPath, + args: [runtimePaths.entryPath, "--version"], + timeout: PREFLIGHT_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => + failWith(`Could not verify the installed t3@${targetVersion}.`, cause), + ), + ); + const preflightVersion = preflight.stdout.trim(); + if (preflight.code !== 0 || preflightVersion !== targetVersion) { + // A completed npm install can still be unusable under this Node or on + // this machine. Remove its sentinel and tree so a retry of the same + // version performs a clean install instead of reusing a known-bad one. + yield* removePinnedRuntimeInstallation({ + baseDir: serverConfig.baseDir, + version: targetVersion, + fs, + path, + }).pipe( + Effect.mapError((error) => + failWith(`Could not remove the failed t3@${targetVersion} installation.`, error), + ), + ); + return yield* failWith( + preflight.code !== 0 + ? `The installed t3@${targetVersion} failed its version check (exit code ${String(preflight.code)}).` + : `The installed runtime did not report the requested t3@${targetVersion} version.`, + ); + } + + if (activeMethod === "boot-service") { + const homeDir = env.HOME ?? ""; + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const previousUnit = yield* fs + .readFileString(unitPath) + .pipe( + Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)), + ); + // Same shape bootService.install writes, so host lifecycle commands + // still recognize the unit as current. + const unit = renderBootServiceUnit({ + nodePath: host.execPath, + t3EntryPath: runtimePaths.entryPath, + baseDir: serverConfig.baseDir, + logPath: path.join(serverConfig.logsDir, "boot-service.log"), + unitPath, + }); + yield* writeUnitAtomically(unitPath, unit).pipe( + Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)), + ); + + const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () { + const reload = yield* runner + .run({ command: "systemctl", args: ["--user", "daemon-reload"] }) + .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause))); + if (reload.code !== 0) { + return yield* failWith( + `Reloading systemd units failed (exit code ${String(reload.code)}).`, + ); + } + }); + + yield* reloadSystemd().pipe( + Effect.catch((reloadError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.mapError((rollbackCause) => + failWith("Could not restore the previous systemd unit.", { + reloadError, + rollbackCause, + }), + ), + // Systemd should still have the old unit in memory after the + // failed reload, but retry after restoring in case it applied a + // partial update before returning an error. + Effect.andThen(reloadSystemd().pipe(Effect.ignore)), + Effect.andThen(Effect.fail(reloadError)), + ), + ), + ); + yield* Effect.logInfo("Server self-update installed; restarting boot service.", { + targetVersion, + }); + // A successful systemd restart stops this process, so the RPC is + // interrupted and the reconnecting client observes the new version. + // A rejected restart returns while the old process is still alive; + // restore the previous unit and report that failure through the RPC. + yield* Effect.gen(function* () { + const restart = yield* runner + .run({ + command: "systemctl", + args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + }) + .pipe( + Effect.mapError((cause) => + failWith("Could not restart the systemd boot service.", cause), + ), + ); + if (restart.code !== 0) { + return yield* failWith( + `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`, + ); + } + }).pipe( + Effect.catch((restartError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.andThen(reloadSystemd()), + Effect.mapError((rollbackError) => + failWith("Could not restore the previous systemd unit.", { + restartError, + rollbackError, + }), + ), + Effect.andThen(Effect.fail(restartError)), + ), + ), + ); + } else { + // Spawn the shim before acknowledging the RPC so ENOENT/EACCES and + // other launch failures leave this server alive and return a useful + // error. The shim itself waits until after the acknowledgement and + // deferred exit before binding the replacement server. + yield* host + .spawnDetached("/bin/sh", [ + "-c", + 'sleep 3; exec "$@"', + "t3-self-update", + host.execPath, + runtimePaths.entryPath, + ...host.cliArgs, + ]) + .pipe( + Effect.mapError((cause) => + failWith("Could not start the replacement t3 process.", cause), + ), + ); + yield* Effect.logInfo("Server self-update installed; respawning.", { targetVersion }); + yield* scheduleRestart( + Effect.try({ + try: () => host.exitProcess(), + catch: (cause) => failWith("Could not exit the replaced t3 process.", cause), + }).pipe( + Effect.catch((error) => + Effect.logError("Server self-update could not exit the replaced process.").pipe( + Effect.annotateLogs({ targetVersion, error: error.reason }), + Effect.ensuring(Ref.set(inFlight, false)), + ), + ), + ), + ); + } + + return { targetVersion, method: activeMethod }; + }).pipe(Effect.onError(() => Ref.set(inFlight, false))); + }); + + return ServerSelfUpdate.of({ update }); +}); + +export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 01567b98d32..181237d76b6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -9,6 +9,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; +import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; @@ -124,6 +125,9 @@ export const make = Effect.gen(function* () { const environmentId = EnvironmentId.make(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + const serverSelfUpdate = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -137,6 +141,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 000a9d99c6b..256f10fb3b8 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -8,6 +8,7 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -2413,6 +2414,123 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 12_000, ); + it.effect( + "does not reuse a cross-repo PR when GitHub omits head identity metadata", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequenceByHeadSelector: { + "octocat:statemachine": [ + `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, + `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, + ], + "fork-seed:statemachine": ["[]"], + statemachine: ["[]"], + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(142); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true); + }), + 20_000, + ); + + it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () => + Effect.sync(() => { + const headContext = { + headBranch: "statemachine", + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + isCrossRepository: true, + }; + + expect( + GitManager.matchesBranchHeadContext( + { + number: 41, + title: "Same-repo PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: Option.none(), + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(false); + + expect( + GitManager.matchesBranchHeadContext( + { + number: 142, + title: "Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(true); + }), + ); + + it.effect("accepts fork PR metadata when origin is the fork checkout remote", () => + Effect.sync(() => { + const headContext = { + headBranch: "t3code/git-audit-stability", + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + isCrossRepository: false, + }; + + expect( + GitManager.matchesBranchHeadContext( + { + number: 2284, + title: "Improve branch mismatch warnings", + url: "https://github.com/pingdotgg/t3code/pull/2284", + baseRefName: "main", + headRefName: "t3code/git-audit-stability", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + }, + headContext, + ), + ).toBe(true); + }), + ); + it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index e59613f7cc8..86e10d9f930 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -252,7 +252,38 @@ function resolvePullRequestHeadRepositoryNameWithOwner( return `${ownerLogin}/${repositoryName}`; } -function matchesBranchHeadContext( +interface PullRequestHeadIdentity { + readonly repositoryNameWithOwner: string | null; + readonly ownerLogin: string | null; +} + +function resolveExpectedHeadIdentity( + headContext: Pick, +): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + headContext.headRepositoryNameWithOwner, + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + +function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + resolvePullRequestHeadRepositoryNameWithOwner(pr), + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + +export function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< BranchHeadContext, @@ -263,44 +294,51 @@ function matchesBranchHeadContext( return false; } - const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( - headContext.headRepositoryNameWithOwner, - ); - const expectedHeadOwner = - normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(expectedHeadRepository); - const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( - resolvePullRequestHeadRepositoryNameWithOwner(pr), - ); - const prHeadOwner = - normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(prHeadRepository); + const expectedHead = resolveExpectedHeadIdentity(headContext); + const pullRequestHead = resolvePullRequestHeadIdentity(pr); - if (headContext.isCrossRepository) { - if (pr.isCrossRepository === false) { - return false; + if (expectedHead.repositoryNameWithOwner) { + if (pullRequestHead.repositoryNameWithOwner) { + if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) { + return false; + } + } + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { + return false; + } } - if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { + } + + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + } + + if (headContext.isCrossRepository) { + if (pr.isCrossRepository === false) { return false; } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + if ( + (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) && + !pullRequestHead.repositoryNameWithOwner && + !pullRequestHead.ownerLogin + ) { return false; } return true; } if (pr.isCrossRepository === true) { - return false; - } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; - } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { - return false; + if ( + (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) || + (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin) + ) { + return false; + } } + return true; } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 25737200a05..fc2df93ba1c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -80,6 +80,7 @@ import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -376,7 +377,11 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), -).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); +).pipe( + Layer.provide(PreviewAutomationBroker.layer), + Layer.provide(ServerSelfUpdate.layer), + Layer.provide(browserApiCorsLayer), +); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ecd2d0ad2b0..7b5956de07f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -713,6 +713,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); assert.equal(reusedForSshScheme, "origin"); + const reusedForBareSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://github.com/pingdotgg/t3code", + }); + assert.equal(reusedForBareSshScheme, "origin"); + + const reusedForSshPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code", + }); + assert.equal(reusedForSshPort, "origin"); + const reusedForSshWithPort = yield* driver.ensureRemote({ cwd, preferredName: "pingdotgg", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 08b1770a0a2..f6f46d1e76e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -78,6 +78,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -296,6 +297,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], + [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], @@ -418,6 +420,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -1476,6 +1479,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverUpdateServer]: (input) => + observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, @@ -2079,6 +2086,7 @@ const makeWsRpcLayer = ( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; return HttpRouter.add( "GET", "/ws", @@ -2101,6 +2109,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 8a713fd9026..3a70390d0c4 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" /> @@ -106,6 +106,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const pathname = useLocation({ select: (location) => location.pathname }); const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); const useSidebarV2 = sidebarV2Enabled && !isOnSettings; + const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); @@ -164,10 +165,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { { }); }); +describe("resolveLocalCheckoutBranchMismatch", () => { + it("detects when a local thread is associated with a different branch than the checkout", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/current", + }), + ).toEqual({ + threadBranch: "feature/thread", + currentBranch: "feature/current", + }); + }); + + it("ignores matching local checkout state", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/thread", + }), + ).toBeNull(); + }); + + it("ignores dedicated worktrees because their checkout is already thread-scoped", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "worktree", + activeWorktreePath: "/repo/.t3/worktrees/feature-thread", + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/current", + }), + ).toBeNull(); + }); + + it("ignores new-worktree base selection before a worktree exists", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "worktree", + activeWorktreePath: null, + activeThreadBranch: "feature/base", + currentGitBranch: "main", + }), + ).toBeNull(); + }); +}); + describe("resolveEnvironmentOptionLabel", () => { it("prefers the primary environment's machine label", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index b16e1f590a9..c083c335292 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -108,6 +108,22 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +export function resolveLocalCheckoutBranchMismatch(input: { + effectiveEnvMode: EnvMode; + activeWorktreePath: string | null; + activeThreadBranch: string | null; + currentGitBranch: string | null; +}): { threadBranch: string; currentBranch: string } | null { + const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; + if (effectiveEnvMode !== "local" || activeWorktreePath !== null) { + return null; + } + if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) { + return null; + } + return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; +} + export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 0354c2e0cd7..c2db48184c7 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -248,7 +248,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( { + void writeTextToClipboard(branchName, "branch name").then( + (didCopy) => { + if (!didCopy) return; + toastManager.add({ + type: "success", + title: "Branch name copied", + description: branchName, + }); + }, + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy branch name", + description: toBranchActionErrorMessage(error), + }), + ); + }, + ); + }, []); + + const handleBranchContextMenu = useCallback( + (event: ReactMouseEvent, branchName: string | null) => { + if (!branchName) return; + const api = readLocalApi(); + if (!api) return; + event.preventDefault(); + event.stopPropagation(); + const items: ContextMenuItem<"copy-branch-name">[] = [ + { id: "copy-branch-name", label: "Copy branch name", icon: "copy" }, + ]; + void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => { + if (action === "copy-branch-name") copyBranchName(branchName); + }); + }, + [copyBranchName], + ); + const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action(); @@ -431,6 +473,7 @@ export function BranchToolbarBranchSelector({ const worktreeBaseBranchCandidate = isInitialBranchesLoadPending ? null : (defaultBranchName ?? currentGitBranch); + useEffect(() => { if ( effectiveEnvMode !== "worktree" || @@ -547,7 +590,11 @@ export function BranchToolbarBranchSelector({ }); // PR pill shown next to the branch selector when the active branch has one. - const branchPr = resolveThreadPr(resolvedActiveBranch, branchStatusQuery.data ?? null); + const branchPr = resolveThreadPr({ + threadBranch: resolvedActiveBranch, + gitStatus: branchStatusQuery.data ?? null, + hasDedicatedWorktree: activeWorktreePath !== null, + }); const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's // state-description tooltip. @@ -624,6 +671,7 @@ export function BranchToolbarBranchSelector({ value={itemValue} className="pe-1.5" onClick={() => selectBranch(refName)} + onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
{itemValue} @@ -674,15 +722,23 @@ export function BranchToolbarBranchSelector({ {branchPrTooltip} ) : null} - } - className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" - disabled={isInitialBranchesLoadPending || isBranchActionPending} + {/* Context menu lives on the wrapper: the disabled Button has + pointer-events-none, so the trigger itself never sees right-clicks + while refs are loading or a branch action is pending. */} + handleBranchContextMenu(event, resolvedActiveBranch)} > - - {triggerLabel} - - + } + className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" + disabled={isInitialBranchesLoadPending || isBranchActionPending} + > + + {triggerLabel} + + +
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39205fa4872..b7b4d835454 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -20,6 +20,7 @@ import { hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -80,6 +81,34 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("resolveThreadMetadataUpdateForNextTurn", () => { + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }; + + it("updates a stale local thread branch to the active checkout", () => { + expect( + resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: modelSelection, + currentBranch: "feature/thread", + nextBranch: "feature/checkout", + }), + ).toEqual({ branch: "feature/checkout", worktreePath: null }); + }); + + it("does not write metadata when the model and branch are unchanged", () => { + expect( + resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: modelSelection, + nextModelSelection: modelSelection, + currentBranch: "feature/current", + nextBranch: "feature/current", + }), + ).toBeNull(); + }); +}); + describe("buildThreadTurnInterruptInput", () => { it("targets the session's active running turn", () => { const activeTurnId = TurnId.make("turn-running"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b32b163bbff..f7bf40842f3 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -27,6 +27,33 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function resolveThreadMetadataUpdateForNextTurn(input: { + currentModelSelection: ModelSelection; + nextModelSelection?: ModelSelection; + currentBranch: string | null; + nextBranch?: string; +}): { + modelSelection?: ModelSelection; + branch?: string; + worktreePath?: null; +} | null { + const nextModelSelection = input.nextModelSelection; + const modelSelectionChanged = + nextModelSelection !== undefined && + (nextModelSelection.model !== input.currentModelSelection.model || + nextModelSelection.instanceId !== input.currentModelSelection.instanceId || + JSON.stringify(nextModelSelection.options ?? null) !== + JSON.stringify(input.currentModelSelection.options ?? null)); + const branchChanged = input.nextBranch !== undefined && input.nextBranch !== input.currentBranch; + if (!modelSelectionChanged && !branchChanged) { + return null; + } + return { + ...(modelSelectionChanged ? { modelSelection: nextModelSelection } : {}), + ...(branchChanged ? { branch: input.nextBranch, worktreePath: null } : {}), + }; +} + export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f108d67b3cc..07084ea2e21 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -216,8 +216,12 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; -import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; +import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + getProviderStatusBannerKey, + ProviderStatusBanner, + shouldShowProviderStatusBanner, +} from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { @@ -246,6 +250,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -258,11 +263,14 @@ import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; +import { ServerUpdateAction } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, + serverUpdateGuidance, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; @@ -1062,6 +1070,10 @@ type LocalThreadErrorEntry = { readonly at: number; }; +function chatActionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -1089,6 +1101,7 @@ function ChatViewContent(props: ChatViewProps) { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); @@ -1770,7 +1783,10 @@ function ChatViewContent(props: ChatViewProps) { hasMultipleRegisteredEnvironments && activeThread ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` : "server"; - const composerBannerItems = useMemo(() => { + const versionMismatchEnvironmentId = + versionMismatch && activeThread ? activeThread.environmentId : null; + const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); + const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { const connection = activeEnvironmentUnavailableState.connection; @@ -1808,7 +1824,12 @@ function ChatViewContent(props: ChatViewProps) { ), }); } - if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { + if ( + showVersionMismatchBanner && + versionMismatch && + versionMismatchDismissKey && + versionMismatchEnvironmentId + ) { items.push({ id: `version-mismatch:${versionMismatchDismissKey}`, variant: "warning", @@ -1817,9 +1838,21 @@ function ChatViewContent(props: ChatViewProps) { description: ( <> Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} - {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. + {versionMismatch.serverVersion}.{" "} + {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} ), + // The desktop-managed guidance is already the description; the action + // slot would only repeat it. + actions: + versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( + + ), dismissLabel: "Dismiss version mismatch warning", onDismiss: () => { dismissVersionMismatch(versionMismatchDismissKey); @@ -1832,9 +1865,12 @@ function ChatViewContent(props: ChatViewProps) { activeEnvironmentUnavailableState, handleReconnectActiveEnvironment, navigate, + setDismissedVersionMismatchKey, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, + versionMismatchEnvironmentId, + versionMismatchSelfUpdate, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; @@ -2279,6 +2315,22 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); + const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< + string | null + >(null); + useEffect(() => { + if (providerStatusBannerKey === null && dismissedProviderStatusBannerKey !== null) { + setDismissedProviderStatusBannerKey(null); + } + }, [dismissedProviderStatusBannerKey, providerStatusBannerKey]); + const visibleProviderStatus = shouldShowProviderStatusBanner( + activeProviderStatus, + dismissedProviderStatusBannerKey, + ) + ? activeProviderStatus + : null; + const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -3196,6 +3248,7 @@ function ChatViewContent(props: ChatViewProps) { threadId: ThreadId; createdAt: string; modelSelection?: ModelSelection; + branch?: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }): Promise> => { @@ -3204,19 +3257,19 @@ function ChatViewContent(props: ChatViewProps) { } let result: AtomCommandResult = AsyncResult.success(undefined); - if ( - input.modelSelection !== undefined && - (input.modelSelection.model !== serverThread.modelSelection.model || - input.modelSelection.instanceId !== serverThread.modelSelection.instanceId || - JSON.stringify(input.modelSelection.options ?? null) !== - JSON.stringify(serverThread.modelSelection.options ?? null)) - ) { + const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: serverThread.modelSelection, + ...(input.modelSelection ? { nextModelSelection: input.modelSelection } : {}), + currentBranch: serverThread.branch, + ...(input.branch ? { nextBranch: input.branch } : {}), + }); + if (metadataUpdate) { result = mapAtomCommandResult( await updateThreadMetadata({ environmentId, input: { threadId: input.threadId, - modelSelection: input.modelSelection, + ...metadataUpdate, }, }), () => undefined, @@ -3710,6 +3763,179 @@ function ChatViewContent(props: ChatViewProps) { requestedEnvMode: envMode, isGitRepo, }); + const localCheckoutBranchMismatch = useMemo( + () => + isServerThread + ? resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: envMode, + activeWorktreePath, + activeThreadBranch, + currentGitBranch: gitStatusQuery.data?.refName ?? null, + }) + : null, + [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], + ); + const [branchRepairAction, setBranchRepairAction] = useState< + "update-thread" | "switch-checkout" | null + >(null); + const handleUpdateThreadToCheckout = useCallback(async () => { + if (!activeThread || !localCheckoutBranchMismatch || branchRepairAction !== null) { + return; + } + setBranchRepairAction("update-thread"); + const updateResult = await updateThreadMetadata({ + environmentId, + input: { + threadId: activeThread.id, + branch: localCheckoutBranchMismatch.currentBranch, + worktreePath: null, + }, + }); + setBranchRepairAction(null); + if (updateResult._tag === "Failure" && !isAtomCommandInterrupted(updateResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update thread branch", + description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), + }), + ); + return; + } + scheduleComposerFocus(); + }, [ + activeThread, + branchRepairAction, + environmentId, + localCheckoutBranchMismatch, + scheduleComposerFocus, + updateThreadMetadata, + ]); + const handleSwitchCheckoutToThread = useCallback(async () => { + if ( + !activeProjectCwd || + !activeThread || + !localCheckoutBranchMismatch || + branchRepairAction !== null + ) { + return; + } + setBranchRepairAction("switch-checkout"); + const checkoutResult = await switchGitRef({ + environmentId, + input: { + cwd: activeProjectCwd, + refName: localCheckoutBranchMismatch.threadBranch, + }, + }); + if (checkoutResult._tag === "Failure") { + setBranchRepairAction(null); + if (!isAtomCommandInterrupted(checkoutResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to switch checkout", + description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)), + }), + ); + } + return; + } + + const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; + if (nextBranch !== activeThread.branch) { + const updateResult = await updateThreadMetadata({ + environmentId, + input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, + }); + if (updateResult._tag === "Failure") { + setBranchRepairAction(null); + if (!isAtomCommandInterrupted(updateResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Checkout switched, but the thread could not be updated", + description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), + }), + ); + } + gitStatusQuery.refresh(); + return; + } + } + gitStatusQuery.refresh(); + setBranchRepairAction(null); + scheduleComposerFocus(); + }, [ + activeProjectCwd, + activeThread, + branchRepairAction, + environmentId, + gitStatusQuery, + localCheckoutBranchMismatch, + scheduleComposerFocus, + switchGitRef, + updateThreadMetadata, + ]); + const composerBannerItems = useMemo(() => { + if (!localCheckoutBranchMismatch) { + return systemComposerBannerItems; + } + const isRepairingBranch = branchRepairAction !== null; + return [ + ...systemComposerBannerItems, + { + id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`, + variant: "warning", + icon: , + title: "You're on a different branch", + className: + "text-base sm:text-sm [&>div]:items-start max-sm:[&>div]:flex-wrap max-sm:[&>div>div:last-child]:w-full max-sm:[&>div>div:last-child]:self-start dark:shadow-none", + actionClassName: + "max-sm:w-full max-sm:border-t max-sm:border-border/60 max-sm:pt-2 max-sm:pl-6 sm:border-l sm:border-border/60 sm:pl-3", + description: ( +

+ This thread is on{" "} + + {localCheckoutBranchMismatch.threadBranch} + + , but you're currently checked out at{" "} + + {localCheckoutBranchMismatch.currentBranch} + + . Sending a message will update the thread. +

+ ), + actions: ( + <> + + + + ), + }, + ]; + }, [ + activeThread?.id, + branchRepairAction, + handleSwitchCheckoutToThread, + handleUpdateThreadToCheckout, + localCheckoutBranchMismatch, + systemComposerBannerItems, + ]); useEffect(() => { setPendingServerThreadEnvMode(null); @@ -4391,6 +4617,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode, }); @@ -4823,6 +5052,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, modelSelection: ctxSelectedModelSelection, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode: nextInteractionMode, }); @@ -4899,6 +5131,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + localCheckoutBranchMismatch, persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, @@ -5357,7 +5590,7 @@ function ChatViewContent(props: ChatViewProps) {
- {/* Error banner */} - setThreadError(activeThread.id, null)} @@ -5403,6 +5634,13 @@ function ChatViewContent(props: ChatViewProps) {
{/* Chat column */}
+ {/* Provider status overlays the timeline without changing its content height. */} +
+ setDismissedProviderStatusBannerKey(providerStatusBannerKey)} + /> +
{/* Messages Wrapper */}
{/* Messages — LegendList handles virtualization and scrolling internally */} @@ -5439,6 +5677,7 @@ function ChatViewContent(props: ChatViewProps) { onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} + topFadeEnabled={!hasTimelineTopBanner} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5471,17 +5710,6 @@ function ChatViewContent(props: ChatViewProps) { : "pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2" } > - {!isDraftHeroState ? ( -