diff --git a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz b/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz deleted file mode 100644 index 8fafc3896813..000000000000 Binary files a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz and /dev/null differ diff --git a/apps/mobile/package.json b/apps/mobile/package.json index dba137f30472..99d06e089e3a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -49,7 +49,6 @@ "@clerk/expo": "catalog:", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", - "@expo/metro-runtime": "~57.0.14", "@expo/ui": "~57.0.14", "@legendapp/list": "catalog:", "@material/material-color-utilities": "0.3.0", @@ -137,9 +136,6 @@ "tailwindcss": "^4.0.0", "typescript": "catalog:" }, - "overrides": { - "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz" - }, "expo": { "install": { "exclude": [ diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 3de335cfd562..91845bb9d0ac 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -284,6 +284,15 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Diagnostics", }, }), + // Deliberately the one settings screen with no `linking:` path. Its params + // are a tap-time snapshot, not a stable resource address: `now` is the + // wall-clock of the tap, `environmentIds` is the usage screen's local + // filter selection, and the window id/kind identify freshly aggregated + // pools. React Navigation round-trips non-path params through the URL as + // query strings, so a path here would bake in a permanently stale + // timestamp and filter. The deep linkable surface is the list at + // `settings/usage`, which rebuilds this state and pushes the detail from a + // tapped account segment. SettingsUsageAccount: createNativeStackScreen({ screen: UsageLimitAccountScreen, options: { title: "Account" }, @@ -649,6 +658,14 @@ const RootStackConfig = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`, options: SOLID_HEADER_OPTIONS, }), + // Deliberately the one root route with no `linking:` path. The route + // carries zero params: its content is a session object (staged model, + // provider groups, and live update callbacks) that the active + // ThreadComposer presents into ExistingThreadSettingsRouteProvider before + // pushing this screen — state no URL can reconstruct. Reached without a + // presented session the screen navigates straight back, so a path would + // only produce a flash-and-dismiss link. Deep links to a thread land on + // `threads/:environmentId/:threadId`, where this sheet is one tap away. ThreadSettingsSheet: createNativeStackScreen({ screen: ExistingThreadSettingsRouteScreen, options: { diff --git a/apps/mobile/src/components/ConfirmDialog.types.ts b/apps/mobile/src/components/ConfirmDialog.types.ts new file mode 100644 index 000000000000..7a32787cc32d --- /dev/null +++ b/apps/mobile/src/components/ConfirmDialog.types.ts @@ -0,0 +1,18 @@ +export type ConfirmDialogRequest = { + readonly title: string; + readonly message?: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly destructive?: boolean; + readonly onConfirm: () => void; + readonly onCancel?: () => void; +}; + +export type TextInputDialogRequest = { + readonly title: string; + readonly initialValue: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index aa1653055b59..67e9d465c573 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -4,25 +4,9 @@ import { Platform, Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; +import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; -export type ConfirmDialogRequest = { - readonly title: string; - readonly message?: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly destructive?: boolean; - readonly onConfirm: () => void; - readonly onCancel?: () => void; -}; - -export type TextInputDialogRequest = { - readonly title: string; - readonly initialValue: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly onConfirm: (value: string) => void; - readonly onCancel?: () => void; -}; +export type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; type DialogRequest = | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index e2bae6101b60..4f06319a6846 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -2,7 +2,7 @@ import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; const NativeControls = requireNativeModule<{ presentFile( diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index 337699e8a61e..85286dbda891 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -3,7 +3,7 @@ import { Alert, Modal, Pressable, View } from "react-native"; import ImageViewing from "react-native-image-viewing"; import { openAttachmentInViewer } from "../lib/attachmentDownload"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaImagePreview } from "./MediaImagePreview"; import { AppText as Text } from "./AppText"; diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 77dd5600bc15..0692109cacde 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -1,30 +1,13 @@ import { useIsFocused } from "@react-navigation/native"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { useEffect, useEffectEvent, useState } from "react"; import { Alert, Keyboard } from "react-native"; -import type { FileBackedComposerAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import type { MediaActionsSource } from "../lib/mediaActions"; import { useRefreshAssetUrl } from "../state/assets"; import { FilePreview } from "./FilePreview"; +import type { FilePreviewSource } from "./FilePreviewModal.types"; -export interface ResolvedFilePreviewSource { - readonly kind: "image" | "pdf" | "document"; - readonly mimeType?: string; - readonly uri: string; - readonly name?: string; - readonly sourceIdentifier?: string; - readonly srcFragment?: string; - readonly actionsSource?: MediaActionsSource; -} - -export type FilePreviewSource = Omit & - ( - | { readonly uri: string } - | { readonly attachment: FileBackedComposerAttachment } - | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } - ); +export type { FilePreviewSource, ResolvedFilePreviewSource } from "./FilePreviewModal.types"; function ResolvedFilePreview(props: { readonly source: FilePreviewSource; diff --git a/apps/mobile/src/components/FilePreviewModal.types.ts b/apps/mobile/src/components/FilePreviewModal.types.ts new file mode 100644 index 000000000000..6d125125c80f --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.types.ts @@ -0,0 +1,21 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; + +import type { FileBackedComposerAttachment } from "../lib/composerImages"; +import type { MediaActionsSource } from "../lib/mediaActions"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf" | "document"; + readonly mimeType?: string; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: FileBackedComposerAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); diff --git a/apps/mobile/src/components/MaterialConfirmDialog.tsx b/apps/mobile/src/components/MaterialConfirmDialog.tsx index c21ad042c39c..7154c7d716d8 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -1,4 +1,4 @@ -import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; +import type { ConfirmDialogRequest } from "./ConfirmDialog.types"; export interface MaterialConfirmDialogProps { readonly request: Pick< diff --git a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx index ceea91b24447..e941fc52eef1 100644 --- a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx @@ -2,7 +2,7 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/ import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; /** Compose content shared by screen controls and native dialogs, inside their existing Host. */ export function MaterialSegmentedButtons( diff --git a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx index 16ec879821e1..76734e14d763 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -3,7 +3,7 @@ import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSegmentedControl.tsx b/apps/mobile/src/components/MaterialSegmentedControl.tsx index eca51d2d3f26..0935da6cd66c 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.tsx @@ -1,4 +1,4 @@ -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( _props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx index 2bc9d11130d7..f08fa78690f7 100644 --- a/apps/mobile/src/components/MaterialSwitch.android.tsx +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -1,6 +1,6 @@ import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; import { View } from "react-native"; -import type { ThemedSwitchProps } from "./ThemedSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; diff --git a/apps/mobile/src/components/MaterialSwitch.types.ts b/apps/mobile/src/components/MaterialSwitch.types.ts new file mode 100644 index 000000000000..657a2139a69e --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.types.ts @@ -0,0 +1,12 @@ +import type { SwitchProps } from "react-native"; + +export type ThemedSwitchProps = Pick< + SwitchProps, + | "accessibilityHint" + | "accessibilityLabel" + | "disabled" + | "onValueChange" + | "style" + | "testID" + | "value" +>; diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 03e317c296af..09bebcb28b66 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useMediaActions } from "../lib/mediaActions"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaActionsMenu } from "./MediaActionsMenu"; import { MediaSourceCaption } from "./MediaSourceCaption"; diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 932fc6779f20..e1d883a01026 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -18,7 +18,7 @@ import { hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, -} from "./projectFaviconCache"; +} from "../lib/projectFaviconRequests"; const EMPTY_FAVICON_URL = Atom.make(null); diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx index e1e61cda26df..8e4d6c3130bc 100644 --- a/apps/mobile/src/components/SegmentedControl.tsx +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -3,21 +3,9 @@ import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-r import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; -export interface SegmentedControlProps { - readonly options: readonly { - readonly value: Value; - readonly label: string; - readonly accessibilityLabel?: string; - }[]; - readonly selected: Value; - readonly onSelect: (value: Value) => void; - /** Compact sizing applies to the non-Material control. */ - readonly size?: "default" | "compact"; - /** "tab" for the view switcher; filters stay plain buttons. */ - readonly role?: "tab" | "button"; - readonly className?: string; -} +export type { SegmentedControlProps } from "./SegmentedControl.types"; export function SegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/SegmentedControl.types.ts b/apps/mobile/src/components/SegmentedControl.types.ts new file mode 100644 index 000000000000..921dcdb1cbde --- /dev/null +++ b/apps/mobile/src/components/SegmentedControl.types.ts @@ -0,0 +1,14 @@ +export interface SegmentedControlProps { + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + /** Compact sizing applies to the non-Material control. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; +} diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index f0cf8701e50c..d88ace54cb49 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,17 +1,9 @@ -import { Platform, Switch, type SwitchProps } from "react-native"; +import { Platform, Switch } from "react-native"; import { MaterialSwitch } from "./MaterialSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; -export type ThemedSwitchProps = Pick< - SwitchProps, - | "accessibilityHint" - | "accessibilityLabel" - | "disabled" - | "onValueChange" - | "style" - | "testID" - | "value" ->; +export type { ThemedSwitchProps } from "./MaterialSwitch.types"; export function ThemedSwitch(props: ThemedSwitchProps) { if (Platform.OS === "android") { diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index c4a7cbdcce06..44a1d3114532 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,7 +15,10 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; -import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; +import { + attachProjectFaviconDatabase, + projectFaviconDatabaseCache, +} from "../lib/projectFaviconDatabaseCache"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -130,7 +133,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -241,7 +244,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( + Effect.promise(() => projectFaviconDatabaseCache.clearEnvironment(environmentId)).pipe( Effect.andThen(database.clearEnvironmentCache(environmentId)), Effect.mapError(mapDatabaseError("clear-environment")), ), diff --git a/apps/mobile/src/dependency-graph.test.ts b/apps/mobile/src/dependency-graph.test.ts new file mode 100644 index 000000000000..f4c953925bcc --- /dev/null +++ b/apps/mobile/src/dependency-graph.test.ts @@ -0,0 +1,288 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +/** + * Dependency-graph guards for the mobile source tree (audit #13). + * + * 1. No circular imports, checked once per platform the way Metro resolves + * modules (`..*` before `.native.*` before the + * generic file). A cycle can hide behind platform resolution: a `.tsx` + * base importing a component whose `.android.tsx` variant type-imports + * back into the base is acyclic on iOS but circular on Android. + * Dynamic `import("...")` calls are excluded from this rule on purpose: + * they are the deliberate async escape hatch (e.g. composer-draft cleanup + * reaching `lib/attachmentUpload`), and Metro resolves them after both + * modules have initialized, so they cannot create an initialization cycle. + * + * 2. Cross-layer edges are ceilinged, not yet banned. `state`, `lib`, + * `native`, and `components` still reach upward into `features` at known + * sites (the app composition root `lib/runtime.ts` legitimately wires + * feature layers). Ceilings count unique `from -> to` module pairs across + * all platforms, including dynamic imports, and may only shrink: when you + * remove one of these imports, lower the constant in the same PR. + * + * Runs in CI as part of the `Test` job (`vp run --filter '!t3' test` picks up + * the `@t3tools/mobile` package test task). + */ + +const SOURCE_ROOT = __dirname; + +/** Metro candidate order per platform (platform, then native, then generic). */ +const PLATFORM_EXTENSION_ORDER = { + android: [".android.ts", ".android.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], + ios: [".ios.ts", ".ios.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], +} as const; + +type Platform = keyof typeof PLATFORM_EXTENSION_ORDER; + +const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray; + +const isGraphFile = (filePath: string): boolean => + /\.tsx?$/.test(filePath) && + !filePath.includes(".test.") && + !filePath.includes("test-support") && + !filePath.endsWith(".d.ts"); + +/** Files Metro would not even bundle for the other platform. */ +function isRelevantForPlatform(filePath: string, platform: Platform): boolean { + const name = NodePath.basename(filePath); + if (platform === "android") { + return !name.includes(".ios."); + } + return !name.includes(".android."); +} + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of NodeFS.readdirSync(dir)) { + const filePath = NodePath.join(dir, entry); + if (NodeFS.statSync(filePath).isDirectory()) { + collectSourceFiles(filePath, out); + } else if (isGraphFile(filePath)) { + out.push(filePath); + } + } + return out; +} + +function resolveRelative( + fromFile: string, + specifier: string, + extensionOrder: ReadonlyArray, +): string | null { + if (!specifier.startsWith(".")) { + return null; + } + const base = NodePath.resolve(NodePath.dirname(fromFile), specifier); + for (const ext of extensionOrder) { + for (const candidate of [base + ext, NodePath.join(base, `index${ext}`)]) { + try { + if (NodeFS.statSync(candidate).isFile()) { + const resolved = NodePath.resolve(candidate); + return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null; + } + } catch { + // Candidate does not exist; try the next one. + } + } + } + return null; +} + +interface ParsedImport { + readonly specifier: string; + readonly isDynamic: boolean; +} + +function parseImports(source: string): ParsedImport[] { + const parsed: ParsedImport[] = []; + const staticRe = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g; + const bareRe = /(?:^|\n)\s*import\s+["']([^"']+)["']/g; + const dynamicRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; + for (const match of source.matchAll(staticRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(bareRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(dynamicRe)) { + parsed.push({ specifier: match[1]!, isDynamic: true }); + } + return parsed; +} + +type Layer = "state" | "lib" | "components" | "native" | "features" | "other"; + +function layerOf(relativePath: string): Layer { + const top = relativePath.split(NodePath.sep)[0]!; + if (top === "features") return "features"; + return top === "state" || top === "lib" || top === "components" || top === "native" + ? (top as Layer) + : "other"; +} + +interface PlatformGraph { + readonly platform: Platform; + readonly files: ReadonlyArray; + /** Static (type or value) import edges keyed by source file. */ + readonly staticEdges: ReadonlyMap>; + /** Unique cross-layer edges, static and dynamic, as "fromRel -> toRel". */ + readonly crossLayerEdges: ReadonlySet; +} + +function buildPlatformGraph(platform: Platform): PlatformGraph { + const extensionOrder = PLATFORM_EXTENSION_ORDER[platform]; + const files = collectSourceFiles(SOURCE_ROOT) + .filter((file) => isRelevantForPlatform(file, platform)) + .sort(); + const staticEdges = new Map(); + const crossLayerEdges = new Set(); + for (const file of files) { + const targets = new Set(); + for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) { + const resolved = resolveRelative(file, specifier, extensionOrder); + if (resolved === null || resolved === file) { + continue; + } + const from = NodePath.relative(SOURCE_ROOT, file); + const to = NodePath.relative(SOURCE_ROOT, resolved); + const fromLayer = layerOf(from); + const toLayer = layerOf(to); + const upward = + (fromLayer === "state" || + fromLayer === "lib" || + fromLayer === "components" || + fromLayer === "native") && + (toLayer === "features" || (fromLayer === "lib" && toLayer === "state")); + if (upward) { + crossLayerEdges.add(`${from} -> ${to}`); + } + if (!isDynamic) { + targets.add(resolved); + } + } + staticEdges.set(file, [...targets]); + } + return { platform, files, staticEdges, crossLayerEdges }; +} + +/** Tarjan strongly-connected components, iterative to bound stack depth. */ +function findCycles(graph: PlatformGraph): ReadonlyArray> { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const cycles: string[][] = []; + let nextIndex = 0; + + const enter = (node: string): void => { + index.set(node, nextIndex); + low.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + }; + + for (const root of graph.files) { + if (index.has(root)) continue; + const work: Array<[string, number]> = [[root, 0]]; + enter(root); + while (work.length > 0) { + const frame = work[work.length - 1]!; + const neighbors = graph.staticEdges.get(frame[0]) ?? []; + let advanced = false; + for (let i = frame[1]; i < neighbors.length; i += 1) { + const child = neighbors[i]!; + if (!graph.staticEdges.has(child)) continue; + if (!index.has(child)) { + work[work.length - 1] = [frame[0], i + 1]; + work.push([child, 0]); + enter(child); + advanced = true; + break; + } else if (onStack.has(child)) { + low.set(frame[0], Math.min(low.get(frame[0])!, index.get(child)!)); + } + } + if (advanced) continue; + work.pop(); + const parent = work[work.length - 1]; + if (parent) { + low.set(parent[0], Math.min(low.get(parent[0])!, low.get(frame[0])!)); + } + if (low.get(frame[0]) === index.get(frame[0])) { + const component: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.push(member); + } while (member !== frame[0]); + if (component.length > 1) { + cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file))); + } + } + } + } + return cycles; +} + +const graphs = PLATFORMS.map(buildPlatformGraph); + +/** Unique upward edge pairs across every platform, sorted for stable diffs. */ +function upwardEdges(): string[] { + const union = new Set(); + for (const graph of graphs) { + for (const edge of graph.crossLayerEdges) { + union.add(edge); + } + } + return [...union].sort(); +} + +describe("mobile dependency graph", () => { + it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => { + const graph = graphs.find((candidate) => candidate.platform === platform)!; + // The graph must see real files; a resolution regression here would make + // both rules vacuously pass. + expect(graph.files.length).toBeGreaterThan(500); + expect(findCycles(graph)).toEqual([]); + }); + + it("keeps upward imports from state/lib/components/native into features at the ceiling", () => { + const edges = upwardEdges(); + const edgesFor = (from: Layer, to: Layer): string[] => + edges.filter((edge) => { + const [source, target] = edge.split(" -> "); + return layerOf(source!) === from && layerOf(target!) === to; + }); + + const ceilings: ReadonlyArray = [ + // state -> features: thread ordering reaching the thread-list model, + // the incoming-share store, the connection controller hook, the + // terminal launch context, and the pending message feed. + // (legacy-plan-mode was pure model logic and moved into state/.) + ["state", "features", 6, "state must not add imports from features"], + // lib -> features: lib/runtime.ts is the app composition root and + // legitimately wires cloud/observability features; the appearance + // helpers and terminal preferences still need untangling. + ["lib", "features", 7, "lib must not add imports from features"], + // components -> features: mostly the appearance preferences provider + // and the layout toolbar bridges. + ["components", "features", 33, "components must not add imports from features"], + // native -> features: native glue reading appearance/keyboard/review features. + ["native", "features", 8, "native must not add imports from features"], + // lib -> state: attachment/session plumbing that predates the cycle + // cleanup; each remaining edge needs a real owner-side seam. + ["lib", "state", 11, "lib must not add imports from state"], + ]; + + for (const [from, to, ceiling, message] of ceilings) { + const layerEdges = edgesFor(from, to); + expect( + layerEdges.length, + `${message}. ${layerEdges.length} edges remain:\n${layerEdges.join("\n")}`, + ).toBeLessThanOrEqual(ceiling); + } + }); +}); diff --git a/apps/mobile/src/features/cloud/cloudDebugLog.ts b/apps/mobile/src/features/cloud/cloudDebugLog.ts index 840a3db55680..7e57b39b7bb8 100644 --- a/apps/mobile/src/features/cloud/cloudDebugLog.ts +++ b/apps/mobile/src/features/cloud/cloudDebugLog.ts @@ -1,18 +1,14 @@ +import { createDebugLogger } from "../../lib/debugLog"; + +const logger = createDebugLogger("cloud", { + enabledInDev: true, + legacyGlobalFlag: "__T3_CLOUD_DEBUG__", +}); + export function isCloudDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true) - ); + return logger.isEnabled(); } export function cloudDebugLog(event: string, data?: Record): void { - if (!isCloudDebugEnabled()) { - return; - } - if (data) { - console.log(`[t3-cloud] ${event}`, data); - } else { - console.log(`[t3-cloud] ${event}`); - } + logger.log(event, data); } diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index d00cf2be4daf..818f5ab8af47 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -4,7 +4,7 @@ import { cn } from "../../lib/cn"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import type { ReviewRenderableLineRow } from "./reviewModel"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; export const REVIEW_MONO_FONT_FAMILY = Platform.select({ ios: "ui-monospace", diff --git a/apps/mobile/src/features/review/reviewHighlightedToken.types.ts b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts new file mode 100644 index 000000000000..58418a32cc7c --- /dev/null +++ b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts @@ -0,0 +1,6 @@ +export interface ReviewHighlightedToken { + content: string; + readonly color: string | null; + readonly fontStyle: number | null; + readonly diffHighlight?: boolean; +} diff --git a/apps/mobile/src/features/review/reviewWordDiffs.ts b/apps/mobile/src/features/review/reviewWordDiffs.ts index 34ac9bc7a746..8dd258a296cc 100644 --- a/apps/mobile/src/features/review/reviewWordDiffs.ts +++ b/apps/mobile/src/features/review/reviewWordDiffs.ts @@ -1,6 +1,6 @@ import { diffWordsWithSpace } from "diff"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; interface ReviewDiffOperation { readonly value: string; diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 3050f1f67ee8..9b69c8d552ea 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -35,12 +35,9 @@ export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErr } } -export interface ReviewHighlightedToken { - content: string; - readonly color: string | null; - readonly fontStyle: number | null; - readonly diffHighlight?: boolean; -} +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; + +export type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", diff --git a/apps/mobile/src/features/terminal/terminalDebugLog.ts b/apps/mobile/src/features/terminal/terminalDebugLog.ts index eb11419b330c..31c8497915c3 100644 --- a/apps/mobile/src/features/terminal/terminalDebugLog.ts +++ b/apps/mobile/src/features/terminal/terminalDebugLog.ts @@ -1,24 +1,21 @@ +import { createDebugLogger } from "../../lib/debugLog"; + /** * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`. * - * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS - * debugger / Metro console to trace release/TestFlight builds. + * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__` + * (or the shared `globalThis.__T3_DEBUG__` filter) in a JS debugger / Metro + * console to trace release/TestFlight builds. */ +const logger = createDebugLogger("terminal", { + enabledInDev: true, + legacyGlobalFlag: "__T3_TERMINAL_DEBUG__", +}); + export function isTerminalDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true) - ); + return logger.isEnabled(); } export function terminalDebugLog(message: string, data?: Record): void { - if (!isTerminalDebugEnabled()) { - return; - } - if (data !== undefined) { - console.log(`[t3-terminal] ${message}`, data); - } else { - console.log(`[t3-terminal] ${message}`); - } + logger.log(message, data); } diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f2801255b3bc..4d069bca9d05 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -89,7 +89,7 @@ import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { resolvePendingTaskInteractionMode, resolveProviderInteractionMode, -} from "./legacy-plan-mode"; +} from "../../state/legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskBranchWorktreePath, diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts index 61c4fb65cdc9..684049155a9e 100644 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; +import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode"; /** * Mobile preferences are device-local, matching the desktop client setting. diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 2c6b27864432..df68a2de3019 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -32,7 +32,7 @@ vi.mock("../state/atom-registry", () => ({ })); // The real read lease and cleanup are covered by the composer ownership suite. -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: () => () => {}, })); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index 10f4fdb7c6c7..1bce3e26774f 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -20,8 +20,8 @@ import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; import { isComposerImageAttachment, isFileBackedComposerAttachment, diff --git a/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts new file mode 100644 index 000000000000..ca9044f32dc6 --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts @@ -0,0 +1,28 @@ +import type { FileBackedComposerAttachment } from "./composerImages"; +import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; + +/** + * Preview retention for saved composer attachment copies. + * + * The durable owners of an attachment file (composer drafts, queued outbox + * messages) live in state, so this module cannot reach the ownership-cleanup + * sweep directly. Composer draft state registers the owner-side cleanup hook + * at module load; until then a release has nothing to retry, because no draft + * store has loaded yet and there is nothing to clean. + */ +type UnusedAttachmentHandler = (attachment: FileBackedComposerAttachment) => void; + +let onAttachmentUnused: UnusedAttachmentHandler | null = null; + +export function registerComposerAttachmentUnusedHandler(handler: UnusedAttachmentHandler): void { + onAttachmentUnused = handler; +} + +/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ +export function retainComposerAttachmentFileForPreview( + attachment: FileBackedComposerAttachment, +): () => void { + return retainComposerAttachmentFile(attachment.fileUri, () => { + onAttachmentUnused?.(attachment); + }); +} diff --git a/apps/mobile/src/lib/debugLog.ts b/apps/mobile/src/lib/debugLog.ts new file mode 100644 index 000000000000..447973503333 --- /dev/null +++ b/apps/mobile/src/lib/debugLog.ts @@ -0,0 +1,64 @@ +/** + * Namespaced, filterable debug logging shared by mobile subsystems. + * + * Ordinary, expected conditions — a queued send failing while the device is + * offline, for example — go through a debug logger instead of `console.warn` + * so warning output stays reserved for failures someone can act on. Output + * uses `console.log` with a `[t3-]` prefix, matching the existing + * cloud and terminal debug logs. (client-runtime cannot host this: its + * tooling bans `console.*` in favor of Effect logging.) + * + * A logger is silent in every build, including development, unless enabled. + * Toggle it from a JS debugger or the Metro console, including on release/TestFlight builds: + * - `globalThis.__T3_DEBUG__ = true` enables every namespace; + * - `globalThis.__T3_DEBUG__ = ["thread-outbox"]` enables only listed ones. + * + * Subsystems whose traces are useful by default in development (`__DEV__`) + * opt in with `enabledInDev`; `legacyGlobalFlag` keeps an older + * subsystem-specific global (e.g. `__T3_CLOUD_DEBUG__`) working. + */ + +export interface DebugLogger { + readonly isEnabled: () => boolean; + readonly log: (event: string, data?: Record) => void; +} + +export interface DebugLoggerOptions { + /** Log whenever `__DEV__` is true, without the global filter. Defaults to false. */ + readonly enabledInDev?: boolean; + /** Name of a legacy subsystem-specific global boolean, e.g. `"__T3_CLOUD_DEBUG__"`. */ + readonly legacyGlobalFlag?: string; +} + +function globalValue(name: string): unknown { + return typeof globalThis === "undefined" + ? undefined + : (globalThis as Record)[name]; +} + +export function createDebugLogger( + namespace: string, + options: DebugLoggerOptions = {}, +): DebugLogger { + const isEnabled = () => { + if (options.enabledInDev === true && typeof __DEV__ !== "undefined" && __DEV__) { + return true; + } + if (options.legacyGlobalFlag !== undefined && globalValue(options.legacyGlobalFlag) === true) { + return true; + } + const filter = globalValue("__T3_DEBUG__"); + return filter === true || (Array.isArray(filter) && filter.includes(namespace)); + }; + const log = (event: string, data?: Record) => { + if (!isEnabled()) { + return; + } + if (data === undefined) { + console.log(`[t3-${namespace}] ${event}`); + } else { + console.log(`[t3-${namespace}] ${event}`, data); + } + }; + return { isEnabled, log }; +} diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts index 3693caa41185..602d5aada56b 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.test.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ exists: vi.fn(), })); -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: mocks.retain, })); vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts index 0d90f7210bab..171fc26c0566 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -3,7 +3,7 @@ import { videoMimeType } from "@t3tools/shared/video"; import type { FileBackedComposerAttachment } from "./composerImages"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; /** Retains the draft original for preview and gives each outgoing share its own lease. */ export async function loadLocalAttachmentPreview( diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts similarity index 97% rename from apps/mobile/src/lib/projectFaviconCache.test.ts rename to apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts index adde56fbf0b1..1e7344a684c3 100644 --- a/apps/mobile/src/lib/projectFaviconCache.test.ts +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts @@ -27,7 +27,7 @@ vi.mock("expo-file-system", () => ({ }, })); -import { downscaleProjectFavicon } from "./projectFaviconCache"; +import { downscaleProjectFavicon } from "./projectFaviconDatabaseCache"; const png = "iVBORw0KGgoAAAAA"; const image = { url: "https://remote/icon.png" }; diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts similarity index 90% rename from apps/mobile/src/lib/projectFaviconCache.ts rename to apps/mobile/src/lib/projectFaviconDatabaseCache.ts index 26a6d848d11d..f24cb2b394f7 100644 --- a/apps/mobile/src/lib/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts @@ -1,5 +1,5 @@ import { - createProjectFaviconCache, + createProjectFaviconCache as createSharedProjectFaviconCache, createProjectFaviconImageLoader, PROJECT_FAVICON_MAX_DATA_URL_LENGTH, PROJECT_FAVICON_THUMBNAIL_SIZE, @@ -78,8 +78,13 @@ export async function downscaleProjectFavicon( throw new Error("Project icon thumbnail exceeds the cache limit."); } -/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ -export const projectFaviconCache = createProjectFaviconCache({ +/** + * The database-backed project favicon cache. Named apart from the shared + * `createProjectFaviconCache` factory it calls and from the in-flight request + * registry in `projectFaviconRequests`. Rows live in `client_cache` so + * Settings → Client storage counts and clears them. + */ +export const projectFaviconDatabaseCache = createSharedProjectFaviconCache({ storage: { list: () => runDatabase((database) => diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconRequests.test.ts similarity index 98% rename from apps/mobile/src/components/projectFaviconCache.test.ts rename to apps/mobile/src/lib/projectFaviconRequests.test.ts index d0582a8b5f5b..540a45473926 100644 --- a/apps/mobile/src/components/projectFaviconCache.test.ts +++ b/apps/mobile/src/lib/projectFaviconRequests.test.ts @@ -6,9 +6,9 @@ import { hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, -} from "./projectFaviconCache"; +} from "./projectFaviconRequests"; -describe("project favicon cache", () => { +describe("project favicon requests", () => { it("ignores callbacks from a superseded URL", () => { const cacheKey = "environment-1:/workspace:v1-favicon.svg"; const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconRequests.ts similarity index 87% rename from apps/mobile/src/components/projectFaviconCache.ts rename to apps/mobile/src/lib/projectFaviconRequests.ts index da77d7613f2d..202142d175e1 100644 --- a/apps/mobile/src/components/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconRequests.ts @@ -1,3 +1,11 @@ +/** + * Request bookkeeping behind `ProjectFavicon`: tracks the favicon URLs with + * mounted views per cache key, keeps one of them current, and remembers which + * keys have already loaded so superseded or already-shown loads settle without + * churn. This is not request coalescing — each mount still issues its own + * load — and it is not the cache; persistence lives in + * `projectFaviconDatabaseCache`. + */ export interface ProjectFaviconRequest { readonly cacheKey: string; readonly faviconUrl: string; diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 15cbd1d9a89f..b2fb759c5680 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -16,7 +16,7 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; @@ -26,7 +26,7 @@ export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ - imageCache: projectFaviconCache, + imageCache: projectFaviconDatabaseCache, createUrl: assetEnvironment.createUrl, preparedConnection: environmentSession.preparedConnectionValueAtom, }); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index c210c54f2fdd..0060e6ab0789 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -74,8 +74,8 @@ export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => Effect.promise(() => scope.type === "all" - ? projectFaviconCache.clearAll() - : projectFaviconCache.clearEnvironment(scope.environmentId), + ? projectFaviconDatabaseCache.clearAll() + : projectFaviconDatabaseCache.clearEnvironment(scope.environmentId), ).pipe( Effect.andThen(MobileDatabase), Effect.flatMap((database) => diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 352367c09a15..dec31f9ce039 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; import { isFileBackedComposerAttachment } from "../lib/composerImages"; import { composerAttachmentUploadKey, @@ -20,7 +21,6 @@ import { composerDraftsAtom, ensureComposerDraftsLoaded, flushComposerDrafts, - retainComposerAttachmentFileForPreview, setComposerDraftAttachmentUpload, } from "./use-composer-drafts"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/state/legacy-plan-mode.test.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.test.ts rename to apps/mobile/src/state/legacy-plan-mode.test.ts diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/state/legacy-plan-mode.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.ts rename to apps/mobile/src/state/legacy-plan-mode.ts diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3d2231fb3ac3..c53dc34e7df4 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; // Keep current writes until a compatible native baseline includes the v4 reader. const THREAD_OUTBOX_SCHEMA_VERSION = 3; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 9f5dad4d5600..fa6b2f303f70 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -179,7 +179,6 @@ import { removeComposerDraftsForEnvironment, replaceComposerDraftAttachments, resetComposerDraftsLoadState, - retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, retargetNewTaskDraft, @@ -194,6 +193,7 @@ import { undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; const DRAFT: ComposerDraft = { text: "hello", diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 734199dcef5e..de886d54fe91 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -35,8 +35,11 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, isComposerAttachmentFileRetained, - retainComposerAttachmentFile, } from "../lib/composerAttachmentFiles"; +import { + registerComposerAttachmentUnusedHandler, + retainComposerAttachmentFileForPreview, +} from "../lib/composerAttachmentPreviewRetention"; import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; @@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup( }); } -/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ -export function retainComposerAttachmentFileForPreview( - attachment: FileBackedComposerAttachment, -): () => void { - return retainComposerAttachmentFile(attachment.fileUri, () => { - scheduleUnusedComposerAttachmentCleanup([attachment]); - }); -} +/** + * Owner-side cleanup hook for the shared preview-retention helper: releasing + * the last preview/upload lease retries the unused-file sweep. Registered here + * because this module owns the draft and outbox references the sweep reads. + */ +registerComposerAttachmentUnusedHandler((attachment) => { + scheduleUnusedComposerAttachmentCleanup([attachment]); +}); function schedulePersistComposerState(): void { if (persistTimer !== null) { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d21af7d79cc3..051fe543ea2b 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -30,7 +30,7 @@ import { uuidv4 } from "../lib/uuid"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { isModelSelectionUnavailable } from "../lib/modelOptions"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import { convertPastedImagesToAttachments, createPastedTextComposerAttachment, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1147e9fd47af..022c0ad6addb 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -17,6 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; +import { createDebugLogger } from "../lib/debugLog"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; @@ -55,6 +56,7 @@ import { type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, + type ThreadOutboxFailureAction, } from "./thread-outbox-model"; import { environmentThreadShells, threadEnvironment } from "./threads"; import { @@ -82,6 +84,92 @@ import { useRemoteConnectionStatus, } from "./use-remote-environment-registry"; +// Ordinary offline behavior (a socket dropping mid-request, a retryable +// attachment upload failure) must not spam `console.warn` on every backoff +// retry; it goes to the filterable `[t3-thread-outbox]` debug log instead. +// Failures the server decided stay on `console.warn`. +const threadOutboxDebug = createDebugLogger("thread-outbox"); + +/** + * On the queued-request path (settings sync, startTurn) the RPC client + * reports ordinary transport drops as the raw socket/worker reason tags, and + * reserves `RpcClientDefect` for client-side protocol violations and decoding + * failures — unlike the shared config-subscription stream, which + * deliberately re-wraps transport causes under that tag. Defects still retry, + * but they are not ordinary offline behavior and must not hide behind the + * offline debug log. + */ +function isRpcClientDecodeDefect(error: unknown): boolean { + if ( + typeof error !== "object" || + error === null || + !("_tag" in error) || + error._tag !== "RpcClientError" + ) { + return false; + } + const reason: unknown = (error as { readonly reason?: unknown }).reason; + return ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + reason._tag === "RpcClientDefect" + ); +} + +function isOrdinaryThreadOutboxTransportFailure(error: unknown): boolean { + return shouldRetryThreadOutboxDelivery(error) && !isRpcClientDecodeDefect(error); +} + +/** + * Logs one queued-message delivery failure and returns the retry-or-restore + * decision for the caller. Ordinary transport retries — what an offline + * device or a flapping socket produces on every backoff attempt — go to the + * debug log. Server-decided failures warn. Settings-sync failures always + * resolve to a retry even when the server rejected the command, so the + * error, not the resolved action, must decide the log level there; routing + * every retry to debug could hide a permanently rejected update forever. + */ +function logThreadOutboxDeliveryFailure(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; + readonly context: Record; +}): ThreadOutboxFailureAction { + const action = resolveThreadOutboxFailureAction({ + stage: input.stage, + error: input.error, + interrupted: input.interrupted, + }); + const details = { ...input.context, stage: input.stage, action }; + const ordinaryTransportRetry = + action === "retry" && + !isRpcClientDecodeDefect(input.error) && + (input.interrupted || + input.stage !== "settings-sync" || + shouldRetryThreadOutboxDelivery(input.error)); + if (ordinaryTransportRetry) { + threadOutboxDebug.log("queued message delivery failed", details); + } else { + console.warn("[thread-outbox] queued message delivery failed", details); + } + return action; +} + +/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. */ +function logThreadOutboxUploadFailure(queuedMessage: QueuedThreadMessage, error: unknown): void { + const context = { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }; + if (isOrdinaryThreadOutboxTransportFailure(error)) { + threadOutboxDebug.log("attachment upload failed; retrying", { ...context, error }); + } else { + console.warn("[thread-outbox] failed to upload attachments", { ...context, error }); + } +} + function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); } @@ -215,14 +303,13 @@ export async function completeQueuedMessageDelivery( ); if (!removed) { forgetAcknowledgedThreadMessage(queuedMessage); - console.warn( - "[thread-outbox] delivered message was edited before cleanup; keeping the newer message", - { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - }, - ); + // Losing the cleanup race to a user edit is an expected outcome the + // caller handles by keeping the newer message; it is not a warning. + threadOutboxDebug.log("delivered message was edited before cleanup", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }); return "edited"; } return "removed"; @@ -667,18 +754,16 @@ export function useThreadOutboxDrain(): void { return null; } const error = Cause.squash(commandResult.cause); - const action = resolveThreadOutboxFailureAction({ + const action = logThreadOutboxDeliveryFailure({ stage, error, interrupted: Cause.hasInterruptsOnly(commandResult.cause), - }); - console.warn("[thread-outbox] queued message delivery failed", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - stage, - cause: commandResult.cause, - action, + context: { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + cause: commandResult.cause, + }, }); return { action, @@ -772,7 +857,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, @@ -900,7 +985,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 68a167083762..8ab1520a6f34 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -37,6 +37,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 81cdb79217fe..e8e7d5cae46a 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -28,7 +28,7 @@ import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; export const DEVICE_HUB_VERSION = "0.10.1"; const AGENT_DEVICE_PACKAGE = "agent-device"; -export const AGENT_DEVICE_VERSION = "0.21.7"; +export const AGENT_DEVICE_VERSION = "0.21.12"; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6837d849d779..96d91ec170e9 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4071,7 +4071,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("generates PR content against the remote base when the local base is stale", () => + it.effect("generates PR content from branch changes when the remote base advances", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -4103,7 +4103,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); + NodeFS.writeFileSync(NodePath.join(peerDir, "later-main.txt"), "unrelated\n"); + yield* runGit(peerDir, ["add", "later-main.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Later main commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + yield* runGit(repoDir, ["fetch", "origin"]); + let generatedCommitSummary = ""; + let generatedDiffSummary = ""; + let generatedDiffPatch = ""; const { manager } = yield* makeManager({ ghScenario: { prListSequence: ["[]", "[]"], @@ -4111,6 +4119,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { textGeneration: { generatePrContent: (input) => { generatedCommitSummary = input.commitSummary; + generatedDiffSummary = input.diffSummary; + generatedDiffPatch = input.diffPatch; return Effect.succeed({ title: "Feature PR", body: "Feature body" }); }, }, @@ -4124,6 +4134,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("created"); expect(generatedCommitSummary).toContain("Feature commit"); expect(generatedCommitSummary).not.toContain("Remote base commit"); + expect(generatedCommitSummary).not.toContain("Later main commit"); + expect(generatedDiffSummary).toContain("feature.txt"); + expect(generatedDiffSummary).not.toContain("later-main.txt"); + expect(generatedDiffPatch).toContain("feature.txt"); + expect(generatedDiffPatch).not.toContain("later-main.txt"); }), ); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b87a89dcf528..48f96b7fe433 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -261,6 +261,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), snapshotForCwd, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 70af46ff6867..59e2138c302f 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -134,11 +134,11 @@ export const CursorDriver: ProviderDriver = { const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); + const modelDiscovery = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); const checkProvider = checkCursorProviderStatus( effectiveConfig, processEnv, - discoverModels, + modelDiscovery.discover, ).pipe( Effect.flatMap((snapshot) => effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" @@ -217,6 +217,7 @@ export const CursorDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: modelDiscovery.invalidate, snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 19cfd22c6964..47826fcc8704 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -742,7 +742,7 @@ describe("discoverCursorModelsViaAcp", () => { apiEndpoint: "", customModels: [], }; - const discover = yield* makeCursorModelDiscovery(settings, { + const { discover, invalidate } = yield* makeCursorModelDiscovery(settings, { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, }); @@ -755,6 +755,10 @@ describe("discoverCursorModelsViaAcp", () => { yield* fileSystem.writeFileString(requestLogPath, ""); expect(yield* discover(about)).toEqual(first); expect(yield* fileSystem.readFileString(requestLogPath)).toBe(""); + yield* invalidate; + expect(yield* discover(about)).toEqual(first); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + yield* fileSystem.writeFileString(requestLogPath, ""); yield* discover({ ...about, version: "2026.08.12" }); expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); yield* fileSystem.writeFileString(requestLogPath, ""); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 6a8b93c88e90..dfaa8ffe338e 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -678,8 +678,11 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, }, ); - return (about: Pick) => - Cache.get(cache, JSON.stringify([about.version, about.auth])); + return { + discover: (about: Pick) => + Cache.get(cache, JSON.stringify([about.version, about.auth])), + invalidate: Cache.invalidateAll(cache), + }; }); function getCursorFallbackModels( diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index bb8ff7835c8f..6a24b49dc930 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -1,15 +1,21 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, + ThreadId, type ServerProvider, } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; +import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; + import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; import type * as CodexAdapter from "../Services/CodexAdapter.ts"; import type * as CursorAdapter from "../Services/CursorAdapter.ts"; @@ -185,3 +191,95 @@ it.layer(layer)("ProviderAdapterRegistryLive", (it) => { ]); })); }); + +it.effect("blocks shared credential session startup and preserves guarded adapter identity", () => + Effect.gen(function* () { + const target = fakeInstances[0]!; + const peer = fakeInstances[1]!; + const auth = yield* ProviderAuthFlow.make({ + instanceId: target.instanceId, + credentialBinding: { owner: "t3", key: "shared-auth" }, + methods: Effect.succeed([ + { id: "browser", name: "Browser", description: null, type: "agent" }, + ]), + authenticate: () => Effect.never, + logout: Effect.void, + }); + const peerAuth = yield* ProviderAuthFlow.make({ + instanceId: peer.instanceId, + credentialBinding: { owner: "t3", key: "shared-auth" }, + methods: Effect.succeed([]), + authenticate: () => Effect.void, + logout: Effect.void, + }); + const session = { + threadId: ThreadId.make("new-session"), + provider: peer.driverKind, + providerInstanceId: peer.instanceId, + status: "ready" as const, + runtimeMode: "approval-required" as const, + createdAt: "2026-09-21T00:00:00.000Z", + updatedAt: "2026-09-21T00:00:00.000Z", + }; + const start = vi.fn(() => Effect.succeed(session)); + const instances = [ + { ...target, auth }, + { ...peer, auth: peerAuth, adapter: { ...peer.adapter, startSession: start } }, + ]; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry.pipe( + Effect.provide( + ProviderAdapterRegistryLayer.ProviderAdapterRegistryLive.pipe( + Layer.provide( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + listInstances: Effect.succeed(instances), + }), + ), + ), + ), + ); + const guarded = yield* registry.getByInstance(peer.instanceId); + assert.strictEqual(yield* registry.getByInstance(peer.instanceId), guarded); + const flow = yield* auth.start("owner"); + const error = yield* guarded + .startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "ProviderAdapterValidationError"); + assert.strictEqual(start.mock.calls.length, 0); + yield* auth.cancel("owner", flow.flowId!); + assert.deepStrictEqual( + yield* guarded.startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }), + session, + ); + assert.strictEqual(start.mock.calls.length, 1); + const entered = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + start.mockImplementation(() => + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }).pipe(Effect.ensuring(Deferred.succeed(stopped, undefined))), + ); + const startup = yield* guarded + .startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + // Signing out through another instance must drain its peer's startup too. + yield* auth.logout(Effect.void); + yield* Deferred.await(stopped); + assert.strictEqual(Exit.isFailure(yield* Fiber.await(startup)), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 9e8e3c5d1f90..5d70ee0d3d75 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,19 +15,89 @@ * * @module ProviderAdapterRegistryLive */ -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderInstanceId, ProviderSetupError, type ProviderSession } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; -import { ProviderUnsupportedError } from "../Errors.ts"; +import { + ProviderUnsupportedError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const isSetupError = Schema.is(ProviderSetupError); + const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { const registry = yield* ProviderInstanceRegistry; + // Stable identity keeps ProviderService's event subscriptions attached once. + const guarded = new WeakMap>(); + const guard = (instance: ProviderInstance) => { + const auth = instance.auth; + if (!auth || (!auth.withAccess && !auth.isChangingCredentials && !auth.credentialBinding)) + return instance.adapter; + const cached = guarded.get(instance); + if (cached) return cached; + const adapter: ProviderAdapterShape = { + ...instance.adapter, + startSession: (input) => { + const start = Effect.gen(function* () { + const binding = auth.credentialBinding; + const related = binding + ? (yield* registry.listInstances).filter( + (peer) => + peer.auth?.credentialBinding?.key === binding.key && + peer.auth.credentialBinding.owner === binding.owner, + ) + : [instance]; + for (const peer of related) { + if (peer.auth?.isChangingCredentials && (yield* peer.auth.isChangingCredentials)) { + return yield* new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "session", + detail: "Provider sign-in is changing. Try again after it finishes.", + }); + } + } + let admitted: Effect.Effect< + ProviderSession, + ProviderAdapterError | ProviderSetupError, + Scope.Scope + > = instance.adapter.startSession(input); + // Every shared owner holds the startup scope. A credential change + // interrupts admitted startup before it can escape the session drain. + for (const peer of related) { + if (peer.auth?.withAccess) admitted = peer.auth.withAccess(admitted); + } + return yield* Effect.scoped(admitted); + }); + // Adapters own established session lifetimes. This scope guards startup; + // ProviderAuthService drains routed sessions before changing credentials. + return start.pipe( + Effect.mapError((cause) => + isSetupError(cause) + ? new ProviderAdapterValidationError({ + provider: instance.driverKind, + operation: "startSession", + issue: cause.detail, + }) + : cause, + ), + ); + }, + }; + guarded.set(instance, adapter); + return adapter; + }; const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => registry.getInstance(instanceId).pipe( @@ -38,7 +108,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun provider: instanceId, }), ) - : Effect.succeed(instance.adapter), + : Effect.succeed(guard(instance)), ), ); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.test.ts b/apps/server/src/provider/Layers/ProviderAuthService.test.ts index fb961b710486..8430ad3e1319 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.test.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.test.ts @@ -112,6 +112,14 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* directoryError?: ProviderSessionDirectoryPersistenceError; stopError?: ProviderServiceError; logoutError?: ProviderSetupError; + sharedCredentials?: boolean; + sharedBusy?: boolean; + sharedBusyEffect?: Effect.Effect; + responds?: boolean; + beforeLogout?: Effect.Effect; + beforeStop?: Effect.Effect; + beforeListSessions?: Effect.Effect; + onLookup?: Effect.Effect; } = {}, ) { const actions: string[] = []; @@ -138,6 +146,18 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* }); const auth: ProviderAuthController = { + ...(input.sharedCredentials + ? { credentialBinding: { owner: "provider" as const, key: "shared" } } + : {}), + ...(input.responds + ? { + respond: Effect.fn(function* (ownerSessionId, request) { + yield* checkOwner(ownerSessionId, request.flowId, "respond"); + actions.push(`respond:${request.response.type}`); + return state; + }), + } + : {}), start: Effect.fn(function* (ownerSessionId, stopSessions) { gateClosed = true; actions.push("close-gate"); @@ -169,6 +189,7 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* actions.push("close-gate"); yield* stopSessions; if (input.logoutError) return yield* input.logoutError; + yield* input.beforeLogout ?? Effect.void; actions.push("native-logout"); state = idle; flowOwner = undefined; @@ -181,13 +202,34 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* const instances = [ makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), + ...(input.sharedCredentials + ? [ + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...auth, + isChangingCredentials: + input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false), + invalidate: Effect.sync(() => { + actions.push("invalidate-shared"); + }), + }, + }), + ] + : []), ]; const service = yield* makeProviderAuthService.pipe( Effect.provide( Layer.mergeAll( Layer.mock(ProviderInstanceRegistry)({ getInstance: (id) => - Effect.succeed(instances.find((instance) => instance.instanceId === id)), + Effect.gen(function* () { + const found = instances.find((instance) => instance.instanceId === id); + yield* input.onLookup ?? Effect.void; + return found; + }), + listInstances: Effect.succeed(instances), subscribeChanges: PubSub.subscribe(registryChanges), }), Layer.mock(ProviderSessionDirectory)({ @@ -202,26 +244,44 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* }), Layer.mock(ProviderService)({ listSessions: () => - Effect.sync(() => { + Effect.gen(function* () { assert.isTrue(gateClosed); actions.push("list-sessions"); + yield* input.beforeListSessions ?? Effect.void; return [...sessions.values()]; }), stopSession: ({ threadId }) => - Effect.suspend(() => { + Effect.gen(function* () { assert.isTrue(gateClosed); actions.push(`stop:${threadId}`); - if (input.stopError) return Effect.fail(input.stopError); + yield* input.beforeStop ?? Effect.void; + if (input.stopError) return yield* input.stopError; sessions.delete(threadId); const binding = bindings.get(threadId); if (binding) bindings.set(threadId, { ...binding, status: "stopped" }); - return Effect.void; }), }), ), ), ); - return { service, actions, sessions, bindings }; + return { + service, + actions, + sessions, + bindings, + auth, + addInstance: (instance: ProviderInstance) => instances.push(instance), + replaceInstance: (replacement: ProviderInstance) => { + const index = instances.findIndex( + (instance) => instance.instanceId === replacement.instanceId, + ); + assert.isAtLeast(index, 0); + instances[index] = replacement; + }, + replaceAuth: (next: ProviderAuthController) => { + instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next }); + }, + }; }); const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( @@ -305,6 +365,56 @@ const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* }); describe("ProviderAuthService", () => { + it.effect( + "stops sessions sharing credentials and invalidates their processes before logout", + () => + Effect.gen(function* () { + const { service, actions, sessions } = yield* makeHarness({ + sharedCredentials: true, + sessions: [ + makeSession("target"), + makeSession("shared", otherInstanceId), + makeSession("unrelated", unsupportedInstanceId), + ], + }); + yield* service.logout({ instanceId }); + assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]); + assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared")); + assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout")); + }), + ); + it.effect("rejects overlapping changes to a shared sign-in", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ + sharedCredentials: true, + sharedBusy: true, + }); + for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) { + const error = yield* task.pipe(Effect.flip); + assert.include(error.detail, "shared sign-in"); + } + assert.deepStrictEqual(actions, []); + }), + ); + it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ responds: true }); + yield* service.start({ instanceId }, owner); + const request = { + instanceId, + flowId, + interactionId: "consent", + response: { type: "browser" as const, action: "accept" as const }, + }; + const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip); + assert.strictEqual(rejected.operation, "respond"); + yield* service.respond(request, owner); + assert.strictEqual(actions.at(-1), "respond:browser"); + const unsupported = yield* makeHarness(); + const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip); + assert.include(error.detail, "does not accept"); + }), + ); it.effect("stops routed sessions before sign-in, including for a disabled instance", () => Effect.gen(function* () { const { service, actions, sessions } = yield* makeHarness({ @@ -606,3 +716,200 @@ describe("ProviderAuthService", () => { }), ); }); + +it.effect("queued logout prompts resolve the current controller after provider replacement", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const lookup = yield* Deferred.make(); + let observeLookup = false; + const harness = yield* makeHarness({ + beforeLogout: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ), + onLookup: Effect.suspend(() => + observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void, + ), + }); + const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + observeLookup = true; + const queued = yield* harness.service + .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }) + .pipe(Effect.forkChild); + yield* Deferred.await(lookup); + let replacementLoggedOut = false; + harness.replaceAuth({ + ...harness.auth, + logout: (stopSessions) => + stopSessions.pipe( + Effect.andThen( + Effect.sync(() => { + replacementLoggedOut = true; + return idleAuthState; + }), + ), + ), + }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + assert.isTrue(yield* Fiber.join(queued)); + assert.isTrue(replacementLoggedOut); + assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1); + }).pipe(Effect.scoped), +); + +it.effect.each(["start", "logout", "prompt"] as const)( + "%s uses the same credential controller for shared exclusion and mutation during replacement", + (action) => + Effect.gen(function* () { + const checked = yield* Deferred.make(); + const continueCheck = yield* Deferred.make(); + const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer"); + const harness = yield* makeHarness({ + sharedCredentials: true, + sharedBusyEffect: Deferred.succeed(checked, undefined).pipe( + Effect.andThen(Deferred.await(continueCheck)), + Effect.as(false), + ), + sessions: [ + makeSession("old-shared", otherInstanceId), + makeSession("replacement-shared", replacementPeerId), + ], + }); + harness.addInstance( + makeInstance({ + instanceId: replacementPeerId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + isChangingCredentials: Effect.succeed(true), + invalidate: Effect.die( + "The unrelated replacement credential binding must stay intact.", + ), + }, + }), + ); + const operation = + action === "start" + ? harness.service.start({ instanceId }, owner) + : action === "logout" + ? harness.service.logout({ instanceId }) + : harness.service.tryHandlePromptCommand({ + instanceId, + text: "/logout", + hasAttachments: false, + }); + const running = yield* operation.pipe(Effect.forkChild); + yield* Deferred.await(checked); + let replacementMutations = 0; + harness.replaceAuth({ + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + start: () => + Effect.sync(() => { + replacementMutations++; + return waitingAuthState; + }), + logout: () => + Effect.sync(() => { + replacementMutations++; + return idleAuthState; + }), + }); + yield* Deferred.succeed(continueCheck, undefined); + yield* Fiber.join(running); + assert.equal(replacementMutations, 0); + assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout"); + assert.isFalse(harness.sessions.has(ThreadId.make("old-shared"))); + assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared"))); + const blocked = yield* Effect.flip(harness.service.logout({ instanceId })); + assert.include(blocked.detail, "shared sign-in"); + assert.equal(replacementMutations, 0); + }).pipe(Effect.scoped), +); + +it.effect.each([ + { owner: "provider" as const, key: "different-binding" }, + { owner: "t3" as const, key: "shared" }, +])( + "does not invalidate a peer that switches credential binding during session draining %#", + (binding) => + Effect.gen(function* () { + const draining = yield* Deferred.make(); + const continueDrain = yield* Deferred.make(); + const harness = yield* makeHarness({ + sharedCredentials: true, + sessions: [makeSession("shared-draining", otherInstanceId)], + beforeStop: Deferred.succeed(draining, undefined).pipe( + Effect.andThen(Deferred.await(continueDrain)), + ), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(draining); + let replacementInvalidated = false; + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: binding, + invalidate: Effect.sync(() => { + replacementInvalidated = true; + }), + }, + }), + ); + yield* Deferred.succeed(continueDrain, undefined); + yield* Fiber.join(logout); + assert.isFalse(replacementInvalidated); + assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); + +it.effect.each(["selection", "drain"] as const)( + "preserves replacement peer sessions when credentials change during session %s", + (phase) => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const proceed = yield* Deferred.make(); + const block = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ); + const harness = yield* makeHarness({ + sharedCredentials: true, + sessions: [ + makeSession("target-draining"), + makeSession("peer-replacement", otherInstanceId), + ], + bindings: [ + makeBinding("target-draining", "running"), + makeBinding("peer-persisted", "running", otherInstanceId), + ], + ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-credentials" }, + invalidate: Effect.die("The replacement peer's credentials must not be invalidated."), + }, + }), + ); + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.join(logout); + assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement"))); + assert.notInclude(harness.actions, "stop:peer-replacement"); + assert.notInclude(harness.actions, "stop:peer-persisted"); + assert.isFalse(harness.sessions.has(ThreadId.make("target-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.ts b/apps/server/src/provider/Layers/ProviderAuthService.ts index 3b69c5082528..cec2a5878880 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.ts @@ -2,8 +2,9 @@ import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; -import { ProviderAuthService } from "../Services/ProviderAuthService.ts"; +import * as ProviderAuthService from "../Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; @@ -12,6 +13,7 @@ export const makeProviderAuthService = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry; const providers = yield* ProviderService; const directory = yield* ProviderSessionDirectory; + const credentialChanges = yield* Semaphore.make(1); const getController = Effect.fn("ProviderAuthService.getController")(function* ( instanceId: ProviderInstanceId, @@ -30,9 +32,25 @@ export const makeProviderAuthService = Effect.gen(function* () { return instance.auth; }); + // Native sessions may still belong to the previous provider after the + // selected model changes. Read session bindings, not the selected model, + // when invalidating credentials for sign-in or sign-out. const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( instanceId: ProviderInstanceId, + binding: ProviderAuthService.ProviderAuthController["credentialBinding"], ) { + const affectedIds = new Set([ + instanceId, + ...(binding === undefined + ? [] + : (yield* registry.listInstances) + .filter( + (instance) => + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ) + .map((instance) => instance.instanceId)), + ]); const bindings = yield* directory.listBindings().pipe( Effect.mapError( () => @@ -44,39 +62,111 @@ export const makeProviderAuthService = Effect.gen(function* () { ), ); const sessions = yield* providers.listSessions(); - const threadIds = new Set( - bindings - .filter( - (binding) => binding.providerInstanceId === instanceId && binding.status !== "stopped", - ) - .map((binding) => binding.threadId), + const sessionsToStop = new Map( + bindings.flatMap((session) => + session.providerInstanceId !== undefined && + affectedIds.has(session.providerInstanceId) && + session.status !== "stopped" + ? [[session.threadId, session.providerInstanceId] as const] + : [], + ), ); for (const session of sessions) { - if (session.providerInstanceId === instanceId) { - threadIds.add(session.threadId); + if (session.providerInstanceId !== undefined && affectedIds.has(session.providerInstanceId)) { + sessionsToStop.set(session.threadId, session.providerInstanceId); } } yield* Effect.forEach( - threadIds, - (threadId) => - providers.stopSession({ threadId }).pipe( - Effect.mapError( - () => - new ProviderSetupError({ - instanceId, - operation: "stopSessions", - detail: "Could not stop all sessions for this provider. Try again.", - }), - ), - ), + sessionsToStop, + ([threadId, sessionInstanceId]) => + Effect.gen(function* () { + if (sessionInstanceId !== instanceId) { + const current = yield* registry.getInstance(sessionInstanceId); + if ( + !binding || + current?.auth?.credentialBinding?.key !== binding.key || + current.auth.credentialBinding.owner !== binding.owner + ) + return; + } + yield* providers.stopSession({ threadId }).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "stopSessions", + detail: "Could not stop all sessions for this provider. Try again.", + }), + ), + ); + }), { discard: true }, ); + if (binding) { + yield* Effect.forEach( + (yield* registry.listInstances).filter( + (instance) => + instance.instanceId !== instanceId && + affectedIds.has(instance.instanceId) && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ), + (instance) => instance.auth?.invalidate ?? Effect.void, + { discard: true }, + ); + } }); - return ProviderAuthService.of({ + const checkSharedBinding = Effect.fnUntraced(function* ( + instanceId: ProviderInstanceId, + operation: "start" | "logout", + auth: ProviderAuthService.ProviderAuthController, + ) { + const binding = auth.credentialBinding; + if (!binding) return; + const instances = yield* registry.listInstances; + for (const instance of instances) { + if ( + instance.instanceId !== instanceId && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner && + instance.auth.isChangingCredentials && + (yield* instance.auth.isChangingCredentials) + ) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: + "Another provider instance is changing this shared sign-in. Finish or cancel it first.", + }); + } + } + }); + + return ProviderAuthService.ProviderAuthService.of({ start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { - const auth = yield* getController(input.instanceId, "start"); - return yield* auth.start(ownerSessionId, stopSessions(input.instanceId)); + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "start"); + yield* checkSharedBinding(input.instanceId, "start", auth); + return yield* auth.start( + ownerSessionId, + stopSessions(input.instanceId, auth.credentialBinding), + input.methodId, + ); + }), + ); + }), + respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "respond"); + if (!auth.respond) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "respond", + detail: "This provider does not accept this sign-in interaction.", + }); + } + return yield* auth.respond(ownerSessionId, input); }), complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { const auth = yield* getController(input.instanceId, "complete"); @@ -87,8 +177,13 @@ export const makeProviderAuthService = Effect.gen(function* () { return yield* auth.cancel(ownerSessionId, input.flowId); }), logout: Effect.fn("ProviderAuthService.logout")(function* (input) { - const auth = yield* getController(input.instanceId, "logout"); - return yield* auth.logout(stopSessions(input.instanceId)); + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + yield* checkSharedBinding(input.instanceId, "logout", auth); + return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + }), + ); }), subscribe: (input, ownerSessionId) => Effect.gen(function* () { @@ -110,11 +205,21 @@ export const makeProviderAuthService = Effect.gen(function* () { if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { return false; } - yield* instance.auth.logout(stopSessions(input.instanceId)); - return true; + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false; + yield* checkSharedBinding(input.instanceId, "logout", auth); + yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + return true; + }), + ); }, ), }); }); -export const ProviderAuthServiceLive = Layer.effect(ProviderAuthService, makeProviderAuthService); +export const ProviderAuthServiceLive = Layer.effect( + ProviderAuthService.ProviderAuthService, + makeProviderAuthService, +); diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 0a2ca28feb73..1f00cb56040a 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -342,6 +342,84 @@ const serviceLayers = (input: { ); describe("ModelManifest service", () => { + it.live("explicit refresh bypasses fresh memory and disk caches", () => { + let fetchCount = 0; + const updated: ModelManifestData = { + ...REMOTE_MANIFEST, + currentModels: { codex: ["gpt-reloaded"] }, + }; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* rebooted.forceRefresh, updated); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* rebooted.current, updated); + assert.deepStrictEqual(yield* (yield* make).current, updated); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-refresh-test", + response: () => Response.json(fetchCount++ === 0 ? REMOTE_MANIFEST : updated), + }), + ), + ); + }); + + it.live("explicit refresh retries immediately after failure and preserves last-good data", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.current, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* (yield* make).current, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 3); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-retry-test", + response: () => + fetchCount++ === 1 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + + it.live("explicit refresh bypasses the retry delay after an initial failure", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-initial-retry-test", + response: () => + fetchCount++ === 0 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => Effect.gen(function* () { const service = yield* make; @@ -457,6 +535,7 @@ describe("ModelManifest service", () => { ), ); assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, BUNDLED_MODEL_MANIFEST); assert.strictEqual(fetchCount, 0); }).pipe( Effect.scoped, diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index f5ad15dd8586..7f2305097b78 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -315,6 +315,8 @@ export class ModelManifest extends Context.Service< readonly current: Effect.Effect; /** Manifest after a TTL-gated remote refresh; never fails. */ readonly refresh: Effect.Effect; + /** Explicit refresh bypasses freshness and retry timers, retaining last-good data. */ + readonly forceRefresh: Effect.Effect; /** Forks `refresh` into the service's own scope. Drivers call this from * provider checks: the fetch is process-shared state, so it must survive * the teardown of whichever instance happened to trigger it. */ @@ -326,6 +328,7 @@ export class ModelManifest extends Context.Service< const BundledOnlyModelManifest: ModelManifest["Service"] = { current: Effect.succeed(BUNDLED_MODEL_MANIFEST), refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), refreshInBackground: Effect.void, }; @@ -369,7 +372,7 @@ export const make = Effect.gen(function* () { }), ); - const refresh = Effect.fn("ModelManifest.refresh")(function* () { + const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) { yield* ensureDiskCacheLoaded; const now = yield* Clock.currentTimeMillis; // A timestamp in the future means the wall clock moved backwards (the @@ -377,8 +380,8 @@ export const make = Effect.gen(function* () { // it as expired: the refetch rewrites both timestamps and self-heals. const isWithin = (sinceMs: number | null, windowMs: number) => sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; - if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; - if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; // The same switch that gates provider CLI update checks. It stops network // fetches only: a manifest already cached on disk from an earlier fetch @@ -413,6 +416,7 @@ export const make = Effect.gen(function* () { return ModelManifest.of({ current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), refresh: guardedRefresh, + forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)), refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/provider/ProviderAuthFlow.test.ts b/apps/server/src/provider/ProviderAuthFlow.test.ts new file mode 100644 index 000000000000..d12b6967c82e --- /dev/null +++ b/apps/server/src/provider/ProviderAuthFlow.test.ts @@ -0,0 +1,520 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ProviderInstanceId, + ProviderSetupError, + type ProviderAuthInteraction, + type ProviderAuthResponse, + type ProviderAuthState, +} from "@t3tools/contracts"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; + +const instanceId = ProviderInstanceId.make("auth-flow-test"); +const method = { id: "browser", name: "Browser", description: null, type: "agent" as const }; + +it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () => + Effect.gen(function* () { + const discovered = yield* Deferred.make>(); + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Deferred.await(discovered), + authenticate: () => Effect.void, + logout: Effect.void, + }); + const pending = yield* controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isUndefined(pending.methods); + yield* Deferred.succeed(discovered, []); + const ready = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.methods !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepEqual(ready.methods, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const makeHarness = Effect.gen(function* () { + const approved = yield* Deferred.make(); + const verified = yield* Deferred.make(); + const started = yield* Deferred.make(); + let attempts = 0; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + attempts++; + yield* context.setInteraction( + { + type: "browser", + id: "consent", + url: "https://example.com/login", + requiresConsent: true, + }, + (response) => + response.type === "browser" && response.action === "accept" + ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(approved); + yield* context.verifying; + yield* Deferred.await(verified); + }), + logout: Effect.void, + }); + const phase = (phase: ProviderAuthState["phase"], owner = "owner") => + controller.subscribe(owner).pipe( + Stream.filter((state) => state.phase === phase), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + return { controller, phase, started, verified, attempts: () => attempts }; +}); + +it.effect("requires owner consent and provider verification before success", () => + Effect.gen(function* () { + const { controller, phase, started, verified, attempts } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + const response = { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser" as const, action: "accept" as const }, + }; + const other = yield* phase("waiting", "other"); + assert.isNull(other.interaction); + assert.isNull(other.flowId); + assert.isTrue( + (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure", + ); + assert.isTrue( + (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe( + Effect.result, + ))._tag === "Failure", + ); + yield* controller.start("owner"); + assert.strictEqual(attempts(), 1); + yield* controller.respond!("owner", response); + yield* phase("verifying"); + yield* Deferred.succeed(verified, undefined); + assert.strictEqual((yield* phase("succeeded")).phase, "succeeded"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("cancellation rejects late consent and permits another login", () => + Effect.gen(function* () { + const { controller, started, phase } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + yield* controller.cancel("owner", state.flowId!); + assert.strictEqual((yield* phase("cancelled")).phase, "cancelled"); + assert.isTrue( + (yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser", action: "accept" }, + }).pipe(Effect.result))._tag === "Failure", + ); + const next = yield* controller.start("other"); + assert.notStrictEqual(next.flowId, state.flowId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("expires pending login and rejects its response", () => + Effect.gen(function* () { + const { controller, started, phase } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + yield* TestClock.adjust(300_001); + assert.include((yield* phase("failed")).message ?? "", "expired"); + assert.isTrue( + (yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser", action: "accept" }, + }).pipe(Effect.result))._tag === "Failure", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("closes admitted provider processes and blocks new ones while login is pending", () => + Effect.gen(function* () { + const { controller, started } = yield* makeHarness; + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* controller.start("owner"); + yield* Deferred.await(started); + assert.isTrue(closed); + assert.isTrue( + (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("shared credential invalidation closes admitted processes before permitting reuse", () => + Effect.gen(function* () { + const { controller } = yield* makeHarness; + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* controller.invalidate!; + assert.isTrue(closed); + assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.each([ + { + interaction: { type: "terminal", id: "terminal", output: "Log in" }, + response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } }, + }, + { + interaction: { + type: "credentials", + id: "credentials", + fields: [{ name: "token", label: "Token", secret: true }], + }, + response: { type: "credentials", values: { token: "private-token" } }, + }, +] satisfies ReadonlyArray<{ + interaction: ProviderAuthInteraction; + response: ProviderAuthResponse; +}>)( + "keeps $interaction.type responses private and waits for adapter verification", + ({ interaction, response }) => + Effect.gen(function* () { + const received = yield* Deferred.make(); + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "binding" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + yield* context.setInteraction(interaction, (response) => + Deferred.succeed(received, response).pipe(Effect.asVoid), + ); + yield* Deferred.await(received); + yield* context.verifying; + return yield* Effect.never; + }), + logout: Effect.void, + }); + const state = yield* controller.start("owner"); + const pending = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepStrictEqual(pending.interaction, interaction); + const hidden = yield* controller + .subscribe("other") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isNull(hidden.interaction); + yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: interaction.id, + response, + }); + assert.deepStrictEqual(yield* Deferred.await(received), response); + const verifying = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "verifying"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.isNull(verifying.interaction); + assert.notInclude(verifying.message ?? "", "private-token"); + yield* controller.cancel("owner", state.flowId!); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("shows a device code only to its owner without treating it as authenticated", () => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "device" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + context + .setInteraction({ + type: "deviceCode", + id: "code", + url: "https://example.com/device", + userCode: "ABCD-EFGH", + }) + .pipe(Effect.andThen(Effect.never)), + logout: Effect.void, + }); + const start = yield* controller.start("owner"); + const waiting = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.strictEqual(waiting.interaction?.type, "deviceCode"); + assert.strictEqual(waiting.authorizationUrl, "https://example.com/device"); + const other = yield* controller + .subscribe("other") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isNull(other.interaction); + assert.isNull(other.authorizationUrl); + yield* controller.cancel("owner", start.flowId!); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.each([ + { + failure: Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "authenticate", + detail: "This account cannot sign in. Choose another account.", + }), + ), + message: "This account cannot sign in. Choose another account.", + }, + { + failure: Effect.die(new Error("private-token in native diagnostics")), + message: "Sign-in failed. Start again.", + }, +])("publishes only safe authentication failure text %#", ({ failure, message }) => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "failure" }, + methods: Effect.succeed([method]), + authenticate: () => failure, + logout: Effect.void, + }); + yield* controller.start("owner"); + const failed = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "failed"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.strictEqual(failed.message, message); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const makeBlockingResponseHarness = Effect.gen(function* () { + const entered = yield* Deferred.make(); + const cleanupStarted = yield* Deferred.make(); + const cleanupReleased = yield* Deferred.make(); + const cleanupFinished = yield* Deferred.make(); + const authenticationFinished = yield* Deferred.make(); + let responses = 0; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "blocked-response" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + yield* context.setInteraction( + { + type: "browser", + id: "blocked", + url: "https://example.com/login", + requiresConsent: true, + }, + () => + Effect.gen(function* () { + responses++; + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* Deferred.succeed(cleanupStarted, undefined); + yield* Deferred.await(cleanupReleased); + yield* Deferred.succeed(cleanupFinished, undefined); + }), + ), + ), + ); + yield* Deferred.await(authenticationFinished); + }), + logout: Effect.void, + }); + const start = yield* controller.start("owner"); + yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + ); + const input = { + instanceId, + flowId: start.flowId!, + interactionId: "blocked", + response: { type: "browser" as const, action: "accept" as const }, + }; + const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(entered); + return { + controller, + input, + response, + cleanupStarted, + cleanupReleased, + cleanupFinished, + authenticationFinished, + responses: () => responses, + }; +}); + +it.effect.each(["cancel", "logout"] as const)( + "%s interrupts and awaits a blocked adapter response before admitting another sign-in", + (action) => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input)); + assert.include(duplicate.detail, "already in progress"); + assert.equal(harness.responses(), 1); + const stopping = yield* ( + action === "cancel" + ? harness.controller.cancel("owner", harness.input.flowId) + : harness.controller.logout(Effect.void) + ).pipe(Effect.forkChild); + yield* Deferred.await(harness.cleanupStarted); + assert.isTrue(yield* harness.controller.isChangingCredentials!); + assert.isUndefined(stopping.pollUnsafe()); + const premature = yield* Effect.flip(harness.controller.start("other")); + assert.include(premature.detail, "in progress"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + yield* Fiber.join(stopping); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + assert.isFalse(yield* harness.controller.isChangingCredentials!); + assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("timeout interrupts and drains a blocked response before publishing failure", () => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + yield* TestClock.adjust(300_001); + yield* Deferred.await(harness.cleanupStarted); + assert.isTrue(yield* harness.controller.isChangingCredentials!); + const duringCleanup = yield* harness.controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.notEqual(duringCleanup.phase, "failed"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + const failed = yield* harness.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "failed"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.include(failed.message ?? "", "expired"); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "successful authentication drains a still-running response before admitting provider access", + () => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + yield* Deferred.succeed(harness.authenticationFinished, undefined); + yield* Deferred.await(harness.cleanupStarted); + const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void)); + assert.include(denied.detail, "changing"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + const succeeded = yield* harness.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "succeeded"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(succeeded.phase, "succeeded"); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + yield* harness.controller.withAccess!(Effect.void); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("closing the controller scope interrupts and drains its adapter response", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const harness = yield* makeBlockingResponseHarness.pipe( + Effect.provideService(Scope.Scope, scope), + ); + const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); + yield* Deferred.await(harness.cleanupStarted); + assert.isUndefined(closing.pollUnsafe()); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + yield* Fiber.join(closing); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("a failed method refresh keeps the last discovered methods", () => + Effect.gen(function* () { + let fail = false; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Effect.suspend(() => + fail + ? Effect.fail( + new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }), + ) + : Effect.succeed([method]), + ), + authenticate: () => Effect.void, + logout: Effect.void, + }); + yield* controller.refreshMethods!; + fail = true; + yield* controller.refreshMethods!; + const state = yield* controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.deepEqual(state.methods, [method]); + assert.strictEqual(state.message, "interrupted"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rebuilding a controller leaves sessions it admitted to their owners", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope)); + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* Scope.close(scope, Exit.void); + assert.isFalse(closed); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ProviderAuthFlow.ts b/apps/server/src/provider/ProviderAuthFlow.ts new file mode 100644 index 000000000000..f01ce92e3fb5 --- /dev/null +++ b/apps/server/src/provider/ProviderAuthFlow.ts @@ -0,0 +1,463 @@ +import { + ProviderSetupError, + type ProviderAuthInteraction, + type ProviderAuthMethod, + type ProviderAuthResponse, + type ProviderAuthState, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type * as ProviderAuthService from "./Services/ProviderAuthService.ts"; + +export interface ProviderAuthFlowContext { + readonly flowId: string; + readonly setInteraction: ( + interaction: ProviderAuthInteraction, + respond?: (response: ProviderAuthResponse) => Effect.Effect, + ) => Effect.Effect; + readonly verifying: Effect.Effect; +} + +const isSetupError = Schema.is(ProviderSetupError); + +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.findErrorOption(cause); + return Option.isSome(error) && isSetupError(error.value) + ? error.value.detail + : "Sign-in failed. Start again."; +}; + +interface Flow { + readonly id: string; + readonly owner: string; + readonly expiresAt: number; + fiber?: Fiber.Fiber; + responseFiber?: Fiber.Fiber; + respond: + | ((response: ProviderAuthResponse) => Effect.Effect) + | undefined; +} + +/** Adapters do login and credential handling; this owns client consent and flow lifetime. */ +export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { + readonly instanceId: ProviderInstanceId; + readonly credentialBinding: NonNullable< + ProviderAuthService.ProviderAuthController["credentialBinding"] + >; + readonly methods: Effect.Effect, ProviderSetupError>; + readonly defaultMethodId?: string; + /** Fail with ProviderSetupError containing safe text for the user, never native token data. */ + readonly authenticate: ( + methodId: string, + context: ProviderAuthFlowContext, + ) => Effect.Effect; + readonly logout: Effect.Effect; + readonly timeoutMs?: number; +}) { + const scope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const lock = yield* Semaphore.make(1); + const timeoutMs = options.timeoutMs ?? 300_000; + const empty: ProviderAuthState = { + instanceId: options.instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + interaction: null, + credentialOwner: options.credentialBinding.owner, + }; + const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty }); + let active: Flow | undefined; + let operation: "idle" | "auth" | "stopping" | "closed" = "idle"; + const sessions = new Set(); + const stopOwnedSessions = Effect.suspend(() => + Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { + discard: true, + concurrency: "unbounded", + }), + ); + const publish = (flow: Flow, patch: Partial) => + Effect.suspend(() => + active === flow + ? SubscriptionRef.update(snapshot, (current) => ({ + owner: flow.owner, + state: { ...current.state, ...patch }, + })) + : Effect.void, + ); + + const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) { + if ( + operation !== "auth" || + !active || + active.owner !== owner || + active.id !== id || + (yield* Clock.currentTimeMillis) >= active.expiresAt + ) { + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "This sign-in is no longer active in this client.", + }); + } + return active; + }); + + // Method discovery does not start a sign-in flow. + const refreshMethods = options.methods.pipe( + Effect.flatMap((methods) => + SubscriptionRef.update(snapshot, (current) => ({ + ...current, + state: { ...current.state, methods }, + })), + ), + // Keep the last known methods when a refresh fails or is interrupted. + Effect.catch((error) => + SubscriptionRef.update(snapshot, (current) => ({ + ...current, + state: { ...current.state, methods: current.state.methods ?? [], message: error.detail }, + })), + ), + ); + yield* refreshMethods.pipe(Effect.forkIn(scope)); + + const controller: ProviderAuthService.ProviderAuthController = { + credentialBinding: options.credentialBinding, + refreshMethods, + invalidate: lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") return; + yield* stopOwnedSessions; + yield* SubscriptionRef.set(snapshot, { + owner: null, + state: { + ...empty, + methods: snapshot.value.state.methods ?? [], + message: "This provider's shared sign-in changed.", + }, + }); + }), + ), + isChangingCredentials: Effect.sync(() => operation !== "idle"), + withAccess: (task) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const parent = yield* Scope.Scope; + const child = yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "session", + detail: "Provider sign-in is changing. Try again after it finishes.", + }); + const child = yield* Scope.make(); + sessions.add(child); + yield* Scope.addFinalizer( + child, + Effect.sync(() => { + sessions.delete(child); + }), + ); + yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); + return child; + }), + ); + const fiber = yield* restore(task).pipe( + Effect.provideService(Scope.Scope, child), + Effect.forkIn(child), + ); + return yield* restore(Fiber.await(fiber)).pipe( + Effect.flatMap((result) => result), + Effect.onExit((result) => + Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, + ), + ); + }), + ), + start: (owner, stopSessions = Effect.void, selectedMethodId) => + lock.withPermit( + Effect.gen(function* () { + if (operation === "auth" && active?.owner === owner) return snapshot.value.state; + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Provider setup is already in progress.", + }); + const id = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Could not start sign-in. Try again.", + }), + ), + ); + const flow: Flow = { + id, + owner, + expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs, + respond: undefined, + }; + active = flow; + operation = "auth"; + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: "starting", + flowId: id, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)), + message: "Starting sign-in.", + }; + yield* SubscriptionRef.set(snapshot, { owner, state }); + flow.fiber = yield* Effect.gen(function* () { + const methods = yield* options.methods; + yield* publish(flow, { methods }); + const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id; + if (!methodId || !methods.some((method) => method.id === methodId)) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "The provider did not advertise this sign-in method.", + }); + yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); + yield* options.authenticate(methodId, { + flowId: id, + setInteraction: (interaction, respond) => + Effect.gen(function* () { + if (active !== flow) return; + flow.respond = respond; + yield* publish(flow, { + phase: "waiting", + interaction, + authorizationUrl: + interaction.type === "browser" || interaction.type === "deviceCode" + ? interaction.url + : null, + message: "Complete sign-in to continue.", + }); + }), + verifying: Effect.gen(function* () { + flow.respond = undefined; + yield* publish(flow, { + phase: "verifying", + interaction: null, + authorizationUrl: null, + message: "Checking provider sign-in.", + }); + }), + }); + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Sign-in expired. Start again.", + }), + ), + }), + Effect.exit, + Effect.flatMap((result) => + Effect.gen(function* () { + const finishing = yield* lock.withPermit( + Effect.sync(() => { + if (active !== flow) return false; + operation = "stopping"; + return true; + }), + ); + if (!finishing) return; + if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + yield* lock.withPermit( + Effect.gen(function* () { + if (active !== flow) return; + yield* publish(flow, { + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + interaction: null, + authorizationUrl: null, + expiresAt: null, + message: Exit.isSuccess(result) + ? "Sign-in complete." + : failureMessage(result.cause), + }); + active = undefined; + operation = "idle"; + }), + ); + }), + ), + Effect.interruptible, + Effect.forkIn(scope), + ); + return state; + }).pipe(Effect.uninterruptible), + ), + respond: (owner, input) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const { flow, fiber, ready } = yield* lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, input.flowId); + const interaction = snapshot.value.state.interaction; + if ( + !interaction || + interaction.id !== input.interactionId || + interaction.type !== input.response.type || + !flow.respond + ) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "This sign-in interaction is no longer available.", + }); + if (flow.responseFiber) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "A sign-in response is already in progress.", + }); + const ready = yield* Deferred.make(); + const callback = flow.respond; + const fiber = yield* Deferred.await(ready).pipe( + Effect.andThen(Effect.suspend(() => callback(input.response))), + Effect.interruptible, + Effect.forkIn(scope), + ); + flow.responseFiber = fiber; + return { flow, fiber, ready }; + }), + ); + yield* Deferred.succeed(ready, undefined); + yield* restore(Fiber.join(fiber)).pipe( + Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)), + Effect.ensuring( + lock.withPermit( + Effect.sync(() => { + if (flow.responseFiber === fiber) delete flow.responseFiber; + }), + ), + ), + ); + return snapshot.value.state; + }), + ), + complete: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "complete", + detail: "This provider does not accept a pasted redirect URL.", + }), + ), + cancel: (owner, id) => + Effect.gen(function* () { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, id); + yield* publish(flow, { + phase: "cancelled", + interaction: null, + authorizationUrl: null, + expiresAt: null, + message: "Sign-in cancelled.", + }); + active = undefined; + operation = "stopping"; + return flow; + }), + ); + if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + operation = "idle"; + return snapshot.value.state; + }).pipe(Effect.uninterruptible), + logout: (stopSessions) => + Effect.gen(function* () { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle" && operation !== "auth") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "logout", + detail: "Provider setup is already stopping.", + }); + operation = "stopping"; + const flow = active; + active = undefined; + return flow; + }), + ); + const result = yield* Effect.gen(function* () { + if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); + yield* options.logout; + }).pipe(Effect.exit); + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: Exit.isSuccess(result) ? "idle" : "failed", + message: Exit.isSuccess(result) ? "Signed out." : "Could not sign out. Try again.", + }; + yield* lock.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + operation = "idle"; + }), + ); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + return state; + }).pipe(Effect.uninterruptible), + subscribe: (owner) => + SubscriptionRef.changes(snapshot).pipe( + Stream.map((current) => + current.owner === null || current.owner === owner + ? current.state + : { + ...current.state, + flowId: null, + authorizationUrl: null, + interaction: null, + expiresAt: null, + message: active + ? "Sign-in is in progress in another client." + : current.state.message, + }, + ), + ), + }; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + operation = "closed"; + const flow = active; + active = undefined; + if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + // Settings edits rebuild instances; admitted sessions (including those of + // peers sharing this binding) end with their own scopes, not this one. + }), + ); + return controller; +}); diff --git a/apps/server/src/provider/ProviderCredentialStore.test.ts b/apps/server/src/provider/ProviderCredentialStore.test.ts new file mode 100644 index 000000000000..0a5ad906e126 --- /dev/null +++ b/apps/server/src/provider/ProviderCredentialStore.test.ts @@ -0,0 +1,49 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; + +it.effect("isolates provider bindings and preserves opaque credentials", () => + Effect.gen(function* () { + const data = new Map(); + const secretStore = ServerSecretStore.of({ + get: (name) => Effect.sync(() => Option.fromUndefinedOr(data.get(name))), + set: (name, value) => + Effect.sync(() => { + data.set(name, value); + }), + remove: (name) => + Effect.sync(() => { + data.delete(name); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + const a = yield* ProviderCredentialStore.make("cursor", "../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const b = yield* ProviderCredentialStore.make("cursor", "work").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const c = yield* ProviderCredentialStore.make("other", "../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const bytes = Uint8Array.from([0, 255, 128, 1]); + yield* a.set(bytes); + assert.deepStrictEqual(Option.getOrThrow(yield* a.get), bytes); + assert.isTrue(Option.isNone(yield* b.get)); + assert.isTrue(Option.isNone(yield* c.get)); + assert.isFalse(a.binding.key.includes("/")); + const long = yield* ProviderCredentialStore.make("a".repeat(64), "b".repeat(64)).pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + assert.isBelow(long.binding.key.length, 255); + const delimiter = yield* ProviderCredentialStore.make("cur", "sor../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + assert.notStrictEqual(delimiter.binding.key, a.binding.key); + yield* a.remove; + assert.isTrue(Option.isNone(yield* a.get)); + }), +); diff --git a/apps/server/src/provider/ProviderCredentialStore.ts b/apps/server/src/provider/ProviderCredentialStore.ts new file mode 100644 index 000000000000..259b441654c9 --- /dev/null +++ b/apps/server/src/provider/ProviderCredentialStore.ts @@ -0,0 +1,21 @@ +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +/** A provider binding stores opaque bytes; only its adapter decodes or refreshes them. */ +export const make = Effect.fn("ProviderCredentialStore.make")(function* ( + driver: string, + bindingId: string, +) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + // Hash the tuple so arbitrary bindings cannot escape or exceed a filename. + const key = `provider-auth-${NodeCrypto.createHash("sha256") + .update(`${driver.length}:${driver}${bindingId}`) + .digest("hex")}`; + return { + binding: { owner: "t3" as const, key }, + get: secrets.get(key), + set: (credentials: Uint8Array) => secrets.set(key, credentials), + remove: secrets.remove(key), + }; +}); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index a5c092233052..059a2508e40f 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -74,6 +74,8 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; + /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ + readonly invalidateCaches?: Effect.Effect; /** * Redeem one banked rate-limit reset credit on the signed-in account, then * re-probe so the snapshot reflects the cleared windows. Account-level, diff --git a/apps/server/src/provider/Services/ProviderAuthService.ts b/apps/server/src/provider/Services/ProviderAuthService.ts index ea89edf78af5..93fd39fb3e3f 100644 --- a/apps/server/src/provider/Services/ProviderAuthService.ts +++ b/apps/server/src/provider/Services/ProviderAuthService.ts @@ -1,12 +1,28 @@ -import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts"; +import type { + ProviderAuthRespondInput, + ProviderAuthStartInput, + ProviderAuthState, + ProviderInstanceId, + ProviderSetupError, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; +import type * as Scope from "effect/Scope"; export interface ProviderAuthController { + /** Equal keys mean these instances share credentials on this environment. */ + readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; + readonly isChangingCredentials?: Effect.Effect; + readonly invalidate?: Effect.Effect; + readonly refreshMethods?: Effect.Effect; + readonly withAccess?: ( + task: Effect.Effect, + ) => Effect.Effect; readonly start: ( ownerSessionId: string, stopSessions?: Effect.Effect, + methodId?: string, ) => Effect.Effect; readonly complete: ( ownerSessionId: string, @@ -16,6 +32,10 @@ export interface ProviderAuthController { ownerSessionId: string, flowId: string, ) => Effect.Effect; + readonly respond?: ( + ownerSessionId: string, + input: ProviderAuthRespondInput, + ) => Effect.Effect; /** The controller closes process admission before it stops routed sessions. */ readonly logout: ( stopSessions: Effect.Effect, @@ -30,13 +50,17 @@ interface ProviderAuthTarget { export interface ProviderAuthServiceShape { readonly start: ( - input: ProviderAuthTarget, + input: ProviderAuthStartInput, ownerSessionId: string, ) => Effect.Effect; readonly complete: ( input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, ownerSessionId: string, ) => Effect.Effect; + readonly respond: ( + input: ProviderAuthRespondInput, + ownerSessionId: string, + ) => Effect.Effect; readonly cancel: ( input: ProviderAuthTarget & { readonly flowId: string }, ownerSessionId: string, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5d46c866a165..362cc7e3128a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -132,6 +132,7 @@ import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationE import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -142,7 +143,10 @@ import { import type { ProviderInstance } from "./provider/ProviderDriver.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + ProviderVersionCache, +} from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; @@ -516,6 +520,7 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + modelManifest?: Partial; usageLimitSources?: Partial; providerService?: Partial; providerAuth?: Partial; @@ -790,6 +795,10 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mergeAll( + Layer.mock(ModelManifest.ModelManifest)({ + forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + ...options?.layers?.modelManifest, + }), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), @@ -6337,6 +6346,99 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const mode of ["all", "targeted", "background"] as const) { + it.effect(`provider refresh invalidates T3 caches before probing (${mode})`, () => { + const driver = ProviderDriverKind.make("codex"); + const instanceIds = [ProviderInstanceId.make("codex"), ProviderInstanceId.make("codex_work")]; + const packageNames = ["@example/personal", "@example/work"]; + const versionCache = new Map( + packageNames.map((name) => [ + name, + { + expiresAt: Number.MAX_SAFE_INTEGER, + version: "1.0.0", + }, + ]), + ); + const invalidated: string[] = []; + const freshMaintenance: string[] = []; + let manifestRefreshed = false; + let probed = false; + const instances = instanceIds.map( + (instanceId, index) => + ({ + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + invalidateCaches: Effect.sync(() => { + invalidated.push(instanceId); + }), + snapshot: { + resolveMaintenance: (options) => + Effect.sync(() => { + assert.isTrue(options?.fresh); + freshMaintenance.push(instanceId); + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: packageNames[index]!, + }); + }), + getSnapshot: Effect.never, + refresh: Effect.never, + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }) satisfies ProviderInstance, + ); + const expected = + mode === "background" ? [] : mode === "targeted" ? [instanceIds[1]!] : instanceIds; + const probe = Effect.sync(() => { + probed = true; + assert.equal(manifestRefreshed, mode !== "background"); + assert.deepEqual(invalidated.toSorted(), expected.toSorted()); + assert.deepEqual(freshMaintenance.toSorted(), expected.toSorted()); + for (let index = 0; index < instanceIds.length; index++) { + assert.equal( + versionCache.has(packageNames[index]!), + !expected.includes(instanceIds[index]!), + ); + } + return []; + }); + return Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + modelManifest: { + forceRefresh: Effect.sync(() => { + manifestRefreshed = true; + return ModelManifest.BUNDLED_MODEL_MANIFEST; + }), + }, + providerInstanceRegistry: { listInstances: Effect.succeed(instances) }, + providerRegistry: { refresh: () => probe, refreshInstance: () => probe }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverRefreshProviders]({ + ...(mode === "targeted" ? { instanceId: instanceIds[1]! } : {}), + ...(mode !== "background" ? { refreshModels: true } : {}), + }), + ), + ); + assert.isTrue(probed); + }).pipe( + Effect.provideService(ProviderVersionCache, versionCache), + Effect.provide(NodeHttpServer.layerTest), + ); + }); + } + it.effect("serves config on reconnect without starting provider probes", () => Effect.gen(function* () { const refresh = vi.fn(() => Effect.never); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 9f91264ee623..5fbae919c258 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2285,13 +2285,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { - const range = `${baseRef}..HEAD`; + const commitRange = `${baseRef}..HEAD`; + // PR diffs start at the common ancestor when the base branch has advanced. + const diffRange = `${baseRef}...HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", range], + ["log", "--oneline", commitRange], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2300,7 +2302,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", range], + ["diff", "--stat", diffRange], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2309,7 +2311,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", range], + ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 652b1ddf3f52..17500460d9ba 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -109,6 +109,8 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; +import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; @@ -561,6 +563,8 @@ const makeWsRpcLayer = ( yield* Effect.context>>(); const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const modelManifest = yield* ModelManifest.ModelManifest; + const providerVersionCache = yield* ProviderMaintenance.ProviderVersionCache; const providerService = yield* ProviderService.ProviderService; const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; @@ -2361,6 +2365,28 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.serverRefreshProviders, Effect.gen(function* () { + // Only explicit catalog refreshes bypass T3's caches. Workspace + // discovery and background status checks retain their timers. + if (input.refreshModels) { + yield* modelManifest.forceRefresh; + const instances = yield* providerInstances.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => + input.instanceId === undefined || input.instanceId === instance.instanceId, + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: true, + }); + if (maintenance.packageName) + providerVersionCache.delete(maintenance.packageName); + }), + { concurrency: "unbounded", discard: true }, + ); + } // An untargeted refresh is "re-read everything's status", which // includes quota from configured usage-limit sources. Awaited, // not forked: the RPC scope closes on return and would @@ -2471,6 +2497,15 @@ const makeWsRpcLayer = ( providerAuth.start(input, currentSessionId), { "rpc.aggregate": "provider" }, ), + [WS_METHODS.providerAuthRespond]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthRespond, + providerAuth.respond(input, currentSessionId), + { + "rpc.aggregate": "provider", + instanceId: input.instanceId, + }, + ), [WS_METHODS.providerAuthComplete]: (input) => observeRpcEffect( WS_METHODS.providerAuthComplete, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a2897ce3ddb0..a2708de8bda3 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,7 +12,7 @@ import { TurnId, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { Atom, AsyncResult } from "effect/unstable/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; @@ -86,7 +86,6 @@ import { shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, - toolGroupConsumesUpwardNavigation, waitForRevertedMessage, prepareRevertedMessageAttachments, } from "./ChatView.logic"; @@ -446,134 +445,6 @@ describe("proactive panels", () => { }); }); -describe("toolGroupConsumesUpwardNavigation", () => { - class ScrollElement extends EventTarget { - scrollTop = 0; - scrollHeight = 100; - clientHeight = 100; - overflowY = "visible"; - - constructor( - readonly parentElement: ScrollElement | null = null, - readonly isToolGroup = false, - ) { - super(); - } - - closest(selector: string): ScrollElement | null { - if (selector !== "[data-tool-group-scroll]") return null; - return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); - } - } - - beforeEach(() => { - vi.stubGlobal("Element", ScrollElement); - vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ - overflowY: element.overflowY, - })); - }); - afterEach(() => vi.unstubAllGlobals()); - - it("releases upward navigation when an overflowing group is at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each([ - { overflowY: "auto", scrollTop: 1 }, - { overflowY: "auto", scrollTop: 0.25 }, - { overflowY: "scroll", scrollTop: 80 }, - ])("consumes upward navigation within a scrolled group: %j", (scroll) => { - const group = Object.assign(new ScrollElement(null, true), { - scrollHeight: 300, - ...scroll, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); - }); - - it.each([100, 300])( - "consumes scrolling in a nested result with a group content height of %i", - (scrollHeight) => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 0.25, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); - }, - ); - - it("releases upward navigation when the group and nested result are both at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "scroll", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }); - - it("ignores targets outside a tool group and non-element targets", () => { - const outside = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); - expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); - expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); - }); - - it("does not consume scrolling from an ancestor beyond the tool group", () => { - const timeline = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - const group = new ScrollElement(timeline, true); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each(["hidden", "clip", "visible"])( - "ignores a non-scrollable child with overflow-y %s", - (overflowY) => { - const group = new ScrollElement(null, true); - const result = Object.assign(new ScrollElement(group), { - overflowY, - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }, - ); - - it("does not consume programmatic scrolling on an overflow-hidden group", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "hidden", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); - }); -}); - const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 928d99c549a6..528024de4393 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -239,22 +239,6 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: { }); } -export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { - const elementTarget = target instanceof Element ? target : null; - const group = elementTarget?.closest("[data-tool-group-scroll]"); - if (!group) return false; - - // A nested result or the group itself can consume an upward scroll. - for (let element = elementTarget; element; element = element.parentElement) { - if (element.scrollTop > 0) { - const overflowY = getComputedStyle(element).overflowY; - if (overflowY === "auto" || overflowY === "scroll") return true; - } - if (element === group) break; - } - return false; -} - export { findRecordedWorktreeSetup, resolveVisibleWorktreeSetup, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b97411e7a44b..3ce6566c041d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -365,6 +365,7 @@ import { import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; +import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -474,7 +475,6 @@ import { shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, codexArtifactTemplatePromptToAppend, - toolGroupConsumesUpwardNavigation, waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; @@ -5466,6 +5466,8 @@ export default function ChatView(props: ChatViewProps) { // Only an upward wheel is a navigation intent; wheeling down while // following either does nothing (at the end) or moves toward it. const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) + return; if (event.deltaY > 0) { timelineScrollIntentRef.current = "toward-end"; if (isAtEndRef.current) { @@ -5474,11 +5476,7 @@ export default function ChatView(props: ChatViewProps) { } else if (event.deltaY < 0) { timelineScrollIntentRef.current = "away-from-end"; } - if ( - event.deltaY < 0 && - contentScrollsUp() && - !toolGroupConsumesUpwardNavigation(event.target) - ) { + if (event.deltaY < 0 && contentScrollsUp()) { handleManualNavigation(); } }; @@ -5528,12 +5526,20 @@ export default function ChatView(props: ChatViewProps) { ) { return; } + if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) + return; + const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; + if ( + scrollNode.contains(event.target) && + !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) + ) + return; switch (event.key) { case "PageUp": case "Home": case "ArrowUp": timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { + if (contentScrollsUp()) { handleManualNavigation(); composerRef.current?.collapseForTimelineScrollKey(event.key); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e258c995e573..7955e1e02b25 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -310,6 +310,7 @@ import { import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { pendingDraftWork } from "./pendingDraftWork"; +import { isTimelineScrollTarget } from "./timelineScrollTarget"; import { createComposerScrollGestureState, recordComposerScrollGestureEvent, @@ -4894,8 +4895,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const scrollNode = getTimelineScrollableNode(); if (!scrollNode) return; - const targetsTimeline = scrollNode.contains(event.target); - if (!targetsTimeline && !composerScrollGestureRef.current.collapseSuppressed) return; + const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY); + if ( + !scrollNode.contains(event.target) && + !composerScrollGestureRef.current.collapseSuppressed + ) + return; if (composerScrollCollapseTimeoutRef.current !== null) { window.clearTimeout(composerScrollCollapseTimeoutRef.current); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 43adf7907dbf..dd35a506d78c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4475,7 +4475,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { if (tone === "thinking") { return { iconName: "brain", - className: "text-foreground", + className: "text-icon-muted", }; } if (tone === "info") { diff --git a/apps/web/src/components/chat/timelineScrollTarget.test.ts b/apps/web/src/components/chat/timelineScrollTarget.test.ts new file mode 100644 index 000000000000..051f7785f5a3 --- /dev/null +++ b/apps/web/src/components/chat/timelineScrollTarget.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createComposerScrollGestureState, + recordComposerScrollGestureEvent, +} from "./composerScrollGesture"; +import { isTimelineScrollTarget } from "./timelineScrollTarget"; + +class ScrollElement extends EventTarget { + scrollTop = 0; + scrollHeight = 100; + clientHeight = 100; + overflowY = "visible"; + overscrollBehaviorY = "auto"; + + constructor(readonly parentElement: ScrollElement | null = null) { + super(); + } + + contains(target: ScrollElement): boolean { + return ( + target === this || (target.parentElement !== null && this.contains(target.parentElement)) + ); + } +} + +function targetsTimeline(target: EventTarget | null, timeline: ScrollElement, deltaY: number) { + return isTimelineScrollTarget(target, timeline as unknown as HTMLElement, deltaY); +} + +function setup() { + const timeline = Object.assign(new ScrollElement(), { + overflowY: "auto", + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 1000, + }); + const group = Object.assign(new ScrollElement(timeline), { + overflowY: "auto", + scrollHeight: 300, + scrollTop: 80, + }); + return { timeline, group, content: new ScrollElement(group) }; +} + +beforeEach(() => { + vi.stubGlobal("Element", ScrollElement); + vi.stubGlobal("getComputedStyle", (element: ScrollElement) => element); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("timeline scroll targets", () => { + it.each([-30, 30])("keeps a nested tool group's scroll out of the timeline: %i", (deltaY) => { + const { timeline, group, content } = setup(); + expect(targetsTimeline(content, timeline, deltaY)).toBe(false); + expect(targetsTimeline(group, timeline, deltaY)).toBe(false); + }); + + it.each([ + { scrollTop: 0, deltaY: -30 }, + { scrollTop: 200, deltaY: 30 }, + ])("allows chaining only past the matching edge: %j", ({ scrollTop, deltaY }) => { + const { timeline, group, content } = setup(); + group.scrollTop = scrollTop; + expect(targetsTimeline(content, timeline, deltaY)).toBe(true); + expect(targetsTimeline(content, timeline, -deltaY)).toBe(false); + }); + + it.each(["contain", "none"])("respects overscroll-y %s at either edge", (overscrollBehaviorY) => { + const { timeline, group, content } = setup(); + group.overscrollBehaviorY = overscrollBehaviorY; + group.scrollTop = 0; + expect(targetsTimeline(content, timeline, -30)).toBe(false); + group.scrollTop = 200; + expect(targetsTimeline(content, timeline, 30)).toBe(false); + group.scrollTop = 0; + group.scrollHeight = group.clientHeight; + expect(targetsTimeline(content, timeline, 30)).toBe(false); + }); + + it("checks nested results even when the tool group cannot scroll", () => { + const { timeline, group } = setup(); + group.scrollTop = 0; + group.scrollHeight = group.clientHeight; + const result = Object.assign(new ScrollElement(group), { + overflowY: "scroll", + scrollHeight: 300, + scrollTop: 0.25, + }); + expect(targetsTimeline(new ScrollElement(result), timeline, -30)).toBe(false); + result.scrollTop = 0; + expect(targetsTimeline(result, timeline, -30)).toBe(true); + }); + + it("checks an outer group when an inner result reaches its edge", () => { + const { timeline, group } = setup(); + const result = Object.assign(new ScrollElement(group), { overflowY: "auto" }); + expect(targetsTimeline(result, timeline, -30)).toBe(false); + group.scrollTop = 0; + expect(targetsTimeline(result, timeline, -30)).toBe(true); + }); + + it.each(["visible", "hidden", "clip"])("ignores overflow-y %s", (overflowY) => { + const { timeline, group, content } = setup(); + group.overflowY = overflowY; + expect(targetsTimeline(content, timeline, -30)).toBe(true); + }); + + it("allows ordinary message content and the outer viewport", () => { + const { timeline } = setup(); + expect(targetsTimeline(new ScrollElement(timeline), timeline, -30)).toBe(true); + expect(targetsTimeline(timeline, timeline, 30)).toBe(true); + }); + + it("rejects outside targets, non-elements, and horizontal-only scrolling", () => { + const { timeline, content } = setup(); + expect(targetsTimeline(new ScrollElement(), timeline, -30)).toBe(false); + expect(targetsTimeline(new EventTarget(), timeline, -30)).toBe(false); + expect(targetsTimeline(null, timeline, -30)).toBe(false); + expect(targetsTimeline(content, timeline, 0)).toBe(false); + }); + + it("does not accumulate nested scrolling toward composer collapse", () => { + const { timeline, group, content } = setup(); + const state = createComposerScrollGestureState(); + const record = (target: ScrollElement, now: number, deltaPx: number) => + recordComposerScrollGestureEvent(state, { + now, + deltaPx, + collapseThresholdPx: 24, + collapseEligible: targetsTimeline(target, timeline, -deltaPx), + canScrollInGestureDirection: timeline.scrollTop > 0, + scrollsTowardLogicalEnd: false, + }); + + expect(record(timeline, 0, 20)).toBe(false); + expect(record(content, 20, 30)).toBe(false); + group.scrollTop = 0; + expect(record(content, 40, 10)).toBe(false); + expect(record(content, 60, 14)).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/timelineScrollTarget.ts b/apps/web/src/components/chat/timelineScrollTarget.ts new file mode 100644 index 000000000000..da87e9a7036d --- /dev/null +++ b/apps/web/src/components/chat/timelineScrollTarget.ts @@ -0,0 +1,31 @@ +// A gesture inside the timeline may belong to a nested tool result or code +// block. Only treat it as timeline navigation if it can chain to the outer list. +export function isTimelineScrollTarget( + target: EventTarget | null, + timeline: HTMLElement, + deltaY: number, +): boolean { + if (!(target instanceof Element) || !timeline.contains(target) || deltaY === 0) return false; + + for ( + let element: Element | null = target; + element && element !== timeline; + element = element.parentElement + ) { + const style = getComputedStyle(element); + if (style.overflowY !== "auto" && style.overflowY !== "scroll") continue; + + const canScroll = + deltaY < 0 + ? element.scrollTop > 0 + : element.scrollTop < element.scrollHeight - element.clientHeight; + if ( + canScroll || + style.overscrollBehaviorY === "contain" || + style.overscrollBehaviorY === "none" + ) { + return false; + } + } + return true; +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index fe7576933857..c39b8cb3c5b4 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -129,97 +129,151 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting updateSettings({ defaultModelSelection: value }); }; + const modelRow = ( + setModel(null)} /> + ) : null + } + control={ + selection && activeEntry ? ( +
+ { + if (representative) + void navigate({ + to: "/settings/providers", + search: { environmentId: representative.environmentId, instanceId }, + }); + }} + onInstanceModelChange={(instanceId, model) => + setModel(createModelSelection(instanceId, model)) + } + /> + {!mixedModel ? ( + {}} + modelOptions={selection.options ?? []} + allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} + triggerVariant="outline" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + onModelOptionsChange={(options) => + setModel(createModelSelection(selection.instanceId, selection.model, options)) + } + /> + ) : null} +
+ ) : ( + No providers available + ) + } + /> + ); + const workspaceRow = ( + updateSettings({ defaultThreadEnvMode: null })} + /> + ) : null + } + control={ + + } + /> + ); + return ( - {category === "general" ? ( + {category === "project" ? ( <> - setModel(null)} /> - ) : null - } - control={ - selection && activeEntry ? ( -
- { - if (representative) - void navigate({ - to: "/settings/providers", - search: { environmentId: representative.environmentId, instanceId }, - }); - }} - onInstanceModelChange={(instanceId, model) => - setModel(createModelSelection(instanceId, model)) - } - /> - {!mixedModel ? ( - {}} - modelOptions={selection.options ?? []} - allowPromptInjectedEffort={false} - planModeEnabled={settings.planModeEnabled} - triggerVariant="outline" - triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} - onModelOptionsChange={(options) => - setModel( - createModelSelection(selection.instanceId, selection.model, options), - ) - } - /> - ) : null} -
- ) : ( - No providers available - ) - } - /> + {modelRow} + {workspaceRow} + + ) : category === "general" ? ( + <> + {modelRow} } /> - updateSettings({ defaultThreadEnvMode: null })} - /> - ) : null - } - control={ - - } - /> + {workspaceRow} + + + + Can't find a setting? Keep this project picked above and hop to any other settings page. + + + {hasMultipleCheckouts ? checkoutChoices : null} diff --git a/apps/web/src/components/settings/ProjectsSettings.tsx b/apps/web/src/components/settings/ProjectsSettings.tsx index 995b11191179..397662c5f700 100644 --- a/apps/web/src/components/settings/ProjectsSettings.tsx +++ b/apps/web/src/components/settings/ProjectsSettings.tsx @@ -3,6 +3,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; import { useSettingsScope } from "./SettingsScopeContext"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; +import { SettingsPageContainer } from "./settingsLayout"; /** Project identity and checkout management for the selected project. */ export function ProjectsSettings() { @@ -22,7 +23,9 @@ export function ProjectsSettings() { checkoutKey={value.checkout ?? null} /> ) : scope.kind === "unavailable" ? ( -

{scope.message}

+ +

{scope.message}

+
) : ( Choose a project to manage its name, icon, checkouts and actions. diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 90d84d1c1efa..2d5d5d751719 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -58,6 +58,7 @@ vi.mock("./settingsLayout", async (importOriginal) => { }; }); +vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null })); vi.mock("react/compiler-runtime", async () => { const { reactHookHarness } = await import("../../test/reactHookHarness"); return { c: reactHookHarness.useMemoCache }; diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx index 29555c337821..c4e019d6fcf7 100644 --- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -1,37 +1,9 @@ -import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import { LayersIcon } from "lucide-react"; -import type { ReactNode } from "react"; - -import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; -import type { EnvironmentPresentation } from "../../state/environments"; -import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { ProjectFavicon } from "../ProjectFavicon"; -import { InlineButton } from "../ui/button"; -import { - Menu, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuRadioItemIndicator, - MenuSeparator, - MenuTrigger, -} from "../ui/menu"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../WorkspaceBreadcrumb"; import { SETTINGS_SECTION_LABELS } from "./settingsSearch"; -import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; -import { - ALL_ENVIRONMENTS_VALUE, - ALL_PROJECTS_VALUE, - environmentAxisValue, - projectAxisValue, - selectEnvironmentAxis, - selectProjectAxis, - settingsScopeEnvironmentLabel, -} from "./settingsScopeAxis"; const SETTINGS_BREADCRUMB_LABELS: Readonly> = { ...SETTINGS_SECTION_LABELS, @@ -44,27 +16,11 @@ function settingsBreadcrumbLabel(pathname: string): string | null { return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null; } -export interface SettingsScopeBreadcrumbProps { - readonly value: SettingsScopeSearch; - readonly groups: readonly SidebarProjectSnapshot[]; - readonly environments: readonly EnvironmentPresentation[]; - readonly onChange: (next: SettingsScopeSearch) => void; -} - /** - * `Settings / Section / Environment / Project`. The last two crumbs are the - * targets a change applies to and read like the usage page's filter: muted at - * "all", foreground once narrowed. A project is the same project on every - * environment, so the environment crumb alone decides where a project - * override is written. + * `Settings / Section`. The scope a change applies to lives at the top of the + * page content, see `SettingsScopeSentence`. */ -export function SettingsBreadcrumb({ - pathname, - scope, -}: { - pathname: string; - scope?: SettingsScopeBreadcrumbProps | undefined; -}) { +export function SettingsBreadcrumb({ pathname }: { pathname: string }) { const sectionLabel = settingsBreadcrumbLabel(pathname); return ( @@ -78,154 +34,6 @@ export function SettingsBreadcrumb({ {sectionLabel ?? "Settings"} - {scope ? ( - <> - - - - - - - - - - ) : null} ); } - -function ScopeMenu({ - ariaLabel, - icon, - label, - narrowed, - children, -}: { - ariaLabel: string; - icon: ReactNode; - label: string; - narrowed: boolean; - children: ReactNode; -}) { - return ( - - } - className="min-w-0 max-w-56 gap-1.5" - > - {icon} - {label} - - {children} - - ); -} - -function EnvironmentScopeMenu({ - value, - groups, - environments, - onChange, -}: SettingsScopeBreadcrumbProps) { - const resolved = resolveSettingsScope(value, groups, environments); - const environmentValue = environmentAxisValue( - value, - resolved.kind === "checkout" ? resolved.environmentId : null, - ); - const selected = environments.find( - (environment) => environment.environmentId === environmentValue, - ); - return ( - - ) : null - } - label={ - selected - ? settingsScopeEnvironmentLabel(selected, environments) - : environmentValue !== ALL_ENVIRONMENTS_VALUE - ? "Unavailable environment" - : "All environments" - } - > - { - if (typeof next === "string") onChange(selectEnvironmentAxis(value, next)); - }} - > - - - - All environments - - - - - {environments.map((environment) => ( - - - - - {settingsScopeEnvironmentLabel(environment, environments)} - - {environment.connection.phase === "connected" ? null : ( - Offline - )} - - - - ))} - - - ); -} - -function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeBreadcrumbProps) { - const selected = groups.find((group) => group.projectKey === value.project); - return ( - : null} - label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")} - > - { - if (typeof next === "string") onChange(selectProjectAxis(value, next)); - }} - > - - - All projects - - - - - {groups.map((group) => ( - - - - {group.displayName} - - - - ))} - - - ); -} diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx new file mode 100644 index 000000000000..5341b72e73e5 --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -0,0 +1,207 @@ +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { useLocation } from "@tanstack/react-router"; +import { ChevronDownIcon, LayersIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import { useEnvironments, type EnvironmentPresentation } from "../../state/environments"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { InlineButton } from "../ui/button"; +import { + Menu, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRadioItemIndicator, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; +import { useOptionalSettingsScope } from "./SettingsScopeContext"; +import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +import { + ALL_ENVIRONMENTS_VALUE, + ALL_PROJECTS_VALUE, + environmentAxisValue, + projectAxisValue, + selectEnvironmentAxis, + selectProjectAxis, + settingsScopeEnvironmentLabel, +} from "./settingsScopeAxis"; + +/** Pages whose every row is saved on this client; they have no scope to pick. */ +export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet = new Set([ + "/settings/appearance", + "/settings/snap-shot", + "/settings/connections", +]); + +interface SettingsScopeMenuProps { + readonly value: SettingsScopeSearch; + readonly groups: readonly SidebarProjectSnapshot[]; + readonly environments: readonly EnvironmentPresentation[]; + readonly onChange: (next: SettingsScopeSearch) => void; +} + +/** + * "Applying settings for across " at the top of a settings + * page. The two pickers are the targets a change is written to. A project is + * the same project on every environment, so the environment alone decides + * where a project override is written. + */ +export function SettingsScopeSentence() { + const scope = useOptionalSettingsScope(); + const pathname = useLocation({ select: (location) => location.pathname }); + const { environments } = useEnvironments(); + if (scope === null || SETTINGS_DEVICE_ONLY_PATHS.has(pathname)) return null; + const props: SettingsScopeMenuProps = { + value: scope.search, + groups: scope.groups, + environments, + onChange: scope.selectScope, + }; + return ( +

+ {/* Each connective stays with its picker so a wrap never strands "on". */} + + Applying settings for + + + + + {/* A legacy checkout link names one environment without `machine`. */} + {scope.search.machine || scope.scope.kind === "checkout" ? "on" : "across"} + + + +

+ ); +} + +function ScopeMenu({ + ariaLabel, + icon, + label, + children, +}: { + ariaLabel: string; + icon: ReactNode; + label: string; + children: ReactNode; +}) { + return ( + + } + className="min-w-0 max-w-72" + > + {icon} + {label} + + + {children} + + ); +} + +function EnvironmentScopeMenu({ value, groups, environments, onChange }: SettingsScopeMenuProps) { + const resolved = resolveSettingsScope(value, groups, environments); + const environmentValue = environmentAxisValue( + value, + resolved.kind === "checkout" ? resolved.environmentId : null, + ); + const selected = environments.find( + (environment) => environment.environmentId === environmentValue, + ); + return ( + + ) : null + } + label={ + selected + ? settingsScopeEnvironmentLabel(selected, environments) + : environmentValue !== ALL_ENVIRONMENTS_VALUE + ? "Unavailable environment" + : "All environments" + } + > + { + if (typeof next === "string") onChange(selectEnvironmentAxis(value, next)); + }} + > + + + + All environments + + + + + {environments.map((environment) => ( + + + + + {settingsScopeEnvironmentLabel(environment, environments)} + + {environment.connection.phase === "connected" ? null : ( + Offline + )} + + + + ))} + + + ); +} + +function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeMenuProps) { + const selected = groups.find((group) => group.projectKey === value.project); + return ( + : null} + label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")} + > + { + if (typeof next === "string") onChange(selectProjectAxis(value, next)); + }} + > + + + All projects + + + + + {groups.map((group) => ( + + + + {group.displayName} + + + + ))} + + + ); +} diff --git a/apps/web/src/components/settings/SnapShotSettings.test.tsx b/apps/web/src/components/settings/SnapShotSettings.test.tsx index 584c224493dc..b8f5bed9979f 100644 --- a/apps/web/src/components/settings/SnapShotSettings.test.tsx +++ b/apps/web/src/components/settings/SnapShotSettings.test.tsx @@ -27,6 +27,7 @@ vi.mock("react/compiler-runtime", async () => { vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} })); vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null })); +vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null })); const bridge = vi.hoisted(() => ({ getSnapShotState: vi.fn<() => Promise>(), setSnapShotShortcutSuppressed: vi.fn(), diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index c204fb9d6b65..7f2a56f33122 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -23,6 +23,7 @@ import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePag import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { useOptionalSettingsScope } from "./SettingsScopeContext"; +import { SettingsScopeSentence } from "./SettingsScopeSentence"; import { isProjectScopedSettingKey, listProjectOverrides, @@ -545,6 +546,7 @@ export function SettingsPageContainer({ data-settings-page-scroll > + {children} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index ef373e49f140..f2775a82ffd6 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -106,6 +106,9 @@ const inlineButtonVariants = cva( default: "text-foreground", muted: "text-muted-foreground hover:text-foreground", destructive: "text-destructive/80 hover:text-destructive", + /** Opens a menu from inside a sentence; the dotted underline marks it as a choice. */ + picker: + "gap-1.5 text-foreground underline decoration-foreground/30 decoration-dotted decoration-from-font underline-offset-4 hover:decoration-foreground hover:decoration-solid data-popup-open:decoration-foreground data-popup-open:decoration-solid", }, }, }, diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx deleted file mode 100644 index 4a00317c7e81..000000000000 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; -import { mergeUsage } from "@t3tools/shared/usageMerge"; -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - useUsage: vi.fn(), - metric: "cost" as "cost" | "tokens" | "limits", - breakdown: "time" as "model" | "time", -})); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useState: vi.fn((initial: unknown) => [ - initial === readUsagePagePreferences - ? { metric: testState.metric, windowDays: 30 } - : typeof initial === "function" - ? { - days: 1, - window: { - sinceDay: "2026-08-10", - untilDay: "2026-08-11", - timeZone: "UTC", - resolution: "hour", - sinceTime: "2026-08-10T12:37:00.000Z", - untilTime: "2026-08-11T12:37:00.000Z", - }, - } - : initial === "cost" - ? testState.metric - : initial === "model" - ? testState.breakdown - : initial, - vi.fn(), - ]), - }; -}); - -vi.mock("../../env", () => ({ isElectron: false })); -vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage })); -vi.mock("../ui/button", () => ({ Button: "button", InlineButton: "button" })); -vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); -vi.mock("../ui/select", () => ({ - Select: "div", - SelectItem: "div", - SelectPopup: "div", - SelectTrigger: "div", - SelectValue: "div", -})); -vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); -vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); -vi.mock("../WorkspaceBreadcrumb", () => ({ - WorkspaceBreadcrumb: "div", - WorkspaceBreadcrumbItem: "div", - WorkspaceBreadcrumbSeparator: "span", -})); -vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); -vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); -vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); -vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); -vi.mock("./usageProviders", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - PROVIDER_PRESENTATION: { - codex: { color: "white", label: "Codex", mark: "span" }, - claude: { color: "orange", label: "Claude Code", mark: "span" }, - }, - }; -}); - -import { UsagePage } from "./UsagePage"; -import { readUsagePagePreferences } from "./usagePagePreferences"; - -const providerTotals = (codex: number, claude: number) => - new Map([ - ["codex", { costUsd: codex, totalTokens: codex * 1_000 }], - ["claude", { costUsd: claude, totalTokens: claude * 1_000 }], - ] as const); - -const modelTotals = Object.freeze([ - { - model: "expensive-model", - provider: "claude" as const, - costUsd: 10, - totalTokens: 100, - records: 1, - unpricedRecords: 0, - costShare: 10 / 16, - }, - { - model: "token-heavy-model", - provider: "codex" as const, - costUsd: 5, - totalTokens: 1_000, - records: 1, - unpricedRecords: 0, - costShare: 5 / 16, - }, - { - model: "token-heavy-cheaper-model", - provider: "codex" as const, - costUsd: 1, - totalTokens: 1_000, - records: 1, - unpricedRecords: 0, - costShare: 1 / 16, - }, - { - model: "unpriced-model", - provider: "codex" as const, - costUsd: 0, - totalTokens: 500, - records: 2, - unpricedRecords: 2, - costShare: 0, - }, -]); - -const environments = [ - { - environmentId: EnvironmentId.make("test-environment"), - label: "Test environment", - isPending: false, - error: null, - summary: { - contractVersion: USAGE_CONTRACT_VERSION, - readAt: "2026-08-11T12:37:00.000Z", - sinceDay: UsageDay.make("2026-08-10"), - untilDay: UsageDay.make("2026-08-11"), - timeZone: "UTC", - buckets: [], - sources: [], - pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 }, - scanDurationMs: 1, - }, - }, -]; - -beforeEach(() => { - testState.metric = "cost"; - testState.breakdown = "time"; - testState.useUsage.mockReturnValue({ - merged: { - ...mergeUsage([], USAGE_CONTRACT_VERSION), - models: modelTotals, - hourly: [ - { - day: "2026-08-10", - hourStart: "2026-08-10T13:37:00.000Z", - costUsd: 13, - totalTokens: 13_000, - byProvider: providerTotals(7, 6), - }, - { - day: "2026-08-11", - hourStart: "2026-08-11T11:37:00.000Z", - costUsd: 11, - totalTokens: 11_000, - byProvider: providerTotals(6, 5), - }, - ], - }, - environments, - selectedEnvironments: environments, - isPending: false, - isPartial: false, - refresh: vi.fn(), - }); -}); - -describe("UsagePage hourly breakdown", () => { - it("keeps recent activity visible first without empty hourly rows", () => { - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - - expect(body.match(/ { - testState.metric = "tokens"; - - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - - expect(body).toMatch(/\$11\.00.*\$13\.00/); - }); -}); - -describe("UsagePage model breakdown", () => { - it("sorts models by cost when the cost metric is selected", () => { - testState.breakdown = "model"; - - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - - expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); - }); - - it("flags a model with no known rates instead of showing it as free", () => { - testState.breakdown = "model"; - - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - const unpricedRow = body.split(" row.includes("unpriced-model")) ?? ""; - - expect(unpricedRow).toContain("Unpriced"); - expect(unpricedRow).not.toContain("$0.00"); - }); - - it("sorts models by token usage when the token metric is selected", () => { - testState.metric = "tokens"; - testState.breakdown = "model"; - - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - - expect(body).toMatch(/token-heavy-model.*token-heavy-cheaper-model.*expensive-model/); - expect(modelTotals.map((model) => model.model)).toEqual([ - "expensive-model", - "token-heavy-model", - "token-heavy-cheaper-model", - "unpriced-model", - ]); - }); -}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index c9177d17275c..87c72f0bc8a4 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -63,6 +63,7 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { sortModelsByTokens } from "./usageBreakdown"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; import { readUsagePagePreferences, @@ -141,9 +142,7 @@ export function UsagePage() { const breakdownModels = useMemo( () => breakdown === "model" && metric === "tokens" - ? merged.models.toSorted( - (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, - ) + ? sortModelsByTokens(merged.models) : merged.models, [breakdown, merged.models, metric], ); diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts new file mode 100644 index 000000000000..db84528ce361 --- /dev/null +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -0,0 +1,31 @@ +import type { ModelTotals } from "@t3tools/shared/usageMerge"; +import { describe, expect, it } from "vite-plus/test"; + +import { sortModelsByTokens } from "./usageBreakdown"; + +const model = (name: string, totalTokens: number, costUsd: number): ModelTotals => ({ + model: name, + provider: "codex", + costUsd, + totalTokens, + records: 1, + unpricedRecords: 0, + costShare: 0, +}); + +describe("sortModelsByTokens", () => { + it("sorts by tokens, breaks ties by cost, and leaves the input alone", () => { + const models = [ + model("lower-cost", 100, 1), + model("more-tokens", 200, 2), + model("higher-cost", 100, 3), + ]; + + expect(sortModelsByTokens(models).map((item) => item.model)).toEqual([ + "more-tokens", + "higher-cost", + "lower-cost", + ]); + expect(models.map((item) => item.model)).toEqual(["lower-cost", "more-tokens", "higher-cost"]); + }); +}); diff --git a/apps/web/src/components/usage/usageBreakdown.ts b/apps/web/src/components/usage/usageBreakdown.ts new file mode 100644 index 000000000000..8245b593e9a9 --- /dev/null +++ b/apps/web/src/components/usage/usageBreakdown.ts @@ -0,0 +1,7 @@ +import type { ModelTotals } from "@t3tools/shared/usageMerge"; + +export function sortModelsByTokens(models: readonly ModelTotals[]) { + return models.toSorted( + (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, + ); +} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index e95895926d4d..5fc6d63f20f6 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -19,9 +19,10 @@ import { SettingsScopeProvider, useSettingsScope, } from "../components/settings/SettingsScopeContext"; -import { useSettingsProjectGroups } from "../components/settings/useSettingsProjectGroups"; import { useEnvironments } from "../state/environments"; import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice"; +import { SETTINGS_DEVICE_ONLY_PATHS } from "../components/settings/SettingsScopeSentence"; +import { SettingsPageContainer } from "../components/settings/settingsLayout"; import { retainSettingsScope, validateSettingsRouteSearch, @@ -47,13 +48,6 @@ function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) ); } -/** Pages whose every row is saved on this client; the scope selects are hidden there. */ -const DEVICE_ONLY_PATHS = new Set([ - "/settings/appearance", - "/settings/snap-shot", - "/settings/connections", -]); - function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) { const { scope, connectedEnvironments } = useSettingsScope(); const { environments } = useEnvironments(); @@ -99,16 +93,23 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child } // Device-local pages ignore the scope entirely; the project page follows // remembered members while a grouping change replaces its URL key. - if (DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") { + if (SETTINGS_DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") { return children; } + // Keep the scope sentence on screen so the selection can be changed back. if (scope.kind === "unavailable") - return

{scope.message}

; + return ( + +

{scope.message}

+
+ ); if (scope.kind === "environment" && connectedEnvironments.length === 0) { return ( -

- Reconnect {scope.label} to change its settings. -

+ +

+ Reconnect {scope.label} to change its settings. +

+
); } return children; @@ -118,11 +119,8 @@ function SettingsContentLayout() { const location = useLocation(); const navigate = useNavigate(); const canGoBack = useCanGoBack(); - const { search, selectScope } = useSettingsScope(); - const groups = useSettingsProjectGroups(); - const { environments } = useEnvironments(); + const { search } = useSettingsScope(); const [restoreSignal, setRestoreSignal] = useState(0); - const showScope = !DEVICE_ONLY_PATHS.has(location.pathname); const navigateBackWithinApp = useCallback(() => { if (canGoBack) { window.history.back(); @@ -157,14 +155,7 @@ function SettingsContentLayout() {
- + {location.pathname === "/settings/general" ? (
( key: ({ environmentId, input }) => JSON.stringify([environmentId, input]), }, }), + respondProviderAuth: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:auth-respond", + tag: WS_METHODS.providerAuthRespond, + }), completeProviderAuth: createEnvironmentRpcCommand(runtime, { label: "environment-data:provider:auth-complete", tag: WS_METHODS.providerAuthComplete, diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index ee69b5ad2aa9..31e5f9d63305 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -161,7 +161,7 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ "gpt-5.6-sol", "gpt-5.6-terra", ]; -export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; +export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-6-luna"; /** Keep the official Antigravity session's current model. Never send this ID to ACP. */ export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default"; export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; diff --git a/packages/contracts/src/providerSetup.test.ts b/packages/contracts/src/providerSetup.test.ts new file mode 100644 index 000000000000..13fb3fca1f11 --- /dev/null +++ b/packages/contracts/src/providerSetup.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { ProviderAuthResponse, ProviderAuthState } from "./providerSetup.ts"; + +const decodeResponse = Schema.decodeUnknownSync(ProviderAuthResponse); +const decodeState = Schema.decodeUnknownSync(ProviderAuthState); + +describe("provider credential responses", () => { + it("accepts the advertised field limit and rejects oversized or invalid fields", () => { + const values = Object.fromEntries( + Array.from({ length: 16 }, (_, i) => [`field_${i}`, "value"]), + ); + expect(decodeResponse({ type: "credentials", values })).toEqual({ + type: "credentials", + values, + }); + expect(() => + decodeResponse({ type: "credentials", values: { ...values, extra: "value" } }), + ).toThrow(); + expect(() => decodeResponse({ type: "credentials", values: { "": "value" } })).toThrow(); + expect(() => + decodeResponse({ type: "credentials", values: { token: "x".repeat(16_385) } }), + ).toThrow(); + }); +}); + +describe("provider auth state", () => { + it("drops auth variants from newer servers instead of rejecting the state", () => { + const method = { id: "browser", name: "Browser", description: null, type: "agent" }; + expect( + decodeState({ + instanceId: "cursor", + phase: "waiting", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + methods: [method, { ...method, id: "passkey", type: "passkey" }], + interaction: { type: "passkey", id: "passkey" }, + credentialOwner: "keychain", + }), + ).toEqual({ + instanceId: "cursor", + phase: "waiting", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + methods: [method], + }); + }); +}); diff --git a/packages/contracts/src/providerSetup.ts b/packages/contracts/src/providerSetup.ts index db6ce1f79aec..04261e934c89 100644 --- a/packages/contracts/src/providerSetup.ts +++ b/packages/contracts/src/providerSetup.ts @@ -1,6 +1,11 @@ import * as Schema from "effect/Schema"; -import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + ForwardCompatibleArray, + ForwardCompatibleOptional, + IsoDateTime, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; export const ProviderSetupInput = Schema.Struct({ @@ -10,6 +15,88 @@ export type ProviderSetupInput = typeof ProviderSetupInput.Type; const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export const ProviderAuthMethod = Schema.Struct({ + id: SetupOperationId, + name: TrimmedNonEmptyString, + description: Schema.NullOr(Schema.String), + type: Schema.Literals(["agent", "terminal", "credentials"]), +}); +export type ProviderAuthMethod = typeof ProviderAuthMethod.Type; + +// These describe client interactions, not OAuth grant types. The provider +// adapter remains responsible for credentials, callbacks, and refresh. +export const ProviderAuthInteraction = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("browser"), + id: SetupOperationId, + url: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)), + requiresConsent: Schema.Boolean, + acceptsCallback: Schema.optionalKey(Schema.Boolean), + }), + Schema.Struct({ + type: Schema.Literal("deviceCode"), + id: SetupOperationId, + url: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)), + userCode: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + }), + Schema.Struct({ + type: Schema.Literal("terminal"), + id: SetupOperationId, + output: Schema.String.check(Schema.isMaxLength(16_384)), + outputOffset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + }), + Schema.Struct({ + type: Schema.Literal("credentials"), + id: SetupOperationId, + fields: Schema.Array( + Schema.Struct({ + name: SetupOperationId, + label: TrimmedNonEmptyString, + secret: Schema.Boolean, + }), + ).check(Schema.isMaxLength(16)), + }), +]); +export type ProviderAuthInteraction = typeof ProviderAuthInteraction.Type; + +export const ProviderAuthResponse = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("browser"), + action: Schema.Literals(["accept", "decline"]), + }), + Schema.Struct({ + type: Schema.Literal("terminal"), + data: Schema.String.check(Schema.isMaxLength(4_096)), + size: Schema.optionalKey( + Schema.Struct({ + cols: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 500 })), + rows: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 200 })), + }), + ), + }), + Schema.Struct({ + type: Schema.Literal("credentials"), + values: Schema.Record(SetupOperationId, Schema.String.check(Schema.isMaxLength(16_384))).check( + Schema.isMaxProperties(16), + ), + }), +]); +export type ProviderAuthResponse = typeof ProviderAuthResponse.Type; + +export const ProviderAuthStartInput = Schema.Struct({ + instanceId: ProviderInstanceId, + methodId: Schema.optionalKey(SetupOperationId), +}); +export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type; + +export const ProviderAuthRespondInput = Schema.Struct({ + instanceId: ProviderInstanceId, + flowId: SetupOperationId, + interactionId: SetupOperationId, + response: ProviderAuthResponse, +}); +export type ProviderAuthRespondInput = typeof ProviderAuthRespondInput.Type; + export const ProviderAuthState = Schema.Struct({ instanceId: ProviderInstanceId, phase: Schema.Literals([ @@ -25,6 +112,13 @@ export const ProviderAuthState = Schema.Struct({ authorizationUrl: Schema.NullOr(Schema.String), expiresAt: Schema.NullOr(IsoDateTime), message: Schema.NullOr(Schema.String), + // Newer servers may add method types, interactions, or owners; older + // clients drop what they cannot decode instead of rejecting the state. + methods: Schema.optionalKey( + ForwardCompatibleArray(ProviderAuthMethod).check(Schema.isMaxLength(32)), + ), + interaction: ForwardCompatibleOptional(Schema.NullOr(ProviderAuthInteraction)), + credentialOwner: ForwardCompatibleOptional(Schema.Literals(["provider", "t3"])), }); export type ProviderAuthState = typeof ProviderAuthState.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 41af6d9f1e9e..dbc143048a72 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -6,6 +6,8 @@ import { ProviderAuthCancelInput, ProviderAuthCompleteInput, ProviderAuthState, + ProviderAuthStartInput, + ProviderAuthRespondInput, ProviderInstallCancelInput, ProviderInstallState, ProviderSetupError, @@ -299,6 +301,7 @@ export const WS_METHODS = { providerAuthStart: "provider.auth.start", providerConsumeResetCredit: "provider.consumeResetCredit", providerAuthComplete: "provider.auth.complete", + providerAuthRespond: "provider.auth.respond", providerAuthCancel: "provider.auth.cancel", providerAuthLogout: "provider.auth.logout", providerAuthSubscribe: "provider.auth.subscribe", @@ -476,7 +479,8 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, */ instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), - /** Explicit user request. Background status refreshes must not open agent sessions. */ + /** Explicit user request: bypass T3-owned caches and rediscover models. + * Background status refreshes must not open agent sessions. */ refreshModels: Schema.optional(Schema.Boolean), }), success: ServerProviderUpdatedPayload, @@ -498,7 +502,13 @@ const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeReset }); const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { - payload: ProviderSetupInput, + payload: ProviderAuthStartInput, + success: ProviderAuthState, + error: ProviderSetupRpcError, +}); + +const WsProviderAuthRespondRpc = Rpc.make(WS_METHODS.providerAuthRespond, { + payload: ProviderAuthRespondInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); @@ -1389,6 +1399,7 @@ export const WsRpcGroup = RpcGroup.make( WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, WsProviderAuthCompleteRpc, + WsProviderAuthRespondRpc, WsProviderAuthCancelRpc, WsProviderAuthLogoutRpc, WsProviderAuthSubscribeRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 692edfb18a65..16b9418ab7cc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -690,14 +690,6 @@ describe("ClientSettings pull request merge methods", () => { }); describe("ServerSettings.providerInstances (slice-2 invariant)", () => { - it("defaults text generation to Luna at low reasoning effort", () => { - expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.6-luna", - options: [{ id: "reasoningEffort", value: "low" }], - }); - }); - it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); }); diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 142d6922f0c4..668d8bcc2723 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -273,6 +273,29 @@ describe("mergeUsage", () => { ]); }); + it("orders models by cost descending", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ provider: "claude", model: "lower-cost", costUsd: 4 }), + bucket({ provider: "codex", model: "higher-cost", costUsd: 9 }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.models.map((model) => model.model)).toEqual(["higher-cost", "lower-cost"]); + }); + it("keeps two machines apart when hostname and home path collide", () => { // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make // one machine's usage vanish. Filesystem identity separates them. diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 980107d19a82..a3c049323bce 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -227,6 +227,7 @@ describe("ssh tunnel scripts", () => { assert.include(script, "remote_node_satisfies_engine()"); assert.include(script, "function satisfiesSemverRange"); assert.include(script, "satisfiesSemverRange(rawVersion, range)"); + assert.include(script, 'prepend_path_if_dir "/home/linuxbrew/.linuxbrew/bin"'); assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 135cb7decae9..0473ffeceae2 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -360,6 +360,7 @@ ensure_remote_node_path() { prepend_path_if_dir "$HOME/.local/bin" prepend_path_if_dir "$HOME/bin" prepend_path_if_dir "/opt/homebrew/bin" + prepend_path_if_dir "/home/linuxbrew/.linuxbrew/bin" prepend_path_if_dir "/usr/local/bin" prepend_path_if_dir "/usr/bin" prepend_path_if_dir "/bin" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d59982535f9c..0bc636397f0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,9 +248,6 @@ importers: '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 - '@expo/metro-runtime': - specifier: ~57.0.14 - version: 57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/ui': specifier: ~57.0.14 version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -13246,6 +13243,7 @@ snapshots: whatwg-fetch: 3.6.20 optionalDependencies: react-dom: 19.2.3(react@19.2.3) + optional: true '@expo/metro-runtime@57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index e33116bb1dbf..96af9d410280 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -17,7 +17,6 @@ const workspaceFiles = [ "apps/desktop/package.json", "apps/web/package.json", "apps/mobile/package.json", - "apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz", "apps/mobile/modules/t3-markdown-text/package.json", "apps/mobile/modules/t3-review-diff/package.json", "apps/mobile/modules/t3-terminal/package.json",