History

Refactor mobile remote search and review state

- Move composer path search and source-control discovery into shared managed state - Persist review async/loading state in atoms and add coverage - Update mobile/web composers and review highlighter initialization

Julius Marminge committed 6 months ago

Commit e793c071 parent ab95f1137 files · +1366 −545Browse files

Unified · Split

37 files
  1. .oxfmtrc.json +2 −0
  2. .oxlintrc.json +2 −0
  3. apps/mobile/src/features/review/ReviewHighlighterProvider.tsx +5 −91
  4. apps/mobile/src/features/review/reviewHighlighterState.test.ts +75 −0
  5. apps/mobile/src/features/review/reviewHighlighterState.ts +154 −0
  6. apps/mobile/src/features/review/reviewModel.ts +2 −1
  7. apps/mobile/src/features/review/reviewState.test.ts +32 −0
  8. apps/mobile/src/features/review/reviewState.ts +73 −0
  9. apps/mobile/src/features/review/shikiReviewHighlighter.ts +1 −1
  10. apps/mobile/src/features/review/useReviewSections.ts +37 −18
  11. apps/mobile/src/features/threads/ThreadComposer.tsx +13 −64
  12. apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +1 −5
  13. apps/mobile/src/state/source-control-discovery-manager.ts +0 −46
  14. apps/mobile/src/state/use-composer-path-search.ts +43 −0
  15. apps/mobile/src/state/use-git-branches.ts +17 −2
  16. apps/mobile/src/state/use-remote-environment-registry.ts +1 −1
  17. apps/mobile/src/state/use-source-control-discovery.ts +51 −23
  18. apps/mobile/src/state/use-thread-detail.ts +1 −1
  19. apps/web/src/components/chat/ChatComposer.tsx +9 −28
  20. apps/web/src/lib/composerPathSearchState.ts +48 −0
  21. apps/web/src/lib/sourceControlDiscoveryState.ts +1 −1
  22. bun.lock +3 −0
  23. package.json +4 −1
  24. packages/client-runtime/src/composerPathSearchState.test.ts +111 −0
  25. packages/client-runtime/src/composerPathSearchState.ts +288 −0
  26. packages/client-runtime/src/gitBranchState.test.ts +33 −0
  27. packages/client-runtime/src/gitBranchState.ts +81 −0
  28. packages/client-runtime/src/index.ts +1 −0
  29. packages/client-runtime/src/sourceControlDiscoveryState.test.ts +102 −0
  30. packages/client-runtime/src/sourceControlDiscoveryState.ts +118 −8
  31. patches/@pierre%2Fdiffs@1.1.20.patch +23 −0
  32. patches/expo-dev-launcher@55.0.28.patch +15 −0
  33. patches/react-native-nitro-modules@0.35.4.patch +16 −0
  34. scripts/mobile-native-static-check.ts +3 −13
  35. scripts/patch-expo-dev-launcher.mjs +0 −63
  36. scripts/patch-pierre-diffs-utils.mjs +0 −105
  37. scripts/patch-react-native-nitro-modules.mjs +0 −73
.oxfmtrc.json+2−0
@@ -9,6 +9,8 @@ · 8 unmodified lines
99 "bun.lock",
1010 "*.tsbuildinfo",
1111 "**/routeTree.gen.ts",
12+ "apps/mobile/android/**",
13+ "apps/mobile/ios/**",
1214 "apps/web/public/mockServiceWorker.js",
1315 "apps/web/src/lib/vendor/qrcodegen.ts",
1416 "apps/mobile/uniwind-types.d.ts",
.oxlintrc.json+2−0
@@ -7,6 +7,8 @@ · 6 unmodified lines
77 "bun.lock",
88 "*.tsbuildinfo",
99 "**/routeTree.gen.ts",
10+ "apps/mobile/android/**",
11+ "apps/mobile/ios/**",
1012 "apps/mobile/uniwind-types.d.ts"
1113 ],
1214 "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"],
apps/mobile/src/features/review/ReviewHighlighterProvider.tsx+5−91
@@ -1,101 +1,15 @@
1−import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
1+import { createContext, type ReactNode, useContext, useMemo } from "react";
22
3−import {
4− getActiveReviewHighlighterEngine,
5− prepareReviewHighlighter,
6− prepareReviewHighlighterLanguages,
7− type ReviewHighlighterEngine,
8−} from "./shikiReviewHighlighter";
3+import { type ReviewHighlighterState, useReviewHighlighterState } from "./reviewHighlighterState";
94
10−type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error";
11−
12−interface ReviewHighlighterContextValue {
13− readonly engine: ReviewHighlighterEngine | null;
14− readonly error: string | null;
15− readonly status: ReviewHighlighterStatus;
16−}
17−
18−const ReviewHighlighterContext = createContext<ReviewHighlighterContextValue>({
5+const ReviewHighlighterContext = createContext<ReviewHighlighterState>({
196 engine: null,
207 error: null,
218 status: "idle",
229 });
2310
24−const REVIEW_INITIAL_LANGUAGES = [
25− "typescript",
26− "tsx",
27− "javascript",
28− "jsx",
29− "json",
30− "yaml",
31− "bash",
32−] as const;
33−
34−function isReviewHighlighterProviderDebugLoggingEnabled(): boolean {
35− return typeof __DEV__ !== "undefined" ? __DEV__ : false;
36−}
37−
38−function logReviewHighlighterProviderDiagnostic(
39− message: string,
40− details?: Record<string, unknown>,
41−): void {
42− if (!isReviewHighlighterProviderDebugLoggingEnabled()) {
43− return;
44− }
45−
46− if (details) {
47− console.log(`[review-highlighter-provider] ${message}`, details);
48− return;
49− }
50−
51− console.log(`[review-highlighter-provider] ${message}`);
52−}
53−
5411 export function ReviewHighlighterProvider(props: { readonly children: ReactNode }) {
55− const [value, setValue] = useState<ReviewHighlighterContextValue>({
56− engine: null,
57− error: null,
58− status: "idle",
59− });
60−
61− useEffect(() => {
62− let cancelled = false;
63−
64− setValue({ engine: null, error: null, status: "initializing" });
65−
66− void (async () => {
67− const startedAt = performance.now();
68− try {
69− await prepareReviewHighlighter();
70− await prepareReviewHighlighterLanguages(REVIEW_INITIAL_LANGUAGES);
71− const engine = await getActiveReviewHighlighterEngine();
72−
73− if (cancelled) {
74− return;
75− }
76−
77− const durationMs = Math.round(performance.now() - startedAt);
78− logReviewHighlighterProviderDiagnostic("initialized", {
79− durationMs,
80− engine,
81− });
82− setValue({ engine, error: null, status: "ready" });
83− } catch (error) {
84− if (cancelled) {
85− return;
86− }
87−
88− const message = error instanceof Error ? error.message : String(error);
89− logReviewHighlighterProviderDiagnostic("initialization failed", { error: message });
90− setValue({ engine: null, error: message, status: "error" });
91− }
92− })();
93−
94− return () => {
95− cancelled = true;
96− };
97− }, []);
98−
12+ const value = useReviewHighlighterState();
9913 const contextValue = useMemo(() => value, [value]);
10014
10115 return (
@@ -105,6 +19,6 @@ export function ReviewHighlighterProvider(props: { readonly children: ReactNode · 3 unmodified lines
10519 );
10620 }
10721
108−export function useReviewHighlighterStatus(): ReviewHighlighterContextValue {
22+export function useReviewHighlighterStatus(): ReviewHighlighterState {
10923 return useContext(ReviewHighlighterContext);
11024 }
apps/mobile/src/features/review/reviewHighlighterState.test.ts+75−0
@@ -0,0 +1,75 @@
1+import { assert, beforeEach, it } from "vitest";
2+import { AtomRegistry } from "effect/unstable/reactivity";
3+
4+import {
5+ createReviewHighlighterManager,
6+ IDLE_REVIEW_HIGHLIGHTER_STATE,
7+} from "./reviewHighlighterState";
8+
9+let registry = AtomRegistry.make();
10+
11+beforeEach(() => {
12+ registry.dispose();
13+ registry = AtomRegistry.make();
14+});
15+
16+function flushAsyncWork(): Promise<void> {
17+ return Promise.resolve().then(() => undefined);
18+}
19+
20+it("initializes review highlighter state once", async () => {
21+ let prepareCalls = 0;
22+ let languageCalls = 0;
23+ let engineCalls = 0;
24+ const manager = createReviewHighlighterManager({
25+ getRegistry: () => registry,
26+ loader: {
27+ prepare: async () => {
28+ prepareCalls += 1;
29+ },
30+ prepareLanguages: async () => {
31+ languageCalls += 1;
32+ },
33+ getEngine: async () => {
34+ engineCalls += 1;
35+ return "javascript";
36+ },
37+ },
38+ });
39+
40+ assert.deepStrictEqual(manager.getSnapshot(), IDLE_REVIEW_HIGHLIGHTER_STATE);
41+
42+ await Promise.all([manager.initialize(), manager.initialize()]);
43+ await manager.initialize();
44+
45+ assert.strictEqual(prepareCalls, 1);
46+ assert.strictEqual(languageCalls, 1);
47+ assert.strictEqual(engineCalls, 1);
48+ assert.deepStrictEqual(manager.getSnapshot(), {
49+ engine: "javascript",
50+ error: null,
51+ status: "ready",
52+ });
53+});
54+
55+it("stores initialization failures in atom state", async () => {
56+ const manager = createReviewHighlighterManager({
57+ getRegistry: () => registry,
58+ loader: {
59+ prepare: async () => {
60+ throw new Error("load failed");
61+ },
62+ prepareLanguages: async () => undefined,
63+ getEngine: async () => "javascript",
64+ },
65+ });
66+
67+ void manager.initialize();
68+ await flushAsyncWork();
69+
70+ assert.deepStrictEqual(manager.getSnapshot(), {
71+ engine: null,
72+ error: "load failed",
73+ status: "error",
74+ });
75+});
apps/mobile/src/features/review/reviewHighlighterState.ts+154−0
@@ -0,0 +1,154 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3+import { useEffect } from "react";
4+
5+import { appAtomRegistry } from "../../state/atom-registry";
6+import {
7+ getActiveReviewHighlighterEngine,
8+ prepareReviewHighlighter,
9+ prepareReviewHighlighterLanguages,
10+ type ReviewHighlighterEngine,
11+} from "./shikiReviewHighlighter";
12+
13+export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error";
14+
15+export interface ReviewHighlighterState {
16+ readonly engine: ReviewHighlighterEngine | null;
17+ readonly error: string | null;
18+ readonly status: ReviewHighlighterStatus;
19+}
20+
21+export interface ReviewHighlighterLoader {
22+ readonly prepare: () => Promise<void>;
23+ readonly prepareLanguages: (languages: ReadonlyArray<string>) => Promise<void>;
24+ readonly getEngine: () => Promise<ReviewHighlighterEngine>;
25+}
26+
27+const REVIEW_INITIAL_LANGUAGES = [
28+ "typescript",
29+ "tsx",
30+ "javascript",
31+ "jsx",
32+ "json",
33+ "yaml",
34+ "bash",
35+] as const;
36+
37+export const IDLE_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({
38+ engine: null,
39+ error: null,
40+ status: "idle",
41+});
42+
43+const INITIALIZING_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({
44+ engine: null,
45+ error: null,
46+ status: "initializing",
47+});
48+
49+export const reviewHighlighterStateAtom = Atom.make(IDLE_REVIEW_HIGHLIGHTER_STATE).pipe(
50+ Atom.keepAlive,
51+ Atom.withLabel("mobile:review-highlighter"),
52+);
53+
54+function isReviewHighlighterProviderDebugLoggingEnabled(): boolean {
55+ return typeof __DEV__ !== "undefined" ? __DEV__ : false;
56+}
57+
58+function logReviewHighlighterProviderDiagnostic(
59+ message: string,
60+ details?: Record<string, unknown>,
61+): void {
62+ if (!isReviewHighlighterProviderDebugLoggingEnabled()) {
63+ return;
64+ }
65+
66+ if (details) {
67+ console.log(`[review-highlighter-provider] ${message}`, details);
68+ return;
69+ }
70+
71+ console.log(`[review-highlighter-provider] ${message}`);
72+}
73+
74+export function createReviewHighlighterManager(config: {
75+ readonly getRegistry: () => AtomRegistry.AtomRegistry;
76+ readonly loader: ReviewHighlighterLoader;
77+ readonly languages?: ReadonlyArray<string>;
78+}) {
79+ let started = false;
80+ let inFlight: Promise<void> | null = null;
81+
82+ function getSnapshot(): ReviewHighlighterState {
83+ return config.getRegistry().get(reviewHighlighterStateAtom);
84+ }
85+
86+ function setState(state: ReviewHighlighterState): void {
87+ config.getRegistry().set(reviewHighlighterStateAtom, state);
88+ }
89+
90+ function initialize(): Promise<void> {
91+ if (inFlight) {
92+ return inFlight;
93+ }
94+
95+ if (started && getSnapshot().status === "ready") {
96+ return Promise.resolve();
97+ }
98+
99+ started = true;
100+ setState(INITIALIZING_REVIEW_HIGHLIGHTER_STATE);
101+
102+ inFlight = (async () => {
103+ const startedAt = performance.now();
104+ try {
105+ await config.loader.prepare();
106+ await config.loader.prepareLanguages(config.languages ?? REVIEW_INITIAL_LANGUAGES);
107+ const engine = await config.loader.getEngine();
108+ const durationMs = Math.round(performance.now() - startedAt);
109+ logReviewHighlighterProviderDiagnostic("initialized", {
110+ durationMs,
111+ engine,
112+ });
113+ setState({ engine, error: null, status: "ready" });
114+ } catch (error) {
115+ const message = error instanceof Error ? error.message : String(error);
116+ logReviewHighlighterProviderDiagnostic("initialization failed", { error: message });
117+ setState({ engine: null, error: message, status: "error" });
118+ } finally {
119+ inFlight = null;
120+ }
121+ })();
122+
123+ return inFlight;
124+ }
125+
126+ function reset(): void {
127+ started = false;
128+ inFlight = null;
129+ setState(IDLE_REVIEW_HIGHLIGHTER_STATE);
130+ }
131+
132+ return {
133+ getSnapshot,
134+ initialize,
135+ reset,
136+ };
137+}
138+
139+const reviewHighlighterManager = createReviewHighlighterManager({
140+ getRegistry: () => appAtomRegistry,
141+ loader: {
142+ prepare: prepareReviewHighlighter,
143+ prepareLanguages: prepareReviewHighlighterLanguages,
144+ getEngine: getActiveReviewHighlighterEngine,
145+ },
146+});
147+
148+export function useReviewHighlighterState(): ReviewHighlighterState {
149+ useEffect(() => {
150+ void reviewHighlighterManager.initialize();
151+ }, []);
152+
153+ return useAtomValue(reviewHighlighterStateAtom);
154+}
apps/mobile/src/features/review/reviewModel.ts+2−1
@@ -1,4 +1,5 @@
1−import { parsePatchFiles, type ChangeTypes, type FileDiffMetadata } from "@pierre/diffs/utils";
1+import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
2+import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
23 import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";
34 import * as Arr from "effect/Array";
45 import * as Order from "effect/Order";
apps/mobile/src/features/review/reviewState.test.ts+32−0
@@ -0,0 +1,32 @@
1+import { assert, it } from "vitest";
2+
3+import {
4+ getReviewAsyncStateSnapshot,
5+ setReviewAsyncError,
6+ setReviewGitDiffsLoading,
7+ setReviewTurnDiffLoading,
8+} from "./reviewState";
9+
10+it("stores review async loading and error state in atoms", () => {
11+ const threadKey = `env-local:thread-review-state-${Date.now()}`;
12+
13+ setReviewGitDiffsLoading(threadKey, true);
14+ setReviewTurnDiffLoading(threadKey, "turn-1", true);
15+ setReviewAsyncError(threadKey, "load failed");
16+
17+ assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), {
18+ loadingGitDiffs: true,
19+ loadingTurnIds: { "turn-1": true },
20+ error: "load failed",
21+ });
22+
23+ setReviewGitDiffsLoading(threadKey, false);
24+ setReviewTurnDiffLoading(threadKey, "turn-1", false);
25+ setReviewAsyncError(threadKey, null);
26+
27+ assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), {
28+ loadingGitDiffs: false,
29+ loadingTurnIds: {},
30+ error: null,
31+ });
32+});
apps/mobile/src/features/review/reviewState.ts+73−0
@@ -9,6 +9,12 @@ import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; · 8 unmodified lines
99
1010 const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<ReviewDiffPreviewSource>>([]);
1111 const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({});
12+const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze<Readonly<Record<string, boolean>>>({});
13+const EMPTY_REVIEW_ASYNC_STATE = Object.freeze<ReviewAsyncState>({
14+ loadingGitDiffs: false,
15+ loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS,
16+ error: null,
17+});
1218 const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze<
1319 Readonly<Record<string, ReadonlyArray<string> | undefined>>
1420 >({});
@@ -24,6 +30,10 @@ const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make<string | null>(null).pip · 9 unmodified lines
2430 Atom.keepAlive,
2531 Atom.withLabel("mobile:review:selected-section-id:null"),
2632 );
33+const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe(
34+ Atom.keepAlive,
35+ Atom.withLabel("mobile:review:async-state:null"),
36+);
2737 const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
2838 Atom.keepAlive,
2939 Atom.withLabel("mobile:review:section-file-ids:null"),
@@ -50,6 +60,13 @@ const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) = · 20 unmodified lines
5060 ),
5161 );
5262
63+const reviewAsyncStateByThreadKeyAtom = Atom.family((threadKey: string) =>
64+ Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe(
65+ Atom.keepAlive,
66+ Atom.withLabel(`mobile:review:async-state:${threadKey}`),
67+ ),
68+);
69+
5370 const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
5471 Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
5572 Atom.keepAlive,
@@ -83,6 +100,7 @@ export interface ReviewCacheForThread { · 27 unmodified lines
83100 readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>;
84101 readonly turnDiffById: Readonly<Record<string, string>>;
85102 readonly selectedSectionId: string | null;
103+ readonly asyncState: ReviewAsyncState;
86104 readonly expandedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;
87105 readonly revealedLargeFileIdsBySection: Readonly<
88106 Record<string, ReadonlyArray<string> | undefined>
@@ -90,6 +108,12 @@ export interface ReviewCacheForThread { · 1 unmodified line
90108 readonly viewedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;
91109 }
92110
111+export interface ReviewAsyncState {
112+ readonly loadingGitDiffs: boolean;
113+ readonly loadingTurnIds: Readonly<Record<string, boolean>>;
114+ readonly error: string | null;
115+}
116+
93117 function buildThreadKey(input: {
94118 readonly environmentId?: EnvironmentId;
95119 readonly threadId?: ThreadId;
@@ -119,6 +143,9 @@ export function useReviewCacheForThread(input: { · 23 unmodified lines
119143 ? reviewSelectedSectionIdByThreadKeyAtom(threadKey)
120144 : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM,
121145 );
146+ const asyncState = useAtomValue(
147+ threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM,
148+ );
122149 const expandedFileIdsBySection = useAtomValue(
123150 threadKey
124151 ? reviewExpandedFileIdsByThreadKeyAtom(threadKey)
@@ -138,6 +165,7 @@ export function useReviewCacheForThread(input: { · 13 unmodified lines
138165 gitSections,
139166 turnDiffById,
140167 selectedSectionId,
168+ asyncState,
141169 expandedFileIdsBySection,
142170 revealedLargeFileIdsBySection,
143171 viewedFileIdsBySection,
@@ -164,6 +192,51 @@ export function setReviewSelectedSectionId(threadKey: string, sectionId: string · 20 unmodified lines
164192 appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId);
165193 }
166194
195+function updateReviewAsyncState(
196+ threadKey: string,
197+ update: (current: ReviewAsyncState) => ReviewAsyncState,
198+): void {
199+ const atom = reviewAsyncStateByThreadKeyAtom(threadKey);
200+ appAtomRegistry.set(atom, update(appAtomRegistry.get(atom)));
201+}
202+
203+export function setReviewGitDiffsLoading(threadKey: string, loadingGitDiffs: boolean): void {
204+ updateReviewAsyncState(threadKey, (current) => ({
205+ ...current,
206+ loadingGitDiffs,
207+ }));
208+}
209+
210+export function setReviewTurnDiffLoading(
211+ threadKey: string,
212+ sectionId: string,
213+ isLoading: boolean,
214+): void {
215+ updateReviewAsyncState(threadKey, (current) => {
216+ const loadingTurnIds = { ...current.loadingTurnIds };
217+ if (isLoading) {
218+ loadingTurnIds[sectionId] = true;
219+ } else {
220+ delete loadingTurnIds[sectionId];
221+ }
222+ return {
223+ ...current,
224+ loadingTurnIds,
225+ };
226+ });
227+}
228+
229+export function setReviewAsyncError(threadKey: string, error: string | null): void {
230+ updateReviewAsyncState(threadKey, (current) => ({
231+ ...current,
232+ error,
233+ }));
234+}
235+
236+export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState {
237+ return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey));
238+}
239+
167240 export function updateReviewExpandedFileIds(
168241 threadKey: string,
169242 sectionId: string,
apps/mobile/src/features/review/shikiReviewHighlighter.ts+1−1
@@ -9,7 +9,7 @@ import typescriptLanguage from "@shikijs/langs/typescript"; · 8 unmodified lines
99 import yamlLanguage from "@shikijs/langs/yaml";
1010 import githubDarkDefault from "@shikijs/themes/github-dark-default";
1111 import githubLightDefault from "@shikijs/themes/github-light-default";
12−import { getFiletypeFromFileName } from "@pierre/diffs/utils";
12+import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName";
1313
1414 import {
1515 resolveReviewHighlighterEngine,
apps/mobile/src/features/review/useReviewSections.ts+37−18
@@ -1,4 +1,4 @@
1−import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1+import { useCallback, useEffect, useMemo, useRef } from "react";
22
33 import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
44
@@ -12,8 +12,11 @@ import { · 7 unmodified lines
1212 getReviewSectionIdForCheckpoint,
1313 } from "./reviewModel";
1414 import {
15+ setReviewAsyncError,
16+ setReviewGitDiffsLoading,
1517 setReviewGitSections,
1618 setReviewSelectedSectionId,
19+ setReviewTurnDiffLoading,
1720 setReviewTurnDiff,
1821 type ReviewCacheForThread,
1922 } from "./reviewState";
@@ -26,9 +29,7 @@ export function useReviewSections(input: { · 6 unmodified lines
2629 const { environmentId, reviewCache, threadId } = input;
2730 const selectedThread = useSelectedThreadDetail();
2831 const { selectedThreadCwd } = useSelectedThreadWorktree();
29− const [loadingTurnIds, setLoadingTurnIds] = useState<Record<string, boolean>>({});
30− const [loadingGitDiffs, setLoadingGitDiffs] = useState(false);
31− const [error, setError] = useState<string | null>(null);
32+ const { error, loadingGitDiffs, loadingTurnIds } = reviewCache.asyncState;
3233 const turnDiffByIdRef = useRef(reviewCache.turnDiffById);
3334
3435 useEffect(() => {
@@ -84,21 +85,32 @@ export function useReviewSections(input: { · 49 unmodified lines
8485
8586 const client = getEnvironmentClient(environmentId);
8687 if (!client) {
87− setError("Remote connection is not ready.");
88+ if (reviewCache.threadKey) {
89+ setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready.");
90+ }
8891 return;
8992 }
9093
91− setLoadingGitDiffs(true);
92− setError(null);
94+ if (reviewCache.threadKey) {
95+ setReviewGitDiffsLoading(reviewCache.threadKey, true);
96+ setReviewAsyncError(reviewCache.threadKey, null);
97+ }
9398 try {
9499 const result = await client.review.getDiffPreview({ cwd: selectedThreadCwd });
95100 if (reviewCache.threadKey) {
96101 setReviewGitSections(reviewCache.threadKey, result.sources);
97102 }
98103 } catch (cause) {
99− setError(cause instanceof Error ? cause.message : "Failed to load review diffs.");
104+ if (reviewCache.threadKey) {
105+ setReviewAsyncError(
106+ reviewCache.threadKey,
107+ cause instanceof Error ? cause.message : "Failed to load review diffs.",
108+ );
109+ }
100110 } finally {
101− setLoadingGitDiffs(false);
111+ if (reviewCache.threadKey) {
112+ setReviewGitDiffsLoading(reviewCache.threadKey, false);
113+ }
102114 }
103115 }, [environmentId, reviewCache.threadKey, selectedThreadCwd]);
104116
@@ -119,12 +131,16 @@ export function useReviewSections(input: { · 14 unmodified lines
119131
120132 const client = getEnvironmentClient(environmentId);
121133 if (!client) {
122− setError("Remote connection is not ready.");
134+ if (reviewCache.threadKey) {
135+ setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready.");
136+ }
123137 return;
124138 }
125139
126− setLoadingTurnIds((current) => ({ ...current, [sectionId]: true }));
127− setError(null);
140+ if (reviewCache.threadKey) {
141+ setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true);
142+ setReviewAsyncError(reviewCache.threadKey, null);
143+ }
128144 try {
129145 const result = await client.orchestration.getTurnDiff({
130146 threadId,
@@ -135,13 +151,16 @@ export function useReviewSections(input: { · 4 unmodified lines
135151 setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff);
136152 }
137153 } catch (cause) {
138− setError(cause instanceof Error ? cause.message : "Failed to load turn diff.");
154+ if (reviewCache.threadKey) {
155+ setReviewAsyncError(
156+ reviewCache.threadKey,
157+ cause instanceof Error ? cause.message : "Failed to load turn diff.",
158+ );
159+ }
139160 } finally {
140− setLoadingTurnIds((current) => {
141− const next = { ...current };
142− delete next[sectionId];
143− return next;
144− });
161+ if (reviewCache.threadKey) {
162+ setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false);
163+ }
145164 }
146165 },
147166 [environmentId, reviewCache.threadKey, threadId],
apps/mobile/src/features/threads/ThreadComposer.tsx+13−64
@@ -42,7 +42,7 @@ import { · 41 unmodified lines
4242 normalizeSearchQuery,
4343 scoreQueryMatch,
4444 } from "@t3tools/shared/searchRanking";
45−import { getEnvironmentClient } from "../../state/environment-session-registry";
45+import { useComposerPathSearch } from "../../state/use-composer-path-search";
4646 import { CLAUDE_AGENT_EFFORT_OPTIONS, type ClaudeAgentEffort } from "./claudeEffortOptions";
4747 import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
4848
@@ -209,12 +209,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 160 unmodified lines
209209
210210 // ── Trigger detection ────────────────────────────────────
211211 const [cursorPosition, setCursorPosition] = useState(0);
212− const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(null);
213− const [pathSearchResults, setPathSearchResults] = useState<
214− ReadonlyArray<{ path: string; kind: "file" | "directory" }>
215− >([]);
216− const [pathSearchLoading, setPathSearchLoading] = useState(false);
217− const pathSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
218212
219213 const handleSelectionChange = useCallback(
220214 (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
@@ -224,60 +218,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 3 unmodified lines
224218 [],
225219 );
226220
227− useEffect(() => {
228− const trigger = detectComposerTrigger(props.draftMessage, cursorPosition);
229− setComposerTrigger(trigger);
230− }, [props.draftMessage, cursorPosition]);
231−
232− // ── File search (debounced) ──────────────────────────────
233− useEffect(() => {
234− if (pathSearchTimerRef.current) {
235− clearTimeout(pathSearchTimerRef.current);
236− pathSearchTimerRef.current = null;
237− }
238−
239− if (composerTrigger?.kind !== "path" || !composerTrigger.query || !props.projectCwd) {
240− setPathSearchResults([]);
241− setPathSearchLoading(false);
242− return;
243− }
244−
245− setPathSearchLoading(true);
246− const query = composerTrigger.query;
247− const cwd = props.projectCwd;
248− const envId = props.environmentId;
249−
250− pathSearchTimerRef.current = setTimeout(() => {
251− const client = getEnvironmentClient(envId);
252− if (!client) {
253− setPathSearchLoading(false);
254− return;
255− }
256−
257− void client.projects
258− .searchEntries({ cwd, query, limit: 20 })
259− .then((result) => {
260− setPathSearchResults(
261− result.entries.map((entry) => ({
262− path: entry.path,
263− kind: entry.kind === "directory" ? ("directory" as const) : ("file" as const),
264− })),
265− );
266− })
267− .catch(() => {
268− setPathSearchResults([]);
269− })
270− .finally(() => {
271− setPathSearchLoading(false);
272− });
273− }, 200);
274−
275− return () => {
276− if (pathSearchTimerRef.current) {
277− clearTimeout(pathSearchTimerRef.current);
278− }
279− };
280− }, [composerTrigger?.kind, composerTrigger?.query, props.projectCwd, props.environmentId]);
221+ const composerTrigger = useMemo<ComposerTrigger | null>(
222+ () => detectComposerTrigger(props.draftMessage, cursorPosition),
223+ [cursorPosition, props.draftMessage],
224+ );
225+ const pathSearch = useComposerPathSearch({
226+ environmentId: props.environmentId,
227+ cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,
228+ query: composerTrigger?.kind === "path" ? composerTrigger.query : null,
229+ });
281230
282231 // ── Build menu items ─────────────────────────────────────
283232 const selectedProviderStatus = useMemo(() => {
@@ -416,7 +365,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 132 unmodified lines
416365 }
417366
418367 if (composerTrigger.kind === "path") {
419− return pathSearchResults.map((entry) => {
368+ return pathSearch.entries.map((entry) => {
420369 const parts = entry.path.split("/");
421370 return {
422371 id: `path:${entry.path}`,
@@ -430,7 +379,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 7 unmodified lines
430379 }
431380
432381 return [];
433− }, [composerTrigger, selectedProviderStatus, pathSearchResults]);
382+ }, [composerTrigger, pathSearch.entries, selectedProviderStatus]);
434383
435384 // ── Handle command selection ──────────────────────────────
436385 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
@@ -680,7 +629,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 243 unmodified lines
680629 <ComposerCommandPopover
681630 items={composerMenuItems}
682631 triggerKind={composerTrigger.kind}
683− isLoading={pathSearchLoading}
632+ isLoading={pathSearch.isPending}
684633 onSelect={handleCommandSelect}
685634 />
686635 </View>
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+1−5
@@ -1,6 +1,6 @@
11 import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
22 import { useRouter } from "expo-router";
3−import { useEffect, useState } from "react";
3+import { useState } from "react";
44 import { Pressable, ScrollView, View } from "react-native";
55 import { useSafeAreaInsets } from "react-native-safe-area-context";
66 import { useThemeColor } from "../../../lib/useThemeColor";
@@ -52,10 +52,6 @@ export function GitBranchesSheet() { · 45 unmodified lines
5252 .map((branch) => branch.name),
5353 );
5454
55− useEffect(() => {
56− void gitActions.refreshSelectedThreadBranches();
57− }, [gitActions]);
58−
5955 return (
6056 <ScrollView
6157 showsVerticalScrollIndicator={false}
apps/mobile/src/state/source-control-discovery-manager.ts+0−46
@@ -1,46 +0,0 @@
1−import {
2− type SourceControlDiscoveryClient,
3− type SourceControlDiscoveryTarget,
4− createSourceControlDiscoveryManager,
5−} from "@t3tools/client-runtime";
6−import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts";
7−
8−import { appAtomRegistry } from "./atom-registry";
9−import { getEnvironmentClient } from "./environment-session-registry";
10−
11−export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({
12− getRegistry: () => appAtomRegistry,
13− getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null,
14−});
15−
16−export function sourceControlDiscoveryTargetForEnvironment(
17− environmentId: EnvironmentId | null,
18−): SourceControlDiscoveryTarget<EnvironmentId> {
19− return { key: environmentId ?? null };
20−}
21−
22−export function refreshSourceControlDiscoveryForEnvironment(
23− environmentId: EnvironmentId | null,
24− client?: SourceControlDiscoveryClient | null,
25−): Promise<SourceControlDiscoveryResult | null> {
26− return sourceControlDiscoveryManager.refresh(
27− sourceControlDiscoveryTargetForEnvironment(environmentId),
28− client ?? undefined,
29− );
30−}
31−
32−export function invalidateSourceControlDiscoveryForEnvironment(
33− environmentId: EnvironmentId | null,
34−): void {
35− sourceControlDiscoveryManager.invalidate(
36− sourceControlDiscoveryTargetForEnvironment(environmentId),
37− );
38−}
39−
40−export function resetSourceControlDiscoveryState(): void {
41− sourceControlDiscoveryManager.reset();
42−}
43−
44−export function resetSourceControlDiscoveryStateForTests(): void {
45− resetSourceControlDiscoveryState();
46−}
apps/mobile/src/state/use-composer-path-search.ts+43−0
@@ -0,0 +1,43 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import {
3+ type ComposerPathSearchState,
4+ type ComposerPathSearchTarget,
5+ EMPTY_COMPOSER_PATH_SEARCH_ATOM,
6+ EMPTY_COMPOSER_PATH_SEARCH_STATE,
7+ composerPathSearchStateAtom,
8+ createComposerPathSearchManager,
9+ getComposerPathSearchTargetKey,
10+ normalizeComposerPathSearchQuery,
11+} from "@t3tools/client-runtime";
12+import { useEffect, useMemo } from "react";
13+
14+import { appAtomRegistry } from "./atom-registry";
15+import {
16+ getEnvironmentClient,
17+ subscribeEnvironmentConnections,
18+} from "./environment-session-registry";
19+
20+const composerPathSearchManager = createComposerPathSearchManager({
21+ getRegistry: () => appAtomRegistry,
22+ getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null,
23+ subscribeClientChanges: subscribeEnvironmentConnections,
24+});
25+
26+export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState {
27+ const stableTarget = useMemo(
28+ () => ({
29+ environmentId: target.environmentId,
30+ cwd: target.cwd,
31+ query: normalizeComposerPathSearchQuery(target.query),
32+ }),
33+ [target.cwd, target.environmentId, target.query],
34+ );
35+ const targetKey = getComposerPathSearchTargetKey(stableTarget);
36+
37+ useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]);
38+
39+ const state = useAtomValue(
40+ targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM,
41+ );
42+ return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state;
43+}
apps/mobile/src/state/use-git-branches.ts+17−2
@@ -1,4 +1,5 @@
11 import { useAtomValue } from "@effect/atom-react";
2+import { useEffect, useMemo } from "react";
23 import {
34 type GitBranchState,
45 type GitBranchTarget,
@@ -10,7 +11,10 @@ import { · 5 unmodified lines
1011 } from "@t3tools/client-runtime";
1112
1213 import { appAtomRegistry } from "./atom-registry";
13−import { getEnvironmentClient } from "./environment-session-registry";
14+import {
15+ getEnvironmentClient,
16+ subscribeEnvironmentConnections,
17+} from "./environment-session-registry";
1418
1519 export const gitBranchManager = createGitBranchManager({
1620 getRegistry: () => appAtomRegistry,
@@ -18,10 +22,21 @@ export const gitBranchManager = createGitBranchManager({ · 1 unmodified line
1822 const client = getEnvironmentClient(environmentId);
1923 return client ? client.git : null;
2024 },
25+ subscribeClientChanges: subscribeEnvironmentConnections,
26+ watchLimit: 100,
2127 });
2228
2329 export function useGitBranches(target: GitBranchTarget): GitBranchState {
24− const targetKey = getGitBranchTargetKey(target);
30+ const stableTarget = useMemo(
31+ () => ({
32+ environmentId: target.environmentId,
33+ cwd: target.cwd,
34+ query: target.query ?? null,
35+ }),
36+ [target.cwd, target.environmentId, target.query],
37+ );
38+ const targetKey = getGitBranchTargetKey(stableTarget);
39+ useEffect(() => gitBranchManager.watch(stableTarget), [stableTarget]);
2540 const state = useAtomValue(
2641 targetKey !== null ? gitBranchStateAtom(targetKey) : EMPTY_GIT_BRANCH_ATOM,
2742 );
apps/mobile/src/state/use-remote-environment-registry.ts+1−1
@@ -31,7 +31,7 @@ import { type ConnectedEnvironmentSummary } from "./remote-runtime-types"; · 30 unmodified lines
3131 import {
3232 invalidateSourceControlDiscoveryForEnvironment,
3333 resetSourceControlDiscoveryState,
34−} from "./source-control-discovery-manager";
34+} from "./use-source-control-discovery";
3535 import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";
3636 import { shellSnapshotManager } from "./use-shell-snapshot";
3737 import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session";
apps/mobile/src/state/use-source-control-discovery.ts+51−23
@@ -2,44 +2,72 @@ import { useAtomValue } from "@effect/atom-react"; · 1 unmodified line
22 import {
33 EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
44 EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
5+ type SourceControlDiscoveryClient,
56 type SourceControlDiscoveryState,
7+ type SourceControlDiscoveryTarget,
8+ createSourceControlDiscoveryManager,
69 getSourceControlDiscoveryTargetKey,
710 sourceControlDiscoveryStateAtom,
811 } from "@t3tools/client-runtime";
9−import type { EnvironmentId } from "@t3tools/contracts";
10−import { useEffect } from "react";
12+import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts";
13+import { useEffect, useMemo } from "react";
1114
15+import { appAtomRegistry } from "./atom-registry";
1216 import {
1317 getEnvironmentClient,
1418 subscribeEnvironmentConnections,
1519 } from "./environment-session-registry";
16−import {
17− refreshSourceControlDiscoveryForEnvironment,
18− sourceControlDiscoveryTargetForEnvironment,
19−} from "./source-control-discovery-manager";
2020
21−export function useSourceControlDiscovery(
21+const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({
22+ getRegistry: () => appAtomRegistry,
23+ getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null,
24+ subscribeClientChanges: subscribeEnvironmentConnections,
25+});
26+
27+function sourceControlDiscoveryTargetForEnvironment(
2228 environmentId: EnvironmentId | null,
23−): SourceControlDiscoveryState {
24− useEffect(() => {
25− if (!environmentId) {
26− return;
27− }
29+): SourceControlDiscoveryTarget<EnvironmentId> {
30+ return { key: environmentId ?? null };
31+}
32+
33+export function refreshSourceControlDiscoveryForEnvironment(
34+ environmentId: EnvironmentId | null,
35+ client?: SourceControlDiscoveryClient | null,
36+): Promise<SourceControlDiscoveryResult | null> {
37+ return sourceControlDiscoveryManager.refresh(
38+ sourceControlDiscoveryTargetForEnvironment(environmentId),
39+ client ?? undefined,
40+ );
41+}
42+
43+export function invalidateSourceControlDiscoveryForEnvironment(
44+ environmentId: EnvironmentId | null,
45+): void {
46+ sourceControlDiscoveryManager.invalidate(
47+ sourceControlDiscoveryTargetForEnvironment(environmentId),
48+ );
49+}
2850
29− const refresh = () => {
30− const client = getEnvironmentClient(environmentId);
31− if (!client) {
32− return;
33− }
51+export function resetSourceControlDiscoveryState(): void {
52+ sourceControlDiscoveryManager.reset();
53+}
3454
35− void refreshSourceControlDiscoveryForEnvironment(environmentId, client.server);
36− };
55+export function resetSourceControlDiscoveryStateForTests(): void {
56+ resetSourceControlDiscoveryState();
57+}
3758
38− refresh();
39− return subscribeEnvironmentConnections(refresh);
40− }, [environmentId]);
59+export function useSourceControlDiscovery(
60+ environmentId: EnvironmentId | null,
61+): SourceControlDiscoveryState {
62+ const target = useMemo(
63+ () => sourceControlDiscoveryTargetForEnvironment(environmentId),
64+ [environmentId],
65+ );
66+
67+ useEffect(() => {
68+ return sourceControlDiscoveryManager.watch(target);
69+ }, [target]);
4170
42− const target = sourceControlDiscoveryTargetForEnvironment(environmentId);
4371 const targetKey = getSourceControlDiscoveryTargetKey(target);
4472 const state = useAtomValue(
4573 targetKey !== null
apps/mobile/src/state/use-thread-detail.ts+1−1
@@ -39,7 +39,7 @@ function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean { · 38 unmodified lines
3939 );
4040 }
4141
42−export const threadDetailManager = createThreadDetailManager({
42+const threadDetailManager = createThreadDetailManager({
4343 getRegistry: () => appAtomRegistry,
4444 getClient: (environmentId) => {
4545 const client = getEnvironmentClient(environmentId);
apps/web/src/components/chat/ChatComposer.tsx+9−28
@@ -2,7 +2,6 @@ import type { · 1 unmodified line
22 ApprovalRequestId,
33 EnvironmentId,
44 ModelSelection,
5− ProjectEntry,
65 ProviderApprovalDecision,
76 ProviderInteractionMode,
87 ResolvedKeybindingsConfig,
@@ -29,9 +28,6 @@ import { · 20 unmodified lines
2928 useRef,
3029 useState,
3130 } from "react";
32−import { useQuery } from "@tanstack/react-query";
33−import { useDebouncedValue } from "@tanstack/react-pacer";
34−import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3531 import {
3632 clampCollapsedComposerCursor,
3733 type ComposerTrigger,
@@ -55,6 +51,7 @@ import { · 17 unmodified lines
5551 insertInlineTerminalContextPlaceholder,
5652 removeInlineTerminalContextPlaceholder,
5753 } from "../../lib/terminalContext";
54+import { useComposerPathSearch } from "../../lib/composerPathSearchState";
5855 import {
5956 shouldUseCompactComposerPrimaryActions,
6057 shouldUseCompactComposerFooter,
@@ -136,8 +133,6 @@ const runtimeModeConfig: Record< · 75 unmodified lines
136133 };
137134
138135 const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
139−const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
140−const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
141136 const COMPOSER_FLOATING_LAYER_SELECTOR = [
142137 '[data-slot="popover-popup"]',
143138 '[data-slot="menu-popup"]',
@@ -833,27 +828,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 689 unmodified lines
833828 const composerTriggerKind = composerTrigger?.kind ?? null;
834829 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
835830 const isPathTrigger = composerTriggerKind === "path";
836− const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(
837− pathTriggerQuery,
838− { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },
839− (debouncerState) => ({ isPending: debouncerState.isPending }),
840− );
841− const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
842− const workspaceEntriesQuery = useQuery(
843− projectSearchEntriesQueryOptions({
844− environmentId,
845− cwd: gitCwd,
846− query: effectivePathQuery,
847− enabled: isPathTrigger,
848− limit: 80,
849− }),
850− );
851− const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
831+ const workspaceEntries = useComposerPathSearch({
832+ environmentId,
833+ cwd: isPathTrigger ? gitCwd : null,
834+ query: isPathTrigger ? pathTriggerQuery : null,
835+ });
852836
853837 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
854838 if (!composerTrigger) return [];
855839 if (composerTrigger.kind === "path") {
856− return workspaceEntries.map((entry) => ({
840+ return workspaceEntries.entries.map((entry) => ({
857841 id: `path:${entry.kind}:${entry.path}`,
858842 type: "path",
859843 path: entry.path,
@@ -919,7 +903,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 59 unmodified lines
919903 );
920904 }
921905 return [];
922− }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]);
906+ }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]);
923907
924908 const composerMenuOpen = Boolean(composerTrigger);
925909 const composerMenuSearchKey = composerTrigger
@@ -984,10 +968,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 58 unmodified lines
984968 ]);
985969
986970 const isComposerMenuLoading =
987− composerTriggerKind === "path" &&
988− ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
989− workspaceEntriesQuery.isLoading ||
990− workspaceEntriesQuery.isFetching);
971+ composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending;
991972 const composerMenuEmptyState = useMemo(() => {
992973 if (composerTriggerKind === "skill") {
993974 return "No skills found. Try / to browse provider commands.";
apps/web/src/lib/composerPathSearchState.ts+48−0
@@ -0,0 +1,48 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import {
3+ type ComposerPathSearchState,
4+ type ComposerPathSearchTarget,
5+ EMPTY_COMPOSER_PATH_SEARCH_ATOM,
6+ EMPTY_COMPOSER_PATH_SEARCH_STATE,
7+ composerPathSearchStateAtom,
8+ createComposerPathSearchManager,
9+ getComposerPathSearchTargetKey,
10+ normalizeComposerPathSearchQuery,
11+} from "@t3tools/client-runtime";
12+import { useEffect, useMemo } from "react";
13+
14+import {
15+ readEnvironmentConnection,
16+ subscribeEnvironmentConnections,
17+} from "../environments/runtime";
18+import { appAtomRegistry } from "../rpc/atomRegistry";
19+
20+const COMPOSER_PATH_SEARCH_LIMIT = 80;
21+const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;
22+
23+const composerPathSearchManager = createComposerPathSearchManager({
24+ getRegistry: () => appAtomRegistry,
25+ getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.projects ?? null,
26+ subscribeClientChanges: subscribeEnvironmentConnections,
27+ limit: COMPOSER_PATH_SEARCH_LIMIT,
28+ debounceMs: COMPOSER_PATH_SEARCH_DEBOUNCE_MS,
29+});
30+
31+export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState {
32+ const stableTarget = useMemo(
33+ () => ({
34+ environmentId: target.environmentId,
35+ cwd: target.cwd,
36+ query: normalizeComposerPathSearchQuery(target.query),
37+ }),
38+ [target.cwd, target.environmentId, target.query],
39+ );
40+ const targetKey = getComposerPathSearchTargetKey(stableTarget);
41+
42+ useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]);
43+
44+ const state = useAtomValue(
45+ targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM,
46+ );
47+ return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state;
48+}
apps/web/src/lib/sourceControlDiscoveryState.ts+1−1
@@ -35,7 +35,7 @@ function sourceControlDiscoveryTarget( · 34 unmodified lines
3535 : { key: environmentId };
3636 }
3737
38−export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({
38+const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({
3939 getRegistry: () => appAtomRegistry,
4040 getClient: (key) => {
4141 if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) {
bun.lock+3−0
@@ -339,7 +339,10 @@ · 338 unmodified lines
339339 "node-pty",
340340 ],
341341 "patchedDependencies": {
342+ "expo-dev-launcher@55.0.28": "patches/expo-dev-launcher@55.0.28.patch",
343+ "react-native-nitro-modules@0.35.4": "patches/react-native-nitro-modules@0.35.4.patch",
342344 "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch",
345+ "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch",
343346 },
344347 "overrides": {
345348 "@effect/atom-react": "catalog:",
package.json+4−1
@@ -92,7 +92,10 @@ · 91 unmodified lines
9292 ]
9393 },
9494 "patchedDependencies": {
95− "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch"
95+ "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch",
96+ "expo-dev-launcher@55.0.28": "patches/expo-dev-launcher@55.0.28.patch",
97+ "react-native-nitro-modules@0.35.4": "patches/react-native-nitro-modules@0.35.4.patch",
98+ "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch"
9699 },
97100 "trustedDependencies": [
98101 "node-pty",
packages/client-runtime/src/composerPathSearchState.test.ts+111−0
@@ -0,0 +1,111 @@
1+import { assert, beforeEach, it } from "vitest";
2+import type { EnvironmentId } from "@t3tools/contracts";
3+import { AtomRegistry } from "effect/unstable/reactivity";
4+
5+import {
6+ type ComposerPathSearchClient,
7+ createComposerPathSearchManager,
8+ EMPTY_COMPOSER_PATH_SEARCH_STATE,
9+ getComposerPathSearchTargetKey,
10+} from "./composerPathSearchState.ts";
11+
12+let registry = AtomRegistry.make();
13+
14+const noop = () => undefined;
15+
16+beforeEach(() => {
17+ registry.dispose();
18+ registry = AtomRegistry.make();
19+});
20+
21+function flushAsyncWork(): Promise<void> {
22+ return Promise.resolve().then(() => undefined);
23+}
24+
25+const TARGET = {
26+ environmentId: "env-local" as EnvironmentId,
27+ cwd: "/repo",
28+ query: "src",
29+};
30+
31+it("derives null keys for inactive path searches", () => {
32+ assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, query: "" }), null);
33+ assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, cwd: null }), null);
34+ assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, environmentId: null }), null);
35+});
36+
37+it("stores path search results in atom state", async () => {
38+ const manager = createComposerPathSearchManager({
39+ getRegistry: () => registry,
40+ debounceMs: 0,
41+ getClient: () => ({
42+ searchEntries: async () => ({
43+ entries: [
44+ { path: "src/index.ts", kind: "file" },
45+ { path: "src/components", kind: "directory" },
46+ ],
47+ truncated: false,
48+ }),
49+ }),
50+ });
51+
52+ manager.search(TARGET);
53+ await flushAsyncWork();
54+
55+ assert.deepStrictEqual(manager.getSnapshot(TARGET), {
56+ entries: [
57+ { path: "src/index.ts", kind: "file" },
58+ { path: "src/components", kind: "directory" },
59+ ],
60+ isPending: false,
61+ error: null,
62+ });
63+});
64+
65+it("ignores stale path search results after a newer request starts", async () => {
66+ let resolveFirst: (value: {
67+ entries: ReadonlyArray<{ path: string; kind: "file" | "directory" }>;
68+ truncated: boolean;
69+ }) => void = noop;
70+ const manager = createComposerPathSearchManager({
71+ getRegistry: () => registry,
72+ debounceMs: 0,
73+ getClient: () => ({
74+ searchEntries: (input: Parameters<ComposerPathSearchClient["searchEntries"]>[0]) => {
75+ if (input.query === "first") {
76+ return new Promise((resolve) => {
77+ resolveFirst = resolve;
78+ });
79+ }
80+ return Promise.resolve({
81+ entries: [{ path: "second.ts", kind: "file" }],
82+ truncated: false,
83+ });
84+ },
85+ }),
86+ });
87+
88+ manager.search({ ...TARGET, query: "first" });
89+ manager.search({ ...TARGET, query: "second" });
90+ await flushAsyncWork();
91+ resolveFirst({ entries: [{ path: "first.ts", kind: "file" }], truncated: false });
92+ await flushAsyncWork();
93+
94+ assert.deepStrictEqual(manager.getSnapshot({ ...TARGET, query: "second" }), {
95+ entries: [{ path: "second.ts", kind: "file" }],
96+ isPending: false,
97+ error: null,
98+ });
99+});
100+
101+it("returns the empty snapshot for inactive targets", () => {
102+ const manager = createComposerPathSearchManager({
103+ getRegistry: () => registry,
104+ getClient: () => null,
105+ });
106+
107+ assert.deepStrictEqual(
108+ manager.getSnapshot({ environmentId: null, cwd: null, query: null }),
109+ EMPTY_COMPOSER_PATH_SEARCH_STATE,
110+ );
111+});
packages/client-runtime/src/composerPathSearchState.ts+288−0
@@ -0,0 +1,288 @@
1+import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
2+import * as Duration from "effect/Duration";
3+import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
5+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
6+
7+export interface ComposerPathSearchEntry {
8+ readonly path: string;
9+ readonly kind: "file" | "directory";
10+ readonly parentPath?: string;
11+}
12+
13+export interface ComposerPathSearchState {
14+ readonly entries: ReadonlyArray<ComposerPathSearchEntry>;
15+ readonly isPending: boolean;
16+ readonly error: string | null;
17+}
18+
19+export interface ComposerPathSearchTarget {
20+ readonly environmentId: EnvironmentId | null;
21+ readonly cwd: string | null;
22+ readonly query: string | null;
23+}
24+
25+export interface ComposerPathSearchClient {
26+ readonly searchEntries: (input: {
27+ readonly cwd: string;
28+ readonly query: string;
29+ readonly limit: number;
30+ }) => Promise<ProjectSearchEntriesResult>;
31+}
32+
33+interface WatchedEntry {
34+ refCount: number;
35+ teardown: () => void;
36+}
37+
38+export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({
39+ entries: [],
40+ isPending: false,
41+ error: null,
42+});
43+
44+const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({
45+ entries: [],
46+ isPending: true,
47+ error: null,
48+});
49+
50+const NOOP: () => void = () => undefined;
51+const DEFAULT_DEBOUNCE_MS = 200;
52+const DEFAULT_LIMIT = 20;
53+
54+export const composerPathSearchStateAtom = Atom.family((key: string) =>
55+ Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe(
56+ Atom.keepAlive,
57+ Atom.withLabel(`composer-path-search:${key}`),
58+ ),
59+);
60+
61+export const EMPTY_COMPOSER_PATH_SEARCH_ATOM = Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe(
62+ Atom.keepAlive,
63+ Atom.withLabel("composer-path-search:null"),
64+);
65+
66+export function normalizeComposerPathSearchQuery(query: string | null): string {
67+ return query?.trim() ?? "";
68+}
69+
70+export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget): string | null {
71+ const query = normalizeComposerPathSearchQuery(target.query);
72+ if (target.environmentId === null || target.cwd === null || query.length === 0) {
73+ return null;
74+ }
75+
76+ return `${target.environmentId}:${target.cwd}:${query}`;
77+}
78+
79+function toSearchEntries(
80+ entries: ProjectSearchEntriesResult["entries"],
81+): ReadonlyArray<ComposerPathSearchEntry> {
82+ return entries.map((entry) => ({
83+ path: entry.path,
84+ kind: entry.kind === "directory" ? "directory" : "file",
85+ ...(entry.parentPath !== undefined ? { parentPath: entry.parentPath } : {}),
86+ }));
87+}
88+
89+export function createComposerPathSearchManager(config: {
90+ readonly getRegistry: () => AtomRegistry.AtomRegistry;
91+ readonly getClient: (environmentId: EnvironmentId) => ComposerPathSearchClient | null;
92+ readonly subscribeClientChanges?: (listener: () => void) => () => void;
93+ readonly debounceMs?: number;
94+ readonly limit?: number;
95+}) {
96+ const watched = new Map<string, WatchedEntry>();
97+ const versions = new Map<string, number>();
98+ const timers = new Map<string, Fiber.Fiber<void, never>>();
99+ const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
100+ const limit = config.limit ?? DEFAULT_LIMIT;
101+
102+ function bumpVersion(targetKey: string): number {
103+ const next = (versions.get(targetKey) ?? 0) + 1;
104+ versions.set(targetKey, next);
105+ return next;
106+ }
107+
108+ function setState(targetKey: string, state: ComposerPathSearchState): void {
109+ config.getRegistry().set(composerPathSearchStateAtom(targetKey), state);
110+ }
111+
112+ function clearTimer(targetKey: string): void {
113+ const fiber = timers.get(targetKey);
114+ if (fiber) {
115+ Effect.runFork(Fiber.interrupt(fiber));
116+ timers.delete(targetKey);
117+ }
118+ }
119+
120+ function getSnapshot(target: ComposerPathSearchTarget): ComposerPathSearchState {
121+ const targetKey = getComposerPathSearchTargetKey(target);
122+ return targetKey === null
123+ ? EMPTY_COMPOSER_PATH_SEARCH_STATE
124+ : config.getRegistry().get(composerPathSearchStateAtom(targetKey));
125+ }
126+
127+ function runSearch(
128+ targetKey: string,
129+ target: ComposerPathSearchTarget & {
130+ readonly environmentId: EnvironmentId;
131+ readonly cwd: string;
132+ },
133+ client: ComposerPathSearchClient,
134+ version: number,
135+ ): void {
136+ void client
137+ .searchEntries({
138+ cwd: target.cwd,
139+ query: normalizeComposerPathSearchQuery(target.query),
140+ limit,
141+ })
142+ .then((result) => {
143+ if (versions.get(targetKey) !== version) {
144+ return;
145+ }
146+ setState(targetKey, {
147+ entries: toSearchEntries(result.entries),
148+ isPending: false,
149+ error: null,
150+ });
151+ })
152+ .catch((error: unknown) => {
153+ if (versions.get(targetKey) !== version) {
154+ return;
155+ }
156+ setState(targetKey, {
157+ entries: [],
158+ isPending: false,
159+ error: error instanceof Error ? error.message : "Failed to search project files.",
160+ });
161+ });
162+ }
163+
164+ function search(target: ComposerPathSearchTarget, client?: ComposerPathSearchClient): void {
165+ const targetKey = getComposerPathSearchTargetKey(target);
166+ if (targetKey === null || target.environmentId === null || target.cwd === null) {
167+ return;
168+ }
169+
170+ const resolved = client ?? config.getClient(target.environmentId);
171+ if (!resolved) {
172+ setState(targetKey, {
173+ entries: [],
174+ isPending: false,
175+ error: "Remote connection is not ready.",
176+ });
177+ return;
178+ }
179+
180+ const version = bumpVersion(targetKey);
181+ clearTimer(targetKey);
182+ setState(targetKey, PENDING_COMPOSER_PATH_SEARCH_STATE);
183+
184+ const readyTarget = {
185+ ...target,
186+ environmentId: target.environmentId,
187+ cwd: target.cwd,
188+ };
189+
190+ if (debounceMs <= 0) {
191+ runSearch(targetKey, readyTarget, resolved, version);
192+ return;
193+ }
194+
195+ const fiber = Effect.runFork(
196+ Effect.sleep(Duration.millis(debounceMs)).pipe(
197+ Effect.andThen(
198+ Effect.sync(() => {
199+ timers.delete(targetKey);
200+ runSearch(targetKey, readyTarget, resolved, version);
201+ }),
202+ ),
203+ ),
204+ );
205+ timers.set(targetKey, fiber);
206+ }
207+
208+ function watch(target: ComposerPathSearchTarget): () => void {
209+ const targetKey = getComposerPathSearchTargetKey(target);
210+ if (targetKey === null || target.environmentId === null || target.cwd === null) {
211+ return NOOP;
212+ }
213+
214+ const existing = watched.get(targetKey);
215+ if (existing) {
216+ existing.refCount += 1;
217+ return () => unwatch(targetKey);
218+ }
219+
220+ let currentClient: ComposerPathSearchClient | null = null;
221+ const sync = () => {
222+ const client = config.getClient(target.environmentId!);
223+ if (!client) {
224+ currentClient = null;
225+ setState(targetKey, {
226+ entries: [],
227+ isPending: false,
228+ error: "Remote connection is not ready.",
229+ });
230+ return;
231+ }
232+
233+ if (currentClient === client) {
234+ return;
235+ }
236+
237+ currentClient = client;
238+ search(target, client);
239+ };
240+
241+ const unsubscribe = config.subscribeClientChanges?.(sync) ?? NOOP;
242+ sync();
243+
244+ watched.set(targetKey, {
245+ refCount: 1,
246+ teardown: () => {
247+ unsubscribe();
248+ clearTimer(targetKey);
249+ bumpVersion(targetKey);
250+ },
251+ });
252+
253+ return () => unwatch(targetKey);
254+ }
255+
256+ function unwatch(targetKey: string): void {
257+ const entry = watched.get(targetKey);
258+ if (!entry) {
259+ return;
260+ }
261+
262+ entry.refCount -= 1;
263+ if (entry.refCount > 0) {
264+ return;
265+ }
266+
267+ entry.teardown();
268+ watched.delete(targetKey);
269+ }
270+
271+ function reset(): void {
272+ for (const entry of watched.values()) {
273+ entry.teardown();
274+ }
275+ watched.clear();
276+ versions.clear();
277+ for (const targetKey of timers.keys()) {
278+ clearTimer(targetKey);
279+ }
280+ }
281+
282+ return {
283+ getSnapshot,
284+ search,
285+ watch,
286+ reset,
287+ };
288+}
packages/client-runtime/src/gitBranchState.test.ts+33−0
@@ -16,6 +16,8 @@ function resetAtomRegistry() { · 15 unmodified lines
1616 atomRegistry = AtomRegistry.make();
1717 }
1818
19+const noop = () => undefined;
20+
1921 const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;
2022
2123 const FIRST_PAGE: GitListBranchesResult = {
@@ -158,4 +160,35 @@ describe("createGitBranchManager", () => { · 136 unmodified lines
158160
159161 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_GIT_BRANCH_STATE);
160162 });
163+
164+ it("watches branches with a ref-counted client-change subscription", async () => {
165+ const mock = createMockClient();
166+ let listener: () => void = noop;
167+ const unsubscribe = vi.fn();
168+ const manager = createGitBranchManager({
169+ getRegistry: () => atomRegistry,
170+ getClient: () => mock.client,
171+ subscribeClientChanges: (nextListener) => {
172+ listener = nextListener;
173+ return unsubscribe;
174+ },
175+ watchLimit: 100,
176+ });
177+
178+ const firstUnwatch = manager.watch(TARGET);
179+ const secondUnwatch = manager.watch(TARGET);
180+ await Promise.resolve();
181+
182+ expect(mock.listRefs).toHaveBeenCalledTimes(1);
183+ expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 });
184+
185+ listener();
186+ await Promise.resolve();
187+ expect(mock.listRefs).toHaveBeenCalledTimes(1);
188+
189+ firstUnwatch();
190+ expect(unsubscribe).not.toHaveBeenCalled();
191+ secondUnwatch();
192+ expect(unsubscribe).toHaveBeenCalledTimes(1);
193+ });
161194 });
packages/client-runtime/src/gitBranchState.ts+81−0
@@ -82,10 +82,22 @@ function mergeBranches( · 81 unmodified lines
8282 export interface GitBranchManagerConfig {
8383 readonly getRegistry: () => AtomRegistry.AtomRegistry;
8484 readonly getClient: (environmentId: EnvironmentId) => GitBranchClient | null;
85+ readonly subscribeClientChanges?: (listener: () => void) => () => void;
86+ readonly watchLimit?: number;
8587 }
8688
89+interface WatchedEntry {
90+ refCount: number;
91+ teardown: () => void;
92+}
93+
94+const NOOP: () => void = () => undefined;
95+
8796 export function createGitBranchManager(config: GitBranchManagerConfig) {
8897 const inFlight = new Map<string, Promise<GitListBranchesResult | null>>();
98+ const watched = new Map<string, WatchedEntry>();
99+ const watchLoadOptions =
100+ config.watchLimit === undefined ? undefined : { limit: config.watchLimit };
89101
90102 function getSnapshot(target: GitBranchTarget): GitBranchState {
91103 const targetKey = getGitBranchTargetKey(target);
@@ -204,6 +216,70 @@ export function createGitBranchManager(config: GitBranchManagerConfig) { · 112 unmodified lines
204216 });
205217 }
206218
219+ function watch(target: GitBranchTarget, client?: GitBranchClient): () => void {
220+ const targetKey = getGitBranchTargetKey(target);
221+ if (targetKey === null || target.environmentId === null || target.cwd === null) {
222+ return NOOP;
223+ }
224+
225+ const existing = watched.get(targetKey);
226+ if (existing) {
227+ existing.refCount += 1;
228+ return () => unwatch(targetKey);
229+ }
230+
231+ let teardown: () => void;
232+
233+ if (client) {
234+ void load(target, client, watchLoadOptions);
235+ teardown = NOOP;
236+ } else if (config.subscribeClientChanges) {
237+ let currentClient: GitBranchClient | null = null;
238+ const sync = () => {
239+ const resolved = config.getClient(target.environmentId!);
240+ if (!resolved) {
241+ currentClient = null;
242+ return;
243+ }
244+ if (currentClient === resolved) {
245+ return;
246+ }
247+
248+ currentClient = resolved;
249+ void load(target, resolved, watchLoadOptions);
250+ };
251+
252+ const unsubscribe = config.subscribeClientChanges(sync);
253+ sync();
254+ teardown = unsubscribe;
255+ } else {
256+ const resolved = config.getClient(target.environmentId);
257+ if (!resolved) {
258+ return NOOP;
259+ }
260+ void load(target, resolved, watchLoadOptions);
261+ teardown = NOOP;
262+ }
263+
264+ watched.set(targetKey, { refCount: 1, teardown });
265+ return () => unwatch(targetKey);
266+ }
267+
268+ function unwatch(targetKey: string): void {
269+ const entry = watched.get(targetKey);
270+ if (!entry) {
271+ return;
272+ }
273+
274+ entry.refCount -= 1;
275+ if (entry.refCount > 0) {
276+ return;
277+ }
278+
279+ entry.teardown();
280+ watched.delete(targetKey);
281+ }
282+
207283 function invalidate(target?: GitBranchTarget): void {
208284 if (target) {
209285 const targetKey = getGitBranchTargetKey(target);
@@ -219,11 +295,16 @@ export function createGitBranchManager(config: GitBranchManagerConfig) { · 9 unmodified lines
219295 }
220296
221297 function reset(): void {
298+ for (const entry of watched.values()) {
299+ entry.teardown();
300+ }
301+ watched.clear();
222302 invalidate();
223303 }
224304
225305 return {
226306 getSnapshot,
307+ watch,
227308 load,
228309 loadNext,
229310 invalidate,
packages/client-runtime/src/index.ts+1−0
@@ -19,3 +19,4 @@ export * from "./wsRpcProtocol.ts"; · 18 unmodified lines
1919 export * from "./wsTransport.ts";
2020 export * from "./wsRpcClient.ts";
2121 export * from "./environmentConnection.ts";
22+export * from "./composerPathSearchState.ts";
packages/client-runtime/src/sourceControlDiscoveryState.test.ts+102−0
@@ -50,11 +50,17 @@ function unresolvedDiscovery() { · 49 unmodified lines
5050
5151 let registry = AtomRegistry.make();
5252
53+const noop = () => undefined;
54+
5355 beforeEach(() => {
5456 registry.dispose();
5557 registry = AtomRegistry.make();
5658 });
5759
60+function flushAsyncWork(): Promise<void> {
61+ return Promise.resolve().then(() => undefined);
62+}
63+
5864 it("stores refreshed discovery data in an atom snapshot", async () => {
5965 const manager = createSourceControlDiscoveryManager({
6066 getRegistry: () => registry,
@@ -181,3 +187,99 @@ it("ignores an in-flight refresh after the target is invalidated", async () => { · 120 unmodified lines
181187 isPending: true,
182188 });
183189 });
190+
191+it("watches a discovery target with ref-counted client-change subscriptions", async () => {
192+ let listener: () => void = noop;
193+ let subscribeCalls = 0;
194+ let unsubscribeCalls = 0;
195+ let discoveryCalls = 0;
196+ const client = {
197+ discoverSourceControl: async () => {
198+ discoveryCalls += 1;
199+ return EMPTY_RESULT;
200+ },
201+ };
202+ const manager = createSourceControlDiscoveryManager({
203+ getRegistry: () => registry,
204+ getClient: () => client,
205+ subscribeClientChanges: (nextListener) => {
206+ subscribeCalls += 1;
207+ listener = nextListener;
208+ return () => {
209+ unsubscribeCalls += 1;
210+ };
211+ },
212+ });
213+
214+ const firstUnwatch = manager.watch({ key: "primary" });
215+ const secondUnwatch = manager.watch({ key: "primary" });
216+ await flushAsyncWork();
217+
218+ assert.strictEqual(subscribeCalls, 1);
219+ assert.strictEqual(discoveryCalls, 1);
220+ assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
221+ data: EMPTY_RESULT,
222+ error: null,
223+ isPending: false,
224+ });
225+
226+ listener();
227+ await flushAsyncWork();
228+ assert.strictEqual(discoveryCalls, 1);
229+
230+ firstUnwatch();
231+ assert.strictEqual(unsubscribeCalls, 0);
232+
233+ secondUnwatch();
234+ assert.strictEqual(unsubscribeCalls, 1);
235+});
236+
237+it("refreshes a watched discovery target when the resolved client is replaced", async () => {
238+ let listener: () => void = noop;
239+ let activeResult = EMPTY_RESULT;
240+ let discoveryCalls = 0;
241+ const firstClient = {
242+ discoverSourceControl: async () => {
243+ discoveryCalls += 1;
244+ return activeResult;
245+ },
246+ };
247+ const secondClient = {
248+ discoverSourceControl: async () => {
249+ discoveryCalls += 1;
250+ return activeResult;
251+ },
252+ };
253+ let activeClient = firstClient;
254+ const manager = createSourceControlDiscoveryManager({
255+ getRegistry: () => registry,
256+ getClient: () => activeClient,
257+ subscribeClientChanges: (nextListener) => {
258+ listener = nextListener;
259+ return () => undefined;
260+ },
261+ });
262+
263+ const unwatch = manager.watch({ key: "primary" });
264+ await flushAsyncWork();
265+
266+ assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
267+ data: EMPTY_RESULT,
268+ error: null,
269+ isPending: false,
270+ });
271+
272+ activeClient = secondClient;
273+ activeResult = GITHUB_RESULT;
274+ listener();
275+ await flushAsyncWork();
276+
277+ assert.strictEqual(discoveryCalls, 2);
278+ assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
279+ data: GITHUB_RESULT,
280+ error: null,
281+ isPending: false,
282+ });
283+
284+ unwatch();
285+});
packages/client-runtime/src/sourceControlDiscoveryState.ts+118−8
@@ -17,6 +17,11 @@ export interface SourceControlDiscoveryClient { · 16 unmodified lines
1717 readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
1818 }
1919
20+interface WatchedEntry {
21+ refCount: number;
22+ teardown: () => void;
23+}
24+
2025 /* --- Constants ------------------------------------------------------ */
2126
2227 export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
@@ -71,13 +76,29 @@ export interface SourceControlDiscoveryManagerConfig<TKey extends string = strin · 48 unmodified lines
7176 * clients without changing the state primitive.
7277 */
7378 readonly getClient: (key: TKey) => SourceControlDiscoveryClient | null;
79+ /**
80+ * Optional: subscribe to environment/client availability changes.
81+ *
82+ * When provided, `watch` refreshes as clients appear or are replaced
83+ * instead of relying on React hooks to manually kick discovery.
84+ */
85+ readonly subscribeClientChanges?: (listener: () => void) => () => void;
7486 }
7587
88+const NOOP: () => void = () => undefined;
89+
7690 export function createSourceControlDiscoveryManager<TKey extends string = string>(
7791 config: SourceControlDiscoveryManagerConfig<TKey>,
7892 ) {
79− const refreshInFlight = new Map<string, Promise<SourceControlDiscoveryResult | null>>();
93+ const refreshInFlight = new Map<
94+ string,
95+ {
96+ readonly client: SourceControlDiscoveryClient;
97+ readonly promise: Promise<SourceControlDiscoveryResult | null>;
98+ }
99+ >();
80100 const refreshVersions = new Map<string, number>();
101+ const watched = new Map<string, WatchedEntry>();
81102
82103 function getRefreshVersion(targetKey: string): number {
83104 return refreshVersions.get(targetKey) ?? 0;
@@ -154,11 +175,6 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 70 unmodified lines
154175 return Promise.resolve(null);
155176 }
156177
157− const existing = refreshInFlight.get(targetKey);
158− if (existing) {
159− return existing;
160− }
161−
162178 const resolvedClient = client ?? config.getClient(targetKey);
163179 if (!resolvedClient) {
164180 const error = new Error("Source control discovery client is unavailable.");
@@ -166,6 +182,15 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 1 unmodified line
166182 return Promise.resolve(getSnapshot(target).data);
167183 }
168184
185+ const existing = refreshInFlight.get(targetKey);
186+ if (existing) {
187+ if (!client || existing.client === resolvedClient) {
188+ return existing.promise;
189+ }
190+
191+ return existing.promise.then(() => refresh(target, resolvedClient));
192+ }
193+
169194 markPending(targetKey);
170195 const refreshVersion = getRefreshVersion(targetKey);
171196 const promise = resolvedClient.discoverSourceControl().then(
@@ -184,11 +209,14 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 12 unmodified lines
184209 );
185210 let tracked: Promise<SourceControlDiscoveryResult | null>;
186211 tracked = promise.finally(() => {
187− if (refreshInFlight.get(targetKey) === tracked) {
212+ if (refreshInFlight.get(targetKey)?.promise === tracked) {
188213 refreshInFlight.delete(targetKey);
189214 }
190215 });
191− refreshInFlight.set(targetKey, tracked);
216+ refreshInFlight.set(targetKey, {
217+ client: resolvedClient,
218+ promise: tracked,
219+ });
192220 return tracked;
193221 }
194222
@@ -228,12 +256,93 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 33 unmodified lines
228256 return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));
229257 }
230258
259+ /**
260+ * Keep discovery warm for `target`.
261+ *
262+ * Multiple callers sharing a target key are ref-counted. With
263+ * `subscribeClientChanges`, the manager refreshes whenever a client first
264+ * appears or is replaced after reconnect.
265+ */
266+ function watch(
267+ target: SourceControlDiscoveryTarget<TKey>,
268+ client?: SourceControlDiscoveryClient,
269+ ): () => void {
270+ const targetKey = getSourceControlDiscoveryTargetKey(target);
271+ if (targetKey === null) {
272+ return NOOP;
273+ }
274+
275+ const existing = watched.get(targetKey);
276+ if (existing) {
277+ existing.refCount += 1;
278+ return () => unwatch(targetKey);
279+ }
280+
281+ let teardown: () => void;
282+
283+ if (client) {
284+ void refresh(target, client);
285+ teardown = NOOP;
286+ } else if (config.subscribeClientChanges) {
287+ let currentClient: SourceControlDiscoveryClient | null = null;
288+
289+ const sync = () => {
290+ const resolved = config.getClient(targetKey);
291+ if (!resolved) {
292+ currentClient = null;
293+ markPending(targetKey);
294+ return;
295+ }
296+
297+ if (currentClient === resolved) {
298+ return;
299+ }
300+
301+ currentClient = resolved;
302+ void refresh(target, resolved);
303+ };
304+
305+ const unsubChanges = config.subscribeClientChanges(sync);
306+ sync();
307+ teardown = unsubChanges;
308+ } else {
309+ const resolved = config.getClient(targetKey);
310+ if (!resolved) {
311+ return NOOP;
312+ }
313+ void refresh(target, resolved);
314+ teardown = NOOP;
315+ }
316+
317+ watched.set(targetKey, { refCount: 1, teardown });
318+ return () => unwatch(targetKey);
319+ }
320+
321+ function unwatch(targetKey: string): void {
322+ const entry = watched.get(targetKey);
323+ if (!entry) {
324+ return;
325+ }
326+
327+ entry.refCount -= 1;
328+ if (entry.refCount > 0) {
329+ return;
330+ }
331+
332+ entry.teardown();
333+ watched.delete(targetKey);
334+ }
335+
231336 /**
232337 * Clear in-flight refresh tracking and reset every known discovery atom.
233338 * Primarily used by tests and runtime teardown.
234339 */
235340 function reset(): void {
236341 const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]);
342+ for (const entry of watched.values()) {
343+ entry.teardown();
344+ }
345+ watched.clear();
237346 refreshInFlight.clear();
238347 for (const key of keys) {
239348 bumpRefreshVersion(key);
@@ -242,6 +351,7 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 2 unmodified lines
242351 }
243352
244353 return {
354+ watch,
245355 refresh,
246356 getSnapshot,
247357 invalidate,
patches/@pierre%2Fdiffs@1.1.20.patch+23−0
@@ -0,0 +1,23 @@
1+diff --git a/package.json b/package.json
2+index c0e762b13b56247b256520754e439cbc16bb797d..d298b87c175ec891d1b50f681c9031f4b1929e67 100644
3+--- a/package.json
4++++ b/package.json
5+@@ -51,6 +51,18 @@
6+ "./worker/worker-portable.js": {
7+ "types": "./dist/worker/worker-portable.d.ts",
8+ "import": "./dist/worker/worker-portable.js"
9++ },
10++ "./types": {
11++ "types": "./dist/types.d.ts",
12++ "import": "./dist/types.js"
13++ },
14++ "./utils/getFiletypeFromFileName": {
15++ "types": "./dist/utils/getFiletypeFromFileName.d.ts",
16++ "import": "./dist/utils/getFiletypeFromFileName.js"
17++ },
18++ "./utils/parsePatchFiles": {
19++ "types": "./dist/utils/parsePatchFiles.d.ts",
20++ "import": "./dist/utils/parsePatchFiles.js"
21+ }
22+ },
23+ "scripts": {
patches/expo-dev-launcher@55.0.28.patch+15−0
@@ -0,0 +1,15 @@
1+diff --git a/ios/EXDevLauncherController.m b/ios/EXDevLauncherController.m
2+index c9ca6fbfe758e6f97d6f05b0c88e382f0eebae2f..7ed12652528ab31350ecbc9cca736bcda7adf3f3 100644
3+--- a/ios/EXDevLauncherController.m
4++++ b/ios/EXDevLauncherController.m
5+@@ -625,7 +625,9 @@ -(void)copyToClipboard:(NSString *)content {
6+ - (void)setDevMenuAppBridge
7+ {
8+ DevMenuManager *manager = [DevMenuManager shared];
9+- [manager updateCurrentBridge:self.appBridge];
10++ // Expo SDK 55's dev launcher still references `self.appBridge`,
11++ // but React Native 0.83 removed that property from the delegate base class.
12++ // Newer Expo builds stop updating the dev menu bridge here.
13+
14+ if (self.manifest != nil) {
15+ [manager updateCurrentManifest:self.manifest manifestURL:self.manifestURL];
patches/react-native-nitro-modules@0.35.4.patch+16−0
@@ -0,0 +1,16 @@
1+diff --git a/package.json b/package.json
2+index 4e4366fb9d05a6c250acfc62e79487475d8ac05b..200e4680d77cbb4f14d22eb986efde1275a0d3e8 100644
3+--- a/package.json
4++++ b/package.json
5+@@ -88,6 +88,11 @@
6+ "jsSrcsDir": "./src",
7+ "android": {
8+ "javaPackageName": "com.margelo.nitro"
9++ },
10++ "ios": {
11++ "modulesProvider": {
12++ "NitroModules": "NativeNitroModules"
13++ }
14+ }
15+ },
16+ "jest": {
scripts/mobile-native-static-check.ts+3−13
@@ -40,8 +40,10 @@ const sourceExtensions = new Set([".swift", ".kt", ".kts"]); · 39 unmodified lines
4040 const excludedDirectories = new Set([
4141 ".expo",
4242 ".git",
43+ "android",
4344 "build",
4445 "DerivedData",
46+ "ios",
4547 "node_modules",
4648 "Pods",
4749 "Vendor",
@@ -188,19 +190,7 @@ const runNativeStaticChecks = Effect.fn("runNativeStaticChecks")(function* () { · 140 unmodified lines
188190 }
189191 }
190192
191− const androidProject = path.join(root, "android");
192− const gradlew = path.join(
193− androidProject,
194− process.platform === "win32" ? "gradlew.bat" : "gradlew",
195− );
196− const fs = yield* FileSystem.FileSystem;
197−
198− if (yield* fs.exists(gradlew)) {
199− const stat = yield* fs.stat(gradlew);
200− if (stat.type === "File") {
201− yield* runCommand(gradlew, ["lint"], androidProject);
202− }
203− }
193+ yield* Console.log("Skipping generated native project folders: android/, ios/.");
204194 });
205195
206196 export const mobileNativeStaticCheckCommand = Command.make("mobile-native-static-check", {}, () =>
scripts/patch-expo-dev-launcher.mjs+0−63
@@ -1,63 +0,0 @@
1−import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2−import { join } from "node:path";
3−
4−const root = process.cwd();
5−
6−const candidates = [join(root, "node_modules/expo-dev-launcher/ios/EXDevLauncherController.m")];
7−
8−const bunStorePath = join(root, "node_modules/.bun");
9−
10−if (existsSync(bunStorePath)) {
11− for (const entry of readdirSync(bunStorePath)) {
12− if (!entry.startsWith("expo-dev-launcher@")) {
13− continue;
14− }
15−
16− candidates.push(
17− join(bunStorePath, entry, "node_modules/expo-dev-launcher/ios/EXDevLauncherController.m"),
18− );
19− }
20−}
21−
22−const targetLine = "[manager updateCurrentBridge:self.appBridge];";
23−const replacement = [
24− " // Expo SDK 55's dev launcher still references `self.appBridge`,",
25− " // but React Native 0.83 removed that property from the delegate base class.",
26− " // Newer Expo builds stop updating the dev menu bridge here.",
27−].join("\n");
28−
29−let patchedCount = 0;
30−let locatedCount = 0;
31−
32−for (const filePath of candidates) {
33− if (!existsSync(filePath)) {
34− continue;
35− }
36−
37− locatedCount += 1;
38− const source = readFileSync(filePath, "utf8");
39−
40− if (source.includes(replacement)) {
41− continue;
42− }
43−
44− if (!source.includes(targetLine)) {
45− throw new Error(
46− `Found expo-dev-launcher source at ${filePath}, but the expected bridge line was missing.`,
47− );
48− }
49−
50− const nextSource = source.replace(` ${targetLine}`, replacement);
51− writeFileSync(filePath, nextSource);
52− patchedCount += 1;
53−}
54−
55−if (patchedCount > 0) {
56− console.log(
57− `[patch-expo-dev-launcher] patched ${patchedCount} expo-dev-launcher installation(s).`,
58− );
59−} else if (locatedCount > 0) {
60− console.log("[patch-expo-dev-launcher] expo-dev-launcher already patched.");
61−} else {
62− console.log("[patch-expo-dev-launcher] expo-dev-launcher not installed; skipping.");
63−}
scripts/patch-pierre-diffs-utils.mjs+0−105
@@ -1,105 +0,0 @@
1−import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2−import { join } from "node:path";
3−
4−const root = process.cwd();
5−const bunStorePath = join(root, "node_modules/.bun");
6−
7−const packageDirs = [];
8−
9−if (existsSync(join(root, "node_modules/@pierre/diffs"))) {
10− packageDirs.push(join(root, "node_modules/@pierre/diffs"));
11−}
12−
13−if (existsSync(bunStorePath)) {
14− for (const entry of readdirSync(bunStorePath)) {
15− if (!entry.startsWith("@pierre+diffs@")) {
16− continue;
17− }
18−
19− packageDirs.push(join(bunStorePath, entry, "node_modules/@pierre/diffs"));
20− }
21−}
22−
23−const utilsIndexJs = [
24− 'export { parsePatchFiles, processFile, processPatch } from "./parsePatchFiles.js";',
25− "export {",
26− " EXTENSION_TO_FILE_FORMAT,",
27− " getCustomExtensionsMap,",
28− " getCustomExtensionsVersion,",
29− " getFiletypeFromFileName,",
30− " replaceCustomExtensions,",
31− " setCustomExtension,",
32− '} from "./getFiletypeFromFileName.js";',
33− "",
34−].join("\n");
35−
36−const utilsIndexDts = [
37− 'export { parsePatchFiles, processFile, processPatch } from "./parsePatchFiles.js";',
38− "export {",
39− " EXTENSION_TO_FILE_FORMAT,",
40− " getCustomExtensionsMap,",
41− " getCustomExtensionsVersion,",
42− " getFiletypeFromFileName,",
43− " replaceCustomExtensions,",
44− " setCustomExtension,",
45− '} from "./getFiletypeFromFileName.js";',
46− 'export type { ChangeTypes, FileDiffMetadata, ParsedPatch, SupportedLanguages } from "../types.js";',
47− "",
48−].join("\n");
49−
50−let patchedCount = 0;
51−let locatedCount = 0;
52−
53−for (const packageDir of packageDirs) {
54− if (!existsSync(packageDir)) {
55− continue;
56− }
57−
58− locatedCount += 1;
59−
60− const packageJsonPath = join(packageDir, "package.json");
61− const utilsIndexJsPath = join(packageDir, "dist/utils/index.js");
62− const utilsIndexDtsPath = join(packageDir, "dist/utils/index.d.ts");
63−
64− const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8"));
65− const currentUtilsExport = pkg.exports?.["./utils"];
66−
67− const expectedUtilsExport = {
68− types: "./dist/utils/index.d.ts",
69− import: "./dist/utils/index.js",
70− };
71−
72− const needsExportPatch =
73− currentUtilsExport?.types !== expectedUtilsExport.types ||
74− currentUtilsExport?.import !== expectedUtilsExport.import;
75−
76− if (needsExportPatch) {
77− pkg.exports = {
78− ...pkg.exports,
79− "./utils": expectedUtilsExport,
80− };
81− writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
82− }
83−
84− const currentJs = existsSync(utilsIndexJsPath) ? readFileSync(utilsIndexJsPath, "utf8") : null;
85− if (currentJs !== utilsIndexJs) {
86− writeFileSync(utilsIndexJsPath, utilsIndexJs);
87− }
88−
89− const currentDts = existsSync(utilsIndexDtsPath) ? readFileSync(utilsIndexDtsPath, "utf8") : null;
90− if (currentDts !== utilsIndexDts) {
91− writeFileSync(utilsIndexDtsPath, utilsIndexDts);
92− }
93−
94− if (needsExportPatch || currentJs !== utilsIndexJs || currentDts !== utilsIndexDts) {
95− patchedCount += 1;
96− }
97−}
98−
99−if (patchedCount > 0) {
100− console.log(`[patch-pierre-diffs-utils] patched ${patchedCount} @pierre/diffs installation(s).`);
101−} else if (locatedCount > 0) {
102− console.log("[patch-pierre-diffs-utils] @pierre/diffs already patched.");
103−} else {
104− console.log("[patch-pierre-diffs-utils] @pierre/diffs not installed; skipping.");
105−}
scripts/patch-react-native-nitro-modules.mjs+0−73
@@ -1,73 +0,0 @@
1−import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2−import { join } from "node:path";
3−
4−const root = process.cwd();
5−
6−const candidates = [join(root, "node_modules/react-native-nitro-modules/package.json")];
7−
8−const bunStorePath = join(root, "node_modules/.bun");
9−
10−if (existsSync(bunStorePath)) {
11− for (const entry of readdirSync(bunStorePath)) {
12− if (!entry.startsWith("react-native-nitro-modules@")) {
13− continue;
14− }
15−
16− candidates.push(
17− join(bunStorePath, entry, "node_modules/react-native-nitro-modules/package.json"),
18− );
19− }
20−}
21−
22−let patchedCount = 0;
23−let locatedCount = 0;
24−
25−for (const filePath of candidates) {
26− if (!existsSync(filePath)) {
27− continue;
28− }
29−
30− locatedCount += 1;
31−
32− const source = readFileSync(filePath, "utf8");
33− const pkg = JSON.parse(source);
34−
35− if (pkg.codegenConfig == null || typeof pkg.codegenConfig !== "object") {
36− throw new Error(
37− `Found react-native-nitro-modules package at ${filePath}, but codegenConfig was missing.`,
38− );
39− }
40−
41− const iosConfig =
42− pkg.codegenConfig.ios != null && typeof pkg.codegenConfig.ios === "object"
43− ? pkg.codegenConfig.ios
44− : {};
45−
46− const modulesProvider =
47− iosConfig.modulesProvider != null && typeof iosConfig.modulesProvider === "object"
48− ? iosConfig.modulesProvider
49− : {};
50−
51− if (modulesProvider.NitroModules === "NativeNitroModules") {
52− continue;
53− }
54−
55− modulesProvider.NitroModules = "NativeNitroModules";
56− iosConfig.modulesProvider = modulesProvider;
57− pkg.codegenConfig.ios = iosConfig;
58−
59− writeFileSync(filePath, `${JSON.stringify(pkg, null, 2)}\n`);
60− patchedCount += 1;
61−}
62−
63−if (patchedCount > 0) {
64− console.log(
65− `[patch-react-native-nitro-modules] patched ${patchedCount} react-native-nitro-modules installation(s).`,
66− );
67−} else if (locatedCount > 0) {
68− console.log("[patch-react-native-nitro-modules] react-native-nitro-modules already patched.");
69−} else {
70− console.log(
71− "[patch-react-native-nitro-modules] react-native-nitro-modules not installed; skipping.",
72− );
73−}