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";
44import { cn } from "../lib/cn";
55import { AppText } from "./AppText";
66import { MaterialConfirmDialog } from "./MaterialConfirmDialog";
7import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types";
78
8export type ConfirmDialogRequest = {
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};
9export type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types";
2610
2711type DialogRequest =
2812 | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest }
apps/mobile/src/components/FilePreview.ios.tsx+1-1
......@@ -2,7 +2,7 @@ import { requireNativeModule } from "expo";
22import { useEffect, useEffectEvent, useId } from "react";
33import { Alert } from "react-native";
44
5import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
5import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types";
66
77const NativeControls = requireNativeModule<{
88 presentFile(
apps/mobile/src/components/FilePreview.tsx+1-1
......@@ -3,7 +3,7 @@ import { Alert, Modal, Pressable, View } from "react-native";
33import ImageViewing from "react-native-image-viewing";
44
55import { openAttachmentInViewer } from "../lib/attachmentDownload";
6import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
6import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types";
77import { MediaImagePreview } from "./MediaImagePreview";
88import { AppText as Text } from "./AppText";
99
apps/mobile/src/components/FilePreviewModal.tsx+2-19
......@@ -1,30 +1,13 @@
11import { useIsFocused } from "@react-navigation/native";
2import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
32import { useEffect, useEffectEvent, useState } from "react";
43import { Alert, Keyboard } from "react-native";
54
6import type { FileBackedComposerAttachment } from "../lib/composerImages";
75import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import type { MediaActionsSource } from "../lib/mediaActions";
96import { useRefreshAssetUrl } from "../state/assets";
107import { FilePreview } from "./FilePreview";
8import type { FilePreviewSource } from "./FilePreviewModal.types";
119
12export interface ResolvedFilePreviewSource {
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 );
10export type { FilePreviewSource, ResolvedFilePreviewSource } from "./FilePreviewModal.types";
2811
2912function ResolvedFilePreview(props: {
3013 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 @@
1import type { ConfirmDialogRequest } from "./ConfirmDialogHost";
1import type { ConfirmDialogRequest } from "./ConfirmDialog.types";
22
33export interface MaterialConfirmDialogProps {
44 readonly request: Pick<
apps/mobile/src/components/MaterialSegmentedButtons.android.tsx+1-1
......@@ -2,7 +2,7 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/
22import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers";
33import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
44import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
5import type { SegmentedControlProps } from "./SegmentedControl";
5import type { SegmentedControlProps } from "./SegmentedControl.types";
66
77/** Compose content shared by screen controls and native dialogs, inside their existing Host. */
88export function MaterialSegmentedButtons<Value extends number | string>(
apps/mobile/src/components/MaterialSegmentedControl.android.tsx+1-1
......@@ -3,7 +3,7 @@ import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android";
33import { View } from "react-native";
44
55import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6import type { SegmentedControlProps } from "./SegmentedControl";
6import type { SegmentedControlProps } from "./SegmentedControl.types";
77
88export function MaterialSegmentedControl<Value extends number | string>(
99 props: SegmentedControlProps<Value>,
apps/mobile/src/components/MaterialSegmentedControl.tsx+1-1
......@@ -1,4 +1,4 @@
1import type { SegmentedControlProps } from "./SegmentedControl";
1import type { SegmentedControlProps } from "./SegmentedControl.types";
22
33export function MaterialSegmentedControl<Value extends number | string>(
44 _props: SegmentedControlProps<Value>,
apps/mobile/src/components/MaterialSwitch.android.tsx+1-1
......@@ -1,6 +1,6 @@
11import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose";
22import { View } from "react-native";
3import type { ThemedSwitchProps } from "./ThemedSwitch";
3import type { ThemedSwitchProps } from "./MaterialSwitch.types";
44
55import { 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";
66import { useMediaActions } from "../lib/mediaActions";
77import { AppText } from "./AppText";
88import { SymbolView } from "./AppSymbol";
9import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
9import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types";
1010import { MediaActionsMenu } from "./MediaActionsMenu";
1111import { 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
33import { AppText as Text } from "./AppText";
44import { cn } from "../lib/cn";
55import { MaterialSegmentedControl } from "./MaterialSegmentedControl";
6import type { SegmentedControlProps } from "./SegmentedControl.types";
67
7export interface SegmentedControlProps<Value extends number | string> {
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}
8export type { SegmentedControlProps } from "./SegmentedControl.types";
219
2210export function SegmentedControl<Value extends number | string>(
2311 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 @@
1import { Platform, Switch, type SwitchProps } from "react-native";
1import { Platform, Switch } from "react-native";
22
33import { MaterialSwitch } from "./MaterialSwitch";
4import type { ThemedSwitchProps } from "./MaterialSwitch.types";
45
5export type ThemedSwitchProps = Pick<
6 SwitchProps,
7 | "accessibilityHint"
8 | "accessibilityLabel"
9 | "disabled"
10 | "onValueChange"
11 | "style"
12 | "testID"
13 | "value"
14>;
6export type { ThemedSwitchProps } from "./MaterialSwitch.types";
157
168export function ThemedSwitch(props: ThemedSwitchProps) {
179 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";
44import { MOBILE_CODE_SURFACE } from "../../lib/typography";
55
66import type { ReviewRenderableLineRow } from "./reviewModel";
7import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";
7import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
88
99export const REVIEW_MONO_FONT_FAMILY = Platform.select({
1010 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 @@
11import { diffWordsWithSpace } from "diff";
22
3import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";
3import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
44
55interface ReviewDiffOperation {
66 readonly value: string;
apps/mobile/src/features/review/shikiReviewHighlighter.ts+3-6
......@@ -35,12 +35,9 @@ export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErr
3535 }
3636}
3737
38export interface ReviewHighlightedToken {
39 content: string;
40 readonly color: string | null;
41 readonly fontStyle: number | null;
42 readonly diffHighlight?: boolean;
43}
38import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
39
40export type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
4441
4542const SHIKI_THEME_NAME_BY_SCHEME = {
4643 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";
8989import {
9090 resolvePendingTaskInteractionMode,
9191 resolveProviderInteractionMode,
92} from "./legacy-plan-mode";
92} from "../../state/legacy-plan-mode";
9393import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
9494import {
9595 resolveNewTaskBranchWorktreePath,
apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts+1-1
......@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import { AsyncResult } from "effect/unstable/reactivity";
33
44import { mobilePreferencesAtom } from "../../state/preferences";
5import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode";
5import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode";
66
77/**
88 * 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", () => ({
3232}));
3333
3434// The real read lease and cleanup are covered by the composer ownership suite.
35vi.mock("../state/use-composer-drafts", () => ({
35vi.mock("./composerAttachmentPreviewRetention", () => ({
3636 retainComposerAttachmentFileForPreview: () => () => {},
3737}));
3838
apps/mobile/src/lib/attachmentUpload.ts+1-1
......@@ -20,8 +20,8 @@ import { appAtomRegistry } from "../state/atom-registry";
2020import { assetEnvironment } from "../state/assets";
2121import { attachmentEnvironment } from "../state/attachments";
2222import { environmentSession } from "../state/session";
23import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
2423import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
24import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention";
2525import {
2626 isComposerImageAttachment,
2727 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(() => ({
66 exists: vi.fn(),
77}));
88
9vi.mock("../state/use-composer-drafts", () => ({
9vi.mock("./composerAttachmentPreviewRetention", () => ({
1010 retainComposerAttachmentFileForPreview: mocks.retain,
1111}));
1212vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share }));
apps/mobile/src/lib/localAttachmentPreview.ts+1-1
......@@ -3,7 +3,7 @@ import { videoMimeType } from "@t3tools/shared/video";
33import type { FileBackedComposerAttachment } from "./composerImages";
44import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
55import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
6import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
6import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention";
77
88/** Retains the draft original for preview and gives each outgoing share its own lease. */
99export async function loadLocalAttachmentPreview(
apps/mobile/src/state/composer-attachment-uploads.ts+1-1
......@@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity";
44import { useEffect, useRef } from "react";
55
66import { prepareTurnAttachments } from "../lib/attachmentUpload";
7import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention";
78import { isFileBackedComposerAttachment } from "../lib/composerImages";
89import {
910 composerAttachmentUploadKey,
......@@ -20,7 +21,6 @@ import {
2021 composerDraftsAtom,
2122 ensureComposerDraftsLoaded,
2223 flushComposerDrafts,
23 retainComposerAttachmentFileForPreview,
2424 setComposerDraftAttachmentUpload,
2525} from "./use-composer-drafts";
2626import { 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";
2626import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
2727import type { DraftComposerAttachment } from "../lib/composerImages";
2828import { scopedThreadKey } from "../lib/scopedEntities";
29import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
29import { resolveProviderInteractionMode } from "./legacy-plan-mode";
3030
3131// Keep current writes until a compatible native baseline includes the v4 reader.
3232const THREAD_OUTBOX_SCHEMA_VERSION = 3;
apps/mobile/src/state/use-composer-drafts.test.ts+1-1
......@@ -179,7 +179,6 @@ import {
179179 removeComposerDraftsForEnvironment,
180180 replaceComposerDraftAttachments,
181181 resetComposerDraftsLoadState,
182 retainComposerAttachmentFileForPreview,
183182 restoreComposerDraftSnapshotState,
184183 restoreCloudComposerDrafts,
185184 retargetNewTaskDraft,
......@@ -194,6 +193,7 @@ import {
194193 undoComposerDraftMerge,
195194 undoComposerDraftMergeState,
196195} from "./use-composer-drafts";
196import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention";
197197
198198const DRAFT: ComposerDraft = {
199199 text: "hello",
apps/mobile/src/state/use-composer-drafts.ts+12-9
......@@ -35,8 +35,11 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
3535import {
3636 composerAttachmentFileReferenceKey,
3737 isComposerAttachmentFileRetained,
38 retainComposerAttachmentFile,
3938} from "../lib/composerAttachmentFiles";
39import {
40 registerComposerAttachmentUnusedHandler,
41 retainComposerAttachmentFileForPreview,
42} from "../lib/composerAttachmentPreviewRetention";
4043import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";
4144import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
4245import { appAtomRegistry } from "./atom-registry";
......@@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup(
957960 });
958961}
959962
960/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */
961export function retainComposerAttachmentFileForPreview(
962 attachment: FileBackedComposerAttachment,
963): () => void {
964 return retainComposerAttachmentFile(attachment.fileUri, () => {
965 scheduleUnusedComposerAttachmentCleanup([attachment]);
966 });
967}
963/**
964 * Owner-side cleanup hook for the shared preview-retention helper: releasing
965 * the last preview/upload lease retries the unused-file sweep. Registered here
966 * because this module owns the draft and outbox references the sweep reads.
967 */
968registerComposerAttachmentUnusedHandler((attachment) => {
969 scheduleUnusedComposerAttachmentCleanup([attachment]);
970});
968971
969972function schedulePersistComposerState(): void {
970973 if (persistTimer !== null) {
apps/mobile/src/state/use-thread-composer-state.ts+1-1
......@@ -30,7 +30,7 @@ import { uuidv4 } from "../lib/uuid";
3030
3131import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
3232import { isModelSelectionUnavailable } from "../lib/modelOptions";
33import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
33import { resolveProviderInteractionMode } from "./legacy-plan-mode";
3434import {
3535 convertPastedImagesToAttachments,
3636 createPastedTextComposerAttachment,