History

Harden remote connect and subscription handling

- Scope cleartext mobile config to development - Fix stale loads, ordering, and terminal reuse - Clean up terminal metadata subscriptions on failure

Julius Marminge committed 6 months ago

Commit 7f03f4d1 parent 4ac261815 files · +276 −47Browse files

Unified · Split

15 files
  1. apps/mobile/app.config.ts +9 −4
  2. apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +11 −1
  3. apps/mobile/src/features/review/reviewWordDiffs.test.ts +31 −0
  4. apps/mobile/src/features/review/reviewWordDiffs.ts +1 −1
  5. apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +5 −5
  6. apps/mobile/src/features/threads/new-task-flow-provider.tsx +17 −3
  7. apps/mobile/src/lib/repositoryGroups.test.ts +68 −4
  8. apps/mobile/src/lib/repositoryGroups.ts +5 −3
  9. apps/server/src/terminal/Layers/Manager.test.ts +21 −0
  10. apps/server/src/terminal/Layers/Manager.ts +11 −1
  11. apps/web/src/components/ChatView.tsx +13 −17
  12. packages/client-runtime/src/vcsRefState.test.ts +25 −0
  13. packages/client-runtime/src/vcsRefState.ts +8 −0
  14. packages/client-runtime/src/wsRpcClient.test.ts +33 −0
  15. packages/client-runtime/src/wsRpcClient.ts +18 −8
