History

deeper in atom

Julius Marminge committed 7 months ago

Commit f8b6e5b1 parent 744f74415 files · +512 −618Browse files

Unified · Split

15 files
  1. apps/web/src/components/BranchToolbarBranchSelector.tsx +48 −40
  2. apps/web/src/components/ChatView.browser.tsx +3 −0
  3. apps/web/src/components/ChatView.tsx +21 −10
  4. apps/web/src/components/DiffPanel.tsx +7 −3
  5. apps/web/src/components/GitActionsControl.tsx +149 −95
  6. apps/web/src/components/PullRequestThreadDialog.tsx +68 −68
  7. apps/web/src/components/Sidebar.tsx +8 −11
  8. apps/web/src/hooks/useThreadActions.ts +15 −9
  9. apps/web/src/lib/gitReactQuery.test.ts +0 −51
  10. apps/web/src/lib/gitReactQuery.ts +0 −224
  11. apps/web/src/rpc/client.test.ts +1 −52
  12. apps/web/src/rpc/client.ts +22 −24
  13. apps/web/src/rpc/gitAtoms.ts +170 −0
  14. apps/web/src/wsNativeApi.test.ts +0 −1
  15. apps/web/src/wsRpcClient.ts +0 −30
apps/web/src/components/BranchToolbarBranchSelector.tsx+48−40
@@ -1,6 +1,8 @@
1−import type { GitBranch } from "@t3tools/contracts";
2−import { useQuery, useQueryClient } from "@tanstack/react-query";
1+import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
2+import { type GitBranch } from "@t3tools/contracts";
33 import { useVirtualizer } from "@tanstack/react-virtual";
4+import * as Option from "effect/Option";
5+import { AsyncResult } from "effect/unstable/reactivity";
46 import { ChevronDownIcon } from "lucide-react";
57 import {
68 type CSSProperties,
@@ -14,14 +16,14 @@ import { · 7 unmodified lines
1416 useTransition,
1517 } from "react";
1618
17−import {
18− gitBranchesQueryOptions,
19− gitQueryKeys,
20− gitStatusQueryOptions,
21− invalidateGitQueries,
22−} from "../lib/gitReactQuery";
23−import { readNativeApi } from "../nativeApi";
2419 import { parsePullRequestReference } from "../pullRequestReference";
20+import { REACTIVITY_KEYS } from "../rpc/client";
21+import {
22+ gitBranchesAtom,
23+ gitCheckoutMutationAtom,
24+ gitCreateBranchMutationAtom,
25+ gitStatusAtom,
26+} from "../rpc/gitAtoms";
2527 import {
2628 dedupeRemoteBranchesWithLocalMatches,
2729 deriveLocalBranchNameFromRemoteRef,
@@ -84,19 +86,23 @@ export function BranchToolbarBranchSelector({ · 56 unmodified lines
8486 onCheckoutPullRequestRequest,
8587 onComposerFocusRequest,
8688 }: BranchToolbarBranchSelectorProps) {
87− const queryClient = useQueryClient();
8889 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
8990 const [branchQuery, setBranchQuery] = useState("");
9091 const deferredBranchQuery = useDeferredValue(branchQuery);
91−
92− const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
93− const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd));
92+ const branchesResult = useAtomValue(gitBranchesAtom(branchCwd));
93+ const branchStatusResult = useAtomValue(gitStatusAtom(branchCwd));
94+ const refreshBranches = useAtomRefresh(gitBranchesAtom(branchCwd));
95+ const refreshBranchStatus = useAtomRefresh(gitStatusAtom(branchCwd));
96+ const checkoutBranch = useAtomSet(gitCheckoutMutationAtom, { mode: "promise" });
97+ const createGitBranch = useAtomSet(gitCreateBranchMutationAtom, { mode: "promise" });
98+ const branchesData = Option.getOrUndefined(AsyncResult.value(branchesResult));
99+ const branchStatus = Option.getOrUndefined(AsyncResult.value(branchStatusResult));
94100 const branches = useMemo(
95− () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []),
96− [branchesQuery.data?.branches],
101+ () => dedupeRemoteBranchesWithLocalMatches(branchesData?.branches ?? []),
102+ [branchesData?.branches],
97103 );
98104 const currentGitBranch =
99− branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null;
105+ branchStatus?.branch ?? branches.find((branch) => branch.current)?.name ?? null;
100106 const canonicalActiveBranch = resolveBranchToolbarValue({
101107 envMode: effectiveEnvMode,
102108 activeWorktreePath,
@@ -155,18 +161,18 @@ export function BranchToolbarBranchSelector({ · 52 unmodified lines
155161 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
156162 );
157163 const [isBranchActionPending, startBranchActionTransition] = useTransition();
164+ const isBranchesLoading =
165+ branches.length === 0 && (AsyncResult.isInitial(branchesResult) || branchesResult.waiting);
158166 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
159167
160168 const runBranchAction = (action: () => Promise<void>) => {
161169 startBranchActionTransition(async () => {
162170 await action().catch(() => undefined);
163− await invalidateGitQueries(queryClient).catch(() => undefined);
164171 });
165172 };
166173
167174 const selectBranch = (branch: GitBranch) => {
168− const api = readNativeApi();
169− if (!api || !branchCwd || isBranchActionPending) return;
175+ if (!branchCwd || isBranchActionPending) return;
170176
171177 // In new-worktree mode, selecting a branch sets the base branch.
172178 if (isSelectingWorktreeBase) {
@@ -200,8 +206,13 @@ export function BranchToolbarBranchSelector({ · 27 unmodified lines
200206 runBranchAction(async () => {
201207 setOptimisticBranch(selectedBranchName);
202208 try {
203− await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name });
204− await invalidateGitQueries(queryClient);
209+ await checkoutBranch({
210+ payload: {
211+ cwd: selectionTarget.checkoutCwd,
212+ branch: branch.name,
213+ },
214+ reactivityKeys: [REACTIVITY_KEYS.git(selectionTarget.checkoutCwd)],
215+ });
205216 } catch (error) {
206217 toastManager.add({
207218 type: "error",
@@ -211,23 +222,14 @@ export function BranchToolbarBranchSelector({ · 3 unmodified lines
211222 return;
212223 }
213224
214− let nextBranchName = selectedBranchName;
215− if (branch.isRemote) {
216− const status = await api.git.status({ cwd: branchCwd }).catch(() => null);
217− if (status?.branch) {
218− nextBranchName = status.branch;
219− }
220− }
221−
222− setOptimisticBranch(nextBranchName);
223− onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
225+ setOptimisticBranch(selectedBranchName);
226+ onSetThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath);
224227 });
225228 };
226229
227230 const createBranch = (rawName: string) => {
228231 const name = rawName.trim();
229− const api = readNativeApi();
230− if (!api || !branchCwd || !name || isBranchActionPending) return;
232+ if (!branchCwd || !name || isBranchActionPending) return;
231233
232234 setIsBranchMenuOpen(false);
233235 onComposerFocusRequest?.();
@@ -236,9 +238,14 @@ export function BranchToolbarBranchSelector({ · 2 unmodified lines
236238 setOptimisticBranch(name);
237239
238240 try {
239− await api.git.createBranch({ cwd: branchCwd, branch: name });
241+ await createGitBranch({
242+ payload: { cwd: branchCwd, branch: name },
243+ });
240244 try {
241− await api.git.checkout({ cwd: branchCwd, branch: name });
245+ await checkoutBranch({
246+ payload: { cwd: branchCwd, branch: name },
247+ reactivityKeys: [REACTIVITY_KEYS.git(branchCwd)],
248+ });
242249 } catch (error) {
243250 toastManager.add({
244251 type: "error",
@@ -287,11 +294,12 @@ export function BranchToolbarBranchSelector({ · 42 unmodified lines
287294 setBranchQuery("");
288295 return;
289296 }
290− void queryClient.invalidateQueries({
291− queryKey: gitQueryKeys.branches(branchCwd),
292− });
297+ if (branchCwd) {
298+ refreshBranches();
299+ refreshBranchStatus();
300+ }
293301 },
294− [branchCwd, queryClient],
302+ [branchCwd, refreshBranchStatus, refreshBranches],
295303 );
296304
297305 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
@@ -425,7 +433,7 @@ export function BranchToolbarBranchSelector({ · 127 unmodified lines
425433 <ComboboxTrigger
426434 render={<Button variant="ghost" size="xs" />}
427435 className="text-muted-foreground/70 hover:text-foreground/80"
428− disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending}
436+ disabled={isBranchesLoading || isBranchActionPending}
429437 >
430438 <span className="max-w-[240px] truncate">{triggerLabel}</span>
431439 <ChevronDownIcon />
apps/web/src/components/ChatView.browser.tsx+3−0
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
3232 import { isMacPlatform } from "../lib/utils";
3333 import { __resetNativeApiForTests } from "../nativeApi";
3434 import { getRouter } from "../router";
35+import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry";
3536 import { useStore } from "../store";
3637 import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
3738 import { estimateTimelineMessageHeight } from "./timelineHeight";
@@ -1121,6 +1122,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1083 unmodified lines
11211122 },
11221123 });
11231124 __resetNativeApiForTests();
1125+ resetAppAtomRegistryForTests();
11241126 await setViewport(DEFAULT_VIEWPORT);
11251127 localStorage.clear();
11261128 document.body.innerHTML = "";
@@ -1143,6 +1145,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 16 unmodified lines
11431145 afterEach(() => {
11441146 customWsRpcResolver = null;
11451147 document.body.innerHTML = "";
1148+ resetAppAtomRegistryForTests();
11461149 });
11471150
11481151 it.each(TEXT_VIEWPORT_MATRIX)(
apps/web/src/components/ChatView.tsx+21−10
@@ -1,3 +1,4 @@
1+import { useAtomSet, useAtomValue } from "@effect/atom-react";
12 import {
23 type ApprovalRequestId,
34 DEFAULT_MODEL_BY_PROVIDER,
@@ -21,12 +22,13 @@ import { · 17 unmodified lines
2122 TerminalOpenInput,
2223 } from "@t3tools/contracts";
2324 import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
25+import * as Option from "effect/Option";
2426 import { truncate } from "@t3tools/shared/String";
2527 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
26−import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
28+import { useQuery } from "@tanstack/react-query";
2729 import { useDebouncedValue } from "@tanstack/react-pacer";
2830 import { useNavigate, useSearch } from "@tanstack/react-router";
29−import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
31+import { AsyncResult } from "effect/unstable/reactivity";
3032 import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3133 import { isElectron } from "../env";
3234 import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
@@ -106,6 +108,8 @@ import { Separator } from "./ui/separator"; · 73 unmodified lines
106108 import { cn, randomUUID } from "~/lib/utils";
107109 import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
108110 import { toastManager } from "./ui/toast";
111+import { gitBranchesAtom, gitCreateWorktreeMutationAtom } from "~/rpc/gitAtoms";
112+import { REACTIVITY_KEYS } from "~/rpc/client";
109113 import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
110114 import { type NewProjectScriptInput } from "./ProjectScriptsControl";
111115 import {
@@ -423,8 +427,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 311 unmodified lines
423427 select: (params) => parseDiffRouteSearch(params),
424428 });
425429 const { resolvedTheme } = useTheme();
426− const queryClient = useQueryClient();
427− const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient }));
430+ const createWorktree = useAtomSet(gitCreateWorktreeMutationAtom, { mode: "promise" });
428431 const composerDraft = useComposerThreadDraft(threadId);
429432 const prompt = composerDraft.prompt;
430433 const composerImages = composerDraft.images;
@@ -1201,7 +1204,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 770 unmodified lines
12011204 (debouncerState) => ({ isPending: debouncerState.isPending }),
12021205 );
12031206 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
1204− const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
1207+ const branchesResult = useAtomValue(gitBranchesAtom(gitCwd));
1208+ const branches = Option.getOrUndefined(AsyncResult.value(branchesResult));
12051209 const keybindings = useServerKeybindings();
12061210 const availableEditors = useServerAvailableEditors();
12071211 const modelOptionsByProvider = useMemo(
@@ -1338,7 +1342,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 130 unmodified lines
13381342 });
13391343 }, [activeProjectCwd, activeThreadWorktreePath]);
13401344 // Default true while loading to avoid toolbar flicker.
1341− const isGitRepo = branchesQuery.data?.isRepo ?? true;
1345+ const isGitRepo = branches?.isRepo ?? true;
13421346 const terminalShortcutLabelOptions = useMemo(
13431347 () => ({
13441348 context: {
@@ -2787,10 +2791,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1442 unmodified lines
27872791 if (baseBranchForWorktree) {
27882792 beginLocalDispatch({ preparingWorktree: true });
27892793 const newBranch = buildTemporaryWorktreeBranchName();
2790− const result = await createWorktreeMutation.mutateAsync({
2791− cwd: activeProject.cwd,
2792− branch: baseBranchForWorktree,
2793− newBranch,
2794+ const result = await createWorktree({
2795+ payload: {
2796+ cwd: activeProject.cwd,
2797+ branch: baseBranchForWorktree,
2798+ path: null,
2799+ newBranch,
2800+ },
2801+ reactivityKeys: [
2802+ REACTIVITY_KEYS.git(activeProject.cwd),
2803+ REACTIVITY_KEYS.project(activeProject.cwd),
2804+ ],
27942805 });
27952806 nextThreadBranch = result.worktree.branch;
27962807 nextThreadWorktreePath = result.worktree.path;
apps/web/src/components/DiffPanel.tsx+7−3
@@ -1,8 +1,11 @@
1+import { useAtomValue } from "@effect/atom-react";
12 import { parsePatchFiles } from "@pierre/diffs";
23 import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
34 import { useQuery } from "@tanstack/react-query";
45 import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
56 import { ThreadId, type TurnId } from "@t3tools/contracts";
7+import * as Option from "effect/Option";
8+import { AsyncResult } from "effect/unstable/reactivity";
69 import {
710 ChevronLeftIcon,
811 ChevronRightIcon,
@@ -19,10 +22,10 @@ import { · 10 unmodified lines
1922 useState,
2023 } from "react";
2124 import { openInPreferredEditor } from "../editorPreferences";
22−import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
2325 import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
2426 import { cn } from "~/lib/utils";
2527 import { readNativeApi } from "../nativeApi";
28+import { gitBranchesAtom } from "../rpc/gitAtoms";
2629 import { resolvePathLinkTarget } from "../terminal-links";
2730 import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
2831 import { useTheme } from "../hooks/useTheme";
@@ -189,8 +192,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 160 unmodified lines
189192 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
190193 );
191194 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
192− const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null));
193− const isGitRepo = gitBranchesQuery.data?.isRepo ?? true;
195+ const gitBranchesResult = useAtomValue(gitBranchesAtom(activeCwd ?? null));
196+ const gitBranches = Option.getOrUndefined(AsyncResult.value(gitBranchesResult));
197+ const isGitRepo = gitBranches?.isRepo ?? true;
194198 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
195199 useTurnDiffSummaries(activeThread);
196200 const orderedTurnDiffSummaries = useMemo(
apps/web/src/components/GitActionsControl.tsx+149−95
@@ -1,10 +1,13 @@
1+import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
12 import type {
2− GitActionProgressEvent,
3+ GitRunStackedActionInput,
34 GitStackedAction,
45 GitStatusResult,
56 ThreadId,
67 } from "@t3tools/contracts";
7−import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8+import { Cause } from "effect";
9+import * as Option from "effect/Option";
10+import { AsyncResult } from "effect/unstable/reactivity";
811 import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
912 import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
1013 import { GitHubIcon } from "./Icons";
@@ -38,18 +41,18 @@ import { ScrollArea } from "~/components/ui/scroll-area"; · 27 unmodified lines
3841 import { Textarea } from "~/components/ui/textarea";
3942 import { toastManager } from "~/components/ui/toast";
4043 import { openInPreferredEditor } from "~/editorPreferences";
41−import {
42− gitBranchesQueryOptions,
43− gitInitMutationOptions,
44− gitMutationKeys,
45− gitPullMutationOptions,
46− gitRunStackedActionMutationOptions,
47− gitStatusQueryOptions,
48− invalidateGitQueries,
49−} from "~/lib/gitReactQuery";
5044 import { randomUUID } from "~/lib/utils";
5145 import { resolvePathLinkTarget } from "~/terminal-links";
5246 import { readNativeApi } from "~/nativeApi";
47+import {
48+ gitBranchesAtom,
49+ gitInitMutationAtom,
50+ gitPullMutationAtom,
51+ gitRunStackedActionMutationAtom,
52+ gitRunStackedActionProgressAtom,
53+ gitStatusAtom,
54+} from "~/rpc/gitAtoms";
55+import { REACTIVITY_KEYS } from "~/rpc/client";
5356
5457 interface GitActionsControlProps {
5558 gitCwd: string | null;
@@ -112,6 +115,13 @@ function resolveProgressDescription(progress: ActiveGitActionProgress): string | · 56 unmodified lines
112115 return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs);
113116 }
114117
118+function toError(error: unknown): Error {
119+ if (error instanceof Error) {
120+ return error;
121+ }
122+ return new Error(typeof error === "string" ? error : "Unknown error");
123+}
124+
115125 function getMenuActionDisabledReason({
116126 item,
117127 gitStatus,
@@ -208,7 +218,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 90 unmodified lines
208218 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
209219 [activeThreadId],
210220 );
211− const queryClient = useQueryClient();
212221 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
213222 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
214223 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
@@ -231,9 +240,22 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 16 unmodified lines
231240 });
232241 }, [threadToastData]);
233242
234− const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
235−
236− const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
243+ const gitStatusResult = useAtomValue(gitStatusAtom(gitCwd));
244+ const branchesResult = useAtomValue(gitBranchesAtom(gitCwd));
245+ const refreshGitStatus = useAtomRefresh(gitStatusAtom(gitCwd));
246+ const refreshGitBranches = useAtomRefresh(gitBranchesAtom(gitCwd));
247+ const initGit = useAtomSet(gitInitMutationAtom, { mode: "promise" });
248+ const pullGit = useAtomSet(gitPullMutationAtom, { mode: "promise" });
249+ const runStackedAction = useAtomSet(gitRunStackedActionMutationAtom, { mode: "promise" });
250+ const runStackedActionResult = useAtomValue(gitRunStackedActionMutationAtom);
251+ const stackedActionProgressEvent = useAtomValue(gitRunStackedActionProgressAtom);
252+ const [isInitPending, setIsInitPending] = useState(false);
253+ const [isPullRunning, setIsPullRunning] = useState(false);
254+ const gitStatus = Option.getOrNull(AsyncResult.value(gitStatusResult));
255+ const gitStatusError = Option.getOrNull(
256+ Option.map(AsyncResult.cause(gitStatusResult), (cause) => toError(Cause.squash(cause))),
257+ );
258+ const branchList = Option.getOrNull(AsyncResult.value(branchesResult));
237259 // Default to true while loading so we don't flash init controls.
238260 const isRepo = branchList?.isRepo ?? true;
239261 const hasOriginRemote = branchList?.hasOriginRemote ?? false;
@@ -242,9 +264,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 2 unmodified lines
242264 !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch;
243265
244266 useEffect(() => {
245− if (!isGitStatusOutOfSync) return;
246− void invalidateGitQueries(queryClient);
247− }, [isGitStatusOutOfSync, queryClient]);
267+ if (!isGitStatusOutOfSync || !gitCwd) return;
268+ refreshGitStatus();
269+ refreshGitBranches();
270+ }, [gitCwd, isGitStatusOutOfSync, refreshGitBranches, refreshGitStatus]);
248271
249272 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;
250273
@@ -252,21 +275,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 1 unmodified line
252275 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
253276 const allSelected = excludedFiles.size === 0;
254277 const noneSelected = selectedFiles.length === 0;
255−
256− const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));
257−
258− const runImmediateGitActionMutation = useMutation(
259− gitRunStackedActionMutationOptions({
260− cwd: gitCwd,
261− queryClient,
262− }),
263− );
264− const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
265−
266− const isRunStackedActionRunning =
267− useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
268− const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
278+ const isRunStackedActionRunning = runStackedActionResult.waiting;
269279 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
280+ const runImmediateGitAction = useCallback(
281+ (input: GitRunStackedActionInput) => runStackedAction(input),
282+ [runStackedAction],
283+ );
284+ const pullCurrentBranch = useCallback(
285+ async (cwd: string) => {
286+ setIsPullRunning(true);
287+ try {
288+ return await pullGit({
289+ payload: { cwd },
290+ reactivityKeys: [REACTIVITY_KEYS.git(cwd)],
291+ });
292+ } finally {
293+ setIsPullRunning(false);
294+ }
295+ },
296+ [pullGit],
297+ );
298+ const initializeGit = useCallback(
299+ async (cwd: string) => {
300+ setIsInitPending(true);
301+ try {
302+ await initGit({
303+ payload: { cwd },
304+ reactivityKeys: [REACTIVITY_KEYS.git(cwd)],
305+ });
306+ } finally {
307+ setIsInitPending(false);
308+ }
309+ },
310+ [initGit],
311+ );
270312 const isDefaultBranch = useMemo(() => {
271313 const branchName = gitStatusForActions?.branch;
272314 if (!branchName) return false;
@@ -307,6 +349,59 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 34 unmodified lines
307349 };
308350 }, [updateActiveProgressToast]);
309351
352+ useEffect(() => {
353+ const event = stackedActionProgressEvent;
354+ const progress = activeGitActionProgressRef.current;
355+ if (!event || !progress) {
356+ return;
357+ }
358+ if (gitCwd && event.cwd !== gitCwd) {
359+ return;
360+ }
361+ if (progress.actionId !== event.actionId) {
362+ return;
363+ }
364+
365+ const now = Date.now();
366+ switch (event.kind) {
367+ case "action_started":
368+ progress.phaseStartedAtMs = now;
369+ progress.hookStartedAtMs = null;
370+ progress.hookName = null;
371+ progress.lastOutputLine = null;
372+ break;
373+ case "phase_started":
374+ progress.title = event.label;
375+ progress.currentPhaseLabel = event.label;
376+ progress.phaseStartedAtMs = now;
377+ progress.hookStartedAtMs = null;
378+ progress.hookName = null;
379+ progress.lastOutputLine = null;
380+ break;
381+ case "hook_started":
382+ progress.title = `Running ${event.hookName}...`;
383+ progress.hookName = event.hookName;
384+ progress.hookStartedAtMs = now;
385+ progress.lastOutputLine = null;
386+ break;
387+ case "hook_output":
388+ progress.lastOutputLine = event.text;
389+ break;
390+ case "hook_finished":
391+ progress.title = progress.currentPhaseLabel ?? "Committing...";
392+ progress.hookName = null;
393+ progress.hookStartedAtMs = null;
394+ progress.lastOutputLine = null;
395+ break;
396+ case "action_finished":
397+ return;
398+ case "action_failed":
399+ return;
400+ }
401+
402+ updateActiveProgressToast();
403+ }, [gitCwd, stackedActionProgressEvent, updateActiveProgressToast]);
404+
310405 const openExistingPr = useCallback(async () => {
311406 const api = readNativeApi();
312407 if (!api) {
@@ -415,69 +510,17 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 102 unmodified lines
415510 });
416511 }
417512
418− const applyProgressEvent = (event: GitActionProgressEvent) => {
419− const progress = activeGitActionProgressRef.current;
420− if (!progress) {
421− return;
422− }
423− if (gitCwd && event.cwd !== gitCwd) {
424− return;
425− }
426− if (progress.actionId !== event.actionId) {
427− return;
428− }
429−
430− const now = Date.now();
431− switch (event.kind) {
432− case "action_started":
433− progress.phaseStartedAtMs = now;
434− progress.hookStartedAtMs = null;
435− progress.hookName = null;
436− progress.lastOutputLine = null;
437− break;
438− case "phase_started":
439− progress.title = event.label;
440− progress.currentPhaseLabel = event.label;
441− progress.phaseStartedAtMs = now;
442− progress.hookStartedAtMs = null;
443− progress.hookName = null;
444− progress.lastOutputLine = null;
445− break;
446− case "hook_started":
447− progress.title = `Running ${event.hookName}...`;
448− progress.hookName = event.hookName;
449− progress.hookStartedAtMs = now;
450− progress.lastOutputLine = null;
451− break;
452− case "hook_output":
453− progress.lastOutputLine = event.text;
454− break;
455− case "hook_finished":
456− progress.title = progress.currentPhaseLabel ?? "Committing...";
457− progress.hookName = null;
458− progress.hookStartedAtMs = null;
459− progress.lastOutputLine = null;
460− break;
461− case "action_finished":
462− // Let the resolved mutation update the toast so we keep the
463− // elapsed description visible until the final success state renders.
464− return;
465− case "action_failed":
466− // Let the rejected mutation publish the error toast to avoid a
467− // transient intermediate state before the final failure message.
468− return;
469− }
470−
471− updateActiveProgressToast();
472− };
513+ if (!gitCwd) {
514+ return;
515+ }
473516
474− const promise = runImmediateGitActionMutation.mutateAsync({
517+ const promise = runImmediateGitAction({
475518 actionId,
519+ cwd: gitCwd,
476520 action,
477521 ...(commitMessage ? { commitMessage } : {}),
478522 ...(featureBranch ? { featureBranch } : {}),
479523 ...(filePaths ? { filePaths } : {}),
480− onProgress: applyProgressEvent,
481524 });
482525
483526 try {
@@ -624,7 +667,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 140 unmodified lines
624667 return;
625668 }
626669 if (quickAction.kind === "run_pull") {
627− const promise = pullMutation.mutateAsync();
670+ if (!gitCwd) {
671+ return;
672+ }
673+ const promise = pullCurrentBranch(gitCwd);
628674 toastManager.promise(promise, {
629675 loading: { title: "Pulling...", data: threadToastData },
630676 success: (result) => ({
@@ -656,7 +702,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 25 unmodified lines
656702 if (quickAction.action) {
657703 void runGitActionWithToast({ action: quickAction.action });
658704 }
659− }, [openExistingPr, pullMutation, quickAction, threadToastData]);
705+ }, [gitCwd, openExistingPr, pullCurrentBranch, quickAction, threadToastData]);
660706
661707 const openDialogForMenuItem = useCallback(
662708 (item: GitActionMenuItem) => {
@@ -733,10 +779,15 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 70 unmodified lines
733779 <Button
734780 variant="outline"
735781 size="xs"
736− disabled={initMutation.isPending}
737− onClick={() => initMutation.mutate()}
782+ disabled={isInitPending}
783+ onClick={() => {
784+ if (!gitCwd) {
785+ return;
786+ }
787+ void initializeGit(gitCwd).catch(() => undefined);
788+ }}
738789 >
739− {initMutation.isPending ? "Initializing..." : "Initialize Git"}
790+ {isInitPending ? "Initializing..." : "Initialize Git"}
740791 </Button>
741792 ) : (
742793 <Group aria-label="Git actions" className="shrink-0">
@@ -778,7 +829,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 35 unmodified lines
778829 <GroupSeparator className="hidden @3xl/header-actions:block" />
779830 <Menu
780831 onOpenChange={(open) => {
781− if (open) void invalidateGitQueries(queryClient);
832+ if (open && gitCwd) {
833+ refreshGitStatus();
834+ refreshGitBranches();
835+ }
782836 }}
783837 >
784838 <MenuTrigger
apps/web/src/components/PullRequestThreadDialog.tsx+68−68
@@ -1,14 +1,13 @@
1−import type { GitResolvePullRequestResult } from "@t3tools/contracts";
2−import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1+import { useAtomSet, useAtomValue } from "@effect/atom-react";
32 import { useDebouncedValue } from "@tanstack/react-pacer";
3+import { Cause, Option } from "effect";
4+import { AsyncResult, Atom } from "effect/unstable/reactivity";
45 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
56
6−import {
7− gitPreparePullRequestThreadMutationOptions,
8− gitResolvePullRequestQueryOptions,
9−} from "~/lib/gitReactQuery";
107 import { cn } from "~/lib/utils";
118 import { parsePullRequestReference } from "~/pullRequestReference";
9+import { gitResolvePullRequestAtom, gitPreparePullRequestThreadMutationAtom } from "~/rpc/gitAtoms";
10+import { REACTIVITY_KEYS } from "~/rpc/client";
1211 import { Button } from "./ui/button";
1312 import {
1413 Dialog,
@@ -30,6 +29,10 @@ interface PullRequestThreadDialogProps { · 15 unmodified lines
3029 onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void;
3130 }
3231
32+function toErrorMessage(error: unknown, fallback: string): string {
33+ return error instanceof Error ? error.message : fallback;
34+}
35+
3336 export function PullRequestThreadDialog({
3437 open,
3538 cwd,
@@ -37,23 +40,28 @@ export function PullRequestThreadDialog({ · 1 unmodified line
3740 onOpenChange,
3841 onPrepared,
3942 }: PullRequestThreadDialogProps) {
40− const queryClient = useQueryClient();
4143 const referenceInputRef = useRef<HTMLInputElement>(null);
4244 const [reference, setReference] = useState(initialReference ?? "");
4345 const [referenceDirty, setReferenceDirty] = useState(false);
4446 const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null);
47+ const [prepareFollowupErrorMessage, setPrepareFollowupErrorMessage] = useState<string | null>(
48+ null,
49+ );
4550 const [debouncedReference, referenceDebouncer] = useDebouncedValue(
4651 reference,
4752 { wait: 450 },
4853 (debouncerState) => ({ isPending: debouncerState.isPending }),
4954 );
55+ const resetPreparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom);
5056
5157 useEffect(() => {
5258 if (!open) return;
5359 setReference(initialReference ?? "");
5460 setReferenceDirty(false);
5561 setPreparingMode(null);
56− }, [initialReference, open]);
62+ setPrepareFollowupErrorMessage(null);
63+ resetPreparePullRequestThread(Atom.Reset);
64+ }, [initialReference, open, resetPreparePullRequestThread]);
5765
5866 useEffect(() => {
5967 if (!open) return;
@@ -68,41 +76,35 @@ export function PullRequestThreadDialog({ · 8 unmodified lines
6876
6977 const parsedReference = parsePullRequestReference(reference);
7078 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
71− const resolvePullRequestQuery = useQuery(
72− gitResolvePullRequestQueryOptions({
73− cwd,
79+ const resolvePullRequestResult = useAtomValue(
80+ gitResolvePullRequestAtom({
81+ cwd: open ? cwd : null,
7482 reference: open ? parsedDebouncedReference : null,
7583 }),
7684 );
77− const cachedPullRequest = useMemo(() => {
78− if (!cwd || !parsedReference) {
79− return null;
80− }
81− const cached = queryClient.getQueryData<GitResolvePullRequestResult>([
82− "git",
83− "pull-request",
84− cwd,
85− parsedReference,
86− ]);
87− return cached?.pullRequest ?? null;
88− }, [cwd, parsedReference, queryClient]);
89− const preparePullRequestThreadMutation = useMutation(
90− gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }),
85+ const preparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom, {
86+ mode: "promise",
87+ });
88+ const preparePullRequestThreadResult = useAtomValue(gitPreparePullRequestThreadMutationAtom);
89+ const resolvePullRequestData = Option.getOrUndefined(AsyncResult.value(resolvePullRequestResult));
90+ const resolvePullRequestError = Option.getOrNull(
91+ Option.map(AsyncResult.cause(resolvePullRequestResult), (cause) => Cause.squash(cause)),
9192 );
93+ const isPreparingPullRequestThread = preparingMode !== null;
9294
9395 const liveResolvedPullRequest =
9496 parsedReference !== null && parsedReference === parsedDebouncedReference
95− ? (resolvePullRequestQuery.data?.pullRequest ?? null)
97+ ? (resolvePullRequestData?.pullRequest ?? null)
9698 : null;
97− const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest;
99+ const resolvedPullRequest = liveResolvedPullRequest;
98100 const isResolving =
99101 open &&
100102 parsedReference !== null &&
101103 resolvedPullRequest === null &&
102104 (referenceDebouncer.state.isPending ||
103105 parsedReference !== parsedDebouncedReference ||
104− resolvePullRequestQuery.isPending ||
105− resolvePullRequestQuery.isFetching);
106+ AsyncResult.isInitial(resolvePullRequestResult) ||
107+ resolvePullRequestResult.waiting);
106108 const statusTone = useMemo(() => {
107109 switch (resolvedPullRequest?.state) {
108110 case "merged":
@@ -115,6 +117,11 @@ export function PullRequestThreadDialog({ · 6 unmodified lines
115117 return "text-muted-foreground";
116118 }
117119 }, [resolvedPullRequest?.state]);
120+ const preparePullRequestThreadErrorMessage = AsyncResult.match(preparePullRequestThreadResult, {
121+ onInitial: () => null,
122+ onSuccess: () => null,
123+ onFailure: (error) => Cause.pretty(error.cause),
124+ });
118125
119126 const handleConfirm = useCallback(
120127 async (mode: "local" | "worktree") => {
@@ -125,29 +132,36 @@ export function PullRequestThreadDialog({ · 4 unmodified lines
125132 if (!parsedReference || !resolvedPullRequest || !cwd) {
126133 return;
127134 }
135+ setPrepareFollowupErrorMessage(null);
128136 setPreparingMode(mode);
129137 try {
130− const result = await preparePullRequestThreadMutation.mutateAsync({
131− reference: parsedReference,
132− mode,
138+ const result = await preparePullRequestThread({
139+ payload: {
140+ cwd,
141+ reference: parsedReference,
142+ mode,
143+ },
144+ reactivityKeys: [REACTIVITY_KEYS.git(cwd)],
133145 });
134− await onPrepared({
135− branch: result.branch,
136− worktreePath: result.worktreePath,
137− });
138− onOpenChange(false);
146+
147+ try {
148+ await onPrepared({
149+ branch: result.branch,
150+ worktreePath: result.worktreePath,
151+ });
152+ onOpenChange(false);
153+ } catch (error) {
154+ setPrepareFollowupErrorMessage(
155+ toErrorMessage(error, "Failed to prepare pull request thread."),
156+ );
157+ }
158+ } catch {
159+ return;
139160 } finally {
140161 setPreparingMode(null);
141162 }
142163 },
143− [
144− cwd,
145− onOpenChange,
146− onPrepared,
147− parsedReference,
148− preparePullRequestThreadMutation,
149− resolvedPullRequest,
150− ],
164+ [cwd, onOpenChange, onPrepared, parsedReference, preparePullRequestThread, resolvedPullRequest],
151165 );
152166
153167 const validationMessage = !referenceDirty
@@ -159,21 +173,17 @@ export function PullRequestThreadDialog({ · 5 unmodified lines
159173 : null;
160174 const errorMessage =
161175 validationMessage ??
162− (resolvedPullRequest === null && resolvePullRequestQuery.isError
163− ? resolvePullRequestQuery.error instanceof Error
164− ? resolvePullRequestQuery.error.message
176+ (resolvedPullRequest === null && AsyncResult.isFailure(resolvePullRequestResult)
177+ ? resolvePullRequestError instanceof Error
178+ ? resolvePullRequestError.message
165179 : "Failed to resolve pull request."
166− : preparePullRequestThreadMutation.error instanceof Error
167− ? preparePullRequestThreadMutation.error.message
168− : preparePullRequestThreadMutation.error
169− ? "Failed to prepare pull request thread."
170− : null);
180+ : (prepareFollowupErrorMessage ?? preparePullRequestThreadErrorMessage));
171181
172182 return (
173183 <Dialog
174184 open={open}
175185 onOpenChange={(nextOpen) => {
176− if (!preparePullRequestThreadMutation.isPending) {
186+ if (!isPreparingPullRequestThread) {
177187 onOpenChange(nextOpen);
178188 }
179189 }}
@@ -202,7 +212,7 @@ export function PullRequestThreadDialog({ · 22 unmodified lines
202212 return;
203213 }
204214 event.preventDefault();
205− if (!isResolving && !preparePullRequestThreadMutation.isPending) {
215+ if (!isResolving && !isPreparingPullRequestThread) {
206216 void handleConfirm("local");
207217 }
208218 }}
@@ -241,7 +251,7 @@ export function PullRequestThreadDialog({ · 32 unmodified lines
241251 variant="outline"
242252 size="sm"
243253 onClick={() => onOpenChange(false)}
244− disabled={preparePullRequestThreadMutation.isPending}
254+ disabled={isPreparingPullRequestThread}
245255 >
246256 Cancel
247257 </Button>
@@ -252,12 +262,7 @@ export function PullRequestThreadDialog({ · 4 unmodified lines
252262 onClick={() => {
253263 void handleConfirm("local");
254264 }}
255− disabled={
256− !cwd ||
257− !resolvedPullRequest ||
258− isResolving ||
259− preparePullRequestThreadMutation.isPending
260− }
265+ disabled={!cwd || !resolvedPullRequest || isResolving || isPreparingPullRequestThread}
261266 >
262267 {preparingMode === "local" ? "Preparing local..." : "Local"}
263268 </Button>
@@ -267,12 +272,7 @@ export function PullRequestThreadDialog({ · 3 unmodified lines
267272 onClick={() => {
268273 void handleConfirm("worktree");
269274 }}
270− disabled={
271− !cwd ||
272− !resolvedPullRequest ||
273− isResolving ||
274− preparePullRequestThreadMutation.isPending
275− }
275+ disabled={!cwd || !resolvedPullRequest || isResolving || isPreparingPullRequestThread}
276276 >
277277 {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"}
278278 </Button>
apps/web/src/components/Sidebar.tsx+8−11
@@ -12,6 +12,8 @@ import { · 11 unmodified lines
1212 } from "lucide-react";
1313 import { ProjectFavicon } from "./ProjectFavicon";
1414 import { autoAnimate } from "@formkit/auto-animate";
15+import { Option } from "effect";
16+import { AsyncResult } from "effect/unstable/reactivity";
1517 import {
1618 useCallback,
1719 useEffect,
@@ -44,7 +46,6 @@ import { · 26 unmodified lines
4446 ThreadId,
4547 type GitStatusResult,
4648 } from "@t3tools/contracts";
47−import { useQueries } from "@tanstack/react-query";
4849 import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
4950 import {
5051 type SidebarProjectSortOrder,
@@ -65,8 +66,8 @@ import { · 14 unmodified lines
6566 threadTraversalDirectionFromCommand,
6667 } from "../keybindings";
6768 import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
68−import { gitStatusQueryOptions } from "../lib/gitReactQuery";
6969 import { readNativeApi } from "../nativeApi";
70+import { useGitStatuses } from "../rpc/gitAtoms";
7071 import { useComposerDraftStore } from "../composerDraftStore";
7172 import { useHandleNewThread } from "../hooks/useHandleNewThread";
7273
@@ -556,19 +557,15 @@ export default function Sidebar() { · 483 unmodified lines
556557 ],
557558 [threadGitTargets],
558559 );
559− const threadGitStatusQueries = useQueries({
560− queries: threadGitStatusCwds.map((cwd) => ({
561− ...gitStatusQueryOptions(cwd),
562− staleTime: 30_000,
563− refetchInterval: 60_000,
564− })),
565− });
560+ const threadGitStatusResults = useGitStatuses(threadGitStatusCwds);
566561 const prByThreadId = useMemo(() => {
567562 const statusByCwd = new Map<string, GitStatusResult>();
568563 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
569564 const cwd = threadGitStatusCwds[index];
570565 if (!cwd) continue;
571− const status = threadGitStatusQueries[index]?.data;
566+ const status = threadGitStatusResults[index]
567+ ? Option.getOrUndefined(AsyncResult.value(threadGitStatusResults[index]!))
568+ : undefined;
572569 if (status) {
573570 statusByCwd.set(cwd, status);
574571 }
@@ -582,7 +579,7 @@ export default function Sidebar() { · 7 unmodified lines
582579 map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null);
583580 }
584581 return map;
585− }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
582+ }, [threadGitStatusCwds, threadGitStatusResults, threadGitTargets]);
586583
587584 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
588585 event.preventDefault();
apps/web/src/hooks/useThreadActions.ts+15−9
@@ -1,14 +1,15 @@
1+import { useAtomSet } from "@effect/atom-react";
12 import { ThreadId } from "@t3tools/contracts";
2−import { useMutation, useQueryClient } from "@tanstack/react-query";
33 import { useNavigate, useParams } from "@tanstack/react-router";
44 import { useCallback } from "react";
55
66 import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
77 import { useComposerDraftStore } from "../composerDraftStore";
88 import { useHandleNewThread } from "./useHandleNewThread";
9−import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
109 import { newCommandId } from "../lib/utils";
1110 import { readNativeApi } from "../nativeApi";
11+import { gitRemoveWorktreeMutationAtom } from "../rpc/gitAtoms";
12+import { REACTIVITY_KEYS } from "../rpc/client";
1213 import { useStore } from "../store";
1314 import { useTerminalStateStore } from "../terminalStateStore";
1415 import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
@@ -28,8 +29,7 @@ export function useThreadActions() { · 13 unmodified lines
2829 });
2930 const navigate = useNavigate();
3031 const { handleNewThread } = useHandleNewThread();
31− const queryClient = useQueryClient();
32− const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
32+ const removeWorktree = useAtomSet(gitRemoveWorktreeMutationAtom, { mode: "promise" });
3333
3434 const archiveThread = useCallback(
3535 async (threadId: ThreadId) => {
@@ -144,10 +144,16 @@ export function useThreadActions() { · 108 unmodified lines
144144 }
145145
146146 try {
147− await removeWorktreeMutation.mutateAsync({
148− cwd: threadProject.cwd,
149− path: orphanedWorktreePath,
150− force: true,
147+ await removeWorktree({
148+ payload: {
149+ cwd: threadProject.cwd,
150+ path: orphanedWorktreePath,
151+ force: true,
152+ },
153+ reactivityKeys: [
154+ REACTIVITY_KEYS.git(threadProject.cwd),
155+ REACTIVITY_KEYS.project(threadProject.cwd),
156+ ],
151157 });
152158 } catch (error) {
153159 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
@@ -170,7 +176,7 @@ export function useThreadActions() { · 16 unmodified lines
170176 clearTerminalState,
171177 appSettings.sidebarThreadSortOrder,
172178 navigate,
173− removeWorktreeMutation,
179+ removeWorktree,
174180 routeThreadId,
175181 ],
176182 );
apps/web/src/lib/gitReactQuery.test.ts+0−51
@@ -1,51 +0,0 @@
1−import { QueryClient } from "@tanstack/react-query";
2−import { describe, expect, it } from "vitest";
3−import {
4− gitMutationKeys,
5− gitPreparePullRequestThreadMutationOptions,
6− gitPullMutationOptions,
7− gitRunStackedActionMutationOptions,
8−} from "./gitReactQuery";
9−
10−describe("gitMutationKeys", () => {
11− it("scopes stacked action keys by cwd", () => {
12− expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual(
13− gitMutationKeys.runStackedAction("/repo/b"),
14− );
15− });
16−
17− it("scopes pull keys by cwd", () => {
18− expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));
19− });
20−
21− it("scopes pull request thread preparation keys by cwd", () => {
22− expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual(
23− gitMutationKeys.preparePullRequestThread("/repo/b"),
24− );
25− });
26−});
27−
28−describe("git mutation options", () => {
29− const queryClient = new QueryClient();
30−
31− it("attaches cwd-scoped mutation key for runStackedAction", () => {
32− const options = gitRunStackedActionMutationOptions({
33− cwd: "/repo/a",
34− queryClient,
35− });
36− expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));
37− });
38−
39− it("attaches cwd-scoped mutation key for pull", () => {
40− const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });
41− expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));
42− });
43−
44− it("attaches cwd-scoped mutation key for preparePullRequestThread", () => {
45− const options = gitPreparePullRequestThreadMutationOptions({
46− cwd: "/repo/a",
47− queryClient,
48− });
49− expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a"));
50− });
51−});
apps/web/src/lib/gitReactQuery.ts+0−224
@@ -1,224 +0,0 @@
1−import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts";
2−import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3−import { ensureNativeApi } from "../nativeApi";
4−import { getWsRpcClient } from "../wsRpcClient";
5−
6−const GIT_STATUS_STALE_TIME_MS = 5_000;
7−const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
8−const GIT_BRANCHES_STALE_TIME_MS = 15_000;
9−const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
10−
11−export const gitQueryKeys = {
12− all: ["git"] as const,
13− status: (cwd: string | null) => ["git", "status", cwd] as const,
14− branches: (cwd: string | null) => ["git", "branches", cwd] as const,
15−};
16−
17−export const gitMutationKeys = {
18− init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const,
19− checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const,
20− runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,
21− pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,
22− preparePullRequestThread: (cwd: string | null) =>
23− ["git", "mutation", "prepare-pull-request-thread", cwd] as const,
24−};
25−
26−export function invalidateGitQueries(queryClient: QueryClient) {
27− return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
28−}
29−
30−export function gitStatusQueryOptions(cwd: string | null) {
31− return queryOptions({
32− queryKey: gitQueryKeys.status(cwd),
33− queryFn: async () => {
34− const api = ensureNativeApi();
35− if (!cwd) throw new Error("Git status is unavailable.");
36− return api.git.status({ cwd });
37− },
38− enabled: cwd !== null,
39− staleTime: GIT_STATUS_STALE_TIME_MS,
40− refetchOnWindowFocus: "always",
41− refetchOnReconnect: "always",
42− refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS,
43− });
44−}
45−
46−export function gitBranchesQueryOptions(cwd: string | null) {
47− return queryOptions({
48− queryKey: gitQueryKeys.branches(cwd),
49− queryFn: async () => {
50− const api = ensureNativeApi();
51− if (!cwd) throw new Error("Git branches are unavailable.");
52− return api.git.listBranches({ cwd });
53− },
54− enabled: cwd !== null,
55− staleTime: GIT_BRANCHES_STALE_TIME_MS,
56− refetchOnWindowFocus: true,
57− refetchOnReconnect: true,
58− refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS,
59− });
60−}
61−
62−export function gitResolvePullRequestQueryOptions(input: {
63− cwd: string | null;
64− reference: string | null;
65−}) {
66− return queryOptions({
67− queryKey: ["git", "pull-request", input.cwd, input.reference] as const,
68− queryFn: async () => {
69− const api = ensureNativeApi();
70− if (!input.cwd || !input.reference) {
71− throw new Error("Pull request lookup is unavailable.");
72− }
73− return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference });
74− },
75− enabled: input.cwd !== null && input.reference !== null,
76− staleTime: 30_000,
77− refetchOnWindowFocus: false,
78− refetchOnReconnect: false,
79− });
80−}
81−
82−export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
83− return mutationOptions({
84− mutationKey: gitMutationKeys.init(input.cwd),
85− mutationFn: async () => {
86− const api = ensureNativeApi();
87− if (!input.cwd) throw new Error("Git init is unavailable.");
88− return api.git.init({ cwd: input.cwd });
89− },
90− onSuccess: async () => {
91− await invalidateGitQueries(input.queryClient);
92− },
93− });
94−}
95−
96−export function gitCheckoutMutationOptions(input: {
97− cwd: string | null;
98− queryClient: QueryClient;
99−}) {
100− return mutationOptions({
101− mutationKey: gitMutationKeys.checkout(input.cwd),
102− mutationFn: async (branch: string) => {
103− const api = ensureNativeApi();
104− if (!input.cwd) throw new Error("Git checkout is unavailable.");
105− return api.git.checkout({ cwd: input.cwd, branch });
106− },
107− onSuccess: async () => {
108− await invalidateGitQueries(input.queryClient);
109− },
110− });
111−}
112−
113−export function gitRunStackedActionMutationOptions(input: {
114− cwd: string | null;
115− queryClient: QueryClient;
116−}) {
117− return mutationOptions({
118− mutationKey: gitMutationKeys.runStackedAction(input.cwd),
119− mutationFn: async ({
120− actionId,
121− action,
122− commitMessage,
123− featureBranch,
124− filePaths,
125− onProgress,
126− }: {
127− actionId: string;
128− action: GitStackedAction;
129− commitMessage?: string;
130− featureBranch?: boolean;
131− filePaths?: string[];
132− onProgress?: (event: GitActionProgressEvent) => void;
133− }) => {
134− if (!input.cwd) throw new Error("Git action is unavailable.");
135− return getWsRpcClient().git.runStackedAction(
136− {
137− actionId,
138− cwd: input.cwd,
139− action,
140− ...(commitMessage ? { commitMessage } : {}),
141− ...(featureBranch ? { featureBranch } : {}),
142− ...(filePaths ? { filePaths } : {}),
143− },
144− ...(onProgress ? [{ onProgress }] : []),
145− );
146− },
147− onSettled: async () => {
148− await invalidateGitQueries(input.queryClient);
149− },
150− });
151−}
152−
153−export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
154− return mutationOptions({
155− mutationKey: gitMutationKeys.pull(input.cwd),
156− mutationFn: async () => {
157− const api = ensureNativeApi();
158− if (!input.cwd) throw new Error("Git pull is unavailable.");
159− return api.git.pull({ cwd: input.cwd });
160− },
161− onSettled: async () => {
162− await invalidateGitQueries(input.queryClient);
163− },
164− });
165−}
166−
167−export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {
168− return mutationOptions({
169− mutationFn: async ({
170− cwd,
171− branch,
172− newBranch,
173− path,
174− }: {
175− cwd: string;
176− branch: string;
177− newBranch: string;
178− path?: string | null;
179− }) => {
180− const api = ensureNativeApi();
181− if (!cwd) throw new Error("Git worktree creation is unavailable.");
182− return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null });
183− },
184− mutationKey: ["git", "mutation", "create-worktree"] as const,
185− onSettled: async () => {
186− await invalidateGitQueries(input.queryClient);
187− },
188− });
189−}
190−
191−export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {
192− return mutationOptions({
193− mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => {
194− const api = ensureNativeApi();
195− if (!cwd) throw new Error("Git worktree removal is unavailable.");
196− return api.git.removeWorktree({ cwd, path, force });
197− },
198− mutationKey: ["git", "mutation", "remove-worktree"] as const,
199− onSettled: async () => {
200− await invalidateGitQueries(input.queryClient);
201− },
202− });
203−}
204−
205−export function gitPreparePullRequestThreadMutationOptions(input: {
206− cwd: string | null;
207− queryClient: QueryClient;
208−}) {
209− return mutationOptions({
210− mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => {
211− const api = ensureNativeApi();
212− if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");
213− return api.git.preparePullRequestThread({
214− cwd: input.cwd,
215− reference,
216− mode,
217− });
218− },
219− mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),
220− onSettled: async () => {
221− await invalidateGitQueries(input.queryClient);
222− },
223− });
224−}
apps/web/src/rpc/client.test.ts+1−52
@@ -2,7 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; · 1 unmodified line
22 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33 import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity";
44
5−import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client";
5+import { WsRpcAtomClient } from "./client";
66
77 type WsEventType = "open" | "message" | "close" | "error";
88 type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
@@ -110,62 +110,11 @@ beforeEach(() => { · 101 unmodified lines
110110 });
111111
112112 afterEach(() => {
113− __resetWsRpcAtomClientForTests();
114113 globalThis.WebSocket = originalWebSocket;
115114 vi.restoreAllMocks();
116115 });
117116
118117 describe("WsRpcAtomClient", () => {
119− it("runs unary requests through the AtomRpc service", async () => {
120− const expectedSettings = {
121− ...DEFAULT_SERVER_SETTINGS,
122− enableAssistantStreaming: true,
123− defaultThreadEnvMode: "worktree" as const,
124− textGenerationModelSelection: {
125− provider: "codex" as const,
126− model: "gpt-5.4",
127− },
128− providers: {
129− codex: {
130− ...DEFAULT_SERVER_SETTINGS.providers.codex,
131− homePath: "/tmp/codex-home",
132− },
133− claudeAgent: {
134− ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent,
135− enabled: false,
136− },
137− },
138− };
139− const requestPromise = runRpc((client) => client(WS_METHODS.serverGetSettings, {}));
140−
141− await waitFor(() => {
142− expect(sockets).toHaveLength(1);
143− });
144−
145− const socket = getSocket();
146− socket.open();
147−
148− await waitFor(() => {
149− expect(socket.sent).toHaveLength(1);
150− });
151−
152− const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string };
153− expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings);
154−
155− socket.serverMessage(
156− JSON.stringify({
157− _tag: "Exit",
158− requestId: requestMessage.id,
159− exit: {
160− _tag: "Success",
161− value: expectedSettings,
162− },
163− }),
164− );
165−
166− await expect(requestPromise).resolves.toEqual(expectedSettings);
167− });
168−
169118 it("exposes atom-backed query state for unary RPC methods", async () => {
170119 const expectedSettings = {
171120 ...DEFAULT_SERVER_SETTINGS,
apps/web/src/rpc/client.ts+22−24
@@ -1,33 +1,31 @@
11 import { WsRpcGroup } from "@t3tools/contracts";
2−import { Effect, Layer, ManagedRuntime } from "effect";
2+import { Layer } from "effect";
33 import { AtomRpc } from "effect/unstable/reactivity";
44
55 import { createWsRpcProtocolLayer } from "./protocol";
66
7+import { Atom } from "effect/unstable/reactivity";
8+import { Duration } from "effect";
9+
10+export const REACTIVITY_KEYS = {
11+ git: (cwd: string) => `git:${cwd}` as const,
12+ project: (cwd: string) => `project:${cwd}` as const,
13+} as const;
14+
15+export const refreshIntervalSignalAtom = Atom.family((interval: Duration.Duration) =>
16+ Atom.readable((get) => {
17+ let count = 0;
18+ const intervalId = window.setInterval(() => {
19+ get.setSelf(++count);
20+ }, Duration.toMillis(interval));
21+ get.addFinalizer(() => {
22+ window.clearInterval(intervalId);
23+ });
24+ return count;
25+ }),
26+);
27+
728 export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", {
829 group: WsRpcGroup,
930 protocol: Layer.suspend(() => createWsRpcProtocolLayer()),
1031 }) {}
11−
12−let sharedRuntime: ManagedRuntime.ManagedRuntime<WsRpcAtomClient, never> | null = null;
13−
14−function getRuntime() {
15− if (sharedRuntime !== null) {
16− return sharedRuntime;
17− }
18−
19− sharedRuntime = ManagedRuntime.make(WsRpcAtomClient.layer);
20− return sharedRuntime;
21−}
22−
23−export function runRpc<TSuccess, TError = never>(
24− execute: (client: typeof WsRpcAtomClient.Service) => Effect.Effect<TSuccess, TError, never>,
25−): Promise<TSuccess> {
26− return getRuntime().runPromise(WsRpcAtomClient.use(execute));
27−}
28−
29−export async function __resetWsRpcAtomClientForTests() {
30− const runtime = sharedRuntime;
31− sharedRuntime = null;
32− await runtime?.dispose();
33−}
apps/web/src/rpc/gitAtoms.ts+170−0
@@ -0,0 +1,170 @@
1+import {
2+ type GitActionProgressEvent,
3+ type GitListBranchesResult,
4+ type GitResolvePullRequestResult,
5+ type GitRunStackedActionInput,
6+ type GitRunStackedActionResult,
7+ type GitStatusResult,
8+ WS_METHODS,
9+} from "@t3tools/contracts";
10+import { Duration, Effect, Stream } from "effect";
11+import { AsyncResult, Atom, Reactivity } from "effect/unstable/reactivity";
12+
13+import { refreshIntervalSignalAtom, WsRpcAtomClient } from "./client";
14+import { REACTIVITY_KEYS } from "./client";
15+import { useMemo } from "react";
16+import { useAtomValue } from "@effect/atom-react";
17+
18+const GIT_ACTION_RESULT_MISSING_ERROR = {
19+ _tag: "GitActionResultMissing",
20+ message: "Git action stream completed without a final result.",
21+} as const;
22+export const gitRunStackedActionProgressAtom = Atom.make<GitActionProgressEvent | null>(null).pipe(
23+ Atom.keepAlive,
24+ Atom.withLabel("git-run-stacked-action-progress"),
25+);
26+
27+const gitStatusQueryAtom = Atom.family((cwd: string) =>
28+ WsRpcAtomClient.query(
29+ WS_METHODS.gitStatus,
30+ { cwd },
31+ {
32+ timeToLive: "5 minutes",
33+ reactivityKeys: [REACTIVITY_KEYS.git(cwd)],
34+ },
35+ ),
36+);
37+
38+export const gitStatusAtom = Atom.family((cwd: string | null) =>
39+ Atom.readable(
40+ (get): AsyncResult.AsyncResult<GitStatusResult, unknown> => {
41+ if (cwd === null) {
42+ return AsyncResult.initial();
43+ }
44+ return get(gitStatusQueryAtom(cwd));
45+ },
46+ (refresh) => {
47+ if (cwd !== null) {
48+ refresh(gitStatusQueryAtom(cwd));
49+ }
50+ },
51+ ),
52+);
53+
54+const gitBranchesQueryAtom = Atom.family((cwd: string) =>
55+ WsRpcAtomClient.query(
56+ WS_METHODS.gitListBranches,
57+ { cwd },
58+ {
59+ timeToLive: "5 minutes",
60+ reactivityKeys: [REACTIVITY_KEYS.git(cwd)],
61+ },
62+ ),
63+);
64+
65+export const gitBranchesAtom = Atom.family((cwd: string | null) =>
66+ Atom.readable(
67+ (get): AsyncResult.AsyncResult<GitListBranchesResult, unknown> => {
68+ if (cwd === null) {
69+ return AsyncResult.initial();
70+ }
71+ return get(gitBranchesQueryAtom(cwd));
72+ },
73+ (refresh) => {
74+ if (cwd !== null) {
75+ refresh(gitBranchesQueryAtom(cwd));
76+ }
77+ },
78+ ),
79+);
80+
81+export const gitCheckoutMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitCheckout);
82+
83+export const gitCreateBranchMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitCreateBranch);
84+
85+export const gitCreateWorktreeMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitCreateWorktree);
86+
87+export const gitInitMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitInit);
88+
89+export const gitPreparePullRequestThreadMutationAtom = WsRpcAtomClient.mutation(
90+ WS_METHODS.gitPreparePullRequestThread,
91+);
92+
93+export const gitPullMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitPull);
94+
95+export const gitRemoveWorktreeMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitRemoveWorktree);
96+
97+export const gitRunStackedActionMutationAtom = WsRpcAtomClient.runtime
98+ .fn<GitRunStackedActionInput>()(
99+ Effect.fn(
100+ function* (input, get) {
101+ get.set(gitRunStackedActionProgressAtom, null);
102+ const client = yield* WsRpcAtomClient;
103+ return yield* client(WS_METHODS.gitRunStackedAction, input).pipe(
104+ Stream.runFoldEffect(
105+ () => null as GitRunStackedActionResult | null,
106+ (result, event) =>
107+ Effect.sync(() => {
108+ get.set(gitRunStackedActionProgressAtom, event);
109+ return event.kind === "action_finished" ? event.result : result;
110+ }),
111+ ),
112+ Effect.flatMap((result) =>
113+ result !== null ? Effect.succeed(result) : Effect.fail(GIT_ACTION_RESULT_MISSING_ERROR),
114+ ),
115+ );
116+ },
117+ (effect, input) =>
118+ Effect.ensuring(effect, Reactivity.invalidate([REACTIVITY_KEYS.git(input.cwd)])),
119+ ),
120+ )
121+ .pipe(Atom.withLabel("git-run-stacked-action"));
122+
123+const gitResolvePullRequestQueryAtom = Atom.family(
124+ (key: { readonly cwd: string; readonly reference: string }) =>
125+ WsRpcAtomClient.query(WS_METHODS.gitResolvePullRequest, key, {
126+ timeToLive: "30 minutes",
127+ reactivityKeys: [REACTIVITY_KEYS.git(key.cwd)],
128+ }),
129+);
130+
131+export const gitResolvePullRequestAtom = Atom.family(
132+ (input: { readonly cwd: string | null; readonly reference: string | null }) =>
133+ Atom.readable(
134+ (get): AsyncResult.AsyncResult<GitResolvePullRequestResult, unknown> => {
135+ if (input.cwd === null || input.reference === null) {
136+ return AsyncResult.initial();
137+ }
138+ return get(
139+ gitResolvePullRequestQueryAtom({
140+ cwd: input.cwd,
141+ reference: input.reference,
142+ }),
143+ );
144+ },
145+ (refresh) => {
146+ if (input.cwd !== null && input.reference !== null) {
147+ refresh(
148+ gitResolvePullRequestQueryAtom({
149+ cwd: input.cwd,
150+ reference: input.reference,
151+ }),
152+ );
153+ }
154+ },
155+ ),
156+);
157+
158+export function useGitStatuses(
159+ cwds: ReadonlyArray<string>,
160+): ReadonlyArray<AsyncResult.AsyncResult<GitStatusResult, unknown>> {
161+ const atoms = useMemo(() => {
162+ const refreshInterval = refreshIntervalSignalAtom(Duration.seconds(60));
163+ return cwds.map((cwd) => gitStatusAtom(cwd).pipe(Atom.makeRefreshOnSignal(refreshInterval)));
164+ }, [cwds]);
165+ const statusesAtom = useMemo(
166+ () => Atom.readable((get) => atoms.map((atom) => get(atom))),
167+ [atoms],
168+ );
169+ return useAtomValue(statusesAtom);
170+}
apps/web/src/wsNativeApi.test.ts+0−1
@@ -55,7 +55,6 @@ const rpcClientMock = { · 54 unmodified lines
5555 git: {
5656 pull: vi.fn(),
5757 status: vi.fn(),
58− runStackedAction: vi.fn(),
5958 listBranches: vi.fn(),
6059 createWorktree: vi.fn(),
6160 removeWorktree: vi.fn(),
apps/web/src/wsRpcClient.ts+0−30
@@ -1,7 +1,4 @@
11 import {
2− type GitActionProgressEvent,
3− type GitRunStackedActionInput,
4− type GitRunStackedActionResult,
52 type NativeApi,
63 ORCHESTRATION_WS_METHODS,
74 type ServerSettingsPatch,
@@ -31,10 +28,6 @@ type RpcStreamMethod<TTag extends RpcTag> = · 23 unmodified lines
3128 ? (listener: (event: TEvent) => void) => () => void
3229 : never;
3330
34−interface GitRunStackedActionOptions {
35− readonly onProgress?: (event: GitActionProgressEvent) => void;
36−}
37−
3831 export interface WsRpcClient {
3932 readonly dispose: () => Promise<void>;
4033 readonly terminal: {
@@ -59,10 +52,6 @@ export interface WsRpcClient { · 18 unmodified lines
5952 readonly git: {
6053 readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;
6154 readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>;
62− readonly runStackedAction: (
63− input: GitRunStackedActionInput,
64− options?: GitRunStackedActionOptions,
65− ) => Promise<GitRunStackedActionResult>;
6655 readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>;
6756 readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>;
6857 readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>;
@@ -136,25 +125,6 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { · 67 unmodified lines
136125 git: {
137126 pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)),
138127 status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)),
139− runStackedAction: async (input, options) => {
140− let result: GitRunStackedActionResult | null = null;
141−
142− await transport.requestStream(
143− (client) => client[WS_METHODS.gitRunStackedAction](input),
144− (event) => {
145− options?.onProgress?.(event);
146− if (event.kind === "action_finished") {
147− result = event.result;
148− }
149− },
150− );
151−
152− if (result) {
153− return result;
154− }
155−
156− throw new Error("Git action stream completed without a final result.");
157− },
158128 listBranches: (input) =>
159129 transport.request((client) => client[WS_METHODS.gitListBranches](input)),
160130 createWorktree: (input) =>