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 @@
1import type { GitBranch } from "@t3tools/contracts";1import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
2import { useQuery, useQueryClient } from "@tanstack/react-query";2import { type GitBranch } from "@t3tools/contracts";
3import { useVirtualizer } from "@tanstack/react-virtual";3import { useVirtualizer } from "@tanstack/react-virtual";
4import * as Option from "effect/Option";
5import { AsyncResult } from "effect/unstable/reactivity";
4import { ChevronDownIcon } from "lucide-react";6import { ChevronDownIcon } from "lucide-react";
5import {7import {
6 type CSSProperties,8 type CSSProperties,
@@ -14,14 +16,14 @@ import { · 7 unmodified lines
14 useTransition,16 useTransition,
15} from "react";17} from "react";
16 18
17import {
18 gitBranchesQueryOptions,
19 gitQueryKeys,
20 gitStatusQueryOptions,
21 invalidateGitQueries,
22} from "../lib/gitReactQuery";
23import { readNativeApi } from "../nativeApi";
24import { parsePullRequestReference } from "../pullRequestReference";19import { parsePullRequestReference } from "../pullRequestReference";
20import { REACTIVITY_KEYS } from "../rpc/client";
21import {
22 gitBranchesAtom,
23 gitCheckoutMutationAtom,
24 gitCreateBranchMutationAtom,
25 gitStatusAtom,
26} from "../rpc/gitAtoms";
25import {27import {
26 dedupeRemoteBranchesWithLocalMatches,28 dedupeRemoteBranchesWithLocalMatches,
27 deriveLocalBranchNameFromRemoteRef,29 deriveLocalBranchNameFromRemoteRef,
@@ -84,19 +86,23 @@ export function BranchToolbarBranchSelector({ · 56 unmodified lines
84 onCheckoutPullRequestRequest,86 onCheckoutPullRequestRequest,
85 onComposerFocusRequest,87 onComposerFocusRequest,
86}: BranchToolbarBranchSelectorProps) {88}: BranchToolbarBranchSelectorProps) {
87 const queryClient = useQueryClient();
88 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);89 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
89 const [branchQuery, setBranchQuery] = useState("");90 const [branchQuery, setBranchQuery] = useState("");
90 const deferredBranchQuery = useDeferredValue(branchQuery);91 const deferredBranchQuery = useDeferredValue(branchQuery);
91 92 const branchesResult = useAtomValue(gitBranchesAtom(branchCwd));
92 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));93 const branchStatusResult = useAtomValue(gitStatusAtom(branchCwd));
93 const branchStatusQuery = useQuery(gitStatusQueryOptions(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));
94 const branches = useMemo(100 const branches = useMemo(
95 () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []),101 () => dedupeRemoteBranchesWithLocalMatches(branchesData?.branches ?? []),
96 [branchesQuery.data?.branches],102 [branchesData?.branches],
97 );103 );
98 const currentGitBranch =104 const currentGitBranch =
99 branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null;105 branchStatus?.branch ?? branches.find((branch) => branch.current)?.name ?? null;
100 const canonicalActiveBranch = resolveBranchToolbarValue({106 const canonicalActiveBranch = resolveBranchToolbarValue({
101 envMode: effectiveEnvMode,107 envMode: effectiveEnvMode,
102 activeWorktreePath,108 activeWorktreePath,
@@ -155,18 +161,18 @@ export function BranchToolbarBranchSelector({ · 52 unmodified lines
155 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,161 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
156 );162 );
157 const [isBranchActionPending, startBranchActionTransition] = useTransition();163 const [isBranchActionPending, startBranchActionTransition] = useTransition();
164 const isBranchesLoading =
165 branches.length === 0 && (AsyncResult.isInitial(branchesResult) || branchesResult.waiting);
158 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;166 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
159 167
160 const runBranchAction = (action: () => Promise<void>) => {168 const runBranchAction = (action: () => Promise<void>) => {
161 startBranchActionTransition(async () => {169 startBranchActionTransition(async () => {
162 await action().catch(() => undefined);170 await action().catch(() => undefined);
163 await invalidateGitQueries(queryClient).catch(() => undefined);
164 });171 });
165 };172 };
166 173
167 const selectBranch = (branch: GitBranch) => {174 const selectBranch = (branch: GitBranch) => {
168 const api = readNativeApi();175 if (!branchCwd || isBranchActionPending) return;
169 if (!api || !branchCwd || isBranchActionPending) return;
170 176
171 // In new-worktree mode, selecting a branch sets the base branch.177 // In new-worktree mode, selecting a branch sets the base branch.
172 if (isSelectingWorktreeBase) {178 if (isSelectingWorktreeBase) {
@@ -200,8 +206,13 @@ export function BranchToolbarBranchSelector({ · 27 unmodified lines
200 runBranchAction(async () => {206 runBranchAction(async () => {
201 setOptimisticBranch(selectedBranchName);207 setOptimisticBranch(selectedBranchName);
202 try {208 try {
203 await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name });209 await checkoutBranch({
204 await invalidateGitQueries(queryClient);210 payload: {
211 cwd: selectionTarget.checkoutCwd,
212 branch: branch.name,
213 },
214 reactivityKeys: [REACTIVITY_KEYS.git(selectionTarget.checkoutCwd)],
215 });
205 } catch (error) {216 } catch (error) {
206 toastManager.add({217 toastManager.add({
207 type: "error",218 type: "error",
@@ -211,23 +222,14 @@ export function BranchToolbarBranchSelector({ · 3 unmodified lines
211 return;222 return;
212 }223 }
213 224
214 let nextBranchName = selectedBranchName;225 setOptimisticBranch(selectedBranchName);
215 if (branch.isRemote) {226 onSetThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath);
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);
224 });227 });
225 };228 };
226 229
227 const createBranch = (rawName: string) => {230 const createBranch = (rawName: string) => {
228 const name = rawName.trim();231 const name = rawName.trim();
229 const api = readNativeApi();232 if (!branchCwd || !name || isBranchActionPending) return;
230 if (!api || !branchCwd || !name || isBranchActionPending) return;
231 233
232 setIsBranchMenuOpen(false);234 setIsBranchMenuOpen(false);
233 onComposerFocusRequest?.();235 onComposerFocusRequest?.();
@@ -236,9 +238,14 @@ export function BranchToolbarBranchSelector({ · 2 unmodified lines
236 setOptimisticBranch(name);238 setOptimisticBranch(name);
237 239
238 try {240 try {
239 await api.git.createBranch({ cwd: branchCwd, branch: name });241 await createGitBranch({
242 payload: { cwd: branchCwd, branch: name },
243 });
240 try {244 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 });
242 } catch (error) {249 } catch (error) {
243 toastManager.add({250 toastManager.add({
244 type: "error",251 type: "error",
@@ -287,11 +294,12 @@ export function BranchToolbarBranchSelector({ · 42 unmodified lines
287 setBranchQuery("");294 setBranchQuery("");
288 return;295 return;
289 }296 }
290 void queryClient.invalidateQueries({297 if (branchCwd) {
291 queryKey: gitQueryKeys.branches(branchCwd),298 refreshBranches();
292 });299 refreshBranchStatus();
300 }
293 },301 },
294 [branchCwd, queryClient],302 [branchCwd, refreshBranchStatus, refreshBranches],
295 );303 );
296 304
297 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);305 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
@@ -425,7 +433,7 @@ export function BranchToolbarBranchSelector({ · 127 unmodified lines
425 <ComboboxTrigger433 <ComboboxTrigger
426 render={<Button variant="ghost" size="xs" />}434 render={<Button variant="ghost" size="xs" />}
427 className="text-muted-foreground/70 hover:text-foreground/80"435 className="text-muted-foreground/70 hover:text-foreground/80"
428 disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending}436 disabled={isBranchesLoading || isBranchActionPending}
429 >437 >
430 <span className="max-w-[240px] truncate">{triggerLabel}</span>438 <span className="max-w-[240px] truncate">{triggerLabel}</span>
431 <ChevronDownIcon />439 <ChevronDownIcon />
apps/web/src/components/ChatView.browser.tsx+3−0
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
32import { isMacPlatform } from "../lib/utils";32import { isMacPlatform } from "../lib/utils";
33import { __resetNativeApiForTests } from "../nativeApi";33import { __resetNativeApiForTests } from "../nativeApi";
34import { getRouter } from "../router";34import { getRouter } from "../router";
35import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry";
35import { useStore } from "../store";36import { useStore } from "../store";
36import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";37import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
37import { estimateTimelineMessageHeight } from "./timelineHeight";38import { estimateTimelineMessageHeight } from "./timelineHeight";
@@ -1121,6 +1122,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1083 unmodified lines
1121 },1122 },
1122 });1123 });
1123 __resetNativeApiForTests();1124 __resetNativeApiForTests();
1125 resetAppAtomRegistryForTests();
1124 await setViewport(DEFAULT_VIEWPORT);1126 await setViewport(DEFAULT_VIEWPORT);
1125 localStorage.clear();1127 localStorage.clear();
1126 document.body.innerHTML = "";1128 document.body.innerHTML = "";
@@ -1143,6 +1145,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 16 unmodified lines
1143 afterEach(() => {1145 afterEach(() => {
1144 customWsRpcResolver = null;1146 customWsRpcResolver = null;
1145 document.body.innerHTML = "";1147 document.body.innerHTML = "";
1148 resetAppAtomRegistryForTests();
1146 });1149 });
1147 1150
1148 it.each(TEXT_VIEWPORT_MATRIX)(1151 it.each(TEXT_VIEWPORT_MATRIX)(
apps/web/src/components/ChatView.tsx+21−10
@@ -1,3 +1,4 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
1import {2import {
2 type ApprovalRequestId,3 type ApprovalRequestId,
3 DEFAULT_MODEL_BY_PROVIDER,4 DEFAULT_MODEL_BY_PROVIDER,
@@ -21,12 +22,13 @@ import { · 17 unmodified lines
21 TerminalOpenInput,22 TerminalOpenInput,
22} from "@t3tools/contracts";23} from "@t3tools/contracts";
23import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";24import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
25import * as Option from "effect/Option";
24import { truncate } from "@t3tools/shared/String";26import { truncate } from "@t3tools/shared/String";
25import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";27import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
26import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";28import { useQuery } from "@tanstack/react-query";
27import { useDebouncedValue } from "@tanstack/react-pacer";29import { useDebouncedValue } from "@tanstack/react-pacer";
28import { useNavigate, useSearch } from "@tanstack/react-router";30import { useNavigate, useSearch } from "@tanstack/react-router";
29import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";31import { AsyncResult } from "effect/unstable/reactivity";
30import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";32import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
31import { isElectron } from "../env";33import { isElectron } from "../env";
32import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";34import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
@@ -106,6 +108,8 @@ import { Separator } from "./ui/separator"; · 73 unmodified lines
106import { cn, randomUUID } from "~/lib/utils";108import { cn, randomUUID } from "~/lib/utils";
107import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";109import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
108import { toastManager } from "./ui/toast";110import { toastManager } from "./ui/toast";
111import { gitBranchesAtom, gitCreateWorktreeMutationAtom } from "~/rpc/gitAtoms";
112import { REACTIVITY_KEYS } from "~/rpc/client";
109import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";113import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
110import { type NewProjectScriptInput } from "./ProjectScriptsControl";114import { type NewProjectScriptInput } from "./ProjectScriptsControl";
111import {115import {
@@ -423,8 +427,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 311 unmodified lines
423 select: (params) => parseDiffRouteSearch(params),427 select: (params) => parseDiffRouteSearch(params),
424 });428 });
425 const { resolvedTheme } = useTheme();429 const { resolvedTheme } = useTheme();
426 const queryClient = useQueryClient();430 const createWorktree = useAtomSet(gitCreateWorktreeMutationAtom, { mode: "promise" });
427 const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient }));
428 const composerDraft = useComposerThreadDraft(threadId);431 const composerDraft = useComposerThreadDraft(threadId);
429 const prompt = composerDraft.prompt;432 const prompt = composerDraft.prompt;
430 const composerImages = composerDraft.images;433 const composerImages = composerDraft.images;
@@ -1201,7 +1204,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 770 unmodified lines
1201 (debouncerState) => ({ isPending: debouncerState.isPending }),1204 (debouncerState) => ({ isPending: debouncerState.isPending }),
1202 );1205 );
1203 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";1206 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));
1205 const keybindings = useServerKeybindings();1209 const keybindings = useServerKeybindings();
1206 const availableEditors = useServerAvailableEditors();1210 const availableEditors = useServerAvailableEditors();
1207 const modelOptionsByProvider = useMemo(1211 const modelOptionsByProvider = useMemo(
@@ -1338,7 +1342,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 130 unmodified lines
1338 });1342 });
1339 }, [activeProjectCwd, activeThreadWorktreePath]);1343 }, [activeProjectCwd, activeThreadWorktreePath]);
1340 // Default true while loading to avoid toolbar flicker.1344 // Default true while loading to avoid toolbar flicker.
1341 const isGitRepo = branchesQuery.data?.isRepo ?? true;1345 const isGitRepo = branches?.isRepo ?? true;
1342 const terminalShortcutLabelOptions = useMemo(1346 const terminalShortcutLabelOptions = useMemo(
1343 () => ({1347 () => ({
1344 context: {1348 context: {
@@ -2787,10 +2791,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1442 unmodified lines
2787 if (baseBranchForWorktree) {2791 if (baseBranchForWorktree) {
2788 beginLocalDispatch({ preparingWorktree: true });2792 beginLocalDispatch({ preparingWorktree: true });
2789 const newBranch = buildTemporaryWorktreeBranchName();2793 const newBranch = buildTemporaryWorktreeBranchName();
2790 const result = await createWorktreeMutation.mutateAsync({2794 const result = await createWorktree({
2791 cwd: activeProject.cwd,2795 payload: {
2792 branch: baseBranchForWorktree,2796 cwd: activeProject.cwd,
2793 newBranch,2797 branch: baseBranchForWorktree,
2798 path: null,
2799 newBranch,
2800 },
2801 reactivityKeys: [
2802 REACTIVITY_KEYS.git(activeProject.cwd),
2803 REACTIVITY_KEYS.project(activeProject.cwd),
2804 ],
2794 });2805 });
2795 nextThreadBranch = result.worktree.branch;2806 nextThreadBranch = result.worktree.branch;
2796 nextThreadWorktreePath = result.worktree.path;2807 nextThreadWorktreePath = result.worktree.path;
apps/web/src/components/DiffPanel.tsx+7−3
@@ -1,8 +1,11 @@
1import { useAtomValue } from "@effect/atom-react";
1import { parsePatchFiles } from "@pierre/diffs";2import { parsePatchFiles } from "@pierre/diffs";
2import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";3import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
3import { useQuery } from "@tanstack/react-query";4import { useQuery } from "@tanstack/react-query";
4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";5import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { ThreadId, type TurnId } from "@t3tools/contracts";6import { ThreadId, type TurnId } from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import { AsyncResult } from "effect/unstable/reactivity";
6import {9import {
7 ChevronLeftIcon,10 ChevronLeftIcon,
8 ChevronRightIcon,11 ChevronRightIcon,
@@ -19,10 +22,10 @@ import { · 10 unmodified lines
19 useState,22 useState,
20} from "react";23} from "react";
21import { openInPreferredEditor } from "../editorPreferences";24import { openInPreferredEditor } from "../editorPreferences";
22import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
23import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";25import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
24import { cn } from "~/lib/utils";26import { cn } from "~/lib/utils";
25import { readNativeApi } from "../nativeApi";27import { readNativeApi } from "../nativeApi";
28import { gitBranchesAtom } from "../rpc/gitAtoms";
26import { resolvePathLinkTarget } from "../terminal-links";29import { resolvePathLinkTarget } from "../terminal-links";
27import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";30import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
28import { useTheme } from "../hooks/useTheme";31import { useTheme } from "../hooks/useTheme";
@@ -189,8 +192,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 160 unmodified lines
189 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,192 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
190 );193 );
191 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;194 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
192 const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null));195 const gitBranchesResult = useAtomValue(gitBranchesAtom(activeCwd ?? null));
193 const isGitRepo = gitBranchesQuery.data?.isRepo ?? true;196 const gitBranches = Option.getOrUndefined(AsyncResult.value(gitBranchesResult));
197 const isGitRepo = gitBranches?.isRepo ?? true;
194 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =198 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
195 useTurnDiffSummaries(activeThread);199 useTurnDiffSummaries(activeThread);
196 const orderedTurnDiffSummaries = useMemo(200 const orderedTurnDiffSummaries = useMemo(
apps/web/src/components/GitActionsControl.tsx+149−95
@@ -1,10 +1,13 @@
1import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
1import type {2import type {
2 GitActionProgressEvent,3 GitRunStackedActionInput,
3 GitStackedAction,4 GitStackedAction,
4 GitStatusResult,5 GitStatusResult,
5 ThreadId,6 ThreadId,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";8import { Cause } from "effect";
9import * as Option from "effect/Option";
10import { AsyncResult } from "effect/unstable/reactivity";
8import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";11import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
9import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";12import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
10import { GitHubIcon } from "./Icons";13import { GitHubIcon } from "./Icons";
@@ -38,18 +41,18 @@ import { ScrollArea } from "~/components/ui/scroll-area"; · 27 unmodified lines
38import { Textarea } from "~/components/ui/textarea";41import { Textarea } from "~/components/ui/textarea";
39import { toastManager } from "~/components/ui/toast";42import { toastManager } from "~/components/ui/toast";
40import { openInPreferredEditor } from "~/editorPreferences";43import { openInPreferredEditor } from "~/editorPreferences";
41import {
42 gitBranchesQueryOptions,
43 gitInitMutationOptions,
44 gitMutationKeys,
45 gitPullMutationOptions,
46 gitRunStackedActionMutationOptions,
47 gitStatusQueryOptions,
48 invalidateGitQueries,
49} from "~/lib/gitReactQuery";
50import { randomUUID } from "~/lib/utils";44import { randomUUID } from "~/lib/utils";
51import { resolvePathLinkTarget } from "~/terminal-links";45import { resolvePathLinkTarget } from "~/terminal-links";
52import { readNativeApi } from "~/nativeApi";46import { readNativeApi } from "~/nativeApi";
47import {
48 gitBranchesAtom,
49 gitInitMutationAtom,
50 gitPullMutationAtom,
51 gitRunStackedActionMutationAtom,
52 gitRunStackedActionProgressAtom,
53 gitStatusAtom,
54} from "~/rpc/gitAtoms";
55import { REACTIVITY_KEYS } from "~/rpc/client";
53 56
54interface GitActionsControlProps {57interface GitActionsControlProps {
55 gitCwd: string | null;58 gitCwd: string | null;
@@ -112,6 +115,13 @@ function resolveProgressDescription(progress: ActiveGitActionProgress): string | · 56 unmodified lines
112 return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs);115 return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs);
113}116}
114 117
118function 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
115function getMenuActionDisabledReason({125function getMenuActionDisabledReason({
116 item,126 item,
117 gitStatus,127 gitStatus,
@@ -208,7 +218,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 90 unmodified lines
208 () => (activeThreadId ? { threadId: activeThreadId } : undefined),218 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
209 [activeThreadId],219 [activeThreadId],
210 );220 );
211 const queryClient = useQueryClient();
212 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);221 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
213 const [dialogCommitMessage, setDialogCommitMessage] = useState("");222 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
214 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());223 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
@@ -231,9 +240,22 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 16 unmodified lines
231 });240 });
232 }, [threadToastData]);241 }, [threadToastData]);
233 242
234 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));243 const gitStatusResult = useAtomValue(gitStatusAtom(gitCwd));
235 244 const branchesResult = useAtomValue(gitBranchesAtom(gitCwd));
236 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(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));
237 // Default to true while loading so we don't flash init controls.259 // Default to true while loading so we don't flash init controls.
238 const isRepo = branchList?.isRepo ?? true;260 const isRepo = branchList?.isRepo ?? true;
239 const hasOriginRemote = branchList?.hasOriginRemote ?? false;261 const hasOriginRemote = branchList?.hasOriginRemote ?? false;
@@ -242,9 +264,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 2 unmodified lines
242 !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch;264 !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch;
243 265
244 useEffect(() => {266 useEffect(() => {
245 if (!isGitStatusOutOfSync) return;267 if (!isGitStatusOutOfSync || !gitCwd) return;
246 void invalidateGitQueries(queryClient);268 refreshGitStatus();
247 }, [isGitStatusOutOfSync, queryClient]);269 refreshGitBranches();
270 }, [gitCwd, isGitStatusOutOfSync, refreshGitBranches, refreshGitStatus]);
248 271
249 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;272 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;
250 273
@@ -252,21 +275,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 1 unmodified line
252 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));275 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
253 const allSelected = excludedFiles.size === 0;276 const allSelected = excludedFiles.size === 0;
254 const noneSelected = selectedFiles.length === 0;277 const noneSelected = selectedFiles.length === 0;
255 278 const isRunStackedActionRunning = runStackedActionResult.waiting;
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;
269 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;279 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 );
270 const isDefaultBranch = useMemo(() => {312 const isDefaultBranch = useMemo(() => {
271 const branchName = gitStatusForActions?.branch;313 const branchName = gitStatusForActions?.branch;
272 if (!branchName) return false;314 if (!branchName) return false;
@@ -307,6 +349,59 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 34 unmodified lines
307 };349 };
308 }, [updateActiveProgressToast]);350 }, [updateActiveProgressToast]);
309 351
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
310 const openExistingPr = useCallback(async () => {405 const openExistingPr = useCallback(async () => {
311 const api = readNativeApi();406 const api = readNativeApi();
312 if (!api) {407 if (!api) {
@@ -415,69 +510,17 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 102 unmodified lines
415 });510 });
416 }511 }
417 512
418 const applyProgressEvent = (event: GitActionProgressEvent) => {513 if (!gitCwd) {
419 const progress = activeGitActionProgressRef.current;514 return;
420 if (!progress) {515 }
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 };
473 516
474 const promise = runImmediateGitActionMutation.mutateAsync({517 const promise = runImmediateGitAction({
475 actionId,518 actionId,
519 cwd: gitCwd,
476 action,520 action,
477 ...(commitMessage ? { commitMessage } : {}),521 ...(commitMessage ? { commitMessage } : {}),
478 ...(featureBranch ? { featureBranch } : {}),522 ...(featureBranch ? { featureBranch } : {}),
479 ...(filePaths ? { filePaths } : {}),523 ...(filePaths ? { filePaths } : {}),
480 onProgress: applyProgressEvent,
481 });524 });
482 525
483 try {526 try {
@@ -624,7 +667,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 140 unmodified lines
624 return;667 return;
625 }668 }
626 if (quickAction.kind === "run_pull") {669 if (quickAction.kind === "run_pull") {
627 const promise = pullMutation.mutateAsync();670 if (!gitCwd) {
671 return;
672 }
673 const promise = pullCurrentBranch(gitCwd);
628 toastManager.promise(promise, {674 toastManager.promise(promise, {
629 loading: { title: "Pulling...", data: threadToastData },675 loading: { title: "Pulling...", data: threadToastData },
630 success: (result) => ({676 success: (result) => ({
@@ -656,7 +702,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 25 unmodified lines
656 if (quickAction.action) {702 if (quickAction.action) {
657 void runGitActionWithToast({ action: quickAction.action });703 void runGitActionWithToast({ action: quickAction.action });
658 }704 }
659 }, [openExistingPr, pullMutation, quickAction, threadToastData]);705 }, [gitCwd, openExistingPr, pullCurrentBranch, quickAction, threadToastData]);
660 706
661 const openDialogForMenuItem = useCallback(707 const openDialogForMenuItem = useCallback(
662 (item: GitActionMenuItem) => {708 (item: GitActionMenuItem) => {
@@ -733,10 +779,15 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 70 unmodified lines
733 <Button779 <Button
734 variant="outline"780 variant="outline"
735 size="xs"781 size="xs"
736 disabled={initMutation.isPending}782 disabled={isInitPending}
737 onClick={() => initMutation.mutate()}783 onClick={() => {
784 if (!gitCwd) {
785 return;
786 }
787 void initializeGit(gitCwd).catch(() => undefined);
788 }}
738 >789 >
739 {initMutation.isPending ? "Initializing..." : "Initialize Git"}790 {isInitPending ? "Initializing..." : "Initialize Git"}
740 </Button>791 </Button>
741 ) : (792 ) : (
742 <Group aria-label="Git actions" className="shrink-0">793 <Group aria-label="Git actions" className="shrink-0">
@@ -778,7 +829,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 35 unmodified lines
778 <GroupSeparator className="hidden @3xl/header-actions:block" />829 <GroupSeparator className="hidden @3xl/header-actions:block" />
779 <Menu830 <Menu
780 onOpenChange={(open) => {831 onOpenChange={(open) => {
781 if (open) void invalidateGitQueries(queryClient);832 if (open && gitCwd) {
833 refreshGitStatus();
834 refreshGitBranches();
835 }
782 }}836 }}
783 >837 >
784 <MenuTrigger838 <MenuTrigger
apps/web/src/components/PullRequestThreadDialog.tsx+68−68
@@ -1,14 +1,13 @@
1import type { GitResolvePullRequestResult } from "@t3tools/contracts";1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useDebouncedValue } from "@tanstack/react-pacer";2import { useDebouncedValue } from "@tanstack/react-pacer";
3import { Cause, Option } from "effect";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";5import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5 6
6import {
7 gitPreparePullRequestThreadMutationOptions,
8 gitResolvePullRequestQueryOptions,
9} from "~/lib/gitReactQuery";
10import { cn } from "~/lib/utils";7import { cn } from "~/lib/utils";
11import { parsePullRequestReference } from "~/pullRequestReference";8import { parsePullRequestReference } from "~/pullRequestReference";
9import { gitResolvePullRequestAtom, gitPreparePullRequestThreadMutationAtom } from "~/rpc/gitAtoms";
10import { REACTIVITY_KEYS } from "~/rpc/client";
12import { Button } from "./ui/button";11import { Button } from "./ui/button";
13import {12import {
14 Dialog,13 Dialog,
@@ -30,6 +29,10 @@ interface PullRequestThreadDialogProps { · 15 unmodified lines
30 onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void;29 onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void;
31}30}
32 31
32function toErrorMessage(error: unknown, fallback: string): string {
33 return error instanceof Error ? error.message : fallback;
34}
35
33export function PullRequestThreadDialog({36export function PullRequestThreadDialog({
34 open,37 open,
35 cwd,38 cwd,
@@ -37,23 +40,28 @@ export function PullRequestThreadDialog({ · 1 unmodified line
37 onOpenChange,40 onOpenChange,
38 onPrepared,41 onPrepared,
39}: PullRequestThreadDialogProps) {42}: PullRequestThreadDialogProps) {
40 const queryClient = useQueryClient();
41 const referenceInputRef = useRef<HTMLInputElement>(null);43 const referenceInputRef = useRef<HTMLInputElement>(null);
42 const [reference, setReference] = useState(initialReference ?? "");44 const [reference, setReference] = useState(initialReference ?? "");
43 const [referenceDirty, setReferenceDirty] = useState(false);45 const [referenceDirty, setReferenceDirty] = useState(false);
44 const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null);46 const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null);
47 const [prepareFollowupErrorMessage, setPrepareFollowupErrorMessage] = useState<string | null>(
48 null,
49 );
45 const [debouncedReference, referenceDebouncer] = useDebouncedValue(50 const [debouncedReference, referenceDebouncer] = useDebouncedValue(
46 reference,51 reference,
47 { wait: 450 },52 { wait: 450 },
48 (debouncerState) => ({ isPending: debouncerState.isPending }),53 (debouncerState) => ({ isPending: debouncerState.isPending }),
49 );54 );
55 const resetPreparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom);
50 56
51 useEffect(() => {57 useEffect(() => {
52 if (!open) return;58 if (!open) return;
53 setReference(initialReference ?? "");59 setReference(initialReference ?? "");
54 setReferenceDirty(false);60 setReferenceDirty(false);
55 setPreparingMode(null);61 setPreparingMode(null);
56 }, [initialReference, open]);62 setPrepareFollowupErrorMessage(null);
63 resetPreparePullRequestThread(Atom.Reset);
64 }, [initialReference, open, resetPreparePullRequestThread]);
57 65
58 useEffect(() => {66 useEffect(() => {
59 if (!open) return;67 if (!open) return;
@@ -68,41 +76,35 @@ export function PullRequestThreadDialog({ · 8 unmodified lines
68 76
69 const parsedReference = parsePullRequestReference(reference);77 const parsedReference = parsePullRequestReference(reference);
70 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);78 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
71 const resolvePullRequestQuery = useQuery(79 const resolvePullRequestResult = useAtomValue(
72 gitResolvePullRequestQueryOptions({80 gitResolvePullRequestAtom({
73 cwd,81 cwd: open ? cwd : null,
74 reference: open ? parsedDebouncedReference : null,82 reference: open ? parsedDebouncedReference : null,
75 }),83 }),
76 );84 );
77 const cachedPullRequest = useMemo(() => {85 const preparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom, {
78 if (!cwd || !parsedReference) {86 mode: "promise",
79 return null;87 });
80 }88 const preparePullRequestThreadResult = useAtomValue(gitPreparePullRequestThreadMutationAtom);
81 const cached = queryClient.getQueryData<GitResolvePullRequestResult>([89 const resolvePullRequestData = Option.getOrUndefined(AsyncResult.value(resolvePullRequestResult));
82 "git",90 const resolvePullRequestError = Option.getOrNull(
83 "pull-request",91 Option.map(AsyncResult.cause(resolvePullRequestResult), (cause) => Cause.squash(cause)),
84 cwd,
85 parsedReference,
86 ]);
87 return cached?.pullRequest ?? null;
88 }, [cwd, parsedReference, queryClient]);
89 const preparePullRequestThreadMutation = useMutation(
90 gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }),
91 );92 );
93 const isPreparingPullRequestThread = preparingMode !== null;
92 94
93 const liveResolvedPullRequest =95 const liveResolvedPullRequest =
94 parsedReference !== null && parsedReference === parsedDebouncedReference96 parsedReference !== null && parsedReference === parsedDebouncedReference
95 ? (resolvePullRequestQuery.data?.pullRequest ?? null)97 ? (resolvePullRequestData?.pullRequest ?? null)
96 : null;98 : null;
97 const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest;99 const resolvedPullRequest = liveResolvedPullRequest;
98 const isResolving =100 const isResolving =
99 open &&101 open &&
100 parsedReference !== null &&102 parsedReference !== null &&
101 resolvedPullRequest === null &&103 resolvedPullRequest === null &&
102 (referenceDebouncer.state.isPending ||104 (referenceDebouncer.state.isPending ||
103 parsedReference !== parsedDebouncedReference ||105 parsedReference !== parsedDebouncedReference ||
104 resolvePullRequestQuery.isPending ||106 AsyncResult.isInitial(resolvePullRequestResult) ||
105 resolvePullRequestQuery.isFetching);107 resolvePullRequestResult.waiting);
106 const statusTone = useMemo(() => {108 const statusTone = useMemo(() => {
107 switch (resolvedPullRequest?.state) {109 switch (resolvedPullRequest?.state) {
108 case "merged":110 case "merged":
@@ -115,6 +117,11 @@ export function PullRequestThreadDialog({ · 6 unmodified lines
115 return "text-muted-foreground";117 return "text-muted-foreground";
116 }118 }
117 }, [resolvedPullRequest?.state]);119 }, [resolvedPullRequest?.state]);
120 const preparePullRequestThreadErrorMessage = AsyncResult.match(preparePullRequestThreadResult, {
121 onInitial: () => null,
122 onSuccess: () => null,
123 onFailure: (error) => Cause.pretty(error.cause),
124 });
118 125
119 const handleConfirm = useCallback(126 const handleConfirm = useCallback(
120 async (mode: "local" | "worktree") => {127 async (mode: "local" | "worktree") => {
@@ -125,29 +132,36 @@ export function PullRequestThreadDialog({ · 4 unmodified lines
125 if (!parsedReference || !resolvedPullRequest || !cwd) {132 if (!parsedReference || !resolvedPullRequest || !cwd) {
126 return;133 return;
127 }134 }
135 setPrepareFollowupErrorMessage(null);
128 setPreparingMode(mode);136 setPreparingMode(mode);
129 try {137 try {
130 const result = await preparePullRequestThreadMutation.mutateAsync({138 const result = await preparePullRequestThread({
131 reference: parsedReference,139 payload: {
132 mode,140 cwd,
141 reference: parsedReference,
142 mode,
143 },
144 reactivityKeys: [REACTIVITY_KEYS.git(cwd)],
133 });145 });
134 await onPrepared({146
135 branch: result.branch,147 try {
136 worktreePath: result.worktreePath,148 await onPrepared({
137 });149 branch: result.branch,
138 onOpenChange(false);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;
139 } finally {160 } finally {
140 setPreparingMode(null);161 setPreparingMode(null);
141 }162 }
142 },163 },
143 [164 [cwd, onOpenChange, onPrepared, parsedReference, preparePullRequestThread, resolvedPullRequest],
144 cwd,
145 onOpenChange,
146 onPrepared,
147 parsedReference,
148 preparePullRequestThreadMutation,
149 resolvedPullRequest,
150 ],
151 );165 );
152 166
153 const validationMessage = !referenceDirty167 const validationMessage = !referenceDirty
@@ -159,21 +173,17 @@ export function PullRequestThreadDialog({ · 5 unmodified lines
159 : null;173 : null;
160 const errorMessage =174 const errorMessage =
161 validationMessage ??175 validationMessage ??
162 (resolvedPullRequest === null && resolvePullRequestQuery.isError176 (resolvedPullRequest === null && AsyncResult.isFailure(resolvePullRequestResult)
163 ? resolvePullRequestQuery.error instanceof Error177 ? resolvePullRequestError instanceof Error
164 ? resolvePullRequestQuery.error.message178 ? resolvePullRequestError.message
165 : "Failed to resolve pull request."179 : "Failed to resolve pull request."
166 : preparePullRequestThreadMutation.error instanceof Error180 : (prepareFollowupErrorMessage ?? preparePullRequestThreadErrorMessage));
167 ? preparePullRequestThreadMutation.error.message
168 : preparePullRequestThreadMutation.error
169 ? "Failed to prepare pull request thread."
170 : null);
171 181
172 return (182 return (
173 <Dialog183 <Dialog
174 open={open}184 open={open}
175 onOpenChange={(nextOpen) => {185 onOpenChange={(nextOpen) => {
176 if (!preparePullRequestThreadMutation.isPending) {186 if (!isPreparingPullRequestThread) {
177 onOpenChange(nextOpen);187 onOpenChange(nextOpen);
178 }188 }
179 }}189 }}
@@ -202,7 +212,7 @@ export function PullRequestThreadDialog({ · 22 unmodified lines
202 return;212 return;
203 }213 }
204 event.preventDefault();214 event.preventDefault();
205 if (!isResolving && !preparePullRequestThreadMutation.isPending) {215 if (!isResolving && !isPreparingPullRequestThread) {
206 void handleConfirm("local");216 void handleConfirm("local");
207 }217 }
208 }}218 }}
@@ -241,7 +251,7 @@ export function PullRequestThreadDialog({ · 32 unmodified lines
241 variant="outline"251 variant="outline"
242 size="sm"252 size="sm"
243 onClick={() => onOpenChange(false)}253 onClick={() => onOpenChange(false)}
244 disabled={preparePullRequestThreadMutation.isPending}254 disabled={isPreparingPullRequestThread}
245 >255 >
246 Cancel256 Cancel
247 </Button>257 </Button>
@@ -252,12 +262,7 @@ export function PullRequestThreadDialog({ · 4 unmodified lines
252 onClick={() => {262 onClick={() => {
253 void handleConfirm("local");263 void handleConfirm("local");
254 }}264 }}
255 disabled={265 disabled={!cwd || !resolvedPullRequest || isResolving || isPreparingPullRequestThread}
256 !cwd ||
257 !resolvedPullRequest ||
258 isResolving ||
259 preparePullRequestThreadMutation.isPending
260 }
261 >266 >
262 {preparingMode === "local" ? "Preparing local..." : "Local"}267 {preparingMode === "local" ? "Preparing local..." : "Local"}
263 </Button>268 </Button>
@@ -267,12 +272,7 @@ export function PullRequestThreadDialog({ · 3 unmodified lines
267 onClick={() => {272 onClick={() => {
268 void handleConfirm("worktree");273 void handleConfirm("worktree");
269 }}274 }}
270 disabled={275 disabled={!cwd || !resolvedPullRequest || isResolving || isPreparingPullRequestThread}
271 !cwd ||
272 !resolvedPullRequest ||
273 isResolving ||
274 preparePullRequestThreadMutation.isPending
275 }
276 >276 >
277 {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"}277 {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"}
278 </Button>278 </Button>
apps/web/src/components/Sidebar.tsx+8−11
@@ -12,6 +12,8 @@ import { · 11 unmodified lines
12} from "lucide-react";12} from "lucide-react";
13import { ProjectFavicon } from "./ProjectFavicon";13import { ProjectFavicon } from "./ProjectFavicon";
14import { autoAnimate } from "@formkit/auto-animate";14import { autoAnimate } from "@formkit/auto-animate";
15import { Option } from "effect";
16import { AsyncResult } from "effect/unstable/reactivity";
15import {17import {
16 useCallback,18 useCallback,
17 useEffect,19 useEffect,
@@ -44,7 +46,6 @@ import { · 26 unmodified lines
44 ThreadId,46 ThreadId,
45 type GitStatusResult,47 type GitStatusResult,
46} from "@t3tools/contracts";48} from "@t3tools/contracts";
47import { useQueries } from "@tanstack/react-query";
48import { useLocation, useNavigate, useParams } from "@tanstack/react-router";49import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
49import {50import {
50 type SidebarProjectSortOrder,51 type SidebarProjectSortOrder,
@@ -65,8 +66,8 @@ import { · 14 unmodified lines
65 threadTraversalDirectionFromCommand,66 threadTraversalDirectionFromCommand,
66} from "../keybindings";67} from "../keybindings";
67import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";68import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
68import { gitStatusQueryOptions } from "../lib/gitReactQuery";
69import { readNativeApi } from "../nativeApi";69import { readNativeApi } from "../nativeApi";
70import { useGitStatuses } from "../rpc/gitAtoms";
70import { useComposerDraftStore } from "../composerDraftStore";71import { useComposerDraftStore } from "../composerDraftStore";
71import { useHandleNewThread } from "../hooks/useHandleNewThread";72import { useHandleNewThread } from "../hooks/useHandleNewThread";
72 73
@@ -556,19 +557,15 @@ export default function Sidebar() { · 483 unmodified lines
556 ],557 ],
557 [threadGitTargets],558 [threadGitTargets],
558 );559 );
559 const threadGitStatusQueries = useQueries({560 const threadGitStatusResults = useGitStatuses(threadGitStatusCwds);
560 queries: threadGitStatusCwds.map((cwd) => ({
561 ...gitStatusQueryOptions(cwd),
562 staleTime: 30_000,
563 refetchInterval: 60_000,
564 })),
565 });
566 const prByThreadId = useMemo(() => {561 const prByThreadId = useMemo(() => {
567 const statusByCwd = new Map<string, GitStatusResult>();562 const statusByCwd = new Map<string, GitStatusResult>();
568 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {563 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
569 const cwd = threadGitStatusCwds[index];564 const cwd = threadGitStatusCwds[index];
570 if (!cwd) continue;565 if (!cwd) continue;
571 const status = threadGitStatusQueries[index]?.data;566 const status = threadGitStatusResults[index]
567 ? Option.getOrUndefined(AsyncResult.value(threadGitStatusResults[index]!))
568 : undefined;
572 if (status) {569 if (status) {
573 statusByCwd.set(cwd, status);570 statusByCwd.set(cwd, status);
574 }571 }
@@ -582,7 +579,7 @@ export default function Sidebar() { · 7 unmodified lines
582 map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null);579 map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null);
583 }580 }
584 return map;581 return map;
585 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);582 }, [threadGitStatusCwds, threadGitStatusResults, threadGitTargets]);
586 583
587 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {584 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
588 event.preventDefault();585 event.preventDefault();
apps/web/src/hooks/useThreadActions.ts+15−9
@@ -1,14 +1,15 @@
1import { useAtomSet } from "@effect/atom-react";
1import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
2import { useMutation, useQueryClient } from "@tanstack/react-query";
3import { useNavigate, useParams } from "@tanstack/react-router";3import { useNavigate, useParams } from "@tanstack/react-router";
4import { useCallback } from "react";4import { useCallback } from "react";
5 5
6import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";6import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
7import { useComposerDraftStore } from "../composerDraftStore";7import { useComposerDraftStore } from "../composerDraftStore";
8import { useHandleNewThread } from "./useHandleNewThread";8import { useHandleNewThread } from "./useHandleNewThread";
9import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
10import { newCommandId } from "../lib/utils";9import { newCommandId } from "../lib/utils";
11import { readNativeApi } from "../nativeApi";10import { readNativeApi } from "../nativeApi";
11import { gitRemoveWorktreeMutationAtom } from "../rpc/gitAtoms";
12import { REACTIVITY_KEYS } from "../rpc/client";
12import { useStore } from "../store";13import { useStore } from "../store";
13import { useTerminalStateStore } from "../terminalStateStore";14import { useTerminalStateStore } from "../terminalStateStore";
14import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";15import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
@@ -28,8 +29,7 @@ export function useThreadActions() { · 13 unmodified lines
28 });29 });
29 const navigate = useNavigate();30 const navigate = useNavigate();
30 const { handleNewThread } = useHandleNewThread();31 const { handleNewThread } = useHandleNewThread();
31 const queryClient = useQueryClient();32 const removeWorktree = useAtomSet(gitRemoveWorktreeMutationAtom, { mode: "promise" });
32 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
33 33
34 const archiveThread = useCallback(34 const archiveThread = useCallback(
35 async (threadId: ThreadId) => {35 async (threadId: ThreadId) => {
@@ -144,10 +144,16 @@ export function useThreadActions() { · 108 unmodified lines
144 }144 }
145 145
146 try {146 try {
147 await removeWorktreeMutation.mutateAsync({147 await removeWorktree({
148 cwd: threadProject.cwd,148 payload: {
149 path: orphanedWorktreePath,149 cwd: threadProject.cwd,
150 force: true,150 path: orphanedWorktreePath,
151 force: true,
152 },
153 reactivityKeys: [
154 REACTIVITY_KEYS.git(threadProject.cwd),
155 REACTIVITY_KEYS.project(threadProject.cwd),
156 ],
151 });157 });
152 } catch (error) {158 } catch (error) {
153 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";159 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
@@ -170,7 +176,7 @@ export function useThreadActions() { · 16 unmodified lines
170 clearTerminalState,176 clearTerminalState,
171 appSettings.sidebarThreadSortOrder,177 appSettings.sidebarThreadSortOrder,
172 navigate,178 navigate,
173 removeWorktreeMutation,179 removeWorktree,
174 routeThreadId,180 routeThreadId,
175 ],181 ],
176 );182 );
apps/web/src/lib/gitReactQuery.test.ts+0−51
@@ -1,51 +0,0 @@
1import { QueryClient } from "@tanstack/react-query";
2import { describe, expect, it } from "vitest";
3import {
4 gitMutationKeys,
5 gitPreparePullRequestThreadMutationOptions,
6 gitPullMutationOptions,
7 gitRunStackedActionMutationOptions,
8} from "./gitReactQuery";
9
10describe("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
28describe("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 @@
1import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts";
2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3import { ensureNativeApi } from "../nativeApi";
4import { getWsRpcClient } from "../wsRpcClient";
5
6const GIT_STATUS_STALE_TIME_MS = 5_000;
7const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
8const GIT_BRANCHES_STALE_TIME_MS = 15_000;
9const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
10
11export 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
17export 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
26export function invalidateGitQueries(queryClient: QueryClient) {
27 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
28}
29
30export 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
46export 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
62export 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
82export 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
96export 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
113export 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
153export 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
167export 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
191export 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
205export 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
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity";3import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity";
4 4
5import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client";5import { WsRpcAtomClient } from "./client";
6 6
7type WsEventType = "open" | "message" | "close" | "error";7type WsEventType = "open" | "message" | "close" | "error";
8type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };8type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
@@ -110,62 +110,11 @@ beforeEach(() => { · 101 unmodified lines
110});110});
111 111
112afterEach(() => {112afterEach(() => {
113 __resetWsRpcAtomClientForTests();
114 globalThis.WebSocket = originalWebSocket;113 globalThis.WebSocket = originalWebSocket;
115 vi.restoreAllMocks();114 vi.restoreAllMocks();
116});115});
117 116
118describe("WsRpcAtomClient", () => {117describe("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
169 it("exposes atom-backed query state for unary RPC methods", async () => {118 it("exposes atom-backed query state for unary RPC methods", async () => {
170 const expectedSettings = {119 const expectedSettings = {
171 ...DEFAULT_SERVER_SETTINGS,120 ...DEFAULT_SERVER_SETTINGS,
apps/web/src/rpc/client.ts+22−24
@@ -1,33 +1,31 @@
1import { WsRpcGroup } from "@t3tools/contracts";1import { WsRpcGroup } from "@t3tools/contracts";
2import { Effect, Layer, ManagedRuntime } from "effect";2import { Layer } from "effect";
3import { AtomRpc } from "effect/unstable/reactivity";3import { AtomRpc } from "effect/unstable/reactivity";
4 4
5import { createWsRpcProtocolLayer } from "./protocol";5import { createWsRpcProtocolLayer } from "./protocol";
6 6
7import { Atom } from "effect/unstable/reactivity";
8import { Duration } from "effect";
9
10export const REACTIVITY_KEYS = {
11 git: (cwd: string) => `git:${cwd}` as const,
12 project: (cwd: string) => `project:${cwd}` as const,
13} as const;
14
15export 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
7export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", {28export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", {
8 group: WsRpcGroup,29 group: WsRpcGroup,
9 protocol: Layer.suspend(() => createWsRpcProtocolLayer()),30 protocol: Layer.suspend(() => createWsRpcProtocolLayer()),
10}) {}31}) {}
11
12let sharedRuntime: ManagedRuntime.ManagedRuntime<WsRpcAtomClient, never> | null = null;
13
14function getRuntime() {
15 if (sharedRuntime !== null) {
16 return sharedRuntime;
17 }
18
19 sharedRuntime = ManagedRuntime.make(WsRpcAtomClient.layer);
20 return sharedRuntime;
21}
22
23export 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
29export 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 @@
1import {
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";
10import { Duration, Effect, Stream } from "effect";
11import { AsyncResult, Atom, Reactivity } from "effect/unstable/reactivity";
12
13import { refreshIntervalSignalAtom, WsRpcAtomClient } from "./client";
14import { REACTIVITY_KEYS } from "./client";
15import { useMemo } from "react";
16import { useAtomValue } from "@effect/atom-react";
17
18const GIT_ACTION_RESULT_MISSING_ERROR = {
19 _tag: "GitActionResultMissing",
20 message: "Git action stream completed without a final result.",
21} as const;
22export const gitRunStackedActionProgressAtom = Atom.make<GitActionProgressEvent | null>(null).pipe(
23 Atom.keepAlive,
24 Atom.withLabel("git-run-stacked-action-progress"),
25);
26
27const 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
38export 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
54const 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
65export 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
81export const gitCheckoutMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitCheckout);
82
83export const gitCreateBranchMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitCreateBranch);
84
85export const gitCreateWorktreeMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitCreateWorktree);
86
87export const gitInitMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitInit);
88
89export const gitPreparePullRequestThreadMutationAtom = WsRpcAtomClient.mutation(
90 WS_METHODS.gitPreparePullRequestThread,
91);
92
93export const gitPullMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitPull);
94
95export const gitRemoveWorktreeMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitRemoveWorktree);
96
97export 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
123const 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
131export 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
158export 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
55 git: {55 git: {
56 pull: vi.fn(),56 pull: vi.fn(),
57 status: vi.fn(),57 status: vi.fn(),
58 runStackedAction: vi.fn(),
59 listBranches: vi.fn(),58 listBranches: vi.fn(),
60 createWorktree: vi.fn(),59 createWorktree: vi.fn(),
61 removeWorktree: vi.fn(),60 removeWorktree: vi.fn(),
apps/web/src/wsRpcClient.ts+0−30
@@ -1,7 +1,4 @@
1import {1import {
2 type GitActionProgressEvent,
3 type GitRunStackedActionInput,
4 type GitRunStackedActionResult,
5 type NativeApi,2 type NativeApi,
6 ORCHESTRATION_WS_METHODS,3 ORCHESTRATION_WS_METHODS,
7 type ServerSettingsPatch,4 type ServerSettingsPatch,
@@ -31,10 +28,6 @@ type RpcStreamMethod<TTag extends RpcTag> = · 23 unmodified lines
31 ? (listener: (event: TEvent) => void) => () => void28 ? (listener: (event: TEvent) => void) => () => void
32 : never;29 : never;
33 30
34interface GitRunStackedActionOptions {
35 readonly onProgress?: (event: GitActionProgressEvent) => void;
36}
37
38export interface WsRpcClient {31export interface WsRpcClient {
39 readonly dispose: () => Promise<void>;32 readonly dispose: () => Promise<void>;
40 readonly terminal: {33 readonly terminal: {
@@ -59,10 +52,6 @@ export interface WsRpcClient { · 18 unmodified lines
59 readonly git: {52 readonly git: {
60 readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;53 readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;
61 readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>;54 readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>;
62 readonly runStackedAction: (
63 input: GitRunStackedActionInput,
64 options?: GitRunStackedActionOptions,
65 ) => Promise<GitRunStackedActionResult>;
66 readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>;55 readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>;
67 readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>;56 readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>;
68 readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>;57 readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>;
@@ -136,25 +125,6 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { · 67 unmodified lines
136 git: {125 git: {
137 pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)),126 pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)),
138 status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)),127 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 },
158 listBranches: (input) =>128 listBranches: (input) =>
159 transport.request((client) => client[WS_METHODS.gitListBranches](input)),129 transport.request((client) => client[WS_METHODS.gitListBranches](input)),
160 createWorktree: (input) =>130 createWorktree: (input) =>