apps/mobile/app.config.ts+9−4
@@ -49,6 +49,7 @@ function resolveAppVariant(value: string | undefined): AppVariant { · 48 unmodified lines
49}49}
50 50
51const variant = VARIANT_CONFIG[APP_VARIANT];51const variant = VARIANT_CONFIG[APP_VARIANT];
52const allowsCleartextTraffic = APP_VARIANT === "development";
52 53
53const config: ExpoConfig = {54const config: ExpoConfig = {
54 name: variant.appName,55 name: variant.appName,
@@ -77,9 +78,13 @@ const config: ExpoConfig = { · 22 unmodified lines
77 supportsTablet: true,78 supportsTablet: true,
78 bundleIdentifier: variant.iosBundleIdentifier,79 bundleIdentifier: variant.iosBundleIdentifier,
79 infoPlist: {80 infoPlist: {
80 NSAppTransportSecurity: {81 ...(allowsCleartextTraffic
81 NSAllowsArbitraryLoads: true,82 ? {
82 },83 NSAppTransportSecurity: {
84 NSAllowsArbitraryLoads: true,
85 },
86 }
87 : {}),
83 ITSAppUsesNonExemptEncryption: false,88 ITSAppUsesNonExemptEncryption: false,
84 },89 },
85 },90 },
@@ -128,7 +133,7 @@ const config: ExpoConfig = { · 42 unmodified lines
128 ],133 ],
129 "expo-secure-store",134 "expo-secure-store",
130 "expo-router",135 "expo-router",
131 "./plugins/withAndroidCleartextTraffic.cjs",136 ...(allowsCleartextTraffic ? ["./plugins/withAndroidCleartextTraffic.cjs"] : []),
132 ],137 ],
133 extra: {138 extra: {
134 appVariant: APP_VARIANT,139 appVariant: APP_VARIANT,
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+11−1
@@ -768,6 +768,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 767 unmodified lines
768 didSet {768 didSet {
769 tokenColorsByHex.removeAll()769 tokenColorsByHex.removeAll()
770 tokenAttributedStringsByRowId.removeAll()770 tokenAttributedStringsByRowId.removeAll()
771 setNeedsDisplayForVisibleBounds()
771 }772 }
772 }773 }
773 private(set) var contentHeight: CGFloat = 0774 private(set) var contentHeight: CGFloat = 0
@@ -1007,6 +1008,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 233 unmodified lines
1007 }1008 }
1008 1009
1009 let point = gesture.location(in: self)1010 let point = gesture.location(in: self)
1011 if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
1012 return
1013 }
1014
1010 guard let rowIndex = rowIndex(at: verticalOffset + point.y),1015 guard let rowIndex = rowIndex(at: verticalOffset + point.y),
1011 rows.indices.contains(rowIndex) else {1016 rows.indices.contains(rowIndex) else {
1012 return1017 return
@@ -1420,6 +1425,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 407 unmodified lines
1420 lastDecelerationTimestamp = 01425 lastDecelerationTimestamp = 0
1421 }1426 }
1422 1427
1428 deinit {
1429 stopHorizontalDeceleration()
1430 }
1431
1423 override func draw(_ rect: CGRect) {1432 override func draw(_ rect: CGRect) {
1424 let drawStartedAt = CACurrentMediaTime()1433 let drawStartedAt = CACurrentMediaTime()
1425 guard let context = UIGraphicsGetCurrentContext() else {1434 guard let context = UIGraphicsGetCurrentContext() else {
@@ -1914,7 +1923,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 488 unmodified lines
1914 let change = row.change ?? "context"1923 let change = row.change ?? "context"
1915 rowBackground(for: change).setFill()1924 rowBackground(for: change).setFill()
1916 context.fill(rect)1925 context.fill(rect)
1917 drawSelectionOverlay(row, rect: rect, context: context)
1918 1926
1919 if change == "add" {1927 if change == "add" {
1920 theme.addBar.setFill()1928 theme.addBar.setFill()
@@ -1926,6 +1934,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 5 unmodified lines
1926 )1934 )
1927 }1935 }
1928 1936
1937 drawSelectionOverlay(row, rect: rect, context: context)
1938
1929 let lineNumber = row.newLineNumber ?? row.oldLineNumber1939 let lineNumber = row.newLineNumber ?? row.oldLineNumber
1930 if let lineNumber {1940 if let lineNumber {
1931 drawRightAlignedText(1941 drawRightAlignedText(
apps/mobile/src/features/review/reviewWordDiffs.test.ts+31−0
@@ -59,4 +59,35 @@ describe("applyDiffRangesToTokens", () => { · 58 unmodified lines
59 },59 },
60 ]);60 ]);
61 });61 });
62
63 it("skips exhausted ranges when later tokens start after the last range", () => {
64 const tokens = [
65 {
66 content: "a",
67 color: "#fff",
68 fontStyle: null,
69 },
70 {
71 content: "b",
72 color: "#fff",
73 fontStyle: null,
74 },
75 ];
76
77 const nextTokens = applyDiffRangesToTokens(tokens, [{ start: 0, end: 1 }]);
78
79 expect(nextTokens).toEqual([
80 {
81 content: "a",
82 color: "#fff",
83 fontStyle: null,
84 diffHighlight: true,
85 },
86 {
87 content: "b",
88 color: "#fff",
89 fontStyle: null,
90 },
91 ]);
92 });
62});93});
apps/mobile/src/features/review/reviewWordDiffs.ts+1−1
@@ -208,7 +208,7 @@ export function applyDiffRangesToTokens( · 207 unmodified lines
208 return;208 return;
209 }209 }
210 210
211 while ((ranges[rangeIndex]?.end ?? 0) <= tokenStart) {211 while (rangeIndex < ranges.length && ranges[rangeIndex]!.end <= tokenStart) {
212 rangeIndex += 1;212 rangeIndex += 1;
213 }213 }
214 214
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx+5−5
@@ -1,6 +1,6 @@
1import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts";1import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { memo, useCallback, useEffect, useState } from "react";3import { memo, useCallback, useEffect, useRef, useState } from "react";
4import { Pressable, View } from "react-native";4import { Pressable, View } from "react-native";
5 5
6import { AppText as Text } from "../../components/AppText";6import { AppText as Text } from "../../components/AppText";
@@ -41,6 +41,8 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( · 34 unmodified lines
41 cols: DEFAULT_TERMINAL_COLS,41 cols: DEFAULT_TERMINAL_COLS,
42 rows: DEFAULT_TERMINAL_ROWS,42 rows: DEFAULT_TERMINAL_ROWS,
43 });43 });
44 const lastGridSizeRef = useRef(lastGridSize);
45 lastGridSizeRef.current = lastGridSize;
44 46
45 const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`;47 const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`;
46 const isRunning = terminal.status === "running" || terminal.status === "starting";48 const isRunning = terminal.status === "running" || terminal.status === "starting";
@@ -73,13 +75,11 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( · 26 unmodified lines
73 terminalId,75 terminalId,
74 cwd: props.cwd,76 cwd: props.cwd,
75 worktreePath: props.worktreePath,77 worktreePath: props.worktreePath,
76 cols: lastGridSize.cols,78 cols: lastGridSizeRef.current.cols,
77 rows: lastGridSize.rows,79 rows: lastGridSizeRef.current.rows,
78 },80 },
79 });81 });
80 }, [82 }, [
81 lastGridSize.cols,
82 lastGridSize.rows,
83 props.cwd,83 props.cwd,
84 props.environmentId,84 props.environmentId,
85 props.threadId,85 props.threadId,
apps/mobile/src/features/threads/new-task-flow-provider.tsx+17−3
@@ -1,4 +1,4 @@
1import React, { useCallback, useEffect, useMemo, useState } from "react";1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 2
3import type {3import type {
4 EnvironmentId,4 EnvironmentId,
@@ -152,6 +152,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 147 unmodified lines
152 const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("local");152 const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("local");
153 const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null);153 const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null);
154 const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null);154 const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null);
155 const branchLoadVersionRef = useRef(0);
155 const [prompt, setPrompt] = useState("");156 const [prompt, setPrompt] = useState("");
156 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);157 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
157 const [submitting, setSubmitting] = useState(false);158 const [submitting, setSubmitting] = useState(false);
@@ -329,11 +330,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 171 unmodified lines
329 }, [availableBranches, branchQuery]);330 }, [availableBranches, branchQuery]);
330 331
331 const setProject = useCallback((project: EnvironmentScopedProjectShell) => {332 const setProject = useCallback((project: EnvironmentScopedProjectShell) => {
333 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
334 branchLoadVersionRef.current += 1;
332 setSelectedEnvironmentId(project.environmentId);335 setSelectedEnvironmentId(project.environmentId);
333 setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id));336 setSelectedProjectKey(nextProjectKey);
337 setSelectedBranchName(null);
338 setSelectedWorktreePath(null);
334 }, []);339 }, []);
335 340
336 const selectEnvironment = useCallback((environmentId: EnvironmentId) => {341 const selectEnvironment = useCallback((environmentId: EnvironmentId) => {
342 branchLoadVersionRef.current += 1;
337 setSelectedEnvironmentId(environmentId);343 setSelectedEnvironmentId(environmentId);
338 setSelectedProjectKey(null);344 setSelectedProjectKey(null);
339 setSelectedBranchName(null);345 setSelectedBranchName(null);
@@ -355,12 +361,17 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 15 unmodified lines
355 return;361 return;
356 }362 }
357 363
364 const loadVersion = ++branchLoadVersionRef.current;
365 const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id);
358 try {366 try {
359 const result = await vcsRefManager.load({367 const result = await vcsRefManager.load({
360 environmentId: selectedProject.environmentId,368 environmentId: selectedProject.environmentId,
361 cwd: selectedProject.workspaceRoot,369 cwd: selectedProject.workspaceRoot,
362 query: null,370 query: null,
363 });371 });
372 if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) {
373 return;
374 }
364 setPendingConnectionError(null);375 setPendingConnectionError(null);
365 const branches = pipe(376 const branches = pipe(
366 result?.refs ?? [],377 result?.refs ?? [],
@@ -377,9 +388,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 10 unmodified lines
377 }388 }
378 }389 }
379 } catch {390 } catch {
391 if (loadVersion !== branchLoadVersionRef.current) {
392 return;
393 }
380 setPendingConnectionError("Failed to load branches.");394 setPendingConnectionError("Failed to load branches.");
381 }395 }
382 }, [selectedBranchName, selectedProject, workspaceMode]);396 }, [selectedBranchName, selectedProject, selectedProjectKey, workspaceMode]);
383 397
384 const value = useMemo<NewTaskFlowContextValue>(398 const value = useMemo<NewTaskFlowContextValue>(
385 () => ({399 () => ({
apps/mobile/src/lib/repositoryGroups.test.ts+68−4
@@ -108,10 +108,74 @@ describe("groupProjectsByRepository", () => { · 107 unmodified lines
108 threadCount: 2,108 threadCount: 2,
109 });109 });
110 expect(110 expect(
111 groups[0]?.projects111 groups[0]?.projects.map((entry) => ({
112 .map((entry) => entry.project.environmentId)112 environmentId: entry.project.environmentId,
113 .toSorted((left, right) => left.localeCompare(right)),113 latestActivityAt: entry.latestActivityAt,
114 ).toEqual(["env-local", "env-staging"]);114 threads: entry.threads.map((thread) => thread.id),
115 })),
116 ).toEqual([
117 {
118 environmentId: "env-local",
119 latestActivityAt: "2026-04-03T12:00:00.000Z",
120 threads: ["thread-1"],
121 },
122 {
123 environmentId: "env-staging",
124 latestActivityAt: "2026-04-02T12:00:00.000Z",
125 threads: ["thread-2"],
126 },
127 ]);
128 expect(groups[0]?.latestActivityAt).toBe("2026-04-03T12:00:00.000Z");
129 });
130
131 it("orders threads, projects, and repository groups by latest activity", () => {
132 const projects = [
133 makeProject({
134 environmentId: EnvironmentId.make("env-local"),
135 id: ProjectId.make("older-project"),
136 title: "Older",
137 }),
138 makeProject({
139 environmentId: EnvironmentId.make("env-local"),
140 id: ProjectId.make("newer-project"),
141 title: "Newer",
142 }),
143 ];
144
145 const threads = [
146 makeThread({
147 environmentId: EnvironmentId.make("env-local"),
148 id: ThreadId.make("older-thread"),
149 projectId: ProjectId.make("older-project"),
150 title: "Older thread",
151 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
152 updatedAt: "2026-04-02T12:00:00.000Z",
153 }),
154 makeThread({
155 environmentId: EnvironmentId.make("env-local"),
156 id: ThreadId.make("newer-thread"),
157 projectId: ProjectId.make("older-project"),
158 title: "Newer thread",
159 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
160 updatedAt: "2026-04-04T12:00:00.000Z",
161 }),
162 makeThread({
163 environmentId: EnvironmentId.make("env-local"),
164 id: ThreadId.make("newest-thread"),
165 projectId: ProjectId.make("newer-project"),
166 title: "Newest thread",
167 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
168 updatedAt: "2026-04-05T12:00:00.000Z",
169 }),
170 ];
171
172 const groups = groupProjectsByRepository({ projects, threads });
173
174 expect(groups.map((group) => group.title)).toEqual(["Newer", "Older"]);
175 expect(groups[1]?.projects[0]?.threads.map((thread) => thread.id)).toEqual([
176 "newer-thread",
177 "older-thread",
178 ]);
115 });179 });
116 180
117 it("falls back to a scoped project key when repository identity is unavailable", () => {181 it("falls back to a scoped project key when repository identity is unavailable", () => {
apps/mobile/src/lib/repositoryGroups.ts+5−3
@@ -8,6 +8,8 @@ import { · 7 unmodified lines
8 EnvironmentScopedThreadShell,8 EnvironmentScopedThreadShell,
9} from "@t3tools/client-runtime";9} from "@t3tools/client-runtime";
10 10
11const DateDescending = Order.flip(Order.Date);
12
11export interface MobileRepositoryProjectGroup {13export interface MobileRepositoryProjectGroup {
12 readonly key: string;14 readonly key: string;
13 readonly project: EnvironmentScopedProjectShell;15 readonly project: EnvironmentScopedProjectShell;
@@ -83,7 +85,7 @@ export function groupProjectsByRepository(input: { · 69 unmodified lines
83 const threads = Arr.sortWith(85 const threads = Arr.sortWith(
84 threadsByProjectKey.get(projectKey) ?? [],86 threadsByProjectKey.get(projectKey) ?? [],
85 (s) => new Date(s.updatedAt ?? s.createdAt),87 (s) => new Date(s.updatedAt ?? s.createdAt),
86 Order.Date,88 DateDescending,
87 );89 );
88 90
89 const latestActivityAt = deriveProjectLatestActivity(project, threads);91 const latestActivityAt = deriveProjectLatestActivity(project, threads);
@@ -123,10 +125,10 @@ export function groupProjectsByRepository(input: { · 33 unmodified lines
123 projects: Arr.sortWith(125 projects: Arr.sortWith(
124 [...existing.projects, projectGroup],126 [...existing.projects, projectGroup],
125 (s) => new Date(s.latestActivityAt),127 (s) => new Date(s.latestActivityAt),
126 Order.Date,128 DateDescending,
127 ),129 ),
128 });130 });
129 }131 }
130 132
131 return Arr.sortWith([...grouped.values()], (s) => new Date(s.latestActivityAt), Order.Date);133 return Arr.sortWith(grouped.values(), (s) => new Date(s.latestActivityAt), DateDescending);
132}134}
apps/server/src/terminal/Layers/Manager.test.ts+21−0
@@ -1351,6 +1351,27 @@ it.layer( · 1350 unmodified lines
1351 }),1351 }),
1352 );1352 );
1353 1353
1354 it.effect("removes terminal metadata subscriptions when initial delivery fails", () =>
1355 Effect.gen(function* () {
1356 const { manager } = yield* createManager();
1357 yield* manager.open(openInput({ threadId: "existing-thread" }));
1358
1359 const leakedLiveEvents = yield* Ref.make(0);
1360 const exit = yield* Effect.exit(
1361 manager.subscribeMetadata((event) =>
1362 event.type === "snapshot"
1363 ? Effect.die("snapshot listener failed")
1364 : Ref.update(leakedLiveEvents, (count) => count + 1),
1365 ),
1366 );
1367
1368 expect(Exit.isFailure(exit)).toBe(true);
1369
1370 yield* manager.open(openInput({ threadId: "new-thread" }));
1371 expect(yield* Ref.get(leakedLiveEvents)).toBe(0);
1372 }),
1373 );
1374
1354 it.effect(1375 it.effect(
1355 "streams attach snapshots followed by live events without duplicate start snapshots",1376 "streams attach snapshots followed by live events without duplicate start snapshots",
1356 () =>1377 () =>
apps/server/src/terminal/Layers/Manager.ts+11−1
@@ -2223,7 +2223,17 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 2222 unmodified lines
2223 unsubscribe?.();2223 unsubscribe?.();
2224 unsubscribe = null;2224 unsubscribe = null;
2225 };2225 };
2226 });2226 }).pipe(
2227 Effect.catchCause((cause) =>
2228 Effect.flatMap(
2229 Effect.sync(() => {
2230 unsubscribe?.();
2231 unsubscribe = null;
2232 }),
2233 () => Effect.failCause(cause),
2234 ),
2235 ),
2236 );
2227 };2237 };
2228 2238
2229 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {2239 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {
apps/web/src/components/ChatView.tsx+13−17
@@ -989,6 +989,10 @@ export default function ChatView(props: ChatViewProps) { · 988 unmodified lines
989 () => activeThreadKnownSessions.map((session) => session.target.terminalId),989 () => activeThreadKnownSessions.map((session) => session.target.terminalId),
990 [activeThreadKnownSessions],990 [activeThreadKnownSessions],
991 );991 );
992 const activeKnownTerminalIds = useMemo(
993 () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])],
994 [activeServerOrderedTerminalIds, terminalUiState.terminalIds],
995 );
992 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);996 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);
993 const activeThreadRef = useMemo(997 const activeThreadRef = useMemo(
994 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),998 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
@@ -2011,7 +2015,7 @@ export default function ChatView(props: ChatViewProps) { · 1016 unmodified lines
2011 if (!api) {2015 if (!api) {
2012 return;2016 return;
2013 }2017 }
2014 const terminalId = nextTerminalId(activeServerOrderedTerminalIds);2018 const terminalId = nextTerminalId(activeKnownTerminalIds);
2015 storeSplitTerminal(activeThreadRef, terminalId);2019 storeSplitTerminal(activeThreadRef, terminalId);
2016 setTerminalFocusRequestId((value) => value + 1);2020 setTerminalFocusRequestId((value) => value + 1);
2017 void (async () => {2021 void (async () => {
@@ -2032,7 +2036,7 @@ export default function ChatView(props: ChatViewProps) { · 14 unmodified lines
2032 })();2036 })();
2033 }, [2037 }, [
2034 activeProject,2038 activeProject,
2035 activeServerOrderedTerminalIds,2039 activeKnownTerminalIds,
2036 activeThreadId,2040 activeThreadId,
2037 activeThreadRef,2041 activeThreadRef,
2038 activeThreadWorktreePath,2042 activeThreadWorktreePath,
@@ -2053,7 +2057,7 @@ export default function ChatView(props: ChatViewProps) { · 14 unmodified lines
2053 if (!api) {2057 if (!api) {
2054 return;2058 return;
2055 }2059 }
2056 const terminalId = nextTerminalId(activeServerOrderedTerminalIds);2060 const terminalId = nextTerminalId(activeKnownTerminalIds);
2057 storeNewTerminal(activeThreadRef, terminalId);2061 storeNewTerminal(activeThreadRef, terminalId);
2058 setTerminalFocusRequestId((value) => value + 1);2062 setTerminalFocusRequestId((value) => value + 1);
2059 void (async () => {2063 void (async () => {
@@ -2074,7 +2078,7 @@ export default function ChatView(props: ChatViewProps) { · 14 unmodified lines
2074 })();2078 })();
2075 }, [2079 }, [
2076 activeProject,2080 activeProject,
2077 activeServerOrderedTerminalIds,2081 activeKnownTerminalIds,
2078 activeThreadId,2082 activeThreadId,
2079 activeThreadRef,2083 activeThreadRef,
2080 activeThreadWorktreePath,2084 activeThreadWorktreePath,
@@ -2086,7 +2090,7 @@ export default function ChatView(props: ChatViewProps) { · 5 unmodified lines
2086 (terminalId: string) => {2090 (terminalId: string) => {
2087 const api = readEnvironmentApi(environmentId);2091 const api = readEnvironmentApi(environmentId);
2088 if (!activeThreadId || !api || !activeThreadRef) return;2092 if (!activeThreadId || !api || !activeThreadRef) return;
2089 const isFinalTerminal = activeServerOrderedTerminalIds.length <= 1;2093 const isFinalTerminal = activeKnownTerminalIds.length <= 1;
2090 const fallbackExitWrite = () =>2094 const fallbackExitWrite = () =>
2091 api.terminal2095 api.terminal
2092 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })2096 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })
@@ -2110,13 +2114,7 @@ export default function ChatView(props: ChatViewProps) { · 17 unmodified lines
2110 storeCloseTerminal(activeThreadRef, terminalId);2114 storeCloseTerminal(activeThreadRef, terminalId);
2111 setTerminalFocusRequestId((value) => value + 1);2115 setTerminalFocusRequestId((value) => value + 1);
2112 },2116 },
2113 [2117 [activeThreadId, activeThreadRef, activeKnownTerminalIds, environmentId, storeCloseTerminal],
2114 activeThreadId,
2115 activeThreadRef,
2116 activeServerOrderedTerminalIds,
2117 environmentId,
2118 storeCloseTerminal,
2119 ],
2120 );2118 );
2121 const runProjectScript = useCallback(2119 const runProjectScript = useCallback(
2122 async (2120 async (
@@ -2139,9 +2137,7 @@ export default function ChatView(props: ChatViewProps) { · 16 unmodified lines
2139 }2137 }
2140 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;2138 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
2141 const baseTerminalId =2139 const baseTerminalId =
2142 terminalUiState.activeTerminalId ||2140 terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
2143 activeServerOrderedTerminalIds[0] ||
2144 DEFAULT_THREAD_TERMINAL_ID;
2145 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);2141 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
2146 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;2142 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
2147 const shouldCreateNewTerminal = wantsNewTerminal;2143 const shouldCreateNewTerminal = wantsNewTerminal;
@@ -2166,7 +2162,7 @@ export default function ChatView(props: ChatViewProps) { · 18 unmodified lines
2166 ...(options?.env ? { extraEnv: options.env } : {}),2162 ...(options?.env ? { extraEnv: options.env } : {}),
2167 });2163 });
2168 const targetTerminalId = shouldCreateNewTerminal2164 const targetTerminalId = shouldCreateNewTerminal
2169 ? nextTerminalId(activeServerOrderedTerminalIds)2165 ? nextTerminalId(activeKnownTerminalIds)
2170 : baseTerminalId;2166 : baseTerminalId;
2171 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal2167 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
2172 ? {2168 ? {
@@ -2218,7 +2214,7 @@ export default function ChatView(props: ChatViewProps) { · 45 unmodified lines
2218 storeSetActiveTerminal,2214 storeSetActiveTerminal,
2219 setLastInvokedScriptByProjectId,2215 setLastInvokedScriptByProjectId,
2220 environmentId,2216 environmentId,
2221 activeServerOrderedTerminalIds,2217 activeKnownTerminalIds,
2222 runningTerminalIds,2218 runningTerminalIds,
2223 terminalUiState.activeTerminalId,2219 terminalUiState.activeTerminalId,
2224 ],2220 ],
packages/client-runtime/src/vcsRefState.test.ts+25−0
@@ -252,6 +252,31 @@ describe("createVcsRefManager", () => { · 251 unmodified lines
252 expect(manager.getSnapshot(queriedTarget)).toEqual(EMPTY_VCS_REF_STATE);252 expect(manager.getSnapshot(queriedTarget)).toEqual(EMPTY_VCS_REF_STATE);
253 });253 });
254 254
255 it("invalidates target in-flight loads before they can write stale data", async () => {
256 const firstLoad = deferred<VcsListRefsResult>();
257 let callCount = 0;
258 const listRefs = vi.fn((async () => {
259 callCount += 1;
260 return callCount === 1 ? firstLoad.promise : SECOND_PAGE;
261 }) satisfies VcsRefClient["listRefs"]);
262 const client = { listRefs } satisfies VcsRefClient;
263 const manager = createVcsRefManager({
264 getRegistry: () => atomRegistry,
265 getClient: () => client,
266 });
267
268 const staleLoad = manager.load(TARGET, client);
269 manager.invalidate(TARGET);
270 const freshLoad = manager.load(TARGET, client);
271
272 expect(listRefs).toHaveBeenCalledTimes(2);
273
274 firstLoad.resolve(FIRST_PAGE);
275 await expect(staleLoad).resolves.toEqual(FIRST_PAGE);
276 await expect(freshLoad).resolves.toEqual(SECOND_PAGE);
277 expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE);
278 });
279
255 it("watches refs with a ref-counted client-change subscription", async () => {280 it("watches refs with a ref-counted client-change subscription", async () => {
256 const mock = createMockClient();281 const mock = createMockClient();
257 let listener: () => void = noop;282 let listener: () => void = noop;
packages/client-runtime/src/vcsRefState.ts+8−0
@@ -389,14 +389,22 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 388 unmodified lines
389 if (target) {389 if (target) {
390 const targetKey = getVcsRefTargetKey(target);390 const targetKey = getVcsRefTargetKey(target);
391 if (targetKey !== null) {391 if (targetKey !== null) {
392 bumpLoadVersion(targetKey);
392 setState(targetKey, EMPTY_VCS_REF_STATE);393 setState(targetKey, EMPTY_VCS_REF_STATE);
394 for (const key of inFlight.keys()) {
395 if (key.startsWith(`${targetKey}:`)) {
396 inFlight.delete(key);
397 }
398 }
393 }399 }
394 return;400 return;
395 }401 }
396 402
397 for (const key of knownVcsRefKeys) {403 for (const key of knownVcsRefKeys) {
404 bumpLoadVersion(key);
398 setState(key, EMPTY_VCS_REF_STATE);405 setState(key, EMPTY_VCS_REF_STATE);
399 }406 }
407 inFlight.clear();
400 }408 }
401 409
402 function invalidateScope(scope: VcsRefScope): void {410 function invalidateScope(scope: VcsRefScope): void {
packages/client-runtime/src/wsRpcClient.test.ts+33−0
@@ -3,6 +3,7 @@ import type { · 2 unmodified lines
3 VcsStatusRemoteResult,3 VcsStatusRemoteResult,
4 VcsStatusStreamEvent,4 VcsStatusStreamEvent,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { ORCHESTRATION_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts";
6import { describe, expect, it, vi } from "vitest";7import { describe, expect, it, vi } from "vitest";
7 8
8vi.mock("./wsTransport.ts", () => ({9vi.mock("./wsTransport.ts", () => ({
@@ -127,4 +128,36 @@ describe("createWsRpcClient", () => { · 118 unmodified lines
127 ],128 ],
128 ]);129 ]);
129 });130 });
131
132 it("tags stream subscriptions for targeted resubscribe handling", () => {
133 const subscribe = vi.fn(() => () => undefined);
134 const transport = {
135 dispose: vi.fn(async () => undefined),
136 reconnect: vi.fn(async () => undefined),
137 request: vi.fn(),
138 requestStream: vi.fn(),
139 subscribe,
140 } satisfies Pick<
141 WsTransport,
142 "dispose" | "reconnect" | "request" | "requestStream" | "subscribe"
143 >;
144
145 const client = createWsRpcClient(transport as unknown as WsTransport);
146 const listener = vi.fn();
147
148 client.terminal.onMetadata(listener);
149 client.vcs.onStatus({ cwd: "/repo" }, listener);
150 client.server.subscribeConfig(listener);
151 client.orchestration.subscribeThread({ threadId: ThreadId.make("thread-1") }, listener);
152
153 const subscribeCalls = subscribe.mock.calls as unknown as Array<
154 readonly [unknown, unknown, { readonly tag?: string }?]
155 >;
156 expect(subscribeCalls.map((call) => call[2]?.tag)).toEqual([
157 WS_METHODS.subscribeTerminalMetadata,
158 WS_METHODS.subscribeVcsStatus,
159 WS_METHODS.subscribeServerConfig,
160 ORCHESTRATION_WS_METHODS.subscribeThread,
161 ]);
162 });
130});163});
packages/client-runtime/src/wsRpcClient.ts+18−8
@@ -23,6 +23,16 @@ interface StreamSubscriptionOptions { · 22 unmodified lines
23 readonly onResubscribe?: () => void;23 readonly onResubscribe?: () => void;
24}24}
25 25
26function subscriptionOptions(
27 options: StreamSubscriptionOptions | undefined,
28 tag: string,
29): StreamSubscriptionOptions & { readonly tag: string } {
30 return {
31 ...options,
32 tag,
33 };
34}
35
26type RpcUnaryMethod<TTag extends RpcTag> =36type RpcUnaryMethod<TTag extends RpcTag> =
27 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>37 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
28 ? (input: RpcInput<TTag>) => Promise<TSuccess>38 ? (input: RpcInput<TTag>) => Promise<TSuccess>
@@ -145,7 +155,7 @@ export function createWsRpcClient( · 116 unmodified lines
145 transport.subscribe(155 transport.subscribe(
146 (client) => client[WS_METHODS.terminalAttach](input),156 (client) => client[WS_METHODS.terminalAttach](input),
147 listener,157 listener,
148 options,158 subscriptionOptions(options, WS_METHODS.terminalAttach),
149 ),159 ),
150 write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),160 write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),
151 resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)),161 resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)),
@@ -156,7 +166,7 @@ export function createWsRpcClient( · 4 unmodified lines
156 transport.subscribe(166 transport.subscribe(
157 (client) => client[WS_METHODS.subscribeTerminalMetadata]({}),167 (client) => client[WS_METHODS.subscribeTerminalMetadata]({}),
158 listener,168 listener,
159 options,169 subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata),
160 ),170 ),
161 },171 },
162 projects: {172 projects: {
@@ -184,7 +194,7 @@ export function createWsRpcClient( · 21 unmodified lines
184 current = applyGitStatusStreamEvent(current, event);194 current = applyGitStatusStreamEvent(current, event);
185 listener(current);195 listener(current);
186 },196 },
187 options,197 subscriptionOptions(options, WS_METHODS.subscribeVcsStatus),
188 );198 );
189 },199 },
190 runStackedAction: async (input, options) => {200 runStackedAction: async (input, options) => {
@@ -238,19 +248,19 @@ export function createWsRpcClient( · 47 unmodified lines
238 transport.subscribe(248 transport.subscribe(
239 (client) => client[WS_METHODS.subscribeServerConfig]({}),249 (client) => client[WS_METHODS.subscribeServerConfig]({}),
240 listener,250 listener,
241 options,251 subscriptionOptions(options, WS_METHODS.subscribeServerConfig),
242 ),252 ),
243 subscribeLifecycle: (listener, options) =>253 subscribeLifecycle: (listener, options) =>
244 transport.subscribe(254 transport.subscribe(
245 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),255 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
246 listener,256 listener,
247 options,257 subscriptionOptions(options, WS_METHODS.subscribeServerLifecycle),
248 ),258 ),
249 subscribeAuthAccess: (listener, options) =>259 subscribeAuthAccess: (listener, options) =>
250 transport.subscribe(260 transport.subscribe(
251 (client) => client[WS_METHODS.subscribeAuthAccess]({}),261 (client) => client[WS_METHODS.subscribeAuthAccess]({}),
252 listener,262 listener,
253 options,263 subscriptionOptions(options, WS_METHODS.subscribeAuthAccess),
254 ),264 ),
255 },265 },
256 orchestration: {266 orchestration: {
@@ -264,13 +274,13 @@ export function createWsRpcClient( · 7 unmodified lines
264 transport.subscribe(274 transport.subscribe(
265 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),275 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),
266 listener,276 listener,
267 options,277 subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeShell),
268 ),278 ),
269 subscribeThread: (input, listener, options) =>279 subscribeThread: (input, listener, options) =>
270 transport.subscribe(280 transport.subscribe(
271 (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input),281 (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input),
272 listener,282 listener,
273 options,283 subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread),
274 ),284 ),
275 },285 },
276 };286 };