| author | |
| committer | |
| log | 5821b778fd29e9c5b6945f4e7150f92d19a0a3da |
| tree | deebcbb3e314a4b9da753bf199b2e91db5e2d03c |
| parent | 5d493d67cedc3401af42e77d6a238bc49204a09b |
| signature | Signed by PGP key B5690EEEBB952194 |
37 files changed, 569 insertions(+), 235 deletions(-)
apps/mobile/src/components/ConfirmDialog.types.ts created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | export type ConfirmDialogRequest = { | ||
| 2 | readonly title: string; | ||
| 3 | readonly message?: string; | ||
| 4 | readonly cancelText?: string; | ||
| 5 | readonly confirmText: string; | ||
| 6 | readonly destructive?: boolean; | ||
| 7 | readonly onConfirm: () => void; | ||
| 8 | readonly onCancel?: () => void; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export type TextInputDialogRequest = { | ||
| 12 | readonly title: string; | ||
| 13 | readonly initialValue: string; | ||
| 14 | readonly cancelText?: string; | ||
| 15 | readonly confirmText: string; | ||
| 16 | readonly onConfirm: (value: string) => void; | ||
| 17 | readonly onCancel?: () => void; | ||
| 18 | }; | ||
apps/mobile/src/components/ConfirmDialogHost.tsx+2-18| ... | @@ -4,25 +4,9 @@ import { Platform, Modal, Pressable, TextInput, View } from "react-native"; | ... | @@ -4,25 +4,9 @@ import { Platform, Modal, Pressable, TextInput, View } from "react-native"; |
| 4 | import { cn } from "../lib/cn"; | 4 | import { cn } from "../lib/cn"; |
| 5 | import { AppText } from "./AppText"; | 5 | import { AppText } from "./AppText"; |
| 6 | import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; | 6 | import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; |
| 7 | import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; | ||
| 7 | 8 | ||
| 8 | export type ConfirmDialogRequest = { | 9 | export type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; |
| 9 | readonly title: string; | ||
| 10 | readonly message?: string; | ||
| 11 | readonly cancelText?: string; | ||
| 12 | readonly confirmText: string; | ||
| 13 | readonly destructive?: boolean; | ||
| 14 | readonly onConfirm: () => void; | ||
| 15 | readonly onCancel?: () => void; | ||
| 16 | }; | ||
| 17 | |||
| 18 | export type TextInputDialogRequest = { | ||
| 19 | readonly title: string; | ||
| 20 | readonly initialValue: string; | ||
| 21 | readonly cancelText?: string; | ||
| 22 | readonly confirmText: string; | ||
| 23 | readonly onConfirm: (value: string) => void; | ||
| 24 | readonly onCancel?: () => void; | ||
| 25 | }; | ||
| 26 | 10 | ||
| 27 | type DialogRequest = | 11 | type DialogRequest = |
| 28 | | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } | 12 | | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } |
apps/mobile/src/components/FilePreview.ios.tsx+1-1| ... | @@ -2,7 +2,7 @@ import { requireNativeModule } from "expo"; | ... | @@ -2,7 +2,7 @@ import { requireNativeModule } from "expo"; |
| 2 | import { useEffect, useEffectEvent, useId } from "react"; | 2 | import { useEffect, useEffectEvent, useId } from "react"; |
| 3 | import { Alert } from "react-native"; | 3 | import { Alert } from "react-native"; |
| 4 | 4 | ||
| 5 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; | 5 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; |
| 6 | 6 | ||
| 7 | const NativeControls = requireNativeModule<{ | 7 | const NativeControls = requireNativeModule<{ |
| 8 | presentFile( | 8 | presentFile( |
apps/mobile/src/components/FilePreview.tsx+1-1| ... | @@ -3,7 +3,7 @@ import { Alert, Modal, Pressable, View } from "react-native"; | ... | @@ -3,7 +3,7 @@ import { Alert, Modal, Pressable, View } from "react-native"; |
| 3 | import ImageViewing from "react-native-image-viewing"; | 3 | import ImageViewing from "react-native-image-viewing"; |
| 4 | 4 | ||
| 5 | import { openAttachmentInViewer } from "../lib/attachmentDownload"; | 5 | import { openAttachmentInViewer } from "../lib/attachmentDownload"; |
| 6 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; | 6 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; |
| 7 | import { MediaImagePreview } from "./MediaImagePreview"; | 7 | import { MediaImagePreview } from "./MediaImagePreview"; |
| 8 | import { AppText as Text } from "./AppText"; | 8 | import { AppText as Text } from "./AppText"; |
| 9 | 9 |
apps/mobile/src/components/FilePreviewModal.tsx+2-19| ... | @@ -1,30 +1,13 @@ | ... | @@ -1,30 +1,13 @@ |
| 1 | import { useIsFocused } from "@react-navigation/native"; | 1 | import { useIsFocused } from "@react-navigation/native"; |
| 2 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { useEffect, useEffectEvent, useState } from "react"; | 2 | import { useEffect, useEffectEvent, useState } from "react"; |
| 4 | import { Alert, Keyboard } from "react-native"; | 3 | import { Alert, Keyboard } from "react-native"; |
| 5 | 4 | ||
| 6 | import type { FileBackedComposerAttachment } from "../lib/composerImages"; | ||
| 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | 5 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | import type { MediaActionsSource } from "../lib/mediaActions"; | ||
| 9 | import { useRefreshAssetUrl } from "../state/assets"; | 6 | import { useRefreshAssetUrl } from "../state/assets"; |
| 10 | import { FilePreview } from "./FilePreview"; | 7 | import { FilePreview } from "./FilePreview"; |
| 8 | import type { FilePreviewSource } from "./FilePreviewModal.types"; | ||
| 11 | 9 | ||
| 12 | export interface ResolvedFilePreviewSource { | 10 | export type { FilePreviewSource, ResolvedFilePreviewSource } from "./FilePreviewModal.types"; |
| 13 | readonly kind: "image" | "pdf" | "document"; | ||
| 14 | readonly mimeType?: string; | ||
| 15 | readonly uri: string; | ||
| 16 | readonly name?: string; | ||
| 17 | readonly sourceIdentifier?: string; | ||
| 18 | readonly srcFragment?: string; | ||
| 19 | readonly actionsSource?: MediaActionsSource; | ||
| 20 | } | ||
| 21 | |||
| 22 | export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> & | ||
| 23 | ( | ||
| 24 | | { readonly uri: string } | ||
| 25 | | { readonly attachment: FileBackedComposerAttachment } | ||
| 26 | | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } | ||
| 27 | ); | ||
| 28 | 11 | ||
| 29 | function ResolvedFilePreview(props: { | 12 | function ResolvedFilePreview(props: { |
| 30 | readonly source: FilePreviewSource; | 13 | readonly source: FilePreviewSource; |
apps/mobile/src/components/FilePreviewModal.types.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import type { FileBackedComposerAttachment } from "../lib/composerImages"; | ||
| 4 | import type { MediaActionsSource } from "../lib/mediaActions"; | ||
| 5 | |||
| 6 | export interface ResolvedFilePreviewSource { | ||
| 7 | readonly kind: "image" | "pdf" | "document"; | ||
| 8 | readonly mimeType?: string; | ||
| 9 | readonly uri: string; | ||
| 10 | readonly name?: string; | ||
| 11 | readonly sourceIdentifier?: string; | ||
| 12 | readonly srcFragment?: string; | ||
| 13 | readonly actionsSource?: MediaActionsSource; | ||
| 14 | } | ||
| 15 | |||
| 16 | export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> & | ||
| 17 | ( | ||
| 18 | | { readonly uri: string } | ||
| 19 | | { readonly attachment: FileBackedComposerAttachment } | ||
| 20 | | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } | ||
| 21 | ); | ||
apps/mobile/src/components/MaterialConfirmDialog.tsx+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; | 1 | import type { ConfirmDialogRequest } from "./ConfirmDialog.types"; |
| 2 | 2 | ||
| 3 | export interface MaterialConfirmDialogProps { | 3 | export interface MaterialConfirmDialogProps { |
| 4 | readonly request: Pick< | 4 | readonly request: Pick< |
apps/mobile/src/components/MaterialSegmentedButtons.android.tsx+1-1| ... | @@ -2,7 +2,7 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/ | ... | @@ -2,7 +2,7 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/ |
| 2 | import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; | 2 | import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; |
| 3 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | 3 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 4 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; | 4 | import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; |
| 5 | import type { SegmentedControlProps } from "./SegmentedControl"; | 5 | import type { SegmentedControlProps } from "./SegmentedControl.types"; |
| 6 | 6 | ||
| 7 | /** Compose content shared by screen controls and native dialogs, inside their existing Host. */ | 7 | /** Compose content shared by screen controls and native dialogs, inside their existing Host. */ |
| 8 | export function MaterialSegmentedButtons<Value extends number | string>( | 8 | export function MaterialSegmentedButtons<Value extends number | string>( |
apps/mobile/src/components/MaterialSegmentedControl.android.tsx+1-1| ... | @@ -3,7 +3,7 @@ import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; | ... | @@ -3,7 +3,7 @@ import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; |
| 3 | import { View } from "react-native"; | 3 | import { View } from "react-native"; |
| 4 | 4 | ||
| 5 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | 5 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 6 | import type { SegmentedControlProps } from "./SegmentedControl"; | 6 | import type { SegmentedControlProps } from "./SegmentedControl.types"; |
| 7 | 7 | ||
| 8 | export function MaterialSegmentedControl<Value extends number | string>( | 8 | export function MaterialSegmentedControl<Value extends number | string>( |
| 9 | props: SegmentedControlProps<Value>, | 9 | props: SegmentedControlProps<Value>, |
apps/mobile/src/components/MaterialSegmentedControl.tsx+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { SegmentedControlProps } from "./SegmentedControl"; | 1 | import type { SegmentedControlProps } from "./SegmentedControl.types"; |
| 2 | 2 | ||
| 3 | export function MaterialSegmentedControl<Value extends number | string>( | 3 | export function MaterialSegmentedControl<Value extends number | string>( |
| 4 | _props: SegmentedControlProps<Value>, | 4 | _props: SegmentedControlProps<Value>, |
apps/mobile/src/components/MaterialSwitch.android.tsx+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; | 1 | import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; |
| 2 | import { View } from "react-native"; | 2 | import { View } from "react-native"; |
| 3 | import type { ThemedSwitchProps } from "./ThemedSwitch"; | 3 | import type { ThemedSwitchProps } from "./MaterialSwitch.types"; |
| 4 | 4 | ||
| 5 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | 5 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 6 | 6 |
apps/mobile/src/components/MaterialSwitch.types.ts created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import type { SwitchProps } from "react-native"; | ||
| 2 | |||
| 3 | export type ThemedSwitchProps = Pick< | ||
| 4 | SwitchProps, | ||
| 5 | | "accessibilityHint" | ||
| 6 | | "accessibilityLabel" | ||
| 7 | | "disabled" | ||
| 8 | | "onValueChange" | ||
| 9 | | "style" | ||
| 10 | | "testID" | ||
| 11 | | "value" | ||
| 12 | >; | ||
apps/mobile/src/components/MediaImagePreview.tsx+1-1| ... | @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | import { useMediaActions } from "../lib/mediaActions"; | 6 | import { useMediaActions } from "../lib/mediaActions"; |
| 7 | import { AppText } from "./AppText"; | 7 | import { AppText } from "./AppText"; |
| 8 | import { SymbolView } from "./AppSymbol"; | 8 | import { SymbolView } from "./AppSymbol"; |
| 9 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; | 9 | import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; |
| 10 | import { MediaActionsMenu } from "./MediaActionsMenu"; | 10 | import { MediaActionsMenu } from "./MediaActionsMenu"; |
| 11 | import { MediaSourceCaption } from "./MediaSourceCaption"; | 11 | import { MediaSourceCaption } from "./MediaSourceCaption"; |
| 12 | 12 |
apps/mobile/src/components/SegmentedControl.tsx+2-14| ... | @@ -3,21 +3,9 @@ import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-r | ... | @@ -3,21 +3,9 @@ import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-r |
| 3 | import { AppText as Text } from "./AppText"; | 3 | import { AppText as Text } from "./AppText"; |
| 4 | import { cn } from "../lib/cn"; | 4 | import { cn } from "../lib/cn"; |
| 5 | import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; | 5 | import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; |
| 6 | import type { SegmentedControlProps } from "./SegmentedControl.types"; | ||
| 6 | 7 | ||
| 7 | export interface SegmentedControlProps<Value extends number | string> { | 8 | export type { SegmentedControlProps } from "./SegmentedControl.types"; |
| 8 | readonly options: readonly { | ||
| 9 | readonly value: Value; | ||
| 10 | readonly label: string; | ||
| 11 | readonly accessibilityLabel?: string; | ||
| 12 | }[]; | ||
| 13 | readonly selected: Value; | ||
| 14 | readonly onSelect: (value: Value) => void; | ||
| 15 | /** Compact sizing applies to the non-Material control. */ | ||
| 16 | readonly size?: "default" | "compact"; | ||
| 17 | /** "tab" for the view switcher; filters stay plain buttons. */ | ||
| 18 | readonly role?: "tab" | "button"; | ||
| 19 | readonly className?: string; | ||
| 20 | } | ||
| 21 | 9 | ||
| 22 | export function SegmentedControl<Value extends number | string>( | 10 | export function SegmentedControl<Value extends number | string>( |
| 23 | props: SegmentedControlProps<Value>, | 11 | props: SegmentedControlProps<Value>, |
apps/mobile/src/components/SegmentedControl.types.ts created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | export interface SegmentedControlProps<Value extends number | string> { | ||
| 2 | readonly options: readonly { | ||
| 3 | readonly value: Value; | ||
| 4 | readonly label: string; | ||
| 5 | readonly accessibilityLabel?: string; | ||
| 6 | }[]; | ||
| 7 | readonly selected: Value; | ||
| 8 | readonly onSelect: (value: Value) => void; | ||
| 9 | /** Compact sizing applies to the non-Material control. */ | ||
| 10 | readonly size?: "default" | "compact"; | ||
| 11 | /** "tab" for the view switcher; filters stay plain buttons. */ | ||
| 12 | readonly role?: "tab" | "button"; | ||
| 13 | readonly className?: string; | ||
| 14 | } | ||
apps/mobile/src/components/ThemedSwitch.tsx+3-11| ... | @@ -1,17 +1,9 @@ | ... | @@ -1,17 +1,9 @@ |
| 1 | import { Platform, Switch, type SwitchProps } from "react-native"; | 1 | import { Platform, Switch } from "react-native"; |
| 2 | 2 | ||
| 3 | import { MaterialSwitch } from "./MaterialSwitch"; | 3 | import { MaterialSwitch } from "./MaterialSwitch"; |
| 4 | import type { ThemedSwitchProps } from "./MaterialSwitch.types"; | ||
| 4 | 5 | ||
| 5 | export type ThemedSwitchProps = Pick< | 6 | export type { ThemedSwitchProps } from "./MaterialSwitch.types"; |
| 6 | SwitchProps, | ||
| 7 | | "accessibilityHint" | ||
| 8 | | "accessibilityLabel" | ||
| 9 | | "disabled" | ||
| 10 | | "onValueChange" | ||
| 11 | | "style" | ||
| 12 | | "testID" | ||
| 13 | | "value" | ||
| 14 | >; | ||
| 15 | 7 | ||
| 16 | export function ThemedSwitch(props: ThemedSwitchProps) { | 8 | export function ThemedSwitch(props: ThemedSwitchProps) { |
| 17 | if (Platform.OS === "android") { | 9 | if (Platform.OS === "android") { |
apps/mobile/src/dependency-graph.test.ts created+288| ... | @@ -0,0 +1,288 @@ | ||
| 1 | import * as NodeFS from "node:fs"; | ||
| 2 | import * as NodePath from "node:path"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Dependency-graph guards for the mobile source tree (audit #13). | ||
| 7 | * | ||
| 8 | * 1. No circular imports, checked once per platform the way Metro resolves | ||
| 9 | * modules (`<name>.<platform>.*` before `<name>.native.*` before the | ||
| 10 | * generic file). A cycle can hide behind platform resolution: a `.tsx` | ||
| 11 | * base importing a component whose `.android.tsx` variant type-imports | ||
| 12 | * back into the base is acyclic on iOS but circular on Android. | ||
| 13 | * Dynamic `import("...")` calls are excluded from this rule on purpose: | ||
| 14 | * they are the deliberate async escape hatch (e.g. composer-draft cleanup | ||
| 15 | * reaching `lib/attachmentUpload`), and Metro resolves them after both | ||
| 16 | * modules have initialized, so they cannot create an initialization cycle. | ||
| 17 | * | ||
| 18 | * 2. Cross-layer edges are ceilinged, not yet banned. `state`, `lib`, | ||
| 19 | * `native`, and `components` still reach upward into `features` at known | ||
| 20 | * sites (the app composition root `lib/runtime.ts` legitimately wires | ||
| 21 | * feature layers). Ceilings count unique `from -> to` module pairs across | ||
| 22 | * all platforms, including dynamic imports, and may only shrink: when you | ||
| 23 | * remove one of these imports, lower the constant in the same PR. | ||
| 24 | * | ||
| 25 | * Runs in CI as part of the `Test` job (`vp run --filter '!t3' test` picks up | ||
| 26 | * the `@t3tools/mobile` package test task). | ||
| 27 | */ | ||
| 28 | |||
| 29 | const SOURCE_ROOT = __dirname; | ||
| 30 | |||
| 31 | /** Metro candidate order per platform (platform, then native, then generic). */ | ||
| 32 | const PLATFORM_EXTENSION_ORDER = { | ||
| 33 | android: [".android.ts", ".android.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], | ||
| 34 | ios: [".ios.ts", ".ios.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], | ||
| 35 | } as const; | ||
| 36 | |||
| 37 | type Platform = keyof typeof PLATFORM_EXTENSION_ORDER; | ||
| 38 | |||
| 39 | const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray<Platform>; | ||
| 40 | |||
| 41 | const isGraphFile = (filePath: string): boolean => | ||
| 42 | /\.tsx?$/.test(filePath) && | ||
| 43 | !filePath.includes(".test.") && | ||
| 44 | !filePath.includes("test-support") && | ||
| 45 | !filePath.endsWith(".d.ts"); | ||
| 46 | |||
| 47 | /** Files Metro would not even bundle for the other platform. */ | ||
| 48 | function isRelevantForPlatform(filePath: string, platform: Platform): boolean { | ||
| 49 | const name = NodePath.basename(filePath); | ||
| 50 | if (platform === "android") { | ||
| 51 | return !name.includes(".ios."); | ||
| 52 | } | ||
| 53 | return !name.includes(".android."); | ||
| 54 | } | ||
| 55 | |||
| 56 | function collectSourceFiles(dir: string, out: string[] = []): string[] { | ||
| 57 | for (const entry of NodeFS.readdirSync(dir)) { | ||
| 58 | const filePath = NodePath.join(dir, entry); | ||
| 59 | if (NodeFS.statSync(filePath).isDirectory()) { | ||
| 60 | collectSourceFiles(filePath, out); | ||
| 61 | } else if (isGraphFile(filePath)) { | ||
| 62 | out.push(filePath); | ||
| 63 | } | ||
| 64 | } | ||
| 65 | return out; | ||
| 66 | } | ||
| 67 | |||
| 68 | function resolveRelative( | ||
| 69 | fromFile: string, | ||
| 70 | specifier: string, | ||
| 71 | extensionOrder: ReadonlyArray<string>, | ||
| 72 | ): string | null { | ||
| 73 | if (!specifier.startsWith(".")) { | ||
| 74 | return null; | ||
| 75 | } | ||
| 76 | const base = NodePath.resolve(NodePath.dirname(fromFile), specifier); | ||
| 77 | for (const ext of extensionOrder) { | ||
| 78 | for (const candidate of [base + ext, NodePath.join(base, `index${ext}`)]) { | ||
| 79 | try { | ||
| 80 | if (NodeFS.statSync(candidate).isFile()) { | ||
| 81 | const resolved = NodePath.resolve(candidate); | ||
| 82 | return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null; | ||
| 83 | } | ||
| 84 | } catch { | ||
| 85 | // Candidate does not exist; try the next one. | ||
| 86 | } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | return null; | ||
| 90 | } | ||
| 91 | |||
| 92 | interface ParsedImport { | ||
| 93 | readonly specifier: string; | ||
| 94 | readonly isDynamic: boolean; | ||
| 95 | } | ||
| 96 | |||
| 97 | function parseImports(source: string): ParsedImport[] { | ||
| 98 | const parsed: ParsedImport[] = []; | ||
| 99 | const staticRe = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g; | ||
| 100 | const bareRe = /(?:^|\n)\s*import\s+["']([^"']+)["']/g; | ||
| 101 | const dynamicRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; | ||
| 102 | for (const match of source.matchAll(staticRe)) { | ||
| 103 | parsed.push({ specifier: match[1]!, isDynamic: false }); | ||
| 104 | } | ||
| 105 | for (const match of source.matchAll(bareRe)) { | ||
| 106 | parsed.push({ specifier: match[1]!, isDynamic: false }); | ||
| 107 | } | ||
| 108 | for (const match of source.matchAll(dynamicRe)) { | ||
| 109 | parsed.push({ specifier: match[1]!, isDynamic: true }); | ||
| 110 | } | ||
| 111 | return parsed; | ||
| 112 | } | ||
| 113 | |||
| 114 | type Layer = "state" | "lib" | "components" | "native" | "features" | "other"; | ||
| 115 | |||
| 116 | function layerOf(relativePath: string): Layer { | ||
| 117 | const top = relativePath.split(NodePath.sep)[0]!; | ||
| 118 | if (top === "features") return "features"; | ||
| 119 | return top === "state" || top === "lib" || top === "components" || top === "native" | ||
| 120 | ? (top as Layer) | ||
| 121 | : "other"; | ||
| 122 | } | ||
| 123 | |||
| 124 | interface PlatformGraph { | ||
| 125 | readonly platform: Platform; | ||
| 126 | readonly files: ReadonlyArray<string>; | ||
| 127 | /** Static (type or value) import edges keyed by source file. */ | ||
| 128 | readonly staticEdges: ReadonlyMap<string, ReadonlyArray<string>>; | ||
| 129 | /** Unique cross-layer edges, static and dynamic, as "fromRel -> toRel". */ | ||
| 130 | readonly crossLayerEdges: ReadonlySet<string>; | ||
| 131 | } | ||
| 132 | |||
| 133 | function buildPlatformGraph(platform: Platform): PlatformGraph { | ||
| 134 | const extensionOrder = PLATFORM_EXTENSION_ORDER[platform]; | ||
| 135 | const files = collectSourceFiles(SOURCE_ROOT) | ||
| 136 | .filter((file) => isRelevantForPlatform(file, platform)) | ||
| 137 | .sort(); | ||
| 138 | const staticEdges = new Map<string, string[]>(); | ||
| 139 | const crossLayerEdges = new Set<string>(); | ||
| 140 | for (const file of files) { | ||
| 141 | const targets = new Set<string>(); | ||
| 142 | for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) { | ||
| 143 | const resolved = resolveRelative(file, specifier, extensionOrder); | ||
| 144 | if (resolved === null || resolved === file) { | ||
| 145 | continue; | ||
| 146 | } | ||
| 147 | const from = NodePath.relative(SOURCE_ROOT, file); | ||
| 148 | const to = NodePath.relative(SOURCE_ROOT, resolved); | ||
| 149 | const fromLayer = layerOf(from); | ||
| 150 | const toLayer = layerOf(to); | ||
| 151 | const upward = | ||
| 152 | (fromLayer === "state" || | ||
| 153 | fromLayer === "lib" || | ||
| 154 | fromLayer === "components" || | ||
| 155 | fromLayer === "native") && | ||
| 156 | (toLayer === "features" || (fromLayer === "lib" && toLayer === "state")); | ||
| 157 | if (upward) { | ||
| 158 | crossLayerEdges.add(`${from} -> ${to}`); | ||
| 159 | } | ||
| 160 | if (!isDynamic) { | ||
| 161 | targets.add(resolved); | ||
| 162 | } | ||
| 163 | } | ||
| 164 | staticEdges.set(file, [...targets]); | ||
| 165 | } | ||
| 166 | return { platform, files, staticEdges, crossLayerEdges }; | ||
| 167 | } | ||
| 168 | |||
| 169 | /** Tarjan strongly-connected components, iterative to bound stack depth. */ | ||
| 170 | function findCycles(graph: PlatformGraph): ReadonlyArray<ReadonlyArray<string>> { | ||
| 171 | const index = new Map<string, number>(); | ||
| 172 | const low = new Map<string, number>(); | ||
| 173 | const onStack = new Set<string>(); | ||
| 174 | const stack: string[] = []; | ||
| 175 | const cycles: string[][] = []; | ||
| 176 | let nextIndex = 0; | ||
| 177 | |||
| 178 | const enter = (node: string): void => { | ||
| 179 | index.set(node, nextIndex); | ||
| 180 | low.set(node, nextIndex); | ||
| 181 | nextIndex += 1; | ||
| 182 | stack.push(node); | ||
| 183 | onStack.add(node); | ||
| 184 | }; | ||
| 185 | |||
| 186 | for (const root of graph.files) { | ||
| 187 | if (index.has(root)) continue; | ||
| 188 | const work: Array<[string, number]> = [[root, 0]]; | ||
| 189 | enter(root); | ||
| 190 | while (work.length > 0) { | ||
| 191 | const frame = work[work.length - 1]!; | ||
| 192 | const neighbors = graph.staticEdges.get(frame[0]) ?? []; | ||
| 193 | let advanced = false; | ||
| 194 | for (let i = frame[1]; i < neighbors.length; i += 1) { | ||
| 195 | const child = neighbors[i]!; | ||
| 196 | if (!graph.staticEdges.has(child)) continue; | ||
| 197 | if (!index.has(child)) { | ||
| 198 | work[work.length - 1] = [frame[0], i + 1]; | ||
| 199 | work.push([child, 0]); | ||
| 200 | enter(child); | ||
| 201 | advanced = true; | ||
| 202 | break; | ||
| 203 | } else if (onStack.has(child)) { | ||
| 204 | low.set(frame[0], Math.min(low.get(frame[0])!, index.get(child)!)); | ||
| 205 | } | ||
| 206 | } | ||
| 207 | if (advanced) continue; | ||
| 208 | work.pop(); | ||
| 209 | const parent = work[work.length - 1]; | ||
| 210 | if (parent) { | ||
| 211 | low.set(parent[0], Math.min(low.get(parent[0])!, low.get(frame[0])!)); | ||
| 212 | } | ||
| 213 | if (low.get(frame[0]) === index.get(frame[0])) { | ||
| 214 | const component: string[] = []; | ||
| 215 | let member: string; | ||
| 216 | do { | ||
| 217 | member = stack.pop()!; | ||
| 218 | onStack.delete(member); | ||
| 219 | component.push(member); | ||
| 220 | } while (member !== frame[0]); | ||
| 221 | if (component.length > 1) { | ||
| 222 | cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file))); | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
| 226 | } | ||
| 227 | return cycles; | ||
| 228 | } | ||
| 229 | |||
| 230 | const graphs = PLATFORMS.map(buildPlatformGraph); | ||
| 231 | |||
| 232 | /** Unique upward edge pairs across every platform, sorted for stable diffs. */ | ||
| 233 | function upwardEdges(): string[] { | ||
| 234 | const union = new Set<string>(); | ||
| 235 | for (const graph of graphs) { | ||
| 236 | for (const edge of graph.crossLayerEdges) { | ||
| 237 | union.add(edge); | ||
| 238 | } | ||
| 239 | } | ||
| 240 | return [...union].sort(); | ||
| 241 | } | ||
| 242 | |||
| 243 | describe("mobile dependency graph", () => { | ||
| 244 | it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => { | ||
| 245 | const graph = graphs.find((candidate) => candidate.platform === platform)!; | ||
| 246 | // The graph must see real files; a resolution regression here would make | ||
| 247 | // both rules vacuously pass. | ||
| 248 | expect(graph.files.length).toBeGreaterThan(500); | ||
| 249 | expect(findCycles(graph)).toEqual([]); | ||
| 250 | }); | ||
| 251 | |||
| 252 | it("keeps upward imports from state/lib/components/native into features at the ceiling", () => { | ||
| 253 | const edges = upwardEdges(); | ||
| 254 | const edgesFor = (from: Layer, to: Layer): string[] => | ||
| 255 | edges.filter((edge) => { | ||
| 256 | const [source, target] = edge.split(" -> "); | ||
| 257 | return layerOf(source!) === from && layerOf(target!) === to; | ||
| 258 | }); | ||
| 259 | |||
| 260 | const ceilings: ReadonlyArray<readonly [Layer, Layer, number, string]> = [ | ||
| 261 | // state -> features: thread ordering reaching the thread-list model, | ||
| 262 | // the incoming-share store, the connection controller hook, the | ||
| 263 | // terminal launch context, and the pending message feed. | ||
| 264 | // (legacy-plan-mode was pure model logic and moved into state/.) | ||
| 265 | ["state", "features", 6, "state must not add imports from features"], | ||
| 266 | // lib -> features: lib/runtime.ts is the app composition root and | ||
| 267 | // legitimately wires cloud/observability features; the appearance | ||
| 268 | // helpers and terminal preferences still need untangling. | ||
| 269 | ["lib", "features", 7, "lib must not add imports from features"], | ||
| 270 | // components -> features: mostly the appearance preferences provider | ||
| 271 | // and the layout toolbar bridges. | ||
| 272 | ["components", "features", 33, "components must not add imports from features"], | ||
| 273 | // native -> features: native glue reading appearance/keyboard/review features. | ||
| 274 | ["native", "features", 8, "native must not add imports from features"], | ||
| 275 | // lib -> state: attachment/session plumbing that predates the cycle | ||
| 276 | // cleanup; each remaining edge needs a real owner-side seam. | ||
| 277 | ["lib", "state", 11, "lib must not add imports from state"], | ||
| 278 | ]; | ||
| 279 | |||
| 280 | for (const [from, to, ceiling, message] of ceilings) { | ||
| 281 | const layerEdges = edgesFor(from, to); | ||
| 282 | expect( | ||
| 283 | layerEdges.length, | ||
| 284 | `${message}. ${layerEdges.length} edges remain:\n${layerEdges.join("\n")}`, | ||
| 285 | ).toBeLessThanOrEqual(ceiling); | ||
| 286 | } | ||
| 287 | }); | ||
| 288 | }); | ||
apps/mobile/src/features/review/reviewDiffRendering.tsx+1-1| ... | @@ -4,7 +4,7 @@ import { cn } from "../../lib/cn"; | ... | @@ -4,7 +4,7 @@ import { cn } from "../../lib/cn"; |
| 4 | import { MOBILE_CODE_SURFACE } from "../../lib/typography"; | 4 | import { MOBILE_CODE_SURFACE } from "../../lib/typography"; |
| 5 | 5 | ||
| 6 | import type { ReviewRenderableLineRow } from "./reviewModel"; | 6 | import type { ReviewRenderableLineRow } from "./reviewModel"; |
| 7 | import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; | 7 | import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; |
| 8 | 8 | ||
| 9 | export const REVIEW_MONO_FONT_FAMILY = Platform.select({ | 9 | export const REVIEW_MONO_FONT_FAMILY = Platform.select({ |
| 10 | ios: "ui-monospace", | 10 | ios: "ui-monospace", |
apps/mobile/src/features/review/reviewHighlightedToken.types.ts created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | export interface ReviewHighlightedToken { | ||
| 2 | content: string; | ||
| 3 | readonly color: string | null; | ||
| 4 | readonly fontStyle: number | null; | ||
| 5 | readonly diffHighlight?: boolean; | ||
| 6 | } | ||
apps/mobile/src/features/review/reviewWordDiffs.ts+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { diffWordsWithSpace } from "diff"; | 1 | import { diffWordsWithSpace } from "diff"; |
| 2 | 2 | ||
| 3 | import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; | 3 | import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; |
| 4 | 4 | ||
| 5 | interface ReviewDiffOperation { | 5 | interface ReviewDiffOperation { |
| 6 | readonly value: string; | 6 | readonly value: string; |
apps/mobile/src/features/review/shikiReviewHighlighter.ts+3-6| ... | @@ -35,12 +35,9 @@ export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErr | ... | @@ -35,12 +35,9 @@ export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErr |
| 35 | } | 35 | } |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | export interface ReviewHighlightedToken { | 38 | import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; |
| 39 | content: string; | 39 | |
| 40 | readonly color: string | null; | 40 | export type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; |
| 41 | readonly fontStyle: number | null; | ||
| 42 | readonly diffHighlight?: boolean; | ||
| 43 | } | ||
| 44 | 41 | ||
| 45 | const SHIKI_THEME_NAME_BY_SCHEME = { | 42 | const SHIKI_THEME_NAME_BY_SCHEME = { |
| 46 | light: "github-light-default", | 43 | light: "github-light-default", |
apps/mobile/src/features/threads/legacy-plan-mode.test.ts deleted-92| ... | @@ -1,92 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | resolvePendingTaskInteractionMode, | ||
| 5 | resolveProviderInteractionMode, | ||
| 6 | } from "./legacy-plan-mode"; | ||
| 7 | |||
| 8 | describe("resolveProviderInteractionMode", () => { | ||
| 9 | it("clears saved plan mode when the provider cannot use T3 interaction modes", () => { | ||
| 10 | expect(resolveProviderInteractionMode({ showInteractionModeToggle: false }, "plan")).toBe( | ||
| 11 | "default", | ||
| 12 | ); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("keeps supported choices and remains compatible with older server status", () => { | ||
| 16 | expect(resolveProviderInteractionMode({ showInteractionModeToggle: true }, "plan")).toBe( | ||
| 17 | "plan", | ||
| 18 | ); | ||
| 19 | expect(resolveProviderInteractionMode({}, "plan")).toBe("plan"); | ||
| 20 | expect(resolveProviderInteractionMode(null, "plan")).toBe("plan"); | ||
| 21 | expect(resolveProviderInteractionMode(undefined, undefined)).toBe("default"); | ||
| 22 | }); | ||
| 23 | }); | ||
| 24 | |||
| 25 | describe("resolvePendingTaskInteractionMode", () => { | ||
| 26 | it.each([false, true])( | ||
| 27 | "clears a queued unsupported plan mode with preferenceLoaded=%s", | ||
| 28 | (preferenceLoaded) => { | ||
| 29 | expect( | ||
| 30 | resolvePendingTaskInteractionMode({ | ||
| 31 | preferenceLoaded, | ||
| 32 | planModeEnabled: true, | ||
| 33 | draftInteractionMode: "plan", | ||
| 34 | queuedInteractionMode: "plan", | ||
| 35 | provider: { showInteractionModeToggle: false }, | ||
| 36 | }), | ||
| 37 | ).toBe("default"); | ||
| 38 | }, | ||
| 39 | ); | ||
| 40 | |||
| 41 | it("preserves a queued plan task while the preference is still loading", () => { | ||
| 42 | expect( | ||
| 43 | resolvePendingTaskInteractionMode({ | ||
| 44 | preferenceLoaded: false, | ||
| 45 | planModeEnabled: false, | ||
| 46 | draftInteractionMode: "plan", | ||
| 47 | queuedInteractionMode: "plan", | ||
| 48 | }), | ||
| 49 | ).toBe("plan"); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("forces build mode once the disabled preference has loaded", () => { | ||
| 53 | expect( | ||
| 54 | resolvePendingTaskInteractionMode({ | ||
| 55 | preferenceLoaded: true, | ||
| 56 | planModeEnabled: false, | ||
| 57 | draftInteractionMode: "plan", | ||
| 58 | queuedInteractionMode: "plan", | ||
| 59 | }), | ||
| 60 | ).toBe("default"); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("keeps a fresh draft in build mode while the preference is loading", () => { | ||
| 64 | expect( | ||
| 65 | resolvePendingTaskInteractionMode({ | ||
| 66 | preferenceLoaded: false, | ||
| 67 | planModeEnabled: false, | ||
| 68 | draftInteractionMode: "plan", | ||
| 69 | queuedInteractionMode: undefined, | ||
| 70 | }), | ||
| 71 | ).toBe("default"); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("honors the draft's mode when the plan preference is enabled", () => { | ||
| 75 | expect( | ||
| 76 | resolvePendingTaskInteractionMode({ | ||
| 77 | preferenceLoaded: true, | ||
| 78 | planModeEnabled: true, | ||
| 79 | draftInteractionMode: "plan", | ||
| 80 | queuedInteractionMode: undefined, | ||
| 81 | }), | ||
| 82 | ).toBe("plan"); | ||
| 83 | expect( | ||
| 84 | resolvePendingTaskInteractionMode({ | ||
| 85 | preferenceLoaded: true, | ||
| 86 | planModeEnabled: true, | ||
| 87 | draftInteractionMode: undefined, | ||
| 88 | queuedInteractionMode: "plan", | ||
| 89 | }), | ||
| 90 | ).toBe("default"); | ||
| 91 | }); | ||
| 92 | }); | ||
apps/mobile/src/features/threads/legacy-plan-mode.ts deleted-46| ... | @@ -1,46 +0,0 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 3 | type ProviderInteractionMode, | ||
| 4 | type ServerProvider, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | |||
| 7 | type InteractionModeProvider = Pick<ServerProvider, "showInteractionModeToggle">; | ||
| 8 | |||
| 9 | /** Normalize saved T3 mode choices without changing native slash commands. */ | ||
| 10 | export function resolveProviderInteractionMode( | ||
| 11 | provider: InteractionModeProvider | null | undefined, | ||
| 12 | interactionMode: ProviderInteractionMode | null | undefined, | ||
| 13 | ): ProviderInteractionMode { | ||
| 14 | return provider?.showInteractionModeToggle === false | ||
| 15 | ? DEFAULT_PROVIDER_INTERACTION_MODE | ||
| 16 | : (interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE); | ||
| 17 | } | ||
| 18 | |||
| 19 | export function resolveLegacyPlanModeEnabled(input: { | ||
| 20 | readonly loaded: boolean; | ||
| 21 | readonly preference: boolean | undefined; | ||
| 22 | }): boolean { | ||
| 23 | return input.loaded && input.preference === true; | ||
| 24 | } | ||
| 25 | |||
| 26 | export function resolvePendingTaskInteractionMode(input: { | ||
| 27 | readonly preferenceLoaded: boolean; | ||
| 28 | readonly planModeEnabled: boolean; | ||
| 29 | readonly draftInteractionMode: ProviderInteractionMode | undefined; | ||
| 30 | readonly queuedInteractionMode: ProviderInteractionMode | undefined; | ||
| 31 | readonly provider?: InteractionModeProvider | null; | ||
| 32 | }): ProviderInteractionMode { | ||
| 33 | if (input.provider?.showInteractionModeToggle === false) { | ||
| 34 | return DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 35 | } | ||
| 36 | if (input.planModeEnabled) { | ||
| 37 | return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 38 | } | ||
| 39 | if (!input.preferenceLoaded) { | ||
| 40 | // Only an existing queued task may retain its previous mode while the | ||
| 41 | // preference is unknown. A fresh draft still defaults to Build so a stale | ||
| 42 | // persisted Plan selection cannot bypass a disabled preference at launch. | ||
| 43 | return input.queuedInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 44 | } | ||
| 45 | return DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 46 | } | ||
apps/mobile/src/features/threads/new-task-flow-provider.tsx+1-1| ... | @@ -89,7 +89,7 @@ import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; | ... | @@ -89,7 +89,7 @@ import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; |
| 89 | import { | 89 | import { |
| 90 | resolvePendingTaskInteractionMode, | 90 | resolvePendingTaskInteractionMode, |
| 91 | resolveProviderInteractionMode, | 91 | resolveProviderInteractionMode, |
| 92 | } from "./legacy-plan-mode"; | 92 | } from "../../state/legacy-plan-mode"; |
| 93 | import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; | 93 | import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; |
| 94 | import { | 94 | import { |
| 95 | resolveNewTaskBranchWorktreePath, | 95 | resolveNewTaskBranchWorktreePath, |
apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts+1-1| ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; | ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | 2 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 3 | 3 | ||
| 4 | import { mobilePreferencesAtom } from "../../state/preferences"; | 4 | import { mobilePreferencesAtom } from "../../state/preferences"; |
| 5 | import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; | 5 | import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode"; |
| 6 | 6 | ||
| 7 | /** | 7 | /** |
| 8 | * Mobile preferences are device-local, matching the desktop client setting. | 8 | * Mobile preferences are device-local, matching the desktop client setting. |
apps/mobile/src/lib/attachmentUpload.test.ts+1-1| ... | @@ -32,7 +32,7 @@ vi.mock("../state/atom-registry", () => ({ | ... | @@ -32,7 +32,7 @@ vi.mock("../state/atom-registry", () => ({ |
| 32 | })); | 32 | })); |
| 33 | 33 | ||
| 34 | // The real read lease and cleanup are covered by the composer ownership suite. | 34 | // The real read lease and cleanup are covered by the composer ownership suite. |
| 35 | vi.mock("../state/use-composer-drafts", () => ({ | 35 | vi.mock("./composerAttachmentPreviewRetention", () => ({ |
| 36 | retainComposerAttachmentFileForPreview: () => () => {}, | 36 | retainComposerAttachmentFileForPreview: () => () => {}, |
| 37 | })); | 37 | })); |
| 38 | 38 |
apps/mobile/src/lib/attachmentUpload.ts+1-1| ... | @@ -20,8 +20,8 @@ import { appAtomRegistry } from "../state/atom-registry"; | ... | @@ -20,8 +20,8 @@ import { appAtomRegistry } from "../state/atom-registry"; |
| 20 | import { assetEnvironment } from "../state/assets"; | 20 | import { assetEnvironment } from "../state/assets"; |
| 21 | import { attachmentEnvironment } from "../state/attachments"; | 21 | import { attachmentEnvironment } from "../state/attachments"; |
| 22 | import { environmentSession } from "../state/session"; | 22 | import { environmentSession } from "../state/session"; |
| 23 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; | ||
| 24 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; | 23 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; |
| 24 | import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; | ||
| 25 | import { | 25 | import { |
| 26 | isComposerImageAttachment, | 26 | isComposerImageAttachment, |
| 27 | isFileBackedComposerAttachment, | 27 | isFileBackedComposerAttachment, |
apps/mobile/src/lib/composerAttachmentPreviewRetention.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import type { FileBackedComposerAttachment } from "./composerImages"; | ||
| 2 | import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; | ||
| 3 | |||
| 4 | /** | ||
| 5 | * Preview retention for saved composer attachment copies. | ||
| 6 | * | ||
| 7 | * The durable owners of an attachment file (composer drafts, queued outbox | ||
| 8 | * messages) live in state, so this module cannot reach the ownership-cleanup | ||
| 9 | * sweep directly. Composer draft state registers the owner-side cleanup hook | ||
| 10 | * at module load; until then a release has nothing to retry, because no draft | ||
| 11 | * store has loaded yet and there is nothing to clean. | ||
| 12 | */ | ||
| 13 | type UnusedAttachmentHandler = (attachment: FileBackedComposerAttachment) => void; | ||
| 14 | |||
| 15 | let onAttachmentUnused: UnusedAttachmentHandler | null = null; | ||
| 16 | |||
| 17 | export function registerComposerAttachmentUnusedHandler(handler: UnusedAttachmentHandler): void { | ||
| 18 | onAttachmentUnused = handler; | ||
| 19 | } | ||
| 20 | |||
| 21 | /** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ | ||
| 22 | export function retainComposerAttachmentFileForPreview( | ||
| 23 | attachment: FileBackedComposerAttachment, | ||
| 24 | ): () => void { | ||
| 25 | return retainComposerAttachmentFile(attachment.fileUri, () => { | ||
| 26 | onAttachmentUnused?.(attachment); | ||
| 27 | }); | ||
| 28 | } | ||
apps/mobile/src/lib/localAttachmentPreview.test.ts+1-1| ... | @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ | ... | @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ |
| 6 | exists: vi.fn(), | 6 | exists: vi.fn(), |
| 7 | })); | 7 | })); |
| 8 | 8 | ||
| 9 | vi.mock("../state/use-composer-drafts", () => ({ | 9 | vi.mock("./composerAttachmentPreviewRetention", () => ({ |
| 10 | retainComposerAttachmentFileForPreview: mocks.retain, | 10 | retainComposerAttachmentFileForPreview: mocks.retain, |
| 11 | })); | 11 | })); |
| 12 | vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); | 12 | vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); |
apps/mobile/src/lib/localAttachmentPreview.ts+1-1| ... | @@ -3,7 +3,7 @@ import { videoMimeType } from "@t3tools/shared/video"; | ... | @@ -3,7 +3,7 @@ import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | import type { FileBackedComposerAttachment } from "./composerImages"; | 3 | import type { FileBackedComposerAttachment } from "./composerImages"; |
| 4 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; | 4 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; |
| 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; | 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; |
| 6 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; | 6 | import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; |
| 7 | 7 | ||
| 8 | /** Retains the draft original for preview and gives each outgoing share its own lease. */ | 8 | /** Retains the draft original for preview and gives each outgoing share its own lease. */ |
| 9 | export async function loadLocalAttachmentPreview( | 9 | export async function loadLocalAttachmentPreview( |
apps/mobile/src/state/composer-attachment-uploads.ts+1-1| ... | @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; | ... | @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; |
| 4 | import { useEffect, useRef } from "react"; | 4 | import { useEffect, useRef } from "react"; |
| 5 | 5 | ||
| 6 | import { prepareTurnAttachments } from "../lib/attachmentUpload"; | 6 | import { prepareTurnAttachments } from "../lib/attachmentUpload"; |
| 7 | import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; | ||
| 7 | import { isFileBackedComposerAttachment } from "../lib/composerImages"; | 8 | import { isFileBackedComposerAttachment } from "../lib/composerImages"; |
| 8 | import { | 9 | import { |
| 9 | composerAttachmentUploadKey, | 10 | composerAttachmentUploadKey, |
| ... | @@ -20,7 +21,6 @@ import { | ... | @@ -20,7 +21,6 @@ import { |
| 20 | composerDraftsAtom, | 21 | composerDraftsAtom, |
| 21 | ensureComposerDraftsLoaded, | 22 | ensureComposerDraftsLoaded, |
| 22 | flushComposerDrafts, | 23 | flushComposerDrafts, |
| 23 | retainComposerAttachmentFileForPreview, | ||
| 24 | setComposerDraftAttachmentUpload, | 24 | setComposerDraftAttachmentUpload, |
| 25 | } from "./use-composer-drafts"; | 25 | } from "./use-composer-drafts"; |
| 26 | import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; | 26 | import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; |
apps/mobile/src/state/legacy-plan-mode.test.ts created+92| ... | @@ -0,0 +1,92 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | resolvePendingTaskInteractionMode, | ||
| 5 | resolveProviderInteractionMode, | ||
| 6 | } from "./legacy-plan-mode"; | ||
| 7 | |||
| 8 | describe("resolveProviderInteractionMode", () => { | ||
| 9 | it("clears saved plan mode when the provider cannot use T3 interaction modes", () => { | ||
| 10 | expect(resolveProviderInteractionMode({ showInteractionModeToggle: false }, "plan")).toBe( | ||
| 11 | "default", | ||
| 12 | ); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("keeps supported choices and remains compatible with older server status", () => { | ||
| 16 | expect(resolveProviderInteractionMode({ showInteractionModeToggle: true }, "plan")).toBe( | ||
| 17 | "plan", | ||
| 18 | ); | ||
| 19 | expect(resolveProviderInteractionMode({}, "plan")).toBe("plan"); | ||
| 20 | expect(resolveProviderInteractionMode(null, "plan")).toBe("plan"); | ||
| 21 | expect(resolveProviderInteractionMode(undefined, undefined)).toBe("default"); | ||
| 22 | }); | ||
| 23 | }); | ||
| 24 | |||
| 25 | describe("resolvePendingTaskInteractionMode", () => { | ||
| 26 | it.each([false, true])( | ||
| 27 | "clears a queued unsupported plan mode with preferenceLoaded=%s", | ||
| 28 | (preferenceLoaded) => { | ||
| 29 | expect( | ||
| 30 | resolvePendingTaskInteractionMode({ | ||
| 31 | preferenceLoaded, | ||
| 32 | planModeEnabled: true, | ||
| 33 | draftInteractionMode: "plan", | ||
| 34 | queuedInteractionMode: "plan", | ||
| 35 | provider: { showInteractionModeToggle: false }, | ||
| 36 | }), | ||
| 37 | ).toBe("default"); | ||
| 38 | }, | ||
| 39 | ); | ||
| 40 | |||
| 41 | it("preserves a queued plan task while the preference is still loading", () => { | ||
| 42 | expect( | ||
| 43 | resolvePendingTaskInteractionMode({ | ||
| 44 | preferenceLoaded: false, | ||
| 45 | planModeEnabled: false, | ||
| 46 | draftInteractionMode: "plan", | ||
| 47 | queuedInteractionMode: "plan", | ||
| 48 | }), | ||
| 49 | ).toBe("plan"); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("forces build mode once the disabled preference has loaded", () => { | ||
| 53 | expect( | ||
| 54 | resolvePendingTaskInteractionMode({ | ||
| 55 | preferenceLoaded: true, | ||
| 56 | planModeEnabled: false, | ||
| 57 | draftInteractionMode: "plan", | ||
| 58 | queuedInteractionMode: "plan", | ||
| 59 | }), | ||
| 60 | ).toBe("default"); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("keeps a fresh draft in build mode while the preference is loading", () => { | ||
| 64 | expect( | ||
| 65 | resolvePendingTaskInteractionMode({ | ||
| 66 | preferenceLoaded: false, | ||
| 67 | planModeEnabled: false, | ||
| 68 | draftInteractionMode: "plan", | ||
| 69 | queuedInteractionMode: undefined, | ||
| 70 | }), | ||
| 71 | ).toBe("default"); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("honors the draft's mode when the plan preference is enabled", () => { | ||
| 75 | expect( | ||
| 76 | resolvePendingTaskInteractionMode({ | ||
| 77 | preferenceLoaded: true, | ||
| 78 | planModeEnabled: true, | ||
| 79 | draftInteractionMode: "plan", | ||
| 80 | queuedInteractionMode: undefined, | ||
| 81 | }), | ||
| 82 | ).toBe("plan"); | ||
| 83 | expect( | ||
| 84 | resolvePendingTaskInteractionMode({ | ||
| 85 | preferenceLoaded: true, | ||
| 86 | planModeEnabled: true, | ||
| 87 | draftInteractionMode: undefined, | ||
| 88 | queuedInteractionMode: "plan", | ||
| 89 | }), | ||
| 90 | ).toBe("default"); | ||
| 91 | }); | ||
| 92 | }); | ||
apps/mobile/src/state/legacy-plan-mode.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 3 | type ProviderInteractionMode, | ||
| 4 | type ServerProvider, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | |||
| 7 | type InteractionModeProvider = Pick<ServerProvider, "showInteractionModeToggle">; | ||
| 8 | |||
| 9 | /** Normalize saved T3 mode choices without changing native slash commands. */ | ||
| 10 | export function resolveProviderInteractionMode( | ||
| 11 | provider: InteractionModeProvider | null | undefined, | ||
| 12 | interactionMode: ProviderInteractionMode | null | undefined, | ||
| 13 | ): ProviderInteractionMode { | ||
| 14 | return provider?.showInteractionModeToggle === false | ||
| 15 | ? DEFAULT_PROVIDER_INTERACTION_MODE | ||
| 16 | : (interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE); | ||
| 17 | } | ||
| 18 | |||
| 19 | export function resolveLegacyPlanModeEnabled(input: { | ||
| 20 | readonly loaded: boolean; | ||
| 21 | readonly preference: boolean | undefined; | ||
| 22 | }): boolean { | ||
| 23 | return input.loaded && input.preference === true; | ||
| 24 | } | ||
| 25 | |||
| 26 | export function resolvePendingTaskInteractionMode(input: { | ||
| 27 | readonly preferenceLoaded: boolean; | ||
| 28 | readonly planModeEnabled: boolean; | ||
| 29 | readonly draftInteractionMode: ProviderInteractionMode | undefined; | ||
| 30 | readonly queuedInteractionMode: ProviderInteractionMode | undefined; | ||
| 31 | readonly provider?: InteractionModeProvider | null; | ||
| 32 | }): ProviderInteractionMode { | ||
| 33 | if (input.provider?.showInteractionModeToggle === false) { | ||
| 34 | return DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 35 | } | ||
| 36 | if (input.planModeEnabled) { | ||
| 37 | return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 38 | } | ||
| 39 | if (!input.preferenceLoaded) { | ||
| 40 | // Only an existing queued task may retain its previous mode while the | ||
| 41 | // preference is unknown. A fresh draft still defaults to Build so a stale | ||
| 42 | // persisted Plan selection cannot bypass a disabled preference at launch. | ||
| 43 | return input.queuedInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 44 | } | ||
| 45 | return DEFAULT_PROVIDER_INTERACTION_MODE; | ||
| 46 | } | ||
apps/mobile/src/state/thread-outbox-model.ts+1-1| ... | @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; |
| 26 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; | 26 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; |
| 27 | import type { DraftComposerAttachment } from "../lib/composerImages"; | 27 | import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 28 | import { scopedThreadKey } from "../lib/scopedEntities"; | 28 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 29 | import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; | 29 | import { resolveProviderInteractionMode } from "./legacy-plan-mode"; |
| 30 | 30 | ||
| 31 | // Keep current writes until a compatible native baseline includes the v4 reader. | 31 | // Keep current writes until a compatible native baseline includes the v4 reader. |
| 32 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; | 32 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; |
apps/mobile/src/state/use-composer-drafts.test.ts+1-1| ... | @@ -179,7 +179,6 @@ import { | ... | @@ -179,7 +179,6 @@ import { |
| 179 | removeComposerDraftsForEnvironment, | 179 | removeComposerDraftsForEnvironment, |
| 180 | replaceComposerDraftAttachments, | 180 | replaceComposerDraftAttachments, |
| 181 | resetComposerDraftsLoadState, | 181 | resetComposerDraftsLoadState, |
| 182 | retainComposerAttachmentFileForPreview, | ||
| 183 | restoreComposerDraftSnapshotState, | 182 | restoreComposerDraftSnapshotState, |
| 184 | restoreCloudComposerDrafts, | 183 | restoreCloudComposerDrafts, |
| 185 | retargetNewTaskDraft, | 184 | retargetNewTaskDraft, |
| ... | @@ -194,6 +193,7 @@ import { | ... | @@ -194,6 +193,7 @@ import { |
| 194 | undoComposerDraftMerge, | 193 | undoComposerDraftMerge, |
| 195 | undoComposerDraftMergeState, | 194 | undoComposerDraftMergeState, |
| 196 | } from "./use-composer-drafts"; | 195 | } from "./use-composer-drafts"; |
| 196 | import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; | ||
| 197 | 197 | ||
| 198 | const DRAFT: ComposerDraft = { | 198 | const DRAFT: ComposerDraft = { |
| 199 | text: "hello", | 199 | text: "hello", |
apps/mobile/src/state/use-composer-drafts.ts+12-9| ... | @@ -35,8 +35,11 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; | ... | @@ -35,8 +35,11 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; |
| 35 | import { | 35 | import { |
| 36 | composerAttachmentFileReferenceKey, | 36 | composerAttachmentFileReferenceKey, |
| 37 | isComposerAttachmentFileRetained, | 37 | isComposerAttachmentFileRetained, |
| 38 | retainComposerAttachmentFile, | ||
| 39 | } from "../lib/composerAttachmentFiles"; | 38 | } from "../lib/composerAttachmentFiles"; |
| 39 | import { | ||
| 40 | registerComposerAttachmentUnusedHandler, | ||
| 41 | retainComposerAttachmentFileForPreview, | ||
| 42 | } from "../lib/composerAttachmentPreviewRetention"; | ||
| 40 | import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; | 43 | import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; |
| 41 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; | 44 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| 42 | import { appAtomRegistry } from "./atom-registry"; | 45 | import { appAtomRegistry } from "./atom-registry"; |
| ... | @@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup( | ... | @@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup( |
| 957 | }); | 960 | }); |
| 958 | } | 961 | } |
| 959 | 962 | ||
| 960 | /** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ | 963 | /** |
| 961 | export function retainComposerAttachmentFileForPreview( | 964 | * Owner-side cleanup hook for the shared preview-retention helper: releasing |
| 962 | attachment: FileBackedComposerAttachment, | 965 | * the last preview/upload lease retries the unused-file sweep. Registered here |
| 963 | ): () => void { | 966 | * because this module owns the draft and outbox references the sweep reads. |
| 964 | return retainComposerAttachmentFile(attachment.fileUri, () => { | 967 | */ |
| 965 | scheduleUnusedComposerAttachmentCleanup([attachment]); | 968 | registerComposerAttachmentUnusedHandler((attachment) => { |
| 966 | }); | 969 | scheduleUnusedComposerAttachmentCleanup([attachment]); |
| 967 | } | 970 | }); |
| 968 | 971 | ||
| 969 | function schedulePersistComposerState(): void { | 972 | function schedulePersistComposerState(): void { |
| 970 | if (persistTimer !== null) { | 973 | if (persistTimer !== null) { |
apps/mobile/src/state/use-thread-composer-state.ts+1-1| ... | @@ -30,7 +30,7 @@ import { uuidv4 } from "../lib/uuid"; | ... | @@ -30,7 +30,7 @@ import { uuidv4 } from "../lib/uuid"; |
| 30 | 30 | ||
| 31 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; | 31 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; |
| 32 | import { isModelSelectionUnavailable } from "../lib/modelOptions"; | 32 | import { isModelSelectionUnavailable } from "../lib/modelOptions"; |
| 33 | import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; | 33 | import { resolveProviderInteractionMode } from "./legacy-plan-mode"; |
| 34 | import { | 34 | import { |
| 35 | convertPastedImagesToAttachments, | 35 | convertPastedImagesToAttachments, |
| 36 | createPastedTextComposerAttachment, | 36 | createPastedTextComposerAttachment, |