authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 17:35:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 17:35:52-07:00
log5821b778fd29e9c5b6945f4e7150f92d19a0a3da
treedeebcbb3e314a4b9da753bf199b2e91db5e2d03c
parent5d493d67cedc3401af42e77d6a238bc49204a09b
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): break module cycles with focused extractions (#13151)


37 files changed, 569 insertions(+), 235 deletions(-)

apps/mobile/src/components/ConfirmDialog.types.ts created+18
...@@ -0,0 +1,18 @@
1export 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
11export 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";
4import { cn } from "../lib/cn";4import { cn } from "../lib/cn";
5import { AppText } from "./AppText";5import { AppText } from "./AppText";
6import { MaterialConfirmDialog } from "./MaterialConfirmDialog";6import { MaterialConfirmDialog } from "./MaterialConfirmDialog";
7import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types";
78
8export type ConfirmDialogRequest = {9export 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
18export 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};
2610
27type DialogRequest =11type 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";
2import { useEffect, useEffectEvent, useId } from "react";2import { useEffect, useEffectEvent, useId } from "react";
3import { Alert } from "react-native";3import { Alert } from "react-native";
44
5import type { ResolvedFilePreviewSource } from "./FilePreviewModal";5import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types";
66
7const NativeControls = requireNativeModule<{7const 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";
3import ImageViewing from "react-native-image-viewing";3import ImageViewing from "react-native-image-viewing";
44
5import { openAttachmentInViewer } from "../lib/attachmentDownload";5import { openAttachmentInViewer } from "../lib/attachmentDownload";
6import type { ResolvedFilePreviewSource } from "./FilePreviewModal";6import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types";
7import { MediaImagePreview } from "./MediaImagePreview";7import { MediaImagePreview } from "./MediaImagePreview";
8import { AppText as Text } from "./AppText";8import { AppText as Text } from "./AppText";
99
apps/mobile/src/components/FilePreviewModal.tsx+2-19
...@@ -1,30 +1,13 @@...@@ -1,30 +1,13 @@
1import { useIsFocused } from "@react-navigation/native";1import { useIsFocused } from "@react-navigation/native";
2import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
3import { useEffect, useEffectEvent, useState } from "react";2import { useEffect, useEffectEvent, useState } from "react";
4import { Alert, Keyboard } from "react-native";3import { Alert, Keyboard } from "react-native";
54
6import type { FileBackedComposerAttachment } from "../lib/composerImages";
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";5import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import type { MediaActionsSource } from "../lib/mediaActions";
9import { useRefreshAssetUrl } from "../state/assets";6import { useRefreshAssetUrl } from "../state/assets";
10import { FilePreview } from "./FilePreview";7import { FilePreview } from "./FilePreview";
8import type { FilePreviewSource } from "./FilePreviewModal.types";
119
12export interface ResolvedFilePreviewSource {10export 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
22export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
23 (
24 | { readonly uri: string }
25 | { readonly attachment: FileBackedComposerAttachment }
26 | { readonly environmentId: EnvironmentId; readonly resource: AssetResource }
27 );
2811
29function ResolvedFilePreview(props: {12function ResolvedFilePreview(props: {
30 readonly source: FilePreviewSource;13 readonly source: FilePreviewSource;
apps/mobile/src/components/FilePreviewModal.types.ts created+21
...@@ -0,0 +1,21 @@
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2
3import type { FileBackedComposerAttachment } from "../lib/composerImages";
4import type { MediaActionsSource } from "../lib/mediaActions";
5
6export 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
16export 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 @@
1import type { ConfirmDialogRequest } from "./ConfirmDialogHost";1import type { ConfirmDialogRequest } from "./ConfirmDialog.types";
22
3export interface MaterialConfirmDialogProps {3export 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/
2import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers";2import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers";
3import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";3import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
4import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";4import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
5import type { SegmentedControlProps } from "./SegmentedControl";5import type { SegmentedControlProps } from "./SegmentedControl.types";
66
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. */
8export function MaterialSegmentedButtons<Value extends number | string>(8export 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";
3import { View } from "react-native";3import { View } from "react-native";
44
5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6import type { SegmentedControlProps } from "./SegmentedControl";6import type { SegmentedControlProps } from "./SegmentedControl.types";
77
8export function MaterialSegmentedControl<Value extends number | string>(8export 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 @@
1import type { SegmentedControlProps } from "./SegmentedControl";1import type { SegmentedControlProps } from "./SegmentedControl.types";
22
3export function MaterialSegmentedControl<Value extends number | string>(3export 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 @@
1import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose";1import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose";
2import { View } from "react-native";2import { View } from "react-native";
3import type { ThemedSwitchProps } from "./ThemedSwitch";3import type { ThemedSwitchProps } from "./MaterialSwitch.types";
44
5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
66
apps/mobile/src/components/MaterialSwitch.types.ts created+12
...@@ -0,0 +1,12 @@
1import type { SwitchProps } from "react-native";
2
3export 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";
6import { useMediaActions } from "../lib/mediaActions";6import { useMediaActions } from "../lib/mediaActions";
7import { AppText } from "./AppText";7import { AppText } from "./AppText";
8import { SymbolView } from "./AppSymbol";8import { SymbolView } from "./AppSymbol";
9import type { ResolvedFilePreviewSource } from "./FilePreviewModal";9import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types";
10import { MediaActionsMenu } from "./MediaActionsMenu";10import { MediaActionsMenu } from "./MediaActionsMenu";
11import { MediaSourceCaption } from "./MediaSourceCaption";11import { MediaSourceCaption } from "./MediaSourceCaption";
1212
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
3import { AppText as Text } from "./AppText";3import { AppText as Text } from "./AppText";
4import { cn } from "../lib/cn";4import { cn } from "../lib/cn";
5import { MaterialSegmentedControl } from "./MaterialSegmentedControl";5import { MaterialSegmentedControl } from "./MaterialSegmentedControl";
6import type { SegmentedControlProps } from "./SegmentedControl.types";
67
7export interface SegmentedControlProps<Value extends number | string> {8export 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}
219
22export function SegmentedControl<Value extends number | string>(10export 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 @@
1export 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 @@
1import { Platform, Switch, type SwitchProps } from "react-native";1import { Platform, Switch } from "react-native";
22
3import { MaterialSwitch } from "./MaterialSwitch";3import { MaterialSwitch } from "./MaterialSwitch";
4import type { ThemedSwitchProps } from "./MaterialSwitch.types";
45
5export type ThemedSwitchProps = Pick<6export type { ThemedSwitchProps } from "./MaterialSwitch.types";
6 SwitchProps,
7 | "accessibilityHint"
8 | "accessibilityLabel"
9 | "disabled"
10 | "onValueChange"
11 | "style"
12 | "testID"
13 | "value"
14>;
157
16export function ThemedSwitch(props: ThemedSwitchProps) {8export 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 @@
1import * as NodeFS from "node:fs";
2import * as NodePath from "node:path";
3import { 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
29const SOURCE_ROOT = __dirname;
30
31/** Metro candidate order per platform (platform, then native, then generic). */
32const 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
37type Platform = keyof typeof PLATFORM_EXTENSION_ORDER;
38
39const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray<Platform>;
40
41const 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. */
48function 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
56function 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
68function 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
92interface ParsedImport {
93 readonly specifier: string;
94 readonly isDynamic: boolean;
95}
96
97function 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
114type Layer = "state" | "lib" | "components" | "native" | "features" | "other";
115
116function 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
124interface 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
133function 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. */
170function 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
230const graphs = PLATFORMS.map(buildPlatformGraph);
231
232/** Unique upward edge pairs across every platform, sorted for stable diffs. */
233function 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
243describe("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";
4import { MOBILE_CODE_SURFACE } from "../../lib/typography";4import { MOBILE_CODE_SURFACE } from "../../lib/typography";
55
6import type { ReviewRenderableLineRow } from "./reviewModel";6import type { ReviewRenderableLineRow } from "./reviewModel";
7import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";7import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
88
9export const REVIEW_MONO_FONT_FAMILY = Platform.select({9export 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 @@
1export 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 @@
1import { diffWordsWithSpace } from "diff";1import { diffWordsWithSpace } from "diff";
22
3import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";3import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
44
5interface ReviewDiffOperation {5interface 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}
3737
38export interface ReviewHighlightedToken {38import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
39 content: string;39
40 readonly color: string | null;40export type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
41 readonly fontStyle: number | null;
42 readonly diffHighlight?: boolean;
43}
4441
45const SHIKI_THEME_NAME_BY_SCHEME = {42const 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 resolvePendingTaskInteractionMode,
5 resolveProviderInteractionMode,
6} from "./legacy-plan-mode";
7
8describe("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
25describe("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 @@
1import {
2 DEFAULT_PROVIDER_INTERACTION_MODE,
3 type ProviderInteractionMode,
4 type ServerProvider,
5} from "@t3tools/contracts";
6
7type InteractionModeProvider = Pick<ServerProvider, "showInteractionModeToggle">;
8
9/** Normalize saved T3 mode choices without changing native slash commands. */
10export 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
19export function resolveLegacyPlanModeEnabled(input: {
20 readonly loaded: boolean;
21 readonly preference: boolean | undefined;
22}): boolean {
23 return input.loaded && input.preference === true;
24}
25
26export 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";
89import {89import {
90 resolvePendingTaskInteractionMode,90 resolvePendingTaskInteractionMode,
91 resolveProviderInteractionMode,91 resolveProviderInteractionMode,
92} from "./legacy-plan-mode";92} from "../../state/legacy-plan-mode";
93import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";93import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
94import {94import {
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";
2import { AsyncResult } from "effect/unstable/reactivity";2import { AsyncResult } from "effect/unstable/reactivity";
33
4import { mobilePreferencesAtom } from "../../state/preferences";4import { mobilePreferencesAtom } from "../../state/preferences";
5import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode";5import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode";
66
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}));
3333
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.
35vi.mock("../state/use-composer-drafts", () => ({35vi.mock("./composerAttachmentPreviewRetention", () => ({
36 retainComposerAttachmentFileForPreview: () => () => {},36 retainComposerAttachmentFileForPreview: () => () => {},
37}));37}));
3838
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";
20import { assetEnvironment } from "../state/assets";20import { assetEnvironment } from "../state/assets";
21import { attachmentEnvironment } from "../state/attachments";21import { attachmentEnvironment } from "../state/attachments";
22import { environmentSession } from "../state/session";22import { environmentSession } from "../state/session";
23import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
24import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";23import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
24import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention";
25import {25import {
26 isComposerImageAttachment,26 isComposerImageAttachment,
27 isFileBackedComposerAttachment,27 isFileBackedComposerAttachment,
apps/mobile/src/lib/composerAttachmentPreviewRetention.ts created+28
...@@ -0,0 +1,28 @@
1import type { FileBackedComposerAttachment } from "./composerImages";
2import { 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 */
13type UnusedAttachmentHandler = (attachment: FileBackedComposerAttachment) => void;
14
15let onAttachmentUnused: UnusedAttachmentHandler | null = null;
16
17export 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. */
22export 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}));
88
9vi.mock("../state/use-composer-drafts", () => ({9vi.mock("./composerAttachmentPreviewRetention", () => ({
10 retainComposerAttachmentFileForPreview: mocks.retain,10 retainComposerAttachmentFileForPreview: mocks.retain,
11}));11}));
12vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share }));12vi.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";
3import type { FileBackedComposerAttachment } from "./composerImages";3import type { FileBackedComposerAttachment } from "./composerImages";
4import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";4import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
5import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";5import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
6import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";6import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention";
77
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. */
9export async function loadLocalAttachmentPreview(9export 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";
4import { useEffect, useRef } from "react";4import { useEffect, useRef } from "react";
55
6import { prepareTurnAttachments } from "../lib/attachmentUpload";6import { prepareTurnAttachments } from "../lib/attachmentUpload";
7import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention";
7import { isFileBackedComposerAttachment } from "../lib/composerImages";8import { isFileBackedComposerAttachment } from "../lib/composerImages";
8import {9import {
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";
26import { useRemoteConnectionStatus } from "./use-remote-environment-registry";26import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
apps/mobile/src/state/legacy-plan-mode.test.ts created+92
...@@ -0,0 +1,92 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 resolvePendingTaskInteractionMode,
5 resolveProviderInteractionMode,
6} from "./legacy-plan-mode";
7
8describe("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
25describe("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 @@
1import {
2 DEFAULT_PROVIDER_INTERACTION_MODE,
3 type ProviderInteractionMode,
4 type ServerProvider,
5} from "@t3tools/contracts";
6
7type InteractionModeProvider = Pick<ServerProvider, "showInteractionModeToggle">;
8
9/** Normalize saved T3 mode choices without changing native slash commands. */
10export 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
19export function resolveLegacyPlanModeEnabled(input: {
20 readonly loaded: boolean;
21 readonly preference: boolean | undefined;
22}): boolean {
23 return input.loaded && input.preference === true;
24}
25
26export 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";
26import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";26import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
27import type { DraftComposerAttachment } from "../lib/composerImages";27import type { DraftComposerAttachment } from "../lib/composerImages";
28import { scopedThreadKey } from "../lib/scopedEntities";28import { scopedThreadKey } from "../lib/scopedEntities";
29import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";29import { resolveProviderInteractionMode } from "./legacy-plan-mode";
3030
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.
32const THREAD_OUTBOX_SCHEMA_VERSION = 3;32const 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";
196import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention";
197197
198const DRAFT: ComposerDraft = {198const 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";
35import {35import {
36 composerAttachmentFileReferenceKey,36 composerAttachmentFileReferenceKey,
37 isComposerAttachmentFileRetained,37 isComposerAttachmentFileRetained,
38 retainComposerAttachmentFile,
39} from "../lib/composerAttachmentFiles";38} from "../lib/composerAttachmentFiles";
39import {
40 registerComposerAttachmentUnusedHandler,
41 retainComposerAttachmentFileForPreview,
42} from "../lib/composerAttachmentPreviewRetention";
40import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";43import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";
41import { SerializedAsyncQueue } from "../lib/serialized-async-queue";44import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
42import { appAtomRegistry } from "./atom-registry";45import { appAtomRegistry } from "./atom-registry";
...@@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup(...@@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup(
957 });960 });
958}961}
959962
960/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */963/**
961export 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]);968registerComposerAttachmentUnusedHandler((attachment) => {
966 });969 scheduleUnusedComposerAttachmentCleanup([attachment]);
967}970});
968971
969function schedulePersistComposerState(): void {972function 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";
3030
31import { makeQueuedMessageMetadata } from "../lib/commandMetadata";31import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
32import { isModelSelectionUnavailable } from "../lib/modelOptions";32import { isModelSelectionUnavailable } from "../lib/modelOptions";
33import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";33import { resolveProviderInteractionMode } from "./legacy-plan-mode";
34import {34import {
35 convertPastedImagesToAttachments,35 convertPastedImagesToAttachments,
36 createPastedTextComposerAttachment,36 createPastedTextComposerAttachment